Files
nucleic/Sources/NucleicApp/SessionDetailView.swift
T

4977 lines
259 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
import NucleicCore
import OSLog
/// Transcript + approval bar + integrate/discard controls for the open session
/// (UX_MACOS "Session detail").
struct SessionDetailView: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorScheme) private var colorScheme
/// Whether this window is key. The composer's glass drops to a flat, bright material when it
/// isn't, which `inactiveGlassScrim` compensates for.
@Environment(\.controlActiveState) private var controlActiveState
/// Panel layout for the session's side panels.
@Environment(PanelLayoutStore.self) private var panels
@State private var integrating = false
/// In-flight flag for the nvrsion trunk promotion ("Integrate") action, mirroring
/// `integrating` for the per-session merge — disables the button while it runs.
@State private var promoting = false
@State private var draft = ""
/// Mesh composer streaming bookkeeping for `draft`: the text this composer last took from
/// the mesh (so a draft consumed elsewhere clears only what came from there, never what the
/// user has typed since), the same text held for one update as the echo to swallow (adopting
/// the shared draft must not stream it straight back out and lock every other device), and
/// which chat those refer to (this view serves them all in turn).
@State private var meshDraft: String?
@State private var meshDraftEcho: String?
@State private var meshDraftSession: SessionID?
/// Avoid repeating the full-draft override regex across the composer's many derived
/// properties on every keystroke. Reference-backed and deliberately unobserved.
@State private var modelOverrideCache = ComposerModelOverrideCache()
/// Files/images staged in the composer (attach button, paste, or drag) to send with the
/// next message; materialized into the worktree on send (see `send()`).
@State private var attachments: [ComposerAttachment] = []
/// True while a file/image drag hovers the composer, to light up the drop zone.
@State private var composerDropTargeted = false
@State private var composerHeight = ChatInputField.restingHeight
/// Measured height of the send/stash action column. Below this height the field's own frame
/// (with its padding) would be shorter than the column, so the row switches to top alignment —
/// otherwise bottom alignment would push the column's top above the field's, overflowing the
/// send button above the text entry (see the `HStack` this feeds below).
@State private var composerActionColumnHeight: CGFloat = 0
/// Measured height of the floating bottom cluster — the pending approval card, when there is
/// one, plus the composer — which hovers over the transcript on Liquid Glass rather than
/// sitting in the layout beneath it. The transcript reserves this much scroll content below
/// its last message (see `transcriptTailInset`) and anchors its bottom fade to it, so both
/// track the cluster as it grows with a taller draft, queued messages, or an approval.
@State private var floatingComposerHeight: CGFloat = 0
@State private var renaming = false
@State private var renameDraft = ""
@State private var summaryExpanded = false
/// Which chat `summaryExpanded` currently describes. Lets the status handler tell a
/// genuine live turn-transition (animate the card open) from the status delta of a chat
/// switch (snap, no animated slide of the transcript) without depending on onChange order.
@State private var summaryStatusSession: SessionID?
/// Drives the composer's orchestra glow, which announces the mode when you switch a chat into
/// orchestra and then retires once you've actually run a turn under it — a permanent halo
/// would just be noise. `orchestraGlowSession` ties the flags to the open chat so switching
/// chats re-arms the announcement; `orchestraTurnStarted`/`orchestraTurnElapsed` track a turn
/// beginning and then finishing under orchestra (enabling it mid-turn doesn't count — orchestra
/// only affects the *next* turn).
@State private var orchestraGlowSession: SessionID?
@State private var orchestraTurnStarted = false
@State private var orchestraTurnElapsed = false
/// Branch names for the open session's project, populated for the autoship
/// destination picker inside `shipControl`.
@State private var shipBranches: [String] = []
/// The user message awaiting a "revert to here" confirmation (its canonical `seq`).
@State private var revertSeq: UInt64?
/// Live scroll-position tracking for the transcript, updated by
/// `onScrollGeometryChange`. True while the user is parked at (within a hair of) the
/// bottom, so live output keeps following; once they scroll up it flips false,
/// following stops, and the jump-to-bottom chevron appears. Starts true — a freshly
/// opened chat is anchored at the bottom.
@State private var isScrolledToBottom = true
/// The live phase of the transcript scroll. Content growth and bottom-anchor re-pins also
/// change scroll geometry, so distance alone cannot tell a real user scroll from layout.
/// Only an interactive/decelerating phase is allowed to disengage following (see
/// `scrollableTranscript`), which prevents streaming text from flipping the gate itself.
@State private var transcriptScrollPhase: ScrollPhase = .idle
/// The scroll geometry reduced to the only thresholds that affect bottom following. Keeping
/// the region (instead of the continuously-changing point distance) makes a long swipe update
/// SwiftUI state only as it crosses a boundary, and lets a newly-started drag apply the latest
/// geometry even when content growth crossed that boundary before the gesture began.
@State private var transcriptBottomRegion: TranscriptScrollFollowPolicy.Region = .atBottom
/// True from an explicit jump (chevron / send) until geometry confirms arrival at the
/// bottom. Scroll-phase and geometry callbacks are independently scheduled; without this
/// latch, a stale deceleration reading can land after the jump and immediately disengage
/// following again.
@State private var jumpingToTranscriptBottom = false
/// Memoizes the last transcript projection across body re-evaluations (see
/// `projectedTranscript`); a reference box so it survives the value-type view's re-inits.
@State private var projectionCache = ProjectionCache()
/// The exact store revision most recently projected by the off-main worker. This observable
/// edge wakes the Markdown/first-layout tasks after the cache's unobserved value is installed.
@State private var transcriptProjectionReadyToken: ProjectionToken?
/// Bumped to force a scroll to the bottom (e.g. when the user sends a message),
/// overriding the at-bottom gate.
@State private var scrollToBottomRequest = 0
/// True for a short window right after a chat opens, while its transcript runs its
/// first layout passes. Scroll-follow and prose-reflow animations are suppressed
/// during this window so a freshly opened chat *snaps* to the bottom in one pass
/// instead of easing/oscillating into place as the initial measurements settle —
/// the tiny up-and-down jitter on open. Cleared a few frames after the open.
@State private var transcriptSettling = true
/// Live width of the chat column, captured from `body`'s outer VStack. The transcript
/// eases its width changes across a horizontal window resize (see the `.animation` in
/// `scrollableTranscript`), keyed to its own GeometryReader width. The composer, dividers,
/// and approval bars sit outside that reader, so they need the same live width to ease in
/// lockstep — otherwise they snap straight to the window edge while the transcript trails
/// behind, and the two look horizontally disconnected mid-drag. This mirrors that width so
/// `chatColumn` can key an identical animation off it.
@State private var columnWidth: CGFloat = 0
/// Gates the transcript's visibility on open: false from the moment a chat opens until its
/// transcript has loaded and its initial layout has pinned to the bottom, then flipped true to
/// reveal it in one shot. The bottom anchor settles over a few layout passes; holding the
/// view invisible across them means a freshly opened chat *appears* already at the tail with
/// no on-screen scroll/jitter, instead of visibly snapping into place over those first frames.
@State private var transcriptRevealed = false
/// The session whose long-history list has been explicitly pinned to the final sentinel after
/// its first real tail chunk mounted. This is separate from the passive bottom-follow state:
/// the first native-list layout can otherwise stop at the last message while later log/status
/// rows are still resolving below it.
@State private var transcriptInitialBottomPinnedSessionID: SessionID?
/// Total scroll-content height of the open transcript, tracked live from the scroll
/// view's geometry. The reveal above waits for this to stop changing (the initial layout
/// and async tool-summary lines grow it for a beat after open), so the chat is shown only
/// once it has truly come to rest at the bottom — never mid-settle.
@State private var transcriptContentHeight: CGFloat = 0
/// The session whose first-paint Markdown has been loaded into `MarkdownText`'s in-memory
/// caches. First layout is gated on this edge as well as AppStore's history-ready edge. Long
/// histories need only their visible tail here; their top-level persisted snapshot follows.
@State private var markdownReadySessionID: SessionID?
/// Projection version whose immediately visible Markdown was actually warmed. A later streamed
/// revision can introduce a cold tail even though the session-level first-paint gate remains
/// satisfied, so render-cost attribution must not treat the whole session as warm forever.
@State private var markdownReadyProjectionVersion: Int?
/// One observable edge for the opportunistic length-sidecar load. The actual lookup table lives
/// in the unobserved projection cache so recording each row does not invalidate the whole List.
@State private var transcriptLengthCacheReadySessionID: SessionID?
/// The most recent hydration token whose top-level Markdown completed the continuous
/// off-main warm pass. Consulted through `covers(_:)`, not equality: completed warmth
/// keeps rows on the synchronous-mount fast path across streaming appends and segment
/// boundary shifts, and is invalidated only by a session switch, a revert, or paged-in
/// older history. Individual visible rows still become ready in local row state, avoiding
/// a full transcript invalidation for every two-row chunk.
@State private var transcriptMarkdownReadyHydrationToken: TranscriptSegmentHydrationToken?
@AppStorage(TranscriptDisplay.showDebugKey) private var showDebugLines = false
@AppStorage(TranscriptDisplay.showLockEventsKey) private var showLockEvents = true
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
/// Whether the service-status pill shows here (it always shows on home); "Only on home
/// view" hides it from the chat composer.
@AppStorage(StatusIndicatorVisibility.storageKey) private var statusVisibilityRaw = StatusIndicatorVisibility.everywhere.rawValue
/// Override: under "Only on home view", still surface the pill here when this chat's model's
/// provider has an active incident (see `showsStatusIndicator`).
@AppStorage(StatusIndicatorVisibility.chatIncidentOverrideKey) private var showStatusInChatOnIncident = true
/// Intelligence routing: on by default; the Settings toggle restores the manual
/// Model + Effort menus. A routed chat snapshots its recorded purpose when opened —
/// typing later turns never reclassifies it — while this local level mirrors the
/// in-session slider and re-resolves the next turn on release.
@AppStorage(ModelCatalog.intelligenceSliderEnabledKey) private var intelligenceSliderEnabled = true
@State private var sliderIntelligenceLevel = IntelligenceLevel.fallback
@State private var sliderIntelligenceLevelPreview: IntelligenceLevel?
@State private var sliderOrchestraSelected = false
@State private var sliderOrchestraPreview: Bool?
@State private var lockedRoutedPurpose = PromptPurpose.general
/// Optimistic pair chosen from the labels. It keeps both words instant while the session
/// mutation crosses an actor or the mesh; the persisted session remains authoritative.
@State private var sliderManualModel: String?
@State private var sliderManualEffort: String?
/// Rail state displaced temporarily by typed `/override:` syntax. Deleting the token restores
/// it; sending consumes the saved state because the concrete pair becomes the chat's choice.
@State private var preComposerOverrideIntelligenceLevel: IntelligenceLevel?
@State private var preComposerOverrideOrchestraSelected: Bool?
/// The corresponding optimistic pair for a rail-driven route. Kept distinct so quota,
/// stashing, and the “automatic routing” menu state do not mistake latency for a manual pin.
@State private var sliderRoutedModel: String?
@State private var sliderRoutedEffort: String?
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
/// Width reserved for the send button, so the bottom controls row can be
/// inset to line up with the text field's trailing edge.
private let sendButtonWidth: CGFloat = 28
/// Horizontal breathing room between transcript text and the column edges, and
/// the base prose size — chat is for reading, so it gets generous margins and a
/// larger default than the macOS 13pt body. The floating summary card is inset
/// by the same amount so it lines up with the transcript's trailing edge.
private let transcriptInset: CGFloat = 28
private let transcriptFontSize: CGFloat = 15
/// Room beneath the last response, before the composer / approval bar. Rendered
/// as a trailing spacer (see `transcript`) so it's part of the scrollable content.
private let transcriptBottomPadding: CGFloat = 36
/// Depth of the gradient that dissolves the transcript as it slides under the floating
/// composer, and how far past the card's top edge the prose is fully gone. Together they
/// place the fade: fully drawn just above the card, dissolving well into it.
///
/// The overlap carries most of the weight. Taking the prose to nothing right at the card's
/// top edge left a blank band under the glass — with nothing behind it to refract, the card
/// read as an opaque slab rather than a window. Letting the dissolve run deep into the card
/// keeps something there to see through, so the glass looks like glass.
private let transcriptFadeDepth: CGFloat = 64
private let transcriptFadeOverlap: CGFloat = 52
/// Scroll-content room beneath the last response: the base breathing room plus the measured
/// height of the glass cluster the transcript now scrolls under, so the tail of a conversation
/// comes to rest clear above the composer instead of behind it.
private var transcriptTailInset: CGFloat {
transcriptBottomPadding + floatingComposerHeight
}
/// Caps the transcript to a word-processor-style measure, centered in the
/// viewport, so prose lines stay a comfortable length on wide windows instead of
/// running edge to edge.
private let contentMaxWidth: CGFloat = 720
/// The summary card's expanded width — the breakpoint below uses this (not the
/// live width) so the card doesn't hop between margin and overlay as it grows.
private let summaryCardMaxWidth: CGFloat = 240
/// Clearance kept between the transcript column and a margin-docked card — both the
/// gap the card leaves beside the prose and the slack in the fit test below.
private let summaryCardColumnGap: CGFloat = 12
/// Whether the side margin beside the centered column is wide enough to hold the
/// (expanded) summary card — at which point the card moves out of the transcript
/// into the right margin instead of floating over the prose. The card docks flush
/// against the column edge (not the window edge), so the margin only needs to fit
/// the card itself plus one gap — no window inset — which keeps the switch from
/// demanding excessive width.
private func summaryCardFitsInMargin(width: CGFloat) -> Bool {
let sideMargin = (width - contentMaxWidth) / 2
return sideMargin >= summaryCardMaxWidth + summaryCardColumnGap
}
private var session: Session? { store.openSession }
/// Whether the open session's project is governed by nvrsion (shared trunk). When it is,
/// every session edits and lands into the one `nucleic/trunk` and the real branch only moves
/// by project-level promotion — so a per-session branch indicator and a redirectable merge
/// destination are meaningless, and both are hidden in the composer.
private var nvrsionActive: Bool {
session.flatMap { store.project($0.projectID) }?.nvrsionActive == true
}
/// Effective Auto state for the composer. Under Nucleic Control every chat runs
/// autonomously — Auto is forced on and its toggle is locked (nvrsion in particular
/// can't function without it) — so this reads on regardless of the stored flag.
/// Off-control it's just the session's own toggle.
private var autoOn: Bool { isControlProject || (session?.auto ?? false) }
/// An archived chat is read-only: the composer is dimmed and inert, its messages
/// can't be reverted, and the send button is replaced by Unarchive (see `composer`).
private var isArchived: Bool { session?.archived ?? false }
/// This chat's shared composer draft as another device left it (mesh composer streaming) —
/// being edited there right now, or settled and waiting to be picked up here.
private var sharedComposerDraft: ComposerTypingState? {
guard let id = session?.id else { return nil }
return store.composerTypingBySession[id]
}
/// Another device is editing this chat's composer right now. While present, this composer is
/// locked and renders the incoming draft above the field; it unlocks when that device stops
/// — and the text it left settles into the field (`sharedComposerDraftChanged`).
private var remoteTyping: ComposerTypingState? {
sharedComposerDraft.flatMap { $0.editing ? $0 : nil }
}
// Effective concrete values (never shows "Default"): the session's override, or
// the app default. Selecting a value sets a concrete override.
private var effectiveModel: String { session?.model ?? store.defaultModel ?? ModelCatalog.fallbackModel }
private var effectiveEffort: String {
let raw = ModelCatalog.clampedEffort(
session?.effort ?? store.defaultEffort ?? ModelCatalog.fallbackEffort,
for: effectiveModel, codexPro: store.isCodexPro)
// Orchestra requires Nucleic Control; if the session's project isn't under control, fall
// back to its underlying level so the menu never shows it active where it can't run.
if ModelCatalog.isOrchestra(raw), !isControlProject {
return OrchestrationMode.resolvedEffort(raw) ?? ModelCatalog.fallbackEffort
}
return raw
}
/// Session context-window occupancy: the most recent turn's *final-call* input
/// (`contextInputTokens`) against the model's window. Not the summed token fields —
/// those accumulate across an agentic turn's many calls and overflow the window.
/// Nil until a turn reports per-call usage.
private var contextUsage: ContextWindowUsage? {
let used = projectionCache.value.metadata.contextInputTokens
guard let used, used > 0 else { return nil }
return ContextWindowUsage(
usedTokens: used, windowTokens: ModelCatalog.contextWindow(for: effectiveModel))
}
/// Whose subscription quota the composer pill reports. Inferred from the effective model —
/// the model picker doubles as the backend selector, so switching a chat to a GPT SKU should
/// switch the pill to Codex usage even before the session's recorded backend catches up —
/// falling back to that recorded backend for SKUs the catalog doesn't map.
private var quotaBackend: BackendID? { BackendID.forModel(effectiveModel) ?? session?.backend }
var body: some View {
VStack(spacing: 0) {
header
Divider()
// The transcript owns the whole pane below the header; the approval card and the
// composer float over its bottom edge on Liquid Glass, so the conversation reads
// *through* them instead of stopping at an opaque strip ruled off by a divider.
// Nothing is hidden by the arrangement: the scroll content reserves the cluster's
// measured height (`transcriptTailInset`), so the tail still rests above the glass,
// and everything that scrolls under it dissolves into the pane (`transcriptBottomFade`)
// rather than sliding under the card's edge as a sharp half-legible line.
ZStack(alignment: .bottom) {
transcript
floatingBottomCluster
}
}
// Mirror the column's live width so the composer/divider/approval column (which live
// outside the transcript's GeometryReader) can ease their width in lockstep with the
// transcript during a horizontal window resize — keeping them horizontally locked
// instead of snapping ahead while the transcript's eased reflow trails behind.
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { columnWidth = $0 }
// The summary card auto-expands to show a finished turn's recap (idle, awaiting input)
// or its live "Working…" progress (running). On a *live* transition within the chat
// you're already viewing, that expand animates — a nice cue. But on a chat *switch* the
// status also "changes" (the previous chat's status → this one's), and animating the
// expand then grew the card's hidden space-twin inside the transcript, sliding all the
// prose down for the length of the animation. That slow slide — text composited (thin
// and fuzzy) while it moves — is what read as the transcript jittering on load. So snap
// to the resting state on open with no animation, and reserve the animation for genuine
// live transitions. `summaryStatusSession` records which chat the expand state belongs
// to, making the live-vs-open decision independent of onChange firing order.
.onChange(of: store.openSessionID) { _, id in
transcriptSettling = true
transcriptRevealed = false
transcriptInitialBottomPinnedSessionID = nil
transcriptContentHeight = 0
isScrolledToBottom = true
transcriptScrollPhase = .idle
transcriptBottomRegion = .atBottom
jumpingToTranscriptBottom = false
markdownReadySessionID = nil
markdownReadyProjectionVersion = nil
transcriptProjectionReadyToken = nil
transcriptLengthCacheReadySessionID = nil
projectionCache.resetProjection()
projectionCache.resetChunkHydration()
transcriptMarkdownReadyHydrationToken = nil
summaryStatusSession = id
summaryExpanded = summaryShouldRestExpanded
}
.onChange(of: session?.status) { _, newStatus in
guard summaryStatusSession == store.openSessionID else { return }
if newStatus == .awaitingInput || newStatus == .running {
withAnimation { summaryExpanded = true }
}
}
// Re-arm the composer's orchestra glow for whichever chat is on screen — a fresh chat (or a
// switch back to one that already ran an orchestra turn) starts the announcement over.
.onChange(of: store.openSessionID, initial: true) { _, id in
orchestraGlowSession = id
orchestraTurnStarted = false
orchestraTurnElapsed = false
let purpose = session?.routedPurpose.flatMap(PromptPurpose.init(rawValue:)) ?? .general
lockedRoutedPurpose = purpose
if let raw = session?.routedLevel, let recorded = IntelligenceLevel(rawValue: raw) {
sliderIntelligenceLevel = recorded
} else {
sliderIntelligenceLevel = closestSessionIntelligenceLevel(
toModel: effectiveModel,
effort: effectiveEffort,
purpose: purpose)
}
sliderIntelligenceLevelPreview = nil
sliderOrchestraSelected = isOrchestra
sliderOrchestraPreview = nil
clearSessionManualSelection()
clearSessionRoutedSelection()
preComposerOverrideIntelligenceLevel = nil
preComposerOverrideOrchestraSelected = nil
}
.onChange(of: composerModelOverride?.selection) { old, selection in
sliderIntelligenceLevelPreview = nil
sliderOrchestraPreview = nil
if let selection {
if old == nil {
preComposerOverrideIntelligenceLevel = sliderIntelligenceLevel
preComposerOverrideOrchestraSelected = sliderOrchestraSelected
}
sliderOrchestraSelected = false
sliderIntelligenceLevel = closestSessionIntelligenceLevel(
toModel: selection.model,
effort: selection.effort,
purpose: routedPurpose)
} else if old != nil {
if let previous = preComposerOverrideIntelligenceLevel {
sliderIntelligenceLevel = previous
}
if let previousOrchestra = preComposerOverrideOrchestraSelected {
sliderOrchestraSelected = previousOrchestra
}
preComposerOverrideIntelligenceLevel = nil
preComposerOverrideOrchestraSelected = nil
}
}
// A non-nil routing purpose can arrive after a mirrored session's first paint. Adopt it
// once, but deliberately ignore nil when a manual pick clears routing provenance so the
// session's original purpose remains locked for any later rail movement.
.onChange(of: session?.routedPurpose, initial: true) { _, raw in
guard let raw, let purpose = PromptPurpose(rawValue: raw) else { return }
lockedRoutedPurpose = purpose
reconcileSessionSelections()
}
// The rail can be moved from ANOTHER device — a phone's `setSessionIntelligence` re-routes
// here and rewrites this session's `routedLevel`. Without this the bar only ever seeded
// itself on `openSessionID` change, so a level set from the phone updated the chat's model
// while this rail stayed visibly parked on the old stop.
//
// Deliberately skipped while a pointer is on the rail (`…LevelPreview != nil`): the user's
// own gesture outranks a concurrent remote edit, and the release commits over it anyway. A
// local move needs no special case — it writes the level first and the host echoes the same
// value back, so this re-seed is a no-op for it.
.onChange(of: session?.routedLevel) { _, raw in
guard sliderIntelligenceLevelPreview == nil,
let raw, let recorded = IntelligenceLevel(rawValue: raw),
recorded != sliderIntelligenceLevel
else { return }
sliderIntelligenceLevel = recorded
clearSessionManualSelection()
clearSessionRoutedSelection()
}
.onChange(of: session?.model) { _, _ in reconcileSessionSelections() }
.onChange(of: session?.effort) { _, _ in reconcileSessionSelections() }
.onChange(of: isOrchestra) { _, selected in
sliderOrchestraSelected = selected
if selected {
clearSessionManualSelection()
clearSessionRoutedSelection()
sliderIntelligenceLevelPreview = nil
sliderIntelligenceLevel = .max
}
}
// Retire that glow once a turn has run start-to-finish under orchestra: arm on an orchestra
// turn beginning, extinguish when it ends. Guarded to the open chat so a trailing status
// change from a chat we just left can't touch the new one's glow.
.onChange(of: isBusy) { _, busy in
guard orchestraGlowSession == store.openSessionID else { return }
if busy {
if isOrchestra { orchestraTurnStarted = true }
} else if orchestraTurnStarted {
orchestraTurnElapsed = true
}
}
// While a turn is in flight, keep the shared-container download indicator fresh so the
// working row can attribute a slow first response to the sandbox still downloading. Cheap
// (one actor hop, no I/O); polls only while busy and refreshes once on the way out to clear
// a stale value. Restarts whenever `isBusy` flips.
.task(id: isBusy) {
await store.refreshContainerDownload()
while isBusy && !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
await store.refreshContainerDownload()
}
}
// Projection/coalescing is the last whole-history operation between canonical events and
// render chunks. Keep it entirely off the main actor; a full authoritative replacement can
// then take as long as it needs without freezing the already-visible startup tail.
.task(id: transcriptProjectionWorkToken, priority: .userInitiated) {
await refreshTranscriptProjection()
}
// Warm the exact top-level Markdown installed by the store without making a long history's
// entire parse snapshot part of first paint. A virtualized transcript warms only its tail,
// reveals immediately, then continues backward through the rest off-main; the native list
// won't construct those older rows until they approach the viewport. Short transcripts
// retain the all-before-layout path because their eager stack needs every row. A turn
// transition refreshes the top-level durable snapshot without hiding an open chat.
.task(id: MarkdownPrewarmToken(
session: store.openSessionID,
historyReady: store.openTranscriptAvailableSessionID,
transcriptVersion: store.openTranscriptVersion,
projectionReadyVersion: transcriptProjectionReadyToken?.version,
busy: isBusy))
{
guard let sessionID = store.openSessionID,
store.openTranscriptAvailableSessionID == sessionID,
let projectionReady = transcriptProjectionReadyToken,
projectionReady == currentProjectionToken
else { return }
let projection = projectedTranscript()
let usesVirtualizedLayout = projection.usesVirtualizedLayout
if markdownReadySessionID != sessionID {
if usesVirtualizedLayout {
// Warm only Markdown directly rendered by the newest bounded segment. A
// collapsed subagent/tool row can own thousands of descendants; traversing
// those here made one nominally tiny chunk take seconds. Older top-level
// segments follow at background priority after visible chunks are ready.
let tailSegment = projection.segments.last
let tailItems = tailSegment.map {
Array(projection.items[$0.range])
} ?? []
let tail = Self.markdownForMount(
in: tailItems,
cardPresentations: projection.cardPresentations)
_ = await MarkdownText.prewarm(
tail, sessionID: sessionID, diskAccess: .memoryOnly)
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
// The tail row seeds its own local readiness from this completed warm-up.
// No root-level mounted set is mutated: one dictionary write per chunk forced
// SwiftUI to rebuild the complete long-history List during rapid scrolling.
markdownReadySessionID = sessionID
markdownReadyProjectionVersion = projectionReady.version
// The top-level snapshot is a separate, reveal-keyed task below. Returning is
// important: even collecting all body strings is an O(history) main-actor walk
// and must not sneak back into the first-paint critical path.
return
} else {
// Eager top-level layout is small, but any one collapsed tool/subagent row may
// own a massive nested stream. Only its header mounts now; descendants remain
// cold until their independently paginated disclosure is opened.
let bodies = Self.markdownForMount(
in: projection.items,
cardPresentations: projection.cardPresentations)
_ = await MarkdownText.prewarm(
bodies, sessionID: sessionID, diskAccess: .memoryOnly)
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
markdownReadySessionID = sessionID
markdownReadyProjectionVersion = projectionReady.version
return
}
}
// Once visible, both layout modes use the background-priority snapshot task below.
// Keeping whole-history collection out of this user-initiated task is essential for a
// transcript whose apparent single tool row contains a very large nested stream.
return
}
// Finish the durable Markdown snapshot only after text is on screen and a live turn is not
// producing revisions. Long histories also wait for the continuous top-level pass. This
// keeps disk snapshot reconstruction out of both first paint and rapid streaming/scrolling;
// the `busy` edge starts one exact refresh when the turn settles.
.task(id: MarkdownSnapshotRefreshToken(
session: store.openSessionID,
historyReady: store.openTranscriptReadySessionID,
projectionReadyVersion: transcriptProjectionReadyToken?.version,
busy: isBusy,
revealed: transcriptRevealed,
segmentsHydrated: transcriptMarkdownReadyHydrationToken), priority: .background)
{
guard transcriptRevealed,
!isBusy,
let sessionID = store.openSessionID,
store.openTranscriptReadySessionID == sessionID,
markdownReadySessionID == sessionID,
transcriptProjectionReadyToken == currentProjectionToken
else { return }
let projection = projectedTranscript()
let virtualized = projection.usesVirtualizedLayout
guard !virtualized
|| transcriptMarkdownReadyHydrationToken
== transcriptSegmentHydrationToken(for: projection)
else { return }
let items = projection.items
let cardPresentations = projection.cardPresentations
let bodies = await Task.detached(priority: .background) {
Self.markdownForMount(
in: items, cardPresentations: cardPresentations)
}.value
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
_ = await MarkdownText.prewarm(bodies, sessionID: sessionID)
}
.toolbar {
// Every item in this group sits on the trailing end of the unified toolbar, directly
// over the channel banner's tint on a non-release build — `.channelBannerHeader()`
// turns each white so it stays legible there, and is a no-op on a shipping release
// (where the header carries no banner color). It replaces the Git menu's old
// unconditional `.tint(.white)`, which was wrong on exactly that release build.
ToolbarItemGroup {
// No Stop item here: the composer's own stop-circle already interrupts the
// running turn, and status refreshes itself, so the toolbar stays uncluttered.
if let session, let project = store.project(session.projectID) {
BuildRunControls(
project: project,
workingDirectory: session.worktreePath,
compact: true)
.channelBannerHeader()
}
Menu {
if nvrsionActive {
// Whole-trunk promote blocks while any chat in this project is mid-turn —
// squashing a half-written trunk out from under a running agent would land
// partial work. The per-chat promote below only waits on *this* chat.
let chatsRunning = session.map { store.isAnySessionRunning(in: $0.projectID) } ?? false
let thisChatRunning = session?.status.hasTurnInFlight ?? true
Section("nvrsion") {
// Ship just this chat's landed work — no need to wait for a long-running
// sibling to finish (NVRSION §6, per-session promotion).
Button("Integrate this chat → \(nvrsionPromoteTarget)") {
Task { await promoteSession() }
}
.disabled(promoting || thisChatRunning)
Button("Integrate trunk → \(nvrsionPromoteTarget)") {
Task { await promoteTrunk() }
}
.disabled(promoting || chatsRunning)
}
}
Section("Merge into \(targetBranch)") {
Button("Squash & merge") { Task { await integrate(.squash) } }
Button("Merge commit") { Task { await integrate(.merge) } }
Button("Rebase & fast-forward") { Task { await integrate(.rebase) } }
}
Section {
Button("Open in Terminal", systemImage: "terminal") { openInTerminal() }
Button("Reveal in Finder", systemImage: "folder") { revealInFinder() }
}
Section {
Button("Copy Branch Name", systemImage: "arrow.triangle.branch") {
copyToClipboard(session?.branch, note: "Copied branch name: \(session?.branch ?? "")")
}
Button("Copy Worktree Path", systemImage: "doc.on.doc") {
copyToClipboard(session?.worktreePath, note: "Copied worktree path")
}
}
Section {
Button("Release File Locks", systemImage: "lock.open") {
Task {
await store.releaseOpenSessionLocks()
await store.noteToOpenSession(
"Released file locks — agents waiting on these files can proceed.",
icon: "lock.open")
}
}
.help("Clear this chat's hold on its changed files so other agents waiting on them can proceed. "
+ "Use if a lock persists after the work stalls; the lock re-arms once this chat merges or edits again.")
}
} label: {
Label("Git", systemImage: "arrow.triangle.branch")
}
.disabled(integrating)
.help("Merge, or open this chat's worktree for any git command")
.channelBannerHeader()
Button { Task { await exportSession() } } label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.help("Export the full chat (with backend/debug info) to a file")
.channelBannerHeader()
Button(role: .destructive) { Task { await store.discardOpenSession() } } label: {
Label("Discard", systemImage: "trash")
}
.channelBannerHeader()
}
}
.alert("Rename chat", isPresented: $renaming) {
TextField("Name", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { Task { await store.renameOpenSession(to: renameDraft) } }
}
.confirmationDialog(
"Revert to this message?",
isPresented: Binding(get: { revertSeq != nil }, set: { if !$0 { revertSeq = nil } }),
presenting: revertSeq
) { seq in
Button("Revert & Edit", role: .destructive) { Task { await performRevert(seq) } }
Button("Cancel", role: .cancel) { revertSeq = nil }
} message: { _ in
Text("This deletes this message and everything after it and rewinds the agent's "
+ "memory to before it. Your files are left unchanged. The message goes back "
+ "into the composer so you can edit and resend it.")
}
}
/// Perform the revert and seed the composer with the reverted message for editing.
private func performRevert(_ seq: UInt64) async {
revertSeq = nil
if let text = await store.revertOpenSession(toSeq: seq) {
draft = text
}
}
private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }
/// Menu item: checkmark on the selected value, a "house" on the app default.
@ViewBuilder
private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
let title = Text(display)
+ (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
+ (isDefault ? Text(" (default)") : Text(""))
if isSelected {
Label { title } icon: { Image(systemName: "checkmark") }
} else if isDefault {
Label { title } icon: { Image(systemName: "house") }
} else {
title
}
}
private var modelMenu: some View {
Menu {
// A chat that leaves routed mode uses this same-backend picker. The Intelligence
// slider remains available only while routing provenance is present.
// A session's backend is fixed at creation, so only offer same-backend models.
ForEach(ModelCatalog.models(for: session?.backend ?? .claudeCode), id: \.self) { sku in
Button { Task { await store.setOpenSessionModel(sku) } } label: {
choiceLabel(ModelCatalog.displayName(sku),
badge: ModelCatalog.contextBadge(for: sku),
isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
}
}
} label: {
(Text("Model: \(ModelCatalog.displayName(effectiveModel))")
+ (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text("")))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(AppTheme.composerStroke, lineWidth: 1))
.fixedSize()
.help("Model for the next turn")
}
/// Whether the next turn runs in orchestra (orchestration) mode.
private var isOrchestra: Bool { ModelCatalog.isOrchestra(effectiveEffort) }
/// The accent for the composer's controls: the project-type accent normally (teal, or the
/// Nucleic Control lavender), swapped to the orchestra gold while orchestra is the active
/// effort — so the Auto/Merge toggles and the mode badge read as "orchestra is on" together
/// with the composer's glow and effort pill. An archived chat is inert (no glow), so it
/// keeps the plain project accent.
private var composerAccent: Color {
isOrchestra && !isArchived ? AppTheme.orchestra : palette.accent
}
/// Whether the composer wears the orchestra glow: orchestra is the active effort, the chat
/// isn't archived (inert), and a full turn hasn't yet elapsed under orchestra — the glow is an
/// announcement that fades once you've run a turn with it (see the glow state above).
private var showsOrchestraGlow: Bool {
(sliderOrchestraPreview ?? sliderOrchestraSelected)
&& !isArchived && !orchestraTurnElapsed
}
/// Whether the open session's project is under Nucleic Control — the gate for Orchestra.
/// Resolved through `projectSummary` so a Control chat on a peer Mac themes and gates
/// identically to a local one (mesh session sync; the owner applies the real control gate).
private var isControlProject: Bool {
session.flatMap { store.projectSummary($0.projectID) }?.isNucleicControlled == true
}
private var effortMenu: some View {
Menu {
ForEach(
ModelCatalog.efforts(for: effectiveModel, codexPro: store.isCodexPro),
id: \.self
) { level in
Button { Task { await store.setOpenSessionEffort(level) } } label: {
choiceLabel(ModelCatalog.effortDisplayName(level),
isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
}
}
// Orchestra is an orchestration mode, not an API level — set it apart below the
// canonical efforts (xhigh + standing consent to fan out to parallel subagents).
// Switching it affects the next turn and remains gated to Control projects.
Divider()
Button { Task { await store.setOpenSessionEffort(ModelCatalog.orchestraEffort) } } label: {
orchestraMenuItem(available: isControlProject)
}
.disabled(!isControlProject)
.help(!isControlProject
? ModelCatalog.orchestraRequiresControlHelp
: ModelCatalog.orchestraBlurb)
} label: {
Group {
if isOrchestra {
OrchestraEffortLabel()
} else {
Text("\(ModelCatalog.effortNoun(for: effectiveModel)): \(ModelCatalog.effortDisplayName(effectiveEffort))")
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(
isOrchestra ? AnyShapeStyle(AppTheme.orchestra.opacity(0.6)) : AnyShapeStyle(AppTheme.composerStroke),
lineWidth: 1))
.fixedSize()
.help(isOrchestra ? ModelCatalog.orchestraBlurb : "Reasoning effort for the next turn")
}
/// The Orchestra row in the effort menu: a sparkles glyph (checkmark when selected),
/// "Orchestra", and either "(default)", or — when unavailable because the project isn't
/// under Nucleic Control — a grayed "Requires Nucleic Control" note.
@ViewBuilder
private func orchestraMenuItem(available: Bool) -> some View {
let isDefault = ModelCatalog.isOrchestra(defaultEffort)
Label {
Text("Orchestra")
+ (available
? (isDefault ? Text(" (default)") : Text(""))
: Text(" — \(ModelCatalog.orchestraRequiresControlNote)").foregroundColor(.secondary))
} icon: {
Image(systemName: isOrchestra ? "checkmark" : OrchestraStyle.symbol)
}
}
// MARK: - Intelligence routing (in-session)
/// Whether this chat's backend can be routed at all: the matrix only covers the Claude
/// and Codex lanes — the ACP wrappers' "reasoning" is cosmetic, so they keep menus.
private var backendSupportsRouting: Bool {
guard let backend = session?.backend else { return false }
return !ModelCatalog.autoReasoningBackends.contains(backend)
}
/// The Intelligence slider replaces the classic model/reasoning pair in every in-session
/// composer whose backend has a real routing matrix. Existing pre-routing chats place the
/// rail at the closest match for their already-fixed pair and acquire provenance only when
/// the user deliberately moves it.
private var intelligenceRoutingActive: Bool {
intelligenceSliderEnabled && backendSupportsRouting
}
private var recordedIntelligenceLevel: IntelligenceLevel {
if isOrchestra { return .max }
guard let raw = session?.routedLevel,
let level = IntelligenceLevel(rawValue: raw)
else {
return closestSessionIntelligenceLevel(
toModel: effectiveModel,
effort: effectiveEffort,
purpose: lockedRoutedPurpose)
}
return level
}
private var routedPurpose: PromptPurpose {
lockedRoutedPurpose
}
private var degradedRoutingProviders: Set<BackendID> {
var degraded = Set<BackendID>()
for feed in store.statusFeeds where feed.hasActiveIncident {
switch feed.provider {
case .claude: degraded.insert(.claudeCode)
case .openai: degraded.insert(.codex)
case .xai: degraded.insert(.grok)
}
}
return degraded
}
private func routedResolution(
for level: IntelligenceLevel,
purpose: PromptPurpose? = nil
) -> IntelligenceRouter.Resolution? {
guard let backend = session?.backend else { return nil }
return IntelligenceRouter.route(
purpose: purpose ?? routedPurpose,
level: level,
connected: store.connectedProviders,
backendLock: backend,
degraded: degradedRoutingProviders,
limits: store.intelligenceRoutingLimits,
codexPro: store.isCodexPro,
fallback: (
effectiveModel,
OrchestrationMode.resolvedEffort(effectiveEffort)
?? ModelCatalog.fallbackEffort))
}
private var sessionIntelligenceLevelBinding: Binding<IntelligenceLevel> {
Binding(
get: {
guard let selection = composerModelOverride?.selection else {
return sliderIntelligenceLevel
}
return closestSessionIntelligenceLevel(
toModel: selection.model,
effort: selection.effort,
purpose: routedPurpose)
},
set: { next in
guard composerModelOverride == nil else { return }
clearSessionManualSelection()
setOptimisticSessionRoute(for: next)
sliderIntelligenceLevel = next
Task { await applySessionIntelligenceLevel(next) }
})
}
private var sessionOrchestraTierBinding: Binding<IntelligenceOrchestraTier?> {
Binding(
get: {
composerModelOverride == nil && sliderOrchestraSelected ? .orchestra : nil
},
set: { tier in
guard composerModelOverride == nil else { return }
clearSessionManualSelection()
clearSessionRoutedSelection()
sliderOrchestraSelected = tier != nil
guard tier != nil else { return }
sliderIntelligenceLevel = .max
Task { await activateSessionOrchestra() }
})
}
/// ⌘⌥↑/⌘⌥→ and ⌘⌥↓/⌘⌥← from the message field, moving the rail exactly as its own arrows do
/// (`IntelligenceRailStep` is shared, so the Max ⇄ Orchestra half-step behaves identically).
/// Inert in an archived chat, matching the rail, which is disabled there.
private func stepSessionIntelligence(_ delta: Int) {
guard session != nil, !isArchived, composerModelOverride == nil else { return }
guard let next = IntelligenceRailStep.stepped(
from: sliderIntelligenceLevel,
orchestraTier: sliderOrchestraSelected ? .orchestra : nil,
delta: delta,
orchestraAvailable: isControlProject)
else { return }
if let tier = next.orchestraTier {
sessionOrchestraTierBinding.wrappedValue = tier
} else {
// Clearing the tier alone leaves the level untouched (its setter returns early on
// nil), so the step off Orchestra has to state the level it lands on.
sessionOrchestraTierBinding.wrappedValue = nil
sessionIntelligenceLevelBinding.wrappedValue = next.level
}
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now)
}
private func applySessionIntelligenceLevel(_ level: IntelligenceLevel) async {
guard let resolution = routedResolution(for: level) else { return }
guard resolution.isAvailable else {
store.lastError = resolution.reason
clearSessionRoutedSelection()
sliderIntelligenceLevel = recordedIntelligenceLevel
sliderOrchestraSelected = isOrchestra
return
}
await store.setOpenSessionModel(resolution.model)
await store.setOpenSessionEffort(resolution.effort)
await store.setOpenSessionRoutingNote(RoutingNote(resolution))
reconcileSessionSelections()
}
private func activateSessionOrchestra() async {
guard isControlProject,
let resolution = routedResolution(for: .max)
else { return }
clearSessionManualSelection()
clearSessionRoutedSelection()
// Max supplies the supervisor; the resisted overrun adds managed subagents to it.
// Keep the session's backend lane fixed, just as every other in-session level does.
await store.setOpenSessionModel(resolution.model)
await store.setOpenSessionEffort(ModelCatalog.orchestraEffort)
await store.setOpenSessionRoutingNote(RoutingNote(
purpose: routedPurpose,
level: .max,
reason: "\(routedPurpose.displayName) · Orchestra — Max with managed subagents"))
}
/// Whether the chat's own lane is inside a reached quota window right now — the
/// provider as a whole, or this model's family. Used to hold a send until the user
/// changes the Intelligence level or the window resets.
private var sessionQuotaBlocked: Bool {
let limits = store.intelligenceRoutingLimits
let lane = session?.backend == .codexExec ? BackendID.codex : session?.backend
if let lane, limits.providers.contains(lane) { return true }
return limits.contains(model: sessionPreviewModel)
}
private var hasOptimisticSessionManualSelection: Bool {
sliderManualModel != nil && sliderManualEffort != nil
}
private var hasOptimisticSessionRoutedSelection: Bool {
sliderRoutedModel != nil && sliderRoutedEffort != nil
}
/// Existing chats cannot switch provider/backend, so only directives naming a model from the
/// chat's current backend are recognized. Invalid syntax remains ordinary prompt text.
private var composerModelOverride: ComposerModelOverride? {
guard let backend = session?.backend else { return nil }
return modelOverrideCache.value(
in: draft,
codexPro: store.isCodexPro,
allowedModels: ModelCatalog.models(for: backend))
}
private var composerOverrideLockReason: String? {
guard let selection = composerModelOverride?.selection else { return nil }
return "Locked by /override: \(ModelCatalog.displayName(selection.model)) · "
+ ModelCatalog.effortDisplayName(selection.effort)
}
/// A missing routing note means this existing chat's concrete pair is intentionally pinned.
/// The optimistic state covers the short interval before that persisted change arrives.
private var sessionManualSelectionActive: Bool {
!hasOptimisticSessionRoutedSelection
&& (composerModelOverride != nil
|| hasOptimisticSessionManualSelection
|| session?.routedPurpose == nil)
}
private var sessionPreviewResolution: IntelligenceRouter.Resolution? {
routedResolution(for: sliderIntelligenceLevelPreview ?? sliderIntelligenceLevel)
}
private var sessionPreviewOrchestraActive: Bool {
sliderOrchestraPreview ?? sliderOrchestraSelected
}
private var sessionPreviewModel: String {
if sliderIntelligenceLevelPreview == nil,
let model = composerModelOverride?.selection.model
{
return model
}
if sessionPreviewOrchestraActive {
return routedResolution(for: .max)?.model ?? effectiveModel
}
if sliderIntelligenceLevelPreview != nil {
return sessionPreviewResolution?.model ?? effectiveModel
}
if hasOptimisticSessionManualSelection, let sliderManualModel {
return sliderManualModel
}
if hasOptimisticSessionRoutedSelection, let sliderRoutedModel {
return sliderRoutedModel
}
return effectiveModel
}
private var sessionPreviewEffort: String {
if sliderIntelligenceLevelPreview == nil,
let effort = composerModelOverride?.selection.effort
{
return effort
}
if sessionPreviewOrchestraActive { return ModelCatalog.orchestraEffort }
if sliderIntelligenceLevelPreview != nil {
return sessionPreviewResolution?.effort ?? effectiveEffort
}
if hasOptimisticSessionManualSelection, let sliderManualEffort {
return sliderManualEffort
}
if hasOptimisticSessionRoutedSelection, let sliderRoutedEffort {
return sliderRoutedEffort
}
return effectiveEffort
}
/// The resolved route spoken as part of the rail's accessibility value — the same fact the
/// subtext below it renders, carried on the control so assistive tech needn't go find it.
private var sessionRouteAccessibilityDescription: String {
"\(ModelCatalog.displayName(sessionPreviewModel)), "
+ "\(ModelCatalog.effortNoun(for: sessionPreviewModel)) "
+ ModelCatalog.effortDisplayName(sessionPreviewEffort)
}
/// The same fixed-footprint subtext as the new-chat composer, with a transparent Menu over
/// the resolved words so becoming interactive adds no chrome, indicator, or spacing change.
private var sessionRouteSelectionPreview: some View {
let model = sessionPreviewModel
let effort = sessionPreviewEffort
return IntelligenceRoutePreview(model: model, effort: effort, isPending: false)
.overlay {
if !sessionPreviewOrchestraActive, composerModelOverride == nil {
Menu {
sessionRouteSelectionMenu(currentModel: model, currentEffort: effort)
} label: {
Rectangle()
.fill(.clear)
.contentShape(Rectangle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.buttonStyle(.plain)
.accessibilityLabel("Choose model and effort")
.help(sessionRouteSelectionHelp(model: model, effort: effort))
}
}
}
@ViewBuilder
private func sessionRouteSelectionMenu(
currentModel: String,
currentEffort: String
) -> some View {
Menu("Model: \(ModelCatalog.displayName(currentModel))") {
ForEach(ModelCatalog.models(for: session?.backend ?? .claudeCode), id: \.self) { sku in
Button {
applySessionManualSelection(model: sku, effort: currentEffort)
} label: {
choiceLabel(
ModelCatalog.displayName(sku),
badge: ModelCatalog.contextBadge(for: sku),
isSelected: sku == currentModel,
isDefault: sku == defaultModel)
}
}
}
Menu(
"\(ModelCatalog.effortNoun(for: currentModel)): "
+ ModelCatalog.effortDisplayName(currentEffort).localizedCapitalized
) {
ForEach(
ModelCatalog.efforts(for: currentModel, codexPro: store.isCodexPro),
id: \.self
) { level in
Button {
applySessionManualSelection(model: currentModel, effort: level)
} label: {
choiceLabel(
ModelCatalog.effortDisplayName(level),
isSelected: level == currentEffort,
isDefault: level == defaultEffort)
}
}
}
if sessionManualSelectionActive {
Divider()
Button("Use Automatic Routing", systemImage: OrchestraStyle.symbol) {
clearSessionManualSelection()
setOptimisticSessionRoute(for: sliderIntelligenceLevel)
Task { await applySessionIntelligenceLevel(sliderIntelligenceLevel) }
}
}
}
private func sessionRouteSelectionHelp(model: String, effort: String) -> String {
let choice = "Next turn: \(ModelCatalog.displayName(model)) · "
+ "\(ModelCatalog.effortDisplayName(effort)). "
if sessionManualSelectionActive {
return choice
+ "Manually selected. Click to choose a model or effort; move the Intelligence "
+ "bar to resume routing with this chat's locked purpose."
}
let reason = session?.routedReason.map { "\($0). " } ?? ""
return choice + reason
+ "This chat keeps its original purpose and does not reclassify later messages. "
+ "Click to choose a model or effort."
}
private func applySessionManualSelection(model: String, effort: String) {
let supportedEffort = closestSessionSupportedEffort(to: effort, for: model)
sliderIntelligenceLevelPreview = nil
sliderOrchestraPreview = nil
sliderOrchestraSelected = false
clearSessionRoutedSelection()
sliderManualModel = model
sliderManualEffort = supportedEffort
sliderIntelligenceLevel = closestSessionIntelligenceLevel(
toModel: model,
effort: supportedEffort,
purpose: routedPurpose)
Task {
await store.setOpenSessionRoutingNote(nil)
await store.setOpenSessionModel(model)
await store.setOpenSessionEffort(supportedEffort)
}
}
private func clearSessionManualSelection() {
sliderManualModel = nil
sliderManualEffort = nil
}
private func setOptimisticSessionRoute(for level: IntelligenceLevel) {
guard let resolution = routedResolution(for: level), resolution.isAvailable else {
clearSessionRoutedSelection()
return
}
sliderRoutedModel = resolution.model
sliderRoutedEffort = resolution.effort
}
private func clearSessionRoutedSelection() {
sliderRoutedModel = nil
sliderRoutedEffort = nil
}
private func reconcileSessionSelections() {
if sliderManualModel == effectiveModel,
sliderManualEffort == effectiveEffort,
session?.routedPurpose == nil
{
clearSessionManualSelection()
}
if sliderRoutedModel == effectiveModel,
sliderRoutedEffort == effectiveEffort,
session?.routedPurpose != nil
{
clearSessionRoutedSelection()
}
}
private func closestSessionSupportedEffort(to effort: String, for model: String) -> String {
let supported = ModelCatalog.efforts(for: model, codexPro: store.isCodexPro)
if supported.contains(effort) { return effort }
return supported.min {
let lhsDistance = abs(sessionEffortRank($0) - sessionEffortRank(effort))
let rhsDistance = abs(sessionEffortRank($1) - sessionEffortRank(effort))
return lhsDistance == rhsDistance
? sessionEffortRank($0) < sessionEffortRank($1)
: lhsDistance < rhsDistance
} ?? ModelCatalog.clampedEffort(effort, for: model, codexPro: store.isCodexPro)
}
private func closestSessionIntelligenceLevel(
toModel model: String,
effort: String,
purpose: PromptPurpose
) -> IntelligenceLevel {
let requestedBackend = BackendID.forModel(model)
var closest = sliderIntelligenceLevel
var closestScore: [Int]?
for level in IntelligenceLevel.allCases {
guard let candidate = routedResolution(for: level, purpose: purpose) else { continue }
let score = [
candidate.model == model ? 0 : 1,
BackendID.forModel(candidate.model) == requestedBackend ? 0 : 1,
abs(IntelligenceRouter.costTier(of: candidate.model)
- IntelligenceRouter.costTier(of: model)),
abs(sessionEffortRank(candidate.effort) - sessionEffortRank(effort)),
abs(level.rawValue - sliderIntelligenceLevel.rawValue),
]
if closestScore == nil || score.lexicographicallyPrecedes(closestScore!) {
closest = level
closestScore = score
}
}
return closest
}
private func sessionEffortRank(_ effort: String) -> Int {
switch effort {
case "low": return 0
case "medium": return 1
case "high", "auto": return 2
case "xhigh": return 3
case "max": return 4
case ModelCatalog.proEffort: return 5
default: return 2
}
}
/// Optical trim on the Intelligence stack. The rail's 28pt frame carries dead space above the
/// track as well as below it, so a small negative top inset closes the gap to the text field —
/// small, because taking all of that space made the rail crowd the field — while the caption
/// is handed a little room beneath, where it would otherwise sit nearly on the card's bottom
/// edge. Two values because the ends want different amounts; both are applied to the reserved
/// footprint as well, so its invariant height stays exact.
private static let intelligenceStackLift: CGFloat = 1
private static let intelligenceCaptionBottomInset: CGFloat = 3
/// Gap between the Intelligence rail and the model·effort caption it resolves to. Negative on
/// purpose: the rail is drawn centered in a 28pt frame sized for its 20pt nub, so it carries
/// ~10pt of its own empty space below the track. Pulling the caption up through that dead
/// space sets the two a few points apart optically, which is what the eye reads as spacing —
/// a positive gap here left the pair looking detached. Shared with the reserved footprint
/// below so the invariant height stays exact.
private static let intelligenceCaptionSpacing: CGFloat = -5
/// Type size for the composer's flat mode controls (Auto, Ship, its branch menu). A step down
/// from the row's `.callout`: with the borders and fills gone these read as quiet labels
/// rather than buttons, and the smaller size is what lets the row lose height without the
/// words crowding each other.
private static let composerControlFont: Font = .subheadline
private var autoToggle: some View {
// Nucleic Control chats always run autonomously, so Auto is forced on and the toggle
// is locked there (nvrsion can't function without it). Off-control it's a normal toggle.
let locked = isControlProject
let on = autoOn
let lockedHelp = "Auto-approve is always on for Nucleic Control chats — they run autonomously."
return Button {
Task { await store.setOpenSessionAuto(!on) }
} label: {
Label("Auto", systemImage: on ? "bolt.fill" : "bolt.slash")
.foregroundStyle(on ? composerAccent : Color.secondary)
// Flat and tight (see `composerControlFont`): no fill, no border, and barely any
// vertical padding — the glyph and the accent carry the state, and the hit area
// still clears the pointer target with the horizontal inset alone.
.font(Self.composerControlFont)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(locked)
.fixedSize()
.help(locked
? lockedHelp
: (on
? "Auto-approve mode on: Claude auto-approves safe actions; destructive ones still ask."
: "Manual approvals: every gated tool asks first."))
// macOS suppresses .help on disabled controls, so overlay a transparent hit area to
// carry the explanation when Auto is locked on under Nucleic Control.
.overlay {
if locked {
Color.clear
.contentShape(Rectangle())
.help(lockedHelp)
}
}
}
/// Combined Merge toggle + destination picker, rendered as one bordered unit so the
/// "Merge" action and the branch it ships into read as a single control. The left half
/// toggles autoship; once autoship is on, a divider and the destination menu slide out
/// within the same border. The destination defaults to the project's autoship branch
/// (the project root repo) and can be redirected to any branch; "Project default"
/// clears the override.
private var shipControl: some View {
let on = session?.autoShip ?? false
let project = session.flatMap { store.project($0.projectID) }
// Autoship is a Nucleic Control capability. Enabling it is blocked off-control; a
// stale "on" (persisted before this gate, or a project that left control) can still
// be turned off, so only disable the toggle when off-control AND currently off.
let controlled = project?.isNucleicControlled == true
let inherited = project?.resolvedAutoShipBranch.value ?? "main"
let override = session?.shipBranch
let current = (override?.isEmpty == false) ? override! : inherited
return HStack(spacing: 0) {
Button {
Task { await store.setOpenSessionAutoShip(!on) }
} label: {
Label("Ship", systemImage: on ? "shippingbox.fill" : "shippingbox")
.foregroundStyle(on ? composerAccent : Color.secondary)
.font(Self.composerControlFont)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!controlled && !on)
.help(!controlled
? "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it."
: (on
? "Autoship on: when the agent finishes, squash-merge this branch into \(current) via the merge queue (keeps Auto on)."
: "Autoship off: finished work waits for a manual merge."))
// macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
// transparent hit area to carry the explanation when Merge is grayed out off-control.
.overlay {
if !controlled && !on {
Color.clear
.contentShape(Rectangle())
.help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
}
}
// The destination is redirectable only on the classic per-session-branch path. Under
// nvrsion there is a single shared trunk promoted at the project level, so there's no
// per-session destination to choose — show just the Merge toggle, no branch picker.
if on && !nvrsionActive {
// With the shared border gone, this hairline is the only thing binding Ship to
// the branch it ships into — so it stays, trimmed to the shorter row.
Divider().frame(height: 11)
shipDestinationMenu(inherited: inherited, override: override, current: current)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
.fixedSize()
}
/// Destination-branch menu nested inside `shipControl`'s shared border.
private func shipDestinationMenu(inherited: String, override: String?, current: String) -> some View {
Menu {
Button {
Task { await store.setOpenSessionShipBranch(nil) }
} label: {
if override?.isEmpty != false {
Label("Project default (\(inherited))", systemImage: "checkmark")
} else {
Text("Project default (\(inherited))")
}
}
if !shipBranches.isEmpty { Divider() }
ForEach(shipBranches, id: \.self) { name in
Button {
Task { await store.setOpenSessionShipBranch(name) }
} label: {
if name == override {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.merge")
Text(current)
Image(systemName: "chevron.down").font(.caption2)
}
.font(Self.composerControlFont)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.contentShape(Rectangle())
}
.menuStyle(.button)
.buttonStyle(.plain)
.help("Destination branch autoship merges this chat into. Defaults to the project's "
+ "autoship branch (the project root repo).")
}
/// Live autoship status for the open session (from the merge queue), as a header pill.
@ViewBuilder private var shipStatusPill: some View {
if let id = session?.id, let status = store.shipStatuses[id], let (text, icon) = shipStatusLabel(status) {
// A conflict/failure is shown in the attention color so it reads as "needs
// attention" — a conflict no longer turns autoship off, so the Merge toggle stays
// on and this pill is the prominent in-header flag. Other statuses stay muted.
let needsAttention: Bool = {
switch status {
case .conflicted, .failed: return true
default: return false
}
}()
Label(text, systemImage: icon)
.font(.caption)
.foregroundStyle(needsAttention ? palette.attention : .secondary)
.help("Autoship status")
}
}
/// Shown when this session chose "Wait for Access" and is queued behind another
/// agent's lock; it clears automatically once the agent is granted and resumes.
@ViewBuilder private var waitingForAccessPill: some View {
if let id = session?.id, store.sessionsWaitingForAccess.contains(id) {
Label("Waiting for access", systemImage: "hourglass")
.font(.caption)
.foregroundStyle(.secondary)
.help("Queued until the conflicting work merges, then resumes automatically.")
}
}
/// Cumulative tokens (input + output) across every completed turn in the open
/// session — a rough running tally of total token spend. `nil` until a turn
/// reports usage. Summed from `turnCompleted` only (not `runFinished`, which would
/// double-count the same turns).
private var sessionTokens: Int? {
projectionCache.value.metadata.sessionTokens
}
/// Start instant of the current/most-recent turn — the last user message in the
/// transcript, falling back to the session's last-message timestamp. Anchors the
/// live elapsed timer while the agent works.
private var currentTurnStart: Date? {
projectionCache.value.metadata.currentTurnStart ?? session?.lastUserMessageAt
}
/// Wall-clock duration of the most recent finished run, in milliseconds. Shown as a
/// static elapsed time once the agent is idle.
private var lastRunDurationMs: Int? {
projectionCache.value.metadata.lastRunDurationMs
}
/// Compact token count, e.g. `1.2k` / `3.4M`.
private func formatTokens(_ n: Int) -> String {
if n >= 1_000_000 { return String(format: "%.1fM", Double(n) / 1_000_000) }
if n >= 1_000 { return String(format: "%.1fk", Double(n) / 1_000) }
return "\(n)"
}
/// `m:ss` (or `h:mm:ss` past an hour) for an elapsed-seconds value.
private func formatDuration(_ seconds: Int) -> String {
let s = max(0, seconds)
if s >= 3600 {
return String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60)
}
return String(format: "%d:%02d", s / 60, s % 60)
}
/// Running token tally next to the status word.
@ViewBuilder private var tokenCounter: some View {
if let tokens = sessionTokens {
Text("\(formatTokens(tokens)) tok")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.help("Total tokens (input + output) used this session")
}
}
/// Elapsed-time readout next to the status word: ticks live while the agent works,
/// then settles on the last run's total duration once it's idle.
@ViewBuilder private var turnTimer: some View {
if isBusy, let start = currentTurnStart {
TimelineView(.periodic(from: Date(), by: 1)) { context in
Label(
formatDuration(Int(context.date.timeIntervalSince(start))),
systemImage: "clock")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.help("Elapsed time for the running turn")
}
} else if let ms = lastRunDurationMs {
Label(formatDuration(ms / 1000), systemImage: "clock")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.help("Duration of the last completed run")
}
}
private func shipStatusLabel(_ status: ShipStatus) -> (String, String)? {
switch status {
case .queued: return ("Queued to ship", "clock")
case .merging: return ("Shipping…", "shippingbox")
case .merged: return ("Shipped", "checkmark.seal")
case .conflicted: return ("Ship conflict", "exclamationmark.triangle")
case .failed: return ("Ship failed", "exclamationmark.triangle")
case .skipped: return nil
}
}
private var targetBranch: String {
guard let session, let project = store.project(session.projectID) else { return "main" }
return session.shipDestination(in: project).value
}
/// Refresh the branch list backing the autoship destination picker for the open
/// session's project.
private func loadShipBranches() async {
guard let session, let project = store.project(session.projectID) else {
shipBranches = []
return
}
shipBranches = await store.branches(in: project)
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(session?.title ?? "Session").font(.headline)
if let s = session {
HStack(spacing: 6) {
Circle().fill(palette.status(s.status, disposition: s.lastTurnDisposition))
.frame(width: 7, height: 7)
Text(s.status.label(disposition: s.lastTurnDisposition))
.font(.caption).foregroundStyle(.secondary)
tokenCounter
turnTimer
if let diff = s.diffStat, diff.filesChanged > 0 {
Text(\(diff.filesChanged) files +\(diff.added) \(diff.removed)")
.font(.caption.monospaced()).foregroundStyle(.secondary)
}
shipStatusPill
waitingForAccessPill
}
}
}
Spacer()
Button {
renameDraft = session?.title ?? ""
renaming = true
} label: {
Image(systemName: "pencil")
}
.buttonStyle(.plain).help("Rename chat")
}
.padding(12)
.background(DetailSurface(layer: .inner))
}
private func summaryCardView() -> some View {
ConversationSummaryCard(
text: store.openSummary,
summarizing: store.summarizing,
completed: store.openSession?.lastTurnDisposition == .completed,
working: isBusy || summarySettling,
isExpanded: $summaryExpanded,
onRefresh: { store.regenerateOpenSummary() })
}
/// The unsettled gap between a turn ending and its disposition being classified: the
/// session has already dropped to `.awaitingInput` (so `isBusy` is false) but the async
/// classifier hasn't yet resolved the turn to `.completed` ("Done") or a real question
/// ("Summary"). Without this the card would flash its neutral "Summary" header for the
/// fraction of a second it takes classification to land, then snap to "Done" — so hold
/// the "Working…" state across the gap and let it resolve straight to the final header.
/// Bounded by `summarizing` (spun up alongside the classifier at turn-end) so a turn that
/// leaves the disposition `nil` for good — e.g. one that ended with no prose to classify —
/// still settles onto "Summary" rather than sticking on "Working…". Mirrors the
/// `ChatCueState.pending` window the sidebar sound cues already defer on.
private var summarySettling: Bool {
session?.status == .awaitingInput
&& session?.lastTurnDisposition == nil
&& store.summarizing
}
/// The immutable projection most recently delivered by `TranscriptProjectionWorker`.
///
/// Returning this cache is intentionally O(1). In particular, a body evaluation must never
/// discover a complete startup transcript and synchronously fold it on the main actor: that was
/// the multi-second barrier after the first tail chunk. The worker task below owns all mutable
/// projection state and advances this snapshot only after its off-main pass completes.
private func projectedTranscript() -> TranscriptViewProjection {
projectionCache.value
}
/// Inputs that define one projection result. The store version is monotonic across previews,
/// authoritative replacement, reverts, and live tail updates; root/filter changes also require
/// a new immutable render snapshot.
private var currentProjectionToken: ProjectionToken {
ProjectionToken(
session: store.openSessionID,
version: store.openTranscriptVersion,
worktree: store.openSession?.worktreePath,
projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
showDebug: showDebugLines,
showLock: showLockEvents)
}
private var transcriptProjectionWorkToken: ProjectionWorkToken {
ProjectionWorkToken(
projection: currentProjectionToken,
historyAvailable: store.openTranscriptAvailableSessionID)
}
/// Project one canonical revision at foreground priority without occupying the main actor.
/// Loading never parks for a scroll gesture: native-list rows are stable and off-main projection
/// can keep advancing while AppKit handles momentum. Results from a superseded preview, filter,
/// or session are discarded. The worker drops canceled calls before they enter the fold, so
/// rapid preview publications do not leave a queue of obsolete whole-history passes behind.
private func refreshTranscriptProjection() async {
let token = currentProjectionToken
guard let sessionID = token.session,
store.openTranscriptAvailableSessionID == sessionID
else { return }
let events = store.openTranscript
// Advanced detail is an exact canonical consumer. Ordinary rendering may seed from P5's
// bounded settled prefix; while canonical hydration is still racing, retaining the seed
// is preferable to blanking the already-visible transcript.
let settledHistory = token.showDebug
&& store.openTranscriptReadySessionID == sessionID
? nil : store.openTranscriptSettledHistory
let worker = projectionCache.worker
guard let result = await worker.update(
events: events,
settledHistory: settledHistory,
worktreeRoot: token.worktree,
projectRoot: token.projectRoot,
showDebug: token.showDebug,
showLock: token.showLock,
markdownCacheWarm: markdownReadySessionID == sessionID
&& markdownReadyProjectionVersion == token.version)
else { return }
guard !Task.isCancelled,
store.openTranscriptAvailableSessionID == sessionID,
currentProjectionToken == token
else { return }
let viewResult = TranscriptViewProjection(
items: result.items,
segments: result.segments,
segmentContentRevisions: result.segmentContentRevisions,
segmentEstimates: result.segmentEstimates,
lockLines: result.lockLines,
cardPresentations: result.cardPresentations,
renderCost: result.renderCost,
usesVirtualizedLayout: result.usesVirtualizedLayout,
metadata: result.metadata,
benchmarkStatistics: result.benchmarkStatistics,
revision: TranscriptRenderRevision(
session: token.session,
transcriptVersion: token.version,
firstEventSeq: events.first?.seq,
renderedItems: result.items))
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) {
projectionCache.token = token
projectionCache.value = viewResult
transcriptProjectionReadyToken = token
}
}
/// A token that changes iff `projectedTranscript`'s inputs change.
private struct ProjectionToken: Hashable {
let session: SessionID?
let version: Int
let worktree: String?
let projectRoot: String?
let showDebug: Bool
let showLock: Bool
}
private struct ProjectionWorkToken: Hashable {
let projection: ProjectionToken
let historyAvailable: SessionID?
}
private struct TranscriptViewProjection {
var items: [TranscriptItem]
var segments: [TranscriptRenderSegment]
var segmentContentRevisions: [Int: UInt64]
var segmentEstimates: [Int: TranscriptRenderSegmentEstimate]
var lockLines: [String: [NoteLock]]
var cardPresentations: TranscriptCardPresentations
var renderCost: TranscriptRenderCost
var usesVirtualizedLayout: Bool
var metadata: TranscriptProjectionMetadata
var benchmarkStatistics: TranscriptRenderBenchmarkStatistics?
/// Changes only when the projection actually rendered by this view changes.
var revision: TranscriptRenderRevision
}
/// Reference box for the memoized projection. A plain (unobserved) class, so mutating it
/// inside `body` neither triggers a re-render nor trips "modifying state during update";
/// `@State` just keeps the same instance alive across this view's body re-evaluations.
@MainActor
private final class ProjectionCache {
var token: ProjectionToken?
var value = TranscriptViewProjection(
items: [], segments: [], segmentContentRevisions: [:], segmentEstimates: [:],
lockLines: [:],
cardPresentations: .empty, renderCost: .empty, usesVirtualizedLayout: false,
metadata: .empty,
benchmarkStatistics: nil,
revision: TranscriptRenderRevision(
session: nil, transcriptVersion: 0, renderedItems: []))
/// Single-owner mutable projection state lives on its own actor. Replacing the worker on a
/// chat switch lets the new foreground request run immediately even if a canceled giant
/// projection from the previous chat is still unwinding on another executor thread.
var worker = TranscriptProjectionWorker()
private let signposter = OSSignposter(
subsystem: "com.nucleic", category: "transcript-performance")
private let logger = Logger(
subsystem: "com.nucleic", category: "transcript-performance")
private var firstPaintSession: SessionID?
private var firstPaintInterval: OSSignpostIntervalState?
/// Runtime length lookup is intentionally unobserved and dictionary-backed. The former
/// array scan plus `@State` write for every measured chunk rebuilt the entire List and
/// approached quadratic work while scrolling through a long transcript.
private var segmentHeights:
[TranscriptRenderLengthContext: [TranscriptRenderSegmentIdentity: Double]] = [:]
/// Segments whose Markdown finished a warm pass and mounted real content at least once.
/// Row readiness used to live only in each recycled row's `@State`; when the table
/// released a scrolled-away row, that state vanished and scrolling back re-showed the
/// placeholder, replaying the placeholder→content height change as a visible jump.
/// Unobserved on purpose — rows consult it while they mount, never through the List body.
private var readySegments: Set<TranscriptRenderSegmentIdentity> = []
private var lengthUpdatedAt: [TranscriptRenderLengthContext: Date] = [:]
/// Per-session debounce: a streaming tail can report several intermediate heights per
/// second. Persist only the last settled measurement, and never perform sidecar I/O on the
/// main actor.
private var lengthSaveTasks: [SessionID: Task<Void, Never>] = [:]
func beginFirstPaint(session: SessionID?) {
if let interval = firstPaintInterval {
signposter.endInterval("TranscriptFirstPaint", interval)
}
firstPaintSession = session
firstPaintInterval = session == nil
? nil
: signposter.beginInterval("TranscriptFirstPaint")
}
func endFirstPaint(session: SessionID?) {
guard session == firstPaintSession,
let interval = firstPaintInterval
else { return }
firstPaintInterval = nil
firstPaintSession = nil
signposter.endInterval("TranscriptFirstPaint", interval)
let rows = value.items.count
let virtualized = value.usesVirtualizedLayout
let benchmark = TranscriptRenderBenchmarkConfiguration.current
if benchmark.isEnabled, let stats = value.benchmarkStatistics {
logger.info(
"benchmark_first_paint variant=\(benchmark.variant.rawValue, privacy: .public) projected_rows=\(stats.topLevelRowCount) rendered_rows=\(rows) virtualized=\(virtualized) render_weight=\(self.value.renderCost.totalWeight) max_row_weight=\(self.value.renderCost.maximumRowWeight) markdown_cache=\(self.value.renderCost.markdownCacheState.rawValue, privacy: .public) presentation_misses=\(self.value.renderCost.coldPresentationCount) nested_children=\(stats.nestedChildCount) max_depth=\(stats.maximumNestedDepth) tool_calls=\(stats.toolCallCount) max_group=\(stats.maximumGroupSize) visible_markdown_bytes=\(stats.visibleMarkdownBytes) card_kinds=\(stats.cardKindSummary, privacy: .public)")
} else {
logger.info(
"first_paint rows=\(rows) virtualized=\(virtualized) render_weight=\(self.value.renderCost.totalWeight) max_row_weight=\(self.value.renderCost.maximumRowWeight)")
}
}
func resetProjection() {
token = nil
value = TranscriptViewProjection(
items: [], segments: [], segmentContentRevisions: [:], segmentEstimates: [:],
lockLines: [:],
cardPresentations: .empty, renderCost: .empty,
usesVirtualizedLayout: false, metadata: .empty,
benchmarkStatistics: nil,
revision: TranscriptRenderRevision(
session: nil, transcriptVersion: 0, renderedItems: []))
worker = TranscriptProjectionWorker()
}
func resetChunkHydration() {
for task in lengthSaveTasks.values { task.cancel() }
lengthSaveTasks = [:]
segmentHeights = [:]
lengthUpdatedAt = [:]
readySegments = []
}
func isSegmentReady(_ segment: TranscriptRenderSegmentIdentity) -> Bool {
readySegments.contains(segment)
}
func markSegmentReady(_ segment: TranscriptRenderSegmentIdentity) {
readySegments.insert(segment)
}
func installLengthLookup(_ lookup: TranscriptRenderLengthLookup) {
segmentHeights = lookup.heights
lengthUpdatedAt = lookup.updatedAt
}
func segmentHeight(
for segment: TranscriptRenderSegmentIdentity,
context: TranscriptRenderLengthContext
) -> Double? {
if let exact = segmentHeights[context]?[segment] { return exact }
let compatible = segmentHeights.keys
.filter { $0.isCompatible(with: context) }
.min { abs($0.contentWidth - context.contentWidth)
< abs($1.contentWidth - context.contentWidth) }
return compatible.flatMap { segmentHeights[$0]?[segment] }
}
func recordSegmentHeight(
_ height: Double,
for segment: TranscriptRenderSegmentIdentity,
context: TranscriptRenderLengthContext,
sessionID: SessionID
) {
if let previous = segmentHeights[context]?[segment], abs(previous - height) < 0.5 {
return
}
segmentHeights[context, default: [:]][segment] = height
lengthUpdatedAt[context] = Date()
// Keep width-resize churn bounded without touching observable view state.
while segmentHeights.count > TranscriptRenderLengthCache.maxLayoutsPerSession,
let oldest = lengthUpdatedAt.min(by: { $0.value < $1.value })?.key
{
segmentHeights[oldest] = nil
lengthUpdatedAt[oldest] = nil
}
lengthSaveTasks[sessionID]?.cancel()
lengthSaveTasks[sessionID] = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(450))
guard !Task.isCancelled,
let self,
let heights = self.segmentHeights[context]
else { return }
let layout = TranscriptRenderLengthLayout(
context: context,
segments: heights.map {
TranscriptRenderSegmentLength(segment: $0.key, height: $0.value)
}.sorted { $0.segment.lowerBound < $1.segment.lowerBound },
updatedAt: self.lengthUpdatedAt[context] ?? Date())
await TranscriptRenderLengthCache.shared.save(layout, sessionID: sessionID)
}
}
}
/// True once the agent's first turn has completed. The summary card stays hidden
/// until then — before the first response there's nothing to summarize (and the
/// summary itself is only generated on turn completion).
private var hasResponse: Bool {
projectionCache.value.metadata.hasResponse
}
/// Whether the floating summary card is shown: once there's a turn to recap, or
/// while the agent is working (the card then shows live "Working…" progress, which
/// is worth seeing even during the very first turn).
private var showSummaryCard: Bool { hasResponse || isBusy }
/// Whether the summary card should rest expanded for the open chat: once a turn has a
/// recap to show (idle, awaiting input) or while one is running (live progress). Used to
/// snap the card into its open-time state without the animated expand that slid the
/// transcript (see the `openSessionID`/`status` handlers in `body`).
private var summaryShouldRestExpanded: Bool {
let status = session?.status
return status == .awaitingInput || status == .running
}
/// True once the selected session has displayable canonical history, that exact revision has
/// completed off-main projection, and its first Markdown batch is warm. The startup value may
/// be a bounded tail preview; authoritative replacement follows without hiding it again.
private var transcriptLoaded: Bool {
guard let sessionID = store.openSessionID else { return false }
return store.openTranscriptAvailableSessionID == sessionID
&& transcriptProjectionReadyToken?.session == sessionID
&& markdownReadySessionID == sessionID
}
private struct MarkdownPrewarmToken: Hashable {
let session: SessionID?
let historyReady: SessionID?
let transcriptVersion: Int
let projectionReadyVersion: Int?
let busy: Bool
}
private struct MarkdownSnapshotRefreshToken: Hashable {
let session: SessionID?
let historyReady: SessionID?
let projectionReadyVersion: Int?
let busy: Bool
let revealed: Bool
let segmentsHydrated: TranscriptSegmentHydrationToken?
}
private struct TranscriptFirstLayoutToken: Hashable {
let session: SessionID?
let loaded: Bool
}
private struct TranscriptSegmentHydrationToken: Hashable {
let session: SessionID?
let revertEpoch: UInt64
let segmentCount: Int
let firstEventSeq: UInt64?
/// Whether a warm pass completed for this token still covers `other`'s history. The
/// segment count is deliberately ignored: streaming appends and boundary re-splits
/// change it many times per turn, but they never un-warm Markdown that has already
/// been parsed — only a different session, a revert, or paged-in older history
/// (a changed `firstEventSeq`) invalidates completed warmth.
func covers(_ other: TranscriptSegmentHydrationToken) -> Bool {
session == other.session
&& revertEpoch == other.revertEpoch
&& firstEventSeq == other.firstEventSeq
}
}
private struct TranscriptInitialBottomPinToken: Hashable {
let session: SessionID?
let tailSegmentID: Int?
}
private func transcriptSegmentHydrationToken(
for projection: TranscriptViewProjection
) -> TranscriptSegmentHydrationToken {
TranscriptSegmentHydrationToken(
session: projection.revision.session,
revertEpoch: session?.revertEpoch ?? 0,
segmentCount: projection.segments.count,
firstEventSeq: projection.revision.firstEventSeq)
}
/// Markdown that is part of the segment being mounted now. Descendants belong to collapsed
/// cards and are deliberately excluded from the mount's latency budget.
nonisolated private static func assistantMarkdownForMount(
in items: [TranscriptItem]
) -> [String] {
items.compactMap { item in
guard case .assistant(_, let text, _) = item else { return nil }
return text
}
}
/// Top-level Markdown that is immediately visible when a segment mounts. Card sources are
/// already identified by the off-main presentation pass, so this does not parse tool input or
/// result payloads on the main actor. Collapsed descendants remain excluded.
nonisolated private static func markdownForMount(
in items: [TranscriptItem],
cardPresentations: TranscriptCardPresentations
) -> [String] {
var bodies = assistantMarkdownForMount(in: items)
for item in items {
switch item {
case .tool(_, let call, _, _, _):
bodies.append(contentsOf:
cardPresentations.tools[call.toolCallID]?.alwaysVisibleMarkdown ?? [])
case .toolGroup(_, _, let calls):
for entry in calls {
bodies.append(contentsOf:
cardPresentations.tools[entry.call.toolCallID]?.alwaysVisibleMarkdown ?? [])
}
default:
break
}
}
return bodies
}
/// Two utility-QoS lanes keep a huge completed transcript from being serialized through one
/// CPU core, while remaining bounded so background parsing cannot swamp input/layout work.
/// Each lane preserves chronological input; `MarkdownText.prewarm` reverses it internally, so
/// both advance from newer prose toward older prose.
nonisolated private static func prewarmTopLevelMarkdown(
_ bodies: [String], sessionID: SessionID
) async {
guard bodies.count >= 24 else {
_ = await MarkdownText.prewarm(
bodies, sessionID: sessionID, diskAccess: .memoryOnly)
return
}
var lanes = [[String](), [String]()]
lanes[0].reserveCapacity((bodies.count + 1) / 2)
lanes[1].reserveCapacity(bodies.count / 2)
for (index, body) in bodies.enumerated() {
lanes[index & 1].append(body)
}
await withTaskGroup(of: Void.self) { group in
for lane in lanes where !lane.isEmpty {
group.addTask(priority: .utility) {
_ = await MarkdownText.prewarm(
lane, sessionID: sessionID, diskAccess: .memoryOnly)
}
}
}
}
private var transcript: some View {
// Gate the ScrollView's creation on loaded content, keyed by session id.
//
// `.defaultScrollAnchor(.bottom)` only takes effect at the scroll view's *first
// appearance*. The transcript loads asynchronously — it's empty when a session
// first opens — so a scroll view created up front first appears empty, anchors
// to nothing, and then content arrives scrolled to the TOP (which the live-follow
// handler then visibly scrolls down, landing partway as the layout settles).
//
// Keying by `openSessionID` and only building the scroll view once the store marks its
// authoritative initial history ready means it first appears with the initial transcript
// present, so the bottom anchor initializes at the true end rather than an empty canvas.
GeometryReader { geo in
// On a wide window the card docks in the right margin, clear of the prose;
// when the margin is too narrow it falls back to floating over the column.
let cardInMargin = showSummaryCard && summaryCardFitsInMargin(width: geo.size.width)
let progressivelyHydrates = transcriptLoaded
&& projectionCache.value.usesVirtualizedLayout
let transcriptVisible = transcriptRevealed || progressivelyHydrates
ZStack {
Group {
if transcriptLoaded {
// The hidden twin only needs to reserve space while the card floats
// over the column; a margin-docked card overlaps no content.
scrollableTranscript(reserveCardSpace: showSummaryCard && !cardInMargin,
containerWidth: geo.size.width)
.id(store.openSessionID)
} else {
Color.clear
}
}
.overlay(alignment: .top) {
if showSummaryCard {
if cardInMargin {
// Docked just past the column's trailing edge, in the margin: left
// edge pinned a gap beyond the column so it sits next to the prose
// and grows rightward into the empty margin (the breakpoint
// guarantees the margin can hold it).
summaryCardView()
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, (geo.size.width + contentMaxWidth) / 2 + summaryCardColumnGap)
.padding(.top, 14)
} else {
// Floats in the top-right corner over the transcript, aligned to
// the column's trailing edge; the hidden twin reserves its space.
summaryCardView()
.frame(maxWidth: contentMaxWidth, alignment: .trailing)
.padding(.horizontal, transcriptInset)
.padding(.top, 14)
.frame(maxWidth: .infinity)
}
}
}
// Short eager histories still settle their complete, exact-height stack
// off-screen. Long histories expose their pre-mounted tail immediately.
.opacity(transcriptVisible ? 1 : 0)
.transaction { txn in
if !transcriptRevealed, !progressivelyHydrates { txn.animation = nil }
}
// Keep a constant-cost stand-in at the bottom until the first real text is ready.
// This is a structural branch—not an opacity crossfade—so placeholder and focused
// transcript layers can never be composited in the same frame.
if !transcriptVisible {
TranscriptChunkPlaceholder(
seed: TranscriptChunkPlaceholder.seed(
for: store.openSessionID?.rawValue),
rowCount: TranscriptRenderSegmenter.rowsPerSegment,
fontSize: transcriptFontSize)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
// Rest above the floating composer, exactly like the real tail it stands in
// for — otherwise the placeholder's last rows load behind the glass and the
// transcript appears to jump up as they're replaced.
.padding(.bottom, transcriptTailInset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
}
}
// End the first-paint interval only after both independent gates have crossed. This
// records through the first visible, bottom-pinned text rather than merely through the
// store's history load or the scroll surface's hidden settling passes.
.onChange(of: transcriptRevealed) { _, revealed in
if revealed, transcriptLoaded {
projectionCache.endFirstPaint(session: store.openSessionID)
}
}
.onChange(of: transcriptLoaded) { _, loaded in
if loaded, transcriptRevealed {
projectionCache.endFirstPaint(session: store.openSessionID)
}
}
}
// The selection handler above synchronously resets scroll/reveal state before any async
// history can arrive. Start the first-paint interval and optional geometry-cache read here;
// neither operation is allowed to withhold the transcript itself.
.task(id: store.openSessionID) {
projectionCache.beginFirstPaint(session: store.openSessionID)
// This file is intentionally tiny and lives on a private serial executor, but it is
// only a refinement. Never let derived cache I/O withhold the canonical live tail. If
// the list has already started mounting by the time it arrives, keep the established
// geometry instead of applying late estimates underneath visible text.
guard let sessionID = store.openSessionID else { return }
let layouts = await TranscriptRenderLengthCache.shared.load(sessionID: sessionID)
let lookup = await Task.detached(priority: .utility) {
TranscriptRenderLengthLookup(layouts: layouts)
}.value
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
guard !transcriptRevealed else { return }
projectionCache.installLengthLookup(lookup)
// One early invalidation installs every cached height at once. Later measurements stay
// entirely inside ProjectionCache and never redraw the whole transcript.
transcriptLengthCacheReadySessionID = sessionID
}
// Start the settle budget when this session's rows actually exist, not when selection
// begins. The old selection-keyed timer could expire during slow I/O and reveal the newly
// created scroll surface before it had performed even one layout pass.
.task(id: TranscriptFirstLayoutToken(
session: store.openSessionID, loaded: transcriptLoaded))
{
guard transcriptLoaded else { return }
if projectedTranscript().usesVirtualizedLayout {
// The newest chunk was mounted before this list's first appearance. There is no
// complete-stack settle to wait for, so make the long-history path interactive on
// its first frame and let older chunks hydrate independently.
transcriptSettling = false
transcriptRevealed = true
return
}
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled, transcriptLoaded else { return }
transcriptSettling = false
// Fallback reveal: a chat whose height never comes to rest (it opened mid-stream)
// would otherwise stay hidden. The height-stable reveal in `scrollableTranscript`
// wins first for any chat that settles; this just caps how long the wait can run.
try? await Task.sleep(for: .milliseconds(150))
guard !Task.isCancelled, transcriptLoaded else { return }
transcriptRevealed = true
}
}
/// A phase-reported scroll gesture or its momentum. `.tracking` deliberately does not
/// count: merely touching a trackpad/mouse can stop deceleration and emit one last stale
/// geometry sample. This is NOT a complete user-scrolling signal — wheel ticks and
/// scrollbar-knob drags never report `.interacting` — so follow-state decisions combine
/// it with `transcriptUserScrollActive`, the AppKit-level ground truth from
/// `TranscriptScrollActivityMonitor`.
private var isUserScrollingTranscript: Bool {
transcriptScrollPhase == .interacting || transcriptScrollPhase == .decelerating
}
private func scrollableTranscript(reserveCardSpace: Bool, containerWidth: CGFloat) -> some View {
let projection = projectedTranscript()
let usesVirtualizedLayout = projection.usesVirtualizedLayout
let tailSegmentID = projection.segments.last?.id
return ScrollViewReader { proxy in
Group {
if usesVirtualizedLayout {
virtualizedTranscript(
projection: projection,
reserveCardSpace: reserveCardSpace,
containerWidth: containerWidth)
} else {
ScrollView {
// The short-history path keeps exact eager geometry. Its hierarchy is
// small enough that realizing every row is cheaper than native row reuse.
eagerTranscriptContent(
projection: projection,
reserveCardSpace: reserveCardSpace,
containerWidth: containerWidth)
}
}
}
// The single-argument `defaultScrollAnchor(.bottom)` governs BOTH the initial
// offset AND how the scroll view re-pins on content size changes — it sticks
// the bottom edge in view as the transcript grows. That native stickiness is
// what we want while the user is parked at the bottom (the chat opens at the
// end and follows live output), but it's also what yanked them back down
// whenever anything changed while they'd scrolled up to read history — it
// overrides any gating we do in `onChange`.
//
// So gate the anchor itself: drop it to `nil` the moment the user scrolls
// away from the bottom, which lets new content land off-screen below instead
// of dragging the viewport. It returns to `.bottom` (following resumes) once
// they're back at the end. `isScrolledToBottom` defaults true, so an opened
// chat still lands on the true bottom in one pass.
//
// This anchor now only governs the eager (short-transcript) path: the
// virtualized path is an AppKit table (`TranscriptTableView`) that owns its own
// scrolling and follow behavior, so none of these SwiftUI scroll modifiers
// reach it.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.scrollContentBackground(.hidden)
.onScrollPhaseChange { oldPhase, newPhase in
if transcriptScrollPhase != newPhase { transcriptScrollPhase = newPhase }
// A genuine new drag takes control from an in-flight programmatic jump.
if newPhase == .interacting, jumpingToTranscriptBottom {
jumpingToTranscriptBottom = false
}
// Geometry can enter `.away` because content grew while no gesture was active;
// its Equatable region then stays unchanged as a later drag begins. Apply the
// held region on the phase edge too, so that drag can still disengage following.
// The phase being *left* counts as well: a drag or its momentum can end on the
// very sample in which streamed growth changed the content height — the
// geometry handler discounts that sample as layout motion — so the gesture's
// final position is applied here, at its `.idle` edge, instead of being lost.
let phaseIsUserScrolling = newPhase == .interacting || newPhase == .decelerating
|| oldPhase == .interacting || oldPhase == .decelerating
let next = TranscriptScrollFollowPolicy.following(
current: isScrolledToBottom,
region: transcriptBottomRegion,
isSettling: transcriptSettling,
isUserScrolling: phaseIsUserScrolling,
isJumpingToBottom: jumpingToTranscriptBottom)
if next != isScrolledToBottom { isScrolledToBottom = next }
}
// Track the live scroll position straight from the scroll view's geometry
// (eager path only): how much content still sits below the viewport bottom.
// Reduce the continuously changing distance to the three policy regions in the
// transform: SwiftUI invokes the main-actor action only at a threshold crossing,
// not once per pixel.
.onScrollGeometryChange(for: TranscriptScrollFollowPolicy.Region.self) { geo in
TranscriptScrollFollowPolicy.region(distanceToBottom:
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y)
} action: { _, region in
if transcriptBottomRegion != region { transcriptBottomRegion = region }
let next = TranscriptScrollFollowPolicy.following(
current: isScrolledToBottom,
region: region,
isSettling: transcriptSettling,
isUserScrolling: isUserScrollingTranscript,
isJumpingToBottom: jumpingToTranscriptBottom)
// A same-value @State write still dirties the surrounding view graph on some
// SwiftUI releases. Only meaningful threshold crossings may invalidate it.
if next != isScrolledToBottom { isScrolledToBottom = next }
if jumpingToTranscriptBottom, region == .atBottom {
jumpingToTranscriptBottom = false
}
}
// Reveal the freshly opened transcript only once its content height has come to
// rest. On open the initial layout — and the async tool-group summary lines seeded
// for history — grow the content for a beat, and `.defaultScrollAnchor(.bottom)`
// re-pins on each growth: that re-pin is the visible on-open scroll/jitter. Track
// the height, and let `.task(id:)` debounce it — the task restarts on every change
// and only reaches its body once the height has held steady. Then pin to the true
// bottom explicitly (un-animated, the way sending a message does — the passive
// anchor alone lands a hair off on partial layout) and flip the view visible, so a
// freshly opened chat appears already parked at the tail with no motion at all.
.onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in
// Only matters until the reveal; freezing it afterward keeps live streaming
// (which changes the height every frame) from re-arming the debounce task.
// Rounded so sub-pixel layout noise can't keep it from ever settling.
if !transcriptRevealed, transcriptContentHeight != height {
transcriptContentHeight = height
}
}
.task(id: transcriptContentHeight) {
guard !transcriptRevealed, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80))
// A long virtualized transcript reveals immediately. Its first geometry sample
// can race this debounce onto the task queue; do not perform a stale delayed
// scroll after the tail is already visible.
guard !Task.isCancelled, !transcriptRevealed else { return }
scrollToEnd(proxy, animated: false)
// The user can take control as soon as the text becomes visible. Leaving the
// settling guard raised for the rest of its fallback timer made an immediate
// trackpad gesture look ignored even though the document had already landed.
transcriptSettling = false
transcriptRevealed = true
}
// Follow the revision of the *rendered tail*, after SwiftUI has installed that
// revision's rows. This is intentionally not keyed to a message count: autoship
// notes and every other standalone transcript/log entry advance `tailItemID` too.
// The fixed anchor sits after trailing status rows and padding, so "end" always
// means the bottom of the final entry of any kind. Scrolling up fails only the follow
// gate, so history reading is never yanked downward while projection and hydration
// continue independently.
// Passive follow is deliberately unanimated. The native bottom anchor supplies
// visual continuity as the last row grows; this call only closes any rounding gap.
// Animating every ~90 ms display batch started overlapping scroll animations faster
// than they could finish, competing with trackpad input and keeping text composited.
.task(id: projection.revision) {
// Yield once so the anchor has the exact position produced by this revision,
// rather than resolving against the previous layout in the update transaction.
await Task.yield()
guard !Task.isCancelled, isScrolledToBottom, !isUserScrollingTranscript else {
return
}
scrollToEnd(proxy, animated: false)
}
// Follow the floating cluster's measured height the same way. A growing composer — a
// draft wrapping onto more lines, queued-message pills, an attachment row — raises the
// glass over the transcript's tail. The tail spacer grows in the same update, but no
// projection revision accompanies it, and the passive bottom anchor alone does not
// reliably re-pin once the user has ever scrolled — so a reader parked at the bottom
// watched the composer climb over the last messages instead of pushing them up. Re-pin
// explicitly, gated exactly like the revision follow so history reading is never
// yanked down — and with the composer's own spring, since the spacer growth this
// closes up is itself animated (see the cluster's measurement) and an instant pin
// would jump the conversation ahead of the gliding glass. The virtualized table
// needs no counterpart: its coordinator applies the tail spacer's declared height
// synchronously in `apply` and glides to the bottom itself.
.task(id: transcriptTailInset) {
await Task.yield()
guard !Task.isCancelled, isScrolledToBottom, !isUserScrollingTranscript else {
return
}
withAnimation(ComposerMotion.layout(reduceMotion)) {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
}
// The tail Markdown is warm before the List exists, so its local row state begins with
// real content. Yield one native layout turn, then pin to the sentinel below every
// trailing row and the bottom padding.
.task(id: TranscriptInitialBottomPinToken(
session: store.openSessionID,
tailSegmentID: tailSegmentID))
{
guard usesVirtualizedLayout,
let sessionID = store.openSessionID,
transcriptInitialBottomPinnedSessionID != sessionID,
tailSegmentID != nil
else { return }
await Task.yield()
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
isScrolledToBottom = true
jumpingToTranscriptBottom = true
scrollToEnd(proxy, animated: false)
transcriptInitialBottomPinnedSessionID = sessionID
}
.onChange(of: isBusy) { _, busy in
if busy, isScrolledToBottom, !isUserScrollingTranscript {
scrollToEnd(proxy, animated: false)
}
}
// An approval / question pane appearing (or swapping) shrinks the transcript
// viewport from the bottom; without this the last messages stay scrolled up
// behind the pane. Re-follow the bottom so the content that prompted the gate
// is visible above it — but only when the user is already parked there, so
// scrolling up to read history isn't yanked back down. The motion clock extends
// that courtesy to scrolls the phases never report (wheel, scrollbar).
.onChange(of: store.openApprovals.first?.id) { _, id in
if id != nil, isScrolledToBottom, !isUserScrollingTranscript {
scrollToEnd(proxy, animated: false)
}
}
// An explicit jump — sending a message — always wins and immediately hands follow
// authority back to the bottom anchor. The latch prevents a stale deceleration
// callback from undoing that decision before the animated jump arrives.
.onChange(of: scrollToBottomRequest) { _, _ in
isScrolledToBottom = true
jumpingToTranscriptBottom = true
scrollToEnd(proxy)
}
// Dissolve the scrolling content into the pane where it passes under the floating
// composer. Masking the scroll surface itself — not the whole transcript layer —
// keeps the chevron below (and the summary card above) out of the gradient, and a
// mask is the only treatment that works on both pane backgrounds: a solid-color
// scrim would be a visible slab over the Ultra Glass window material.
.mask(alignment: .bottom) { transcriptBottomFade }
// Floating chevron above the chat bar, shown only while scrolled up; tap
// to jump back to the latest output. The fade in/out is animated on
// `isScrolledToBottom`, but the animation is scoped to *this overlay* only.
//
// A blanket `.animation(value: isScrolledToBottom)` on the whole ScrollView (as
// this once was) also animates the `.defaultScrollAnchor` re-pin and any layout
// shift that lands in the same transaction. While a freshly opened chat settles,
// its first layout passes can flip `isScrolledToBottom` a couple of times before
// it rests true; the blanket animation smoothed each of those flips into a 150ms
// eased scroll nudge — the tiny up-and-down jitter before the chat settled at the
// bottom. Scoping the animation to the button keeps its transition while letting
// anchor corrections snap instantly, so the chat lands at the bottom in one motion.
.overlay(alignment: .bottom) {
ZStack {
if !isScrolledToBottom {
JumpToBottomButton {
// One jump channel for both transcript paths: the request
// counter's onChange drives the eager ScrollView's proxy jump,
// and the AppKit table observes the counter directly.
scrollToBottomRequest += 1
}
// Ride above the floating cluster rather than the pane's bottom edge,
// which the composer now occupies.
.padding(.bottom, floatingComposerHeight + 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
}
}
}
/// Exact eager content for ordinary-sized chats. Keeping this path preserves the zero-estimate
/// bottom anchor and the already-smooth scrolling behavior of short transcripts.
private func eagerTranscriptContent(
projection: TranscriptViewProjection,
reserveCardSpace: Bool,
containerWidth: CGFloat
) -> some View {
VStack(alignment: .leading, spacing: 6) {
transcriptPreamble(reserveCardSpace: reserveCardSpace)
ForEach(projection.segments) { segment in
transcriptRows(
projection.items[segment.range],
cardPresentations: projection.cardPresentations)
}
transcriptTrailingRows
transcriptBottomAnchor
}
.font(.system(size: transcriptFontSize))
// Hand each tool card the lock lines folded onto it (keyed by tool-call id), so the lock
// renders as part of the edit rather than as a row that sorts after the call.
.environment(\.lockLinesByToolCall, projection.lockLines)
.environment(\.transcriptCardPresentations, projection.cardPresentations)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
.padding(.top, 14)
.frame(maxWidth: .infinity)
// Width-only animation: live transcript growth remains immediate. Suppressed during the
// initial settle so opening a chat never eases through intermediate wrapping widths.
.animation(transcriptSettling ? nil : .easeOut(duration: 0.2), value: containerWidth)
}
/// AppKit-backed row virtualization for long histories. `List` recycles off-screen batches
/// through its native table implementation and retains resolved variable row heights; unlike
/// `LazyVStack`, it does not keep revising one coarse SwiftUI height estimate while momentum is
/// in flight. Each list row is a bounded segment, limiting mount/diff work without
/// turning every tiny note into a separate native row.
private func virtualizedTranscript(
projection: TranscriptViewProjection,
reserveCardSpace: Bool,
containerWidth: CGFloat
) -> some View {
let lengthContext = transcriptLengthContext(containerWidth: containerWidth)
let renderedSessionID = projection.revision.session
let hydrationToken = transcriptSegmentHydrationToken(for: projection)
let tailSegmentID = projection.segments.last?.id
let globalContentRevision = UInt64(
bitPattern: Int64(projection.revision.transcriptVersion))
func cellRevision(_ content: UInt64) -> AnyHashable {
AnyHashable(TranscriptTableCellRevisionToken(
session: renderedSessionID,
content: content,
fontSize: transcriptFontSize,
width: Int(containerWidth.rounded()),
showDebugLines: showDebugLines,
showLockEvents: showLockEvents,
busy: isBusy,
colorSchemeIsDark: colorScheme == .dark))
}
// Once the continuous warm pass has parsed the whole history, every row can mount its
// real content synchronously: the row then realizes at its final height and there is no
// later placeholder→content swap to shift the document under an in-flight scroll.
// Coverage is monotonic (`covers`, not equality): a streaming turn appends segments and
// re-splits boundaries constantly, and requiring an exact token match made this flag
// flicker off on every one of those revisions. Each flicker demoted rows whose segment
// identity had shifted back to placeholders at cold estimated heights, then swapped
// them to content again when the pass caught up milliseconds later — the transcript
// visibly breathing/jumping under a reader parked in history while the agent worked.
_ = tailSegmentID
var rows: [TranscriptTableRow] = []
if reserveCardSpace {
rows.append(TranscriptTableRow(
id: "preamble",
contentRevision: cellRevision(globalContentRevision),
reservedHeight: 120
) { [self] in
tableRowContent(top: 14, projection: projection) {
transcriptPreamble(reserveCardSpace: true)
}
})
}
for segment in projection.segments {
let isFirst = segment.id == projection.segments.first?.id
let identity = transcriptSegmentIdentity(segment, in: projection.items)
let reservedHeight = transcriptSegmentReservedHeight(
segment, identity: identity,
estimate: projection.segmentEstimates[segment.id],
context: lengthContext)
let top: CGFloat = isFirst && !reserveCardSpace ? 14 : 3
rows.append(TranscriptTableRow(
id: "seg-\(identity.lowerBound)-\(identity.rowCount)-"
+ "\(identity.firstItemID)-\(identity.lastItemID)",
contentRevision: cellRevision(
projection.segmentContentRevisions[segment.id] ?? globalContentRevision),
reservedHeight: reservedHeight + top + 3,
onMeasured: { [self] height in
// Store the content's own height (minus the row chrome), matching what
// reservation lookups expect on the next open.
recordTranscriptSegmentHeight(
max(1, height - top - 3), identity: identity,
context: lengthContext, sessionID: renderedSessionID)
}
) { [self] in
tableRowContent(top: top, projection: projection) {
transcriptRows(
projection.items[segment.range],
cardPresentations: projection.cardPresentations)
}
})
}
if hasTranscriptTrailingRows {
let top: CGFloat = projection.segments.isEmpty && !reserveCardSpace ? 14 : 3
rows.append(TranscriptTableRow(
id: "trailing",
contentRevision: cellRevision(globalContentRevision),
reservedHeight: 44
) { [self] in
tableRowContent(top: top, projection: projection) {
VStack(alignment: .leading, spacing: 6) {
transcriptTrailingRows
}
}
})
}
let anchorNeedsTopInset = !reserveCardSpace
&& projection.segments.isEmpty
&& !hasTranscriptTrailingRows
let tailTop: CGFloat = anchorNeedsTopInset ? 14 : 3
var tailRevisionHasher = Hasher()
tailRevisionHasher.combine(globalContentRevision)
tailRevisionHasher.combine(transcriptTailInset)
tailRevisionHasher.combine(tailTop)
rows.append(TranscriptTableRow(
id: "tail-inset",
contentRevision: cellRevision(UInt64(
bitPattern: Int64(tailRevisionHasher.finalize()))),
reservedHeight: transcriptTailInset + tailTop,
// The spacer's height IS the declared inset — the coordinator applies changes to it
// synchronously (and re-pins the bottom while following), so a growing composer
// pushes the conversation up in the same pass instead of covering its tail.
isFixedHeight: true
) { [self] in
tableRowContent(top: tailTop, bottom: 0, projection: projection) {
Color.clear.frame(height: transcriptTailInset)
}
})
return TranscriptTableView(
rows: rows,
contentRevision: AnyHashable(TranscriptTableRevisionToken(
session: renderedSessionID,
transcriptVersion: projection.revision.transcriptVersion,
fontSize: transcriptFontSize,
width: Int(containerWidth.rounded()),
showDebugLines: showDebugLines,
showLockEvents: showLockEvents,
busy: isBusy,
tailInset: Int((transcriptTailInset * 2).rounded()),
colorSchemeIsDark: colorScheme == .dark)),
scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom)
.task(id: hydrationToken, priority: .utility)
{
await hydrateTranscriptSegments(
projection.segments, items: projection.items,
cardPresentations: projection.cardPresentations,
token: hydrationToken)
}
}
/// Continuously warm all top-level Markdown newest-to-oldest on two bounded utility lanes. The
/// shared pass avoids thousands of detached tasks, actor hops, signposts, and main-actor sets.
/// A visible row may warm the same tiny cache entry concurrently at user-initiated priority;
/// the background pass is never canceled or restarted merely because the user scrolls.
private func hydrateTranscriptSegments(
_ segments: [TranscriptRenderSegment],
items: [TranscriptItem],
cardPresentations: TranscriptCardPresentations,
token: TranscriptSegmentHydrationToken
) async {
guard let sessionID = store.openSessionID, !segments.isEmpty else { return }
// Drop completed warmth only when it no longer covers this history (session switch,
// revert, paged-in older events). A mere segment-count change must not reset it: the
// already-parsed Markdown is still warm, and clearing the token here would flicker
// every row's synchronous-mount fast path off for the duration of each catch-up pass.
if transcriptMarkdownReadyHydrationToken?.covers(token) != true {
transcriptMarkdownReadyHydrationToken = nil
}
let collection = Task.detached(priority: .utility) {
Self.markdownForMount(in: items, cardPresentations: cardPresentations)
}
let bodies = await withTaskCancellationHandler {
await collection.value
} onCancel: {
collection.cancel()
}
guard !Task.isCancelled, store.openSessionID == sessionID else { return }
await Self.prewarmTopLevelMarkdown(bodies, sessionID: sessionID)
guard !Task.isCancelled,
store.openSessionID == sessionID,
transcriptSegmentHydrationToken(for: projectedTranscript()) == token
else { return }
transcriptMarkdownReadyHydrationToken = token
}
/// Foreground cache fill for one native row. Readiness is returned to that row, which stores it
/// locally; completing this must never dirty the root transcript view.
private func prepareVisibleTranscriptSegment(
_ segment: TranscriptRenderSegment,
items: [TranscriptItem],
cardPresentations: TranscriptCardPresentations
) async -> Bool {
guard let sessionID = store.openSessionID else { return false }
let segmentItems = Array(items[segment.range])
let bodies = Self.markdownForMount(
in: segmentItems, cardPresentations: cardPresentations)
guard !bodies.isEmpty else { return true }
let preparation = Task.detached(priority: .userInitiated) {
await MarkdownText.prewarm(
bodies, sessionID: sessionID, diskAccess: .memoryOnly)
}
_ = await withTaskCancellationHandler {
await preparation.value
} onCancel: {
preparation.cancel()
}
return !Task.isCancelled
&& !preparation.isCancelled
&& store.openSessionID == sessionID
}
/// Composite refresh token for the AppKit transcript table. Visible row content can only
/// change when one of these inputs changes, so equal tokens let the table skip all cell
/// refresh work on unrelated body re-evaluations.
private struct TranscriptTableRevisionToken: Hashable {
let session: SessionID?
let transcriptVersion: Int
let fontSize: CGFloat
let width: Int
let showDebugLines: Bool
let showLockEvents: Bool
let busy: Bool
let tailInset: Int
let colorSchemeIsDark: Bool
}
/// Row-local counterpart to ``TranscriptTableRevisionToken``. The context fields affect every
/// row; `content` comes from the projection worker's per-segment revision so an unrelated live
/// tail does not replace a settled visible hosting tree.
private struct TranscriptTableCellRevisionToken: Hashable {
let session: SessionID?
let content: UInt64
let fontSize: CGFloat
let width: Int
let showDebugLines: Bool
let showLockEvents: Bool
let busy: Bool
let colorSchemeIsDark: Bool
}
/// One AppKit table row's SwiftUI content, carrying the reading-column layout and the
/// environment the rows relied on the `List` container to provide. An `NSHostingView`
/// does not inherit this view's environment, so everything the transcript rows read —
/// palette, panel layout, lock lines, card presentations, the base font — is injected
/// explicitly here.
private func tableRowContent<Content: View>(
top: CGFloat,
bottom: CGFloat = 3,
projection: TranscriptViewProjection,
@ViewBuilder content: () -> Content
) -> AnyView {
AnyView(
content()
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, top)
.padding(.bottom, bottom)
.font(.system(size: transcriptFontSize))
.environment(\.appPalette, palette)
.environment(panels)
.environment(\.lockLinesByToolCall, projection.lockLines)
.environment(\.transcriptCardPresentations, projection.cardPresentations))
}
private func transcriptLengthContext(containerWidth: CGFloat) -> TranscriptRenderLengthContext {
let available = max(1, containerWidth - transcriptInset * 2)
return TranscriptRenderLengthContext(
contentWidth: Int(min(contentMaxWidth, available).rounded()),
rowsPerSegment: TranscriptRenderSegmenter.rowsPerSegment,
showDebugLines: showDebugLines,
showLockEvents: showLockEvents,
revertEpoch: session?.revertEpoch ?? 0)
}
private func transcriptSegmentIdentity(
_ segment: TranscriptRenderSegment, in items: [TranscriptItem]
) -> TranscriptRenderSegmentIdentity {
let slice = items[segment.range]
return TranscriptRenderSegmentIdentity(
lowerBound: segment.range.lowerBound,
rowCount: segment.length,
firstItemID: slice.first?.id ?? "",
lastItemID: slice.last?.id ?? "")
}
/// Every segment gets a vertical reservation before its row body mounts. A same-width stored
/// measurement is exact for settled history; a nearby-width one is the next-best estimate.
/// The cold-cache fallback is deliberately based only on the segment's `length` metadata, so
/// constructing the complete canvas never has to visit a transcript row or parse its text.
private func transcriptSegmentReservedHeight(
_ segment: TranscriptRenderSegment,
identity: TranscriptRenderSegmentIdentity,
estimate: TranscriptRenderSegmentEstimate?,
context: TranscriptRenderLengthContext
) -> CGFloat {
// Establish the one observable dependency for an asynchronously installed sidecar. The
// heights themselves stay in ProjectionCache, where both lookup and recording are O(1).
_ = transcriptLengthCacheReadySessionID
if let height = projectionCache.segmentHeight(for: identity, context: context),
height.isFinite, height > 0 {
return CGFloat(height)
}
let internalSpacing = CGFloat(max(0, segment.length - 1)) * 6
let coldEstimate = estimate?.height(contentWidth: context.contentWidth)
?? Double(segment.length * 48)
return CGFloat(coldEstimate) + internalSpacing
}
private func recordTranscriptSegmentHeight(
_ measuredHeight: CGFloat,
identity: TranscriptRenderSegmentIdentity,
context: TranscriptRenderLengthContext,
sessionID: SessionID?
) {
guard let sessionID,
store.openSessionID == sessionID,
measuredHeight.isFinite, measuredHeight > 0
else { return }
// Half-point precision ignores harmless subpixel churn while retaining exact-enough native
// row geometry across reopens.
let height = Double((measuredHeight * 2).rounded() / 2)
projectionCache.recordSegmentHeight(
height, for: identity, context: context, sessionID: sessionID)
}
/// Removes every default table inset/background so a virtualized row occupies the exact same
/// centered reading column as the eager stack. Half the 6pt transcript spacing lives on each
/// adjoining native row; internal rows retain their normal 6pt `VStack` spacing.
private func virtualizedTranscriptRow<Content: View>(
top: CGFloat = 3,
bottom: CGFloat = 3,
@ViewBuilder content: () -> Content
) -> some View {
content()
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
.frame(maxWidth: .infinity, alignment: .center)
.listRowInsets(EdgeInsets(top: top, leading: 0, bottom: bottom, trailing: 0))
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
private var hasTranscriptTrailingRows: Bool { isBusy }
@ViewBuilder
private func transcriptPreamble(reserveCardSpace: Bool) -> some View {
// An invisible twin of the floating summary card: it occupies the card's exact height
// at the top so the first message is pushed below the visible card rather than covered.
// Skipped when the card is docked in the margin, where it overlaps no content.
if reserveCardSpace {
summaryCardView()
.hidden()
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
private func transcriptRows(
_ items: ArraySlice<TranscriptItem>,
cardPresentations: TranscriptCardPresentations
) -> some View {
TranscriptRenderSegmentView(
items: items,
cardPresentations: cardPresentations,
// Revert is a transcript rewrite, so it's only offered while idle — never mid-turn
// (the controller also refuses then) and never in an archived chat.
onRevert: (isBusy || isArchived) ? nil : { revertSeq = $0 },
onLogin: startClaudeLogin,
onOpenVMMonitor: { panels.revealVMMonitor() },
// Peer-owned execs run on the owner, where there is no remote Skip command yet.
onSkip: (isArchived || store.openHostID != nil)
? nil
: { call in Task { await store.skipOpenSessionToolCall(call) } },
// Stall resolution likewise acts only on a local session's in-flight command.
onResolveStall: (isArchived || store.openHostID != nil)
? nil
: { stallID, kill in
Task { await store.resolveOpenSessionStall(stallID, kill: kill) }
},
// A plan can dispatch more workers than its card lists inline.
onOpenSubagents: { panels.revealSubagents() })
.equatable()
.frame(maxWidth: .infinity, alignment: .leading)
}
@ViewBuilder
private var transcriptTrailingRows: some View {
if isBusy {
WorkingIndicator(text: progressText).id(Self.workingID)
}
}
/// What the open chat is blocked on in the file-lock queue, when it is — drives the
/// contention card in the composer's glass. Local only: peer lock state is not mirrored.
private var openLockWait: AppStore.LockWaitInfo? {
guard let id = session?.id, store.openHostID == nil else { return nil }
return store.lockWaits[id]
}
/// Whether the composer's reveal slot has anything to show — a permission/question request,
/// a lock-contention card, or both.
private var hasComposerRevealContent: Bool {
store.openApprovals.first != nil || openLockWait != nil
}
/// A real trailing element and fixed scroll target. Kept as its own final stack child so the
/// target is unambiguously below messages, notes/log lines, status rows, and bottom padding.
private var transcriptBottomAnchor: some View {
Color.clear
.frame(height: transcriptTailInset)
.id(Self.bottomAnchorID)
}
private static let workingID = "nucleic.working-indicator"
private static let bottomAnchorID = "nucleic.transcript-bottom"
private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
// Always scroll to the trailing spacer, so the resting position shows the
// final transcript entry of any kind with the full bottom padding beneath it.
if animated {
withAnimation {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
} else {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
}
/// A live, human label for what the agent is doing right now, derived from the
/// most recent transcript event.
private var progressText: String {
// Blocked on a human, not the agent: the spinner is a "waiting on you" cue.
// AskUserQuestion collects answers; ExitPlanMode is a three-way plan review.
if let approval = store.openApprovals.first {
switch approval.toolName {
case AskUserQuestion.toolName: return "Waiting for answers…"
case ExitPlanMode.toolName: return "Waiting for plan review…"
default: return "Waiting for approval…"
}
}
for event in store.openTranscript.reversed() {
// Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events
// during the model's reasoning pause — often the long gap right after an
// AskUserQuestion answer or a tool result, before any visible output. They mean the
// model is actively thinking, so surface that. Without this the walk skips every raw
// ping (and the tool result) and lands back on the last tool call, freezing the
// indicator on e.g. "Running AskUserQuestion…" for the whole (often many-second)
// thinking pause — which reads as a stall even though the model is working.
if TranscriptProjection.parseThinkingTokens(event) != nil { return "Thinking…" }
switch event.kind {
case .toolCallStarted(let call), .toolCallCompleted(let call):
return Self.gerund(for: call.name)
case .thinking:
return "Thinking…"
case .assistantText(let chunk) where chunk.isPartial:
return "Writing…"
case .toolResult:
// The tool has finished (its result landed); the model is now working on what
// comes next. Stop here rather than walking back to the call behind it and
// reporting a completed tool as still "Running <tool>…".
return "Working…"
case .userText:
// The agent hasn't produced anything since the user's message. If the shared sandbox
// container is still downloading, that — not a stuck agent — is the delay; name it.
if let download = store.containerDownload { return Self.downloadText(download) }
return "Working…"
default:
continue
}
}
if let download = store.containerDownload { return Self.downloadText(download) }
return "Working…"
}
/// The working-row label for an in-flight container download (e.g. "Downloading sandbox image
/// 42%…"), so a first turn waiting on the sandbox reads as "downloading", not a hung agent.
private static func downloadText(_ download: ContainerDownloadProgress) -> String {
if let fraction = download.fraction {
return "\(download.label) \(Int((fraction * 100).rounded()))%…"
}
return "\(download.label)…"
}
/// Turns a tool name into a present-progressive status line — "Read" → "Reading…",
/// mirroring the past-tense families in `ConversationIntelligence`. Unknown tools
/// fall back to "Running <name>…" so we never guess a malformed gerund.
private static func gerund(for name: String) -> String {
switch name {
case "Read": return "Reading…"
case "Write": return "Writing…"
case "Edit", "MultiEdit", "NotebookEdit": return "Editing…"
case "Bash", "BashOutput": return "Running…"
case "Grep", "Glob": return "Searching…"
case "WebFetch": return "Fetching…"
case "WebSearch": return "Searching the web…"
case "Task": return "Delegating…"
case MCPApprovalServer.qualifiedHostExecToolName:
return "Running a command on host…"
// The VM/container tools name where the work is happening ("Running on a macOS VM…")
// rather than echoing their `mcp__nucleic__…` wire name.
default: return SandboxToolDisplay.gerund(for: name)
?? "Running \(name)…"
}
}
private var isBusy: Bool {
guard let status = session?.status else { return false }
return status == .running || status == .awaitingApproval || status == .provisioning
}
/// Read-only branch/worktree label, pinned to the upper-right of the composer —
/// mirroring where the project/git controls sit on the home chat bar.
private var branchLabel: some View {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
Text(session?.branch ?? "—")
}
.font(.callout)
.foregroundStyle(.secondary)
.help(session?.worktreePath.map { "Worktree: \($0)" } ?? "Branch for this chat")
}
/// Replaces `branchLabel` for nvrsion sessions: a small "nvrsion" badge in the same spot,
/// in the project accent so it reads as an active mode — without surfacing the underlying
/// shared-workspace branch the way a raw branch name would.
private var nvrsionLabel: some View {
HStack(spacing: 4) {
Image(systemName: "infinity").font(.caption)
Text("nvrsion")
}
.font(.callout)
.foregroundStyle(composerAccent)
.help("nvrsion is on for this project — edits version continuously in a shared workspace.")
}
/// The branch an nvrsion promote ships into — the project's real default branch (the
/// promote target), distinct from `targetBranch`, which is the per-session autoship
/// destination and meaningless under nvrsion's shared trunk.
private var nvrsionPromoteTarget: String {
session.flatMap { store.project($0.projectID) }?.defaultBranch.value ?? "main"
}
/// Whether the service-status pill shows beside this chat's composer. Always when the
/// visibility setting is "Everywhere"; under "Only on home view" it still surfaces here when
/// the chat-incident override is on and this chat's model's provider has an active incident —
/// so an outage on the model you're actually using never stays hidden in chat.
private var showsStatusIndicator: Bool {
if StatusIndicatorVisibility(rawValue: statusVisibilityRaw) == .everywhere { return true }
guard showStatusInChatOnIncident,
let provider = StatusProvider.forModel(effectiveModel) else { return false }
return store.statusFeed(for: provider)?.hasActiveIncident == true
}
private var hasSendableContent: Bool {
let prompt = composerModelOverride?.promptText(in: draft) ?? draft
return !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| !attachments.isEmpty
}
/// Hold a fully automatic routed send when its current lane is quota-blocked. Orchestra
/// and a concrete manual pair own their choices, matching the new-chat composer.
private var routedRouteAvailable: Bool {
!intelligenceRoutingActive
|| composerModelOverride != nil
|| sessionManualSelectionActive
|| isOrchestra
|| !sessionQuotaBlocked
}
/// The send button's tooltip while quota-held (mirrors the send guard's message).
private var quotaHoldNote: String {
"\(ModelCatalog.displayName(sessionPreviewModel))'s provider has reached its usage "
+ "limit — send again after the window resets, or move the Intelligence bar "
+ "to another level."
}
/// Whether there is content and a usable routed destination.
private var canSend: Bool { hasSendableContent && routedRouteAvailable }
/// A little ink in the composer's glass — see `ComposerGlass`, shared with the floating
/// new-chat composer so the two cards read as the same surface.
private var composerGlassTint: Color { ComposerGlass.tint(colorScheme) }
/// Compensation for the glass's *inactive* appearance. When the window isn't key the system
/// stops sampling and refracting and substitutes a flat, light material — brighter than the
/// pane it sits on in dark mode, so a background window's composer glares as an opaque gray
/// bar. The material itself isn't ours to restyle, but a scrim slipped between it and the
/// card's contents is: `.background` sits above the glass and below the controls, so this
/// darkens the slab without washing out the text on it. Clear while the window is key —
/// the real glass needs no help — and clear in light mode, where the fallback is already
/// close to the surrounding pane.
private var inactiveGlassScrim: Color {
guard controlActiveState != .key, colorScheme == .dark else { return .clear }
return .black.opacity(0.42)
}
/// The pane's floating bottom furniture. A pending permission/question — and a chat parked in
/// the file-lock queue — is rendered inside the composer's glass shell, so it reads as the
/// composer growing an input section rather than a separate card appearing above it. The whole
/// shell is measured as one block — the height the transcript reserves beneath its last message
/// and anchors its fade to — so the transcript stays in step throughout the grow/collapse
/// animation.
private var floatingBottomCluster: some View {
VStack(spacing: 0) {
// This slot remains mounted while its child transitions, so the fade mask stays fixed
// at the composer's top edge instead of travelling with the request contents.
ZStack(alignment: .bottom) {
// Both cards share one reveal slot, stacked, so a lock wait that coincides with a
// permission request grows the same glass instead of opening a second surface.
VStack(spacing: 8) {
// A chat parked in the file-lock queue (or caught in a lock deadlock) must
// explain itself where the user is about to type into what looks like a
// frozen chat — beside the send button, not buried at the transcript's tail.
if let wait = openLockWait, let id = session?.id {
LockContentionBanner(
wait: wait,
deadlocked: store.deadlockedSessions.contains(id),
onReleaseHolder: { holder in
Task { await store.forceReleaseLocks(holder.sessionID) }
})
.transition(ComposerMotion.inputTransition(reduceMotion))
}
if let approval = store.openApprovals.first {
Group {
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionBar(request: approval, questions: questions)
} else {
ApprovalBar(request: approval)
}
}
.id(approval.id)
.transition(ComposerMotion.inputTransition(reduceMotion))
}
}
// The bottom inset is both breathing room and a clear reveal runway. At rest
// the card ends before the gradient; in motion its contents pass through it.
// Dropped to zero when the slot is empty, so an idle composer's glass is exactly
// the composer — the insets belong to the cards, not to the slot.
.padding(.horizontal, hasComposerRevealContent ? 8 : 0)
.padding(.vertical, hasComposerRevealContent ? 8 : 0)
}
// Keep the reveal slot at the composer's full width even when it has no child. Only
// its height can now change, so the outer glass grows straight upward instead of the
// request resolving from an intrinsic point into its final horizontal footprint.
.frame(maxWidth: .infinity, alignment: .bottom)
.mask(alignment: .bottom) {
VStack(spacing: 0) {
Color.black
LinearGradient(
colors: [.black, .clear], startPoint: .top, endPoint: .bottom)
.frame(height: 8)
}
}
composer
}
.background(inactiveGlassScrim, in: .rect(cornerRadius: 16))
.glassEffect(.regular.tint(composerGlassTint), in: .rect(cornerRadius: 16))
.animation(.easeInOut(duration: 0.15), value: controlActiveState)
// The glass itself does not claim pointer events. The solid shape prevents clicks in the
// request/composer breathing room from landing on the transcript behind the floating card.
.contentShape(.rect(cornerRadius: 16))
.shadow(color: .black.opacity(0.18), radius: 12, x: 0, y: 4)
.chatColumn(
maxWidth: contentMaxWidth, inset: transcriptInset,
resizeWidth: columnWidth, settling: transcriptSettling)
// Do not animate the inherited approval of a chat that is merely being opened; live
// requests animate once the transcript has completed its first stable layout.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: store.openApprovals.first?.id)
// The lock card grows and collapses the same glass, so it rides the same spring.
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: openLockWait != nil)
.animation(
transcriptSettling ? nil : ComposerMotion.layout(reduceMotion),
value: composerHeight)
.padding(.bottom, 12)
// The one measurement both the transcript's tail spacer and its fade are placed from.
// The cluster's own growth is a spring (`ComposerMotion.layout`), but this measurement
// lands as one final value — geometry reports the layout endpoint, not the animation's
// frames — so an un-animated write would snap everything placed from it (the tail
// spacer, the fade, the chevron) while the glass is still gliding. Re-animating the
// write with the same spring keeps them all in step with the composer. While a freshly
// opened chat settles, the write stays instant: its first measurement must place the
// transcript, not slide it.
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
if transcriptSettling {
floatingComposerHeight = height
} else {
withAnimation(ComposerMotion.layout(reduceMotion)) {
floatingComposerHeight = height
}
}
}
}
/// The transcript's bottom dissolve. Fully drawn until just above the floating cluster, then a
/// gradient that takes the prose to nothing a short way inside the glass, and nothing at all
/// below that — so a line scrolling under the composer fades away instead of being sliced by
/// the card's edge or showing through it half-legibly. Anchored to the measured cluster height,
/// so it rides up and down with the card.
private var transcriptBottomFade: some View {
VStack(spacing: 0) {
Color.black
LinearGradient(
colors: [.black, .black.opacity(0)], startPoint: .top, endPoint: .bottom)
.frame(height: transcriptFadeDepth)
Color.clear
.frame(height: max(0, floatingComposerHeight - transcriptFadeOverlap))
}
}
private var composer: some View {
// Tighter than the 8pt used between controls *within* a row: the things stacked here are
// small pills, a rail and its caption, and a full 8pt gap around each left the card's
// lower half reading much taller than the field it belongs to.
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
// A per-session branch name only makes sense on the classic worktree path. nvrsion
// sessions share one workspace, so instead of leaking that internal name we show a
// simple "nvrsion is on" badge in the same spot.
if nvrsionActive {
nvrsionLabel
} else {
branchLabel
}
Spacer()
// Provider status + context window + subscription quota, sitting just above
// the chat box's upper-right corner. Trailing-inset to line the text up with
// the text field's trailing edge (matching the controls row below the field).
if showsStatusIndicator {
StatusFeedIndicator(palette: palette, monochrome: true)
}
QuotaIndicator(
palette: palette, contextUsage: contextUsage, backend: quotaBackend,
monochrome: true)
}
.padding(.trailing, sendButtonWidth + 8)
// Follow-ups the user submitted while the agent is still working, held to send the
// instant the current turn finishes. Each is shown as its own pill (with its
// attachments) and can be cancelled individually; they still send as one combined turn.
if let queued = session?.queuedMessages, !queued.isEmpty {
VStack(alignment: .leading, spacing: 6) {
ForEach(queued) { message in
queuedMessageBar(message)
}
}
.padding(.trailing, sendButtonWidth + 8)
}
// Staged attachments (attach button / paste / drag), shown above the field. Hidden in
// an archived chat, where the composer is inert.
if !attachments.isEmpty && !isArchived {
ComposerAttachmentBar(attachments: $attachments)
.padding(.trailing, sendButtonWidth + 8)
}
// Someone is typing in this chat on another device: their draft streams in live
// here while the field below is locked (one typer per session at a time).
if let typing = remoteTyping {
remoteTypingBar(typing)
.padding(.trailing, sendButtonWidth + 8)
}
HStack(
// Archived chats only ever show the shorter Unarchive button, not the
// full action column, so the top/bottom heuristic below (which exists to
// keep a live Send/Stop pinned to the top of a tall growing field) doesn't
// apply — always pin Unarchive to the field's bottom instead.
alignment: isArchived
? .bottom
: composerHeight + 16 < composerActionColumnHeight ? .top : .bottom,
spacing: 8
) {
// Conversational sessions can always be resumed by sending — even
// after an interrupt or an errored turn — so the composer stays
// usable whenever a session is open. While a turn runs, sending
// *queues* the message (see `send()`); the trailing button shows Stop
// when the box is empty and a send/queue arrow once the user types.
ChatInputField(
text: $draft,
placeholder: isArchived ? "Unarchive to send messages…"
: remoteTyping.map { "\(typerName($0)) is typing…" } ?? "Message the agent…",
submitMode: submitMode,
isEnabled: session != nil && !isArchived && remoteTyping == nil,
height: $composerHeight, onSend: { _ in send() },
onAttach: { attachments.append(contentsOf: $0) },
onDropTargetedChanged: { composerDropTargeted = $0 },
// ⌘⌥-arrow moves the Intelligence rail from inside the field. Only offered
// while the rail is actually on screen; otherwise the model/effort menus
// are the control and the chord is left alone.
onIntelligenceStep: intelligenceRoutingActive
&& composerModelOverride == nil
? { stepSessionIntelligence($0) } : nil,
modelOverride: composerModelOverride)
.frame(height: composerHeight)
.padding(8)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 8))
// Light up the field's border while a file/image drag hovers it.
.overlay {
if composerDropTargeted {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(composerAccent, lineWidth: 2)
.allowsHitTesting(false)
}
}
// An archived chat is inert, so don't glow even if its effort was orchestra; the
// glow also retires once orchestra has run for a full turn (see
// `showsOrchestraGlow`). Cap the pulse below the home screen's full swell so the
// glow stays a calm presence while you're reading a conversation.
.orchestraGlow(active: showsOrchestraGlow, peakBrightness: 0.6)
// The largest action stack (Stop + stash) is always present as a hidden layout
// footprint. Session selection briefly used to render the smaller Send stack —
// or only Unarchive — before metadata loaded, changing this row's height and
// pulling the entire bottom-anchored composer upward a frame later.
ZStack(alignment: .bottom) {
composerActionColumnFootprint
if isArchived {
// Read-only chat: the send/cmd-return control is replaced by Unarchive,
// the only way to make the composer and messages interactive again. No
// stash button either — there's nothing to type, so nothing to set aside.
Button(action: unarchive) {
Text("Unarchive")
.font(.callout.weight(.medium))
.fixedSize()
}
.buttonStyle(.borderedProminent)
.help("Unarchive this chat to send messages and interact with it")
} else {
composerActionColumn
}
}
}
.onPreferenceChange(ComposerActionColumnHeightKey.self) { composerActionColumnHeight = $0 }
HStack(spacing: 8) {
autoToggle
// Shipping implies Auto, so the Merge control is only meaningful when Auto
// is on. It slides out from behind the Auto button when Auto is enabled
// and tucks back under it when Auto is turned off. Under nvrsion the trunk is
// promoted at the project level, so a per-chat Merge toggle is meaningless and
// hidden entirely.
if autoOn && !nvrsionActive {
shipControl
.transition(.move(edge: .leading).combined(with: .opacity))
}
// Attach files/images. Sits with the left-hand controls (not beside the field) so
// it never indents the composer. The row's `.disabled(isArchived)` keeps it inert
// in an archived chat.
ComposerAttachButton(isEnabled: session != nil) { picked in
attachments.append(contentsOf: picked)
}
Spacer()
// Reserve the rail + route-preview stack even while the selected session's
// backend metadata is unavailable. The fallback Model/Effort menus are shorter;
// without this invariant footprint the composer begins one caption-line lower
// and pops up as soon as a routable session finishes loading.
ZStack(alignment: .trailing) {
composerIntelligenceControlsFootprint
if intelligenceRoutingActive {
VStack(alignment: .leading, spacing: Self.intelligenceCaptionSpacing) {
IntelligenceSlider(
level: sessionIntelligenceLevelBinding,
orchestraTier: sessionOrchestraTierBinding,
enabled: !isArchived && composerModelOverride == nil,
orchestraAvailable: isControlProject,
animationsSettled: !transcriptSettling,
composerText: draft,
routeDescription: sessionRouteAccessibilityDescription,
unavailableReason: composerOverrideLockReason
?? (isArchived
? "This chat is archived — its Intelligence level can't be changed."
: nil),
onLevelPreviewChanged: { preview in
if sliderIntelligenceLevelPreview != preview {
sliderIntelligenceLevelPreview = preview
}
},
onOrchestraPreviewChanged: { sliderOrchestraPreview = $0 })
sessionRouteSelectionPreview
}
.padding(.top, -Self.intelligenceStackLift)
.padding(.bottom, Self.intelligenceCaptionBottomInset)
} else {
HStack(spacing: 8) {
modelMenu
effortMenu
}
}
}
// The stash box, in the send button's column so the ⬇︎ above drops straight
// into it. It takes the place of this row's trailing inset rather than adding
// height beside the text field. Archived chats disable the whole row, the box
// with it — there's no live composer to pull a prompt into.
PromptStashBox(accent: composerAccent, onPull: pull)
.frame(width: sendButtonWidth)
}
.animation(.easeInOut(duration: 0.25), value: autoOn)
.animation(.easeInOut(duration: 0.25), value: session?.autoShip)
.task(id: session?.projectID) { await loadShipBranches() }
// Auto is locked on under Nucleic Control; reconcile any legacy chat that predates
// that rule so the agent actually runs autonomously, matching the locked-on toggle.
// (New chats are already created auto-on.) Owner-only: a chat mirrored from a peer
// Mac is reconciled where it lives — firing setSessionAuto at the owner on every
// open would bump its updatedAt and reorder Recents mesh-wide just for looking.
.task(id: session?.id) {
if store.openHostID == nil, isControlProject, session?.auto == false {
await store.setOpenSessionAuto(true)
}
}
.font(.callout)
// The mode/model controls go inert (and dim) alongside the field in an
// archived chat — only Unarchive stays live.
.disabled(isArchived)
}
// Card padding — the chrome inside the glass, in place of the bare top/bottom insets the
// divider-ruled strip used to carry. The bottom is trimmed below the top: the controls
// row ends in a caption line ("GPT-5.6 Sol · Extra") whose descender space already reads
// as padding, so matching the top's inset there left the card bottom-heavy.
.padding(.horizontal, 14)
// An attached request already contributes the card's top inset; close the space between
// the two sections so they read as one surface. The same layout spring eases this value.
.padding(.top, store.openApprovals.first == nil ? 10 : 5)
.padding(.bottom, 4)
// Stream this composer's draft to the mesh (throttled in the store) so every other
// device viewing this chat sees it live and locks its own composer; an emptied field —
// including the clear in `send()` — consumes the shared draft and empties theirs too.
.onChange(of: draft) { _, text in
guard let id = session?.id, !isArchived else { return }
// Text this composer just took from the mesh is already the shared draft — echoing
// it back out would claim the editing lock on every other device for a change the
// user never made.
guard meshDraftEcho != text else { meshDraftEcho = nil; return }
meshDraftEcho = nil
store.composerDraftChanged(id, text: text)
}
// The other device stopped typing (or its draft was consumed): move its text down into
// this field, or clear it — the composer's text is one draft shared across the mesh.
.onChange(of: sharedComposerDraft) { old, new in
sharedComposerDraftChanged(from: old, to: new)
}
// Switching chats (or closing to none) releases the editing lock on the one we left,
// leaving its text as that chat's shared draft for whoever picks it up next.
.onChange(of: session?.id) { old, _ in
if let old { store.composerDraftEnded(old) }
}
.onDisappear {
if let id = session?.id { store.composerDraftEnded(id) }
}
}
/// Fixed trailing-column contents for the message row. The hidden title-sized Stop symbol
/// makes Send and Stop occupy the same vertical slot; the stash arrow therefore stays on the
/// same baseline through both live status changes and the first frame of a session switch.
private var composerActionColumn: some View {
VStack(spacing: 6) {
ZStack {
Image(systemName: "stop.circle.fill")
.font(.title2)
.hidden()
.accessibilityHidden(true)
if isBusy && !canSend {
Button { Task { await store.interruptOpenSession() } } label: {
Image(systemName: "stop.circle.fill").font(.title2)
}
.buttonStyle(.plain)
.help("Interrupt the running turn")
} else {
Button(action: send) {
SubmitKeyIcon(mode: submitMode)
}
.buttonStyle(.plain)
.help(routedRouteAvailable
? (isBusy
? "Queue this message — it sends when the agent finishes its turn"
: submitMode.detail)
: quotaHoldNote)
// Locked while another device is typing here — the mesh-wide "one typer
// per session" contract, matching the disabled field beside it.
.disabled(!canSend || remoteTyping != nil)
}
}
Divider()
PromptStashDropButton(canStash: hasStashableDraft, onStash: stashDraft)
}
.frame(width: sendButtonWidth)
}
/// The action column's maximum natural height, retained even when an archived session swaps
/// the column for its shorter Unarchive button.
private var composerActionColumnFootprint: some View {
VStack(spacing: 6) {
Image(systemName: "stop.circle.fill").font(.title2)
// Mirrors PromptStashDropButton's glyph metrics exactly (13pt / 15pt tall) so this
// invariant footprint keeps the real column's height.
Image(systemName: "arrow.down")
.font(.system(size: 13, weight: .semibold))
.frame(height: 15)
}
.frame(width: sendButtonWidth)
.background {
GeometryReader { proxy in
Color.clear.preference(key: ComposerActionColumnHeightKey.self, value: proxy.size.height)
}
}
.hidden()
.accessibilityHidden(true)
.allowsHitTesting(false)
}
/// Dynamic-Type-aware maximum footprint of the lower control row. Reusing the real route
/// preview keeps this exactly in step with its scaled caption height without a magic constant.
private var composerIntelligenceControlsFootprint: some View {
VStack(alignment: .leading, spacing: Self.intelligenceCaptionSpacing) {
Color.clear.frame(
width: IntelligenceSlider.preferredWidth,
height: IntelligenceSlider.preferredHeight)
IntelligenceRoutePreview(model: nil, effort: nil, isPending: false)
}
.padding(.top, -Self.intelligenceStackLift)
.padding(.bottom, Self.intelligenceCaptionBottomInset)
.hidden()
.accessibilityHidden(true)
.allowsHitTesting(false)
}
/// The friendly label for a remote typer, never blank.
private func typerName(_ typing: ComposerTypingState) -> String {
typing.deviceName.isEmpty ? "Another device" : typing.deviceName
}
/// This chat's shared draft changed on another device — apply it to the field.
///
/// A *settled* entry (the other device stopped typing) moves into the composer: the text
/// carries on where they left off, on every device, instead of vanishing with the lock. A
/// tombstone means the draft was consumed there (sent) or emptied, so this field empties
/// too — but only the part that came from the mesh; anything typed here since stands, and is
/// itself the newer shared draft. An `editing` entry applies nothing: it renders live in the
/// bar above the (locked) field until it settles.
private func sharedComposerDraftChanged(
from old: ComposerTypingState?, to new: ComposerTypingState?
) {
guard let id = session?.id, !isArchived else { meshDraftSession = nil; return }
// This value is keyed off the open chat, so switching chats changes it too — that's a
// different chat's draft arriving, not a change to the one that was on screen.
let switched = meshDraftSession != id
meshDraftSession = id
guard let new else {
guard !switched, old != nil, draft == meshDraft else { return }
meshDraft = nil
applyMeshDraft("")
return
}
guard !new.editing else { return }
// On a chat switch, adopt only into an empty field: the text sitting there was carried
// in from the chat we just left, and is not this chat's draft to overwrite.
guard !switched || draft.isEmpty else { return }
meshDraft = new.text
applyMeshDraft(new.text)
}
/// Put mesh-sourced text in the composer without streaming it back out (see the `draft`
/// `onChange` — `meshDraftEcho` is the one update it swallows).
private func applyMeshDraft(_ text: String) {
guard draft != text else { return }
meshDraftEcho = text
draft = text
}
/// The live view of another device's in-progress draft for this chat, shown where the
/// queued pills sit while the field below is locked. Head-truncated: the tail is where the
/// typing is happening, so it's the part that must stay visible.
private func remoteTypingBar(_ typing: ComposerTypingState) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "ellipsis.bubble")
Text("\(typerName(typing)) is typing…")
Spacer(minLength: 0)
}
.font(.callout)
.foregroundStyle(composerAccent)
if !typing.text.isEmpty {
Text(typing.text)
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(4)
.truncationMode(.head)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 12))
.help("Someone is composing a message for this chat on another device. When they stop, "
+ "their text moves into this composer so you can carry on from it.")
}
/// A "message queued" pill shown above the composer while a follow-up waits for the current
/// turn to finish. Shows the message text and its attachment chips; tapping ✕ cancels just
/// this message and drops its text (and attachments) back in the composer.
private func queuedMessageBar(_ message: QueuedMessage) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: "clock")
.foregroundStyle(.secondary)
Text(queuedDisplayText(message))
.lineLimit(1)
.truncationMode(.tail)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
Button {
Task { await cancelQueued(message) }
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Cancel this queued message")
}
// The queued message's attachments, shown as display-only chips (the whole message is
// cancelled as a unit via ✕ above, so the chips carry no per-attachment remove).
if !message.attachments.isEmpty, let worktree = session?.worktreePath {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(message.attachments) { attachment in
ComposerAttachmentChip(
attachment: .fromQueued(attachment, worktreeRoot: worktree),
onRemove: nil)
}
}
.padding(.vertical, 2)
}
}
}
.font(.callout)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(AppTheme.composerFill, in: .rect(cornerRadius: 12))
.help("This message will send automatically when the agent finishes its current turn.")
}
/// The one-line label for a queued pill: the message text, or — for an attachment-only
/// message — the attachment names (the chips below already show them), so it's never blank.
private func queuedDisplayText(_ message: QueuedMessage) -> String {
if !message.text.isEmpty { return message.text }
return message.attachments.map(\.filename).joined(separator: ", ")
}
/// Cancel a single queued message and, unless the user has already started a new message,
/// refill the composer with its text and restore its attachments as real chips.
private func cancelQueued(_ message: QueuedMessage) async {
guard let removed = await store.cancelOpenQueuedMessage(id: message.id) else { return }
let composerEmpty = draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& attachments.isEmpty
guard composerEmpty else { return }
draft = removed.text
if let worktree = session?.worktreePath {
attachments = removed.attachments.map { .fromQueued($0, worktreeRoot: worktree) }
}
}
/// Whether the ⬇︎ button has anything to drop into the stash — typed text, or staged
/// attachments on their own (which the stash carries along with the prompt).
private var hasStashableDraft: Bool {
!isArchived && hasSendableContent
}
/// Set the typed prompt (and anything staged with it) aside and clear the composer, tagged
/// with this chat's name so the stash still reads sensibly once it holds prompts from
/// several chats. Clearing the draft also ends the mesh typing indicator, exactly as
/// sending does.
private func stashDraft() {
guard PromptStash.shared.stash(
draft, attachments: attachments, origin: session?.title, settings: stashedSettings)
else { return }
draft = ""
attachments = []
}
/// This chat's setup, captured alongside the prompt. Automatic routing records its level;
/// a concrete choice from the clickable labels records the pair while the rail remains at
/// its nearest matching detent.
private var stashedSettings: StashedComposerSettings {
StashedComposerSettings(
projectID: session?.projectID.rawValue,
projectName: session.flatMap { store.project($0.projectID) }?.name,
branch: nvrsionActive ? nil : session?.branch,
intelligence: intelligenceRoutingActive
&& !sessionManualSelectionActive
&& composerModelOverride == nil
&& !isOrchestra ? sliderIntelligenceLevel : nil,
orchestra: composerModelOverride == nil && isOrchestra ? true : nil,
model: composerModelOverride?.selection.model ?? (sessionManualSelectionActive
? sessionPreviewModel : (intelligenceRoutingActive ? nil : effectiveModel)),
effort: composerModelOverride?.selection.effort ?? (sessionManualSelectionActive
? sessionPreviewEffort
: (intelligenceRoutingActive || isOrchestra ? nil : effectiveEffort)),
auto: autoOn,
ship: session?.autoShip)
}
/// Load a prompt pulled out of the stash back into this composer, media and all, and put
/// this chat on the intelligence setting the prompt was written for. Project, branch and
/// worktree are fixed for an existing chat, and Auto/Ship are its own standing policy
/// rather than one prompt's — so those are shown in the viewer but not applied here.
private func pull(_ prompt: StashedPrompt) {
appendPulledPrompt(prompt.text, to: &draft)
attachments.append(contentsOf: prompt.attachments.map(\.composerAttachment))
guard let settings = prompt.settings, !isArchived else { return }
if settings.orchestra == true, isControlProject {
clearSessionManualSelection()
clearSessionRoutedSelection()
sliderOrchestraSelected = true
sliderIntelligenceLevel = .max
Task { await activateSessionOrchestra() }
} else if let level = settings.intelligence, intelligenceRoutingActive {
clearSessionManualSelection()
setOptimisticSessionRoute(for: level)
sliderOrchestraSelected = false
sliderIntelligenceLevel = level
Task { await applySessionIntelligenceLevel(level) }
} else {
// A manual pair travels together so its labels and nearest detent update atomically.
if let model = settings.model, let effort = settings.effort {
applySessionManualSelection(model: model, effort: effort)
} else {
if let model = settings.model { Task { await store.setOpenSessionModel(model) } }
if let effort = settings.effort { Task { await store.setOpenSessionEffort(effort) } }
}
}
}
private func send() {
// Quota hold: refuse the send with its draft untouched until the user moves the
// Intelligence bar to an available lane or the current window resets.
guard routedRouteAvailable else {
store.lastError = quotaHoldNote
return
}
// Locked while another device is typing here (mirrors the disabled field/button —
// this guards the keyboard path against a lock landing mid-keystroke).
guard remoteTyping == nil else { return }
let typedOverride = composerModelOverride
let trimmed = (typedOverride?.promptText(in: draft) ?? draft)
.trimmingCharacters(in: .whitespacesAndNewlines)
// Allow an attachment-only message (no typed text), but never an empty send.
guard !trimmed.isEmpty || !attachments.isEmpty else { return }
// Hand the staged attachments to the send Task and clear the composer immediately.
let staged = attachments
if let selection = typedOverride?.selection {
// Consume the temporary restore point: after send this concrete pair intentionally
// becomes the chat's manual lane, just like choosing it from the routed subtext.
preComposerOverrideIntelligenceLevel = nil
preComposerOverrideOrchestraSelected = nil
sliderOrchestraSelected = false
clearSessionRoutedSelection()
sliderManualModel = selection.model
sliderManualEffort = selection.effort
sliderIntelligenceLevel = closestSessionIntelligenceLevel(
toModel: selection.model,
effort: selection.effort,
purpose: routedPurpose)
}
draft = ""
attachments = []
// While the agent is busy this queues the message (the controller holds it and sends
// it the moment the current turn finishes); otherwise it sends immediately.
// Sending is a deliberate "show me what happens next" — jump to the bottom even
// if the user had scrolled up to read history.
scrollToBottomRequest += 1
let text = SingularityPreparation.prepare(trimmed)
Task {
// Read each attachment's bytes off the main thread — media can be large.
let pending = await Task.detached { staged.compactMap { try? $0.pending() } }.value
if let selection = typedOverride?.selection {
await store.setOpenSessionRoutingNote(nil)
await store.setOpenSessionModel(selection.model)
await store.setOpenSessionEffort(selection.effort)
}
// The slider resolves on release, not on send. The quota hold at the top of
// `send()` already refused a limited lane before the draft was cleared.
await store.sendToOpenSession(text, attachments: pending)
}
}
/// Flip the open chat out of the archive, restoring an interactive composer and
/// transcript. Wired to the Unarchive button that replaces send while archived.
/// Routed through the open-session verb so it reaches a peer Mac's chat too — the
/// id-addressed setter silently no-ops without a local controller.
private func unarchive() {
Task { await store.setOpenSessionArchived(false) }
}
/// Menu label for an integration strategy, reused in the chat log line.
private func integrationLabel(_ strategy: IntegrationStrategy) -> String {
switch strategy {
case .squash: return "Squash & merge"
case .merge: return "Merge commit"
case .rebase: return "Rebase & fast-forward"
}
}
private func integrate(_ strategy: IntegrationStrategy) async {
integrating = true
defer { integrating = false }
let label = integrationLabel(strategy)
switch await store.integrateOpenSession(strategy: strategy) {
case .clean(let into, let commit):
await store.noteToOpenSession(
"\(label)\(into.value) · \(commit.prefix(7))", icon: "checkmark.seal")
case .conflicted(let paths):
await store.noteToOpenSession(
"\(label)\(targetBranch): conflicts in \(paths.count) file\(paths.count == 1 ? "" : "s")",
icon: "exclamationmark.triangle")
case .none:
await store.noteToOpenSession("\(label)\(targetBranch): failed", icon: "exclamationmark.triangle")
}
}
/// Promote the project's nvrsion trunk into its real default branch and note the
/// outcome in the chat — the chat-surface equivalent of the settings "Promote trunk"
/// button, mirroring `integrate()`'s success/failure note pattern.
private func promoteTrunk() async {
guard let session, let project = store.project(session.projectID) else { return }
promoting = true
defer { promoting = false }
let base = project.defaultBranch.value
if await store.promoteNvrsionTrunk(project.id) {
await store.noteToOpenSession("Integrated trunk → \(base)", icon: "checkmark.seal")
} else {
await store.noteToOpenSession(
store.lastError ?? "Integrate → \(base): failed", icon: "exclamationmark.triangle")
}
}
/// Promote just this chat's landed work into the real default branch (NVRSION §6, per-session
/// promotion) and note the outcome — the per-chat counterpart of `promoteTrunk()`, so a finished
/// chat can ship without waiting on a long-running sibling to complete its turn.
private func promoteSession() async {
guard let session, let project = store.project(session.projectID) else { return }
promoting = true
defer { promoting = false }
let base = project.defaultBranch.value
if await store.promoteNvrsionSession(session.id) {
await store.noteToOpenSession("Integrated this chat → \(base)", icon: "checkmark.seal")
} else {
await store.noteToOpenSession(
store.lastError ?? "Integrate this chat → \(base): failed", icon: "exclamationmark.triangle")
}
}
private func openInTerminal() {
guard let path = session?.worktreePath else { return }
let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app")
NSWorkspace.shared.open(
[URL(fileURLWithPath: path)], withApplicationAt: terminal,
configuration: NSWorkspace.OpenConfiguration())
Task { await store.noteToOpenSession("Opened worktree in Terminal", icon: "terminal") }
}
private func revealInFinder() {
guard let path = session?.worktreePath else { return }
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
Task { await store.noteToOpenSession("Revealed worktree in Finder", icon: "folder") }
}
/// Start the browser sign-in for this chat's provider through Nucleic's credential broker —
/// Codex (ChatGPT) for the Codex backends, Claude otherwise. The provider's browser page is
/// theirs; no terminal or manual CLI command is exposed.
private func startClaudeLogin() {
switch session?.backend {
case .codex, .codexExec:
Task { await store.loginCodex() }
default:
Task { await store.loginClaude() }
}
}
private func copyToClipboard(_ string: String?, note: String) {
guard let string, !string.isEmpty else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
Task { await store.noteToOpenSession(note, icon: "doc.on.doc") }
}
private func exportSession() async {
guard let export = await store.exportOpenSession() else { return }
let panel = NSSavePanel()
panel.nameFieldStringValue = export.filename
panel.canCreateDirectories = true
panel.title = "Export Chat"
// `begin()`, not `runModal()`: no nested modal run loop on the main thread.
guard await panel.begin() == .OK, let url = panel.url else { return }
do {
try export.contents.write(to: url, atomically: true, encoding: .utf8)
NSWorkspace.shared.activateFileViewerSelecting([url])
} catch {
store.lastError = "Export failed: \(error)"
}
}
}
struct TranscriptProjectionMetadata: Sendable {
let hasResponse: Bool
let sessionTokens: Int?
let contextInputTokens: Int?
let currentTurnStart: Date?
let lastRunDurationMs: Int?
static let empty = Self(
hasResponse: false,
sessionTokens: nil,
contextInputTokens: nil,
currentTurnStart: nil,
lastRunDurationMs: nil)
}
/// Single-owner projection pipeline for the open transcript. `IncrementalTranscriptProjection`
/// intentionally is not thread-safe; actor ownership preserves its stable-prefix state while all
/// full-fold fallbacks, filters, auth cleanup, and range construction stay off the main actor.
actor TranscriptProjectionWorker {
struct Output: Sendable {
let items: [TranscriptItem]
let segments: [TranscriptRenderSegment]
let segmentContentRevisions: [Int: UInt64]
let segmentEstimates: [Int: TranscriptRenderSegmentEstimate]
let lockLines: [String: [NoteLock]]
let cardPresentations: TranscriptCardPresentations
let renderCost: TranscriptRenderCost
let usesVirtualizedLayout: Bool
let metadata: TranscriptProjectionMetadata
let benchmarkStatistics: TranscriptRenderBenchmarkStatistics?
}
private let projector = IncrementalTranscriptProjection()
private var segmenter = TranscriptRenderSegmenter()
private var estimateCache:
[TranscriptRenderSegmentIdentity: TranscriptRenderSegmentEstimate] = [:]
/// Once a session takes the native-list path, keep that hierarchy stable as Markdown and
/// presentation caches warm or a tail revision becomes temporarily cheaper.
private var hasVirtualized = false
/// Specialized-card parsing is memoized here, on the same off-main actor as projection.
private var cardPresentationBuilder = TranscriptCardPresentationBuilder()
private var renderCostBuilder = TranscriptRenderCostBuilder()
private var settledMetadataWatermark: UInt64?
private var settledMetadata: TranscriptProjectionMetadata = .empty
private let signposter = OSSignposter(
subsystem: "com.nucleic", category: "transcript-performance")
private let logger = Logger(
subsystem: "com.nucleic", category: "transcript-performance")
func update(
events: [AgentEvent],
settledHistory: TranscriptRenderIndex.SettledHistory? = nil,
worktreeRoot: String?,
projectRoot: String?,
showDebug: Bool,
showLock: Bool,
markdownCacheWarm: Bool = false
) -> Output? {
// SwiftUI cancels the prior `.task(id:)` as soon as a newer preview/full-history revision
// arrives. Actor calls already queued before that cancellation still enter this method;
// rejecting them here prevents obsolete O(history) folds from serializing the newest one.
guard !Task.isCancelled else { return nil }
let started = ContinuousClock.now
let interval = signposter.beginInterval("TranscriptProjection")
defer { signposter.endInterval("TranscriptProjection", interval) }
projector.setSettledPrefix(settledHistory)
let projectionEvents: [AgentEvent]
if let settledHistory {
let boundary = Self.firstEvent(after: settledHistory.watermarkSequence, in: events)
projectionEvents = Array(events[boundary...])
if settledMetadataWatermark != settledHistory.watermarkSequence {
settledMetadata = Self.metadata(in: settledHistory.events)
settledMetadataWatermark = settledHistory.watermarkSequence
}
} else {
projectionEvents = events
settledMetadata = .empty
settledMetadataWatermark = nil
}
var result = projector.update(events: projectionEvents, worktreeRoot: worktreeRoot)
guard !Task.isCancelled else { return nil }
if !showDebug { result.items = result.items.filter { !$0.isDebug } }
if !showLock { result.items = result.items.filter { !$0.isLockEvent } }
result.items = TranscriptRow.hidingResolvedAuthArtifacts(result.items)
guard !Task.isCancelled else { return nil }
let cardPresentations = cardPresentationBuilder.update(
items: result.items,
// Settled cards use their bounded payload fingerprint fallback. Only the live tail's
// canonical revisions need scanning on each streaming update.
events: projectionEvents,
worktreeRoot: worktreeRoot,
projectRoot: projectRoot,
lockLines: result.lockLines)
guard !Task.isCancelled else { return nil }
// All variants consume this exact projection. Only the text baseline derives a smaller
// presentation list; production/skeleton/generic retain identical row identities.
let projectedItems = result.items
let benchmark = TranscriptRenderBenchmarkConfiguration.current
if benchmark.variant == .textOnly {
result.items = projectedItems.filter(TranscriptRow.isTextOnlyBenchmarkItem)
}
let renderCost = renderCostBuilder.update(
items: result.items,
cardPresentations: cardPresentations,
markdownCacheState: markdownCacheWarm ? .warm : .cold)
if renderCost.shouldVirtualize { hasVirtualized = true }
let usesVirtualizedLayout = hasVirtualized
let segments = segmenter.update(rowWeights: renderCost.rowWeights)
let segmentContentRevisions = TranscriptRenderSegmentContentRevision.make(
segments: segments,
items: result.items,
cardPresentations: cardPresentations,
lockLines: result.lockLines)
var liveEstimateIdentities = Set<TranscriptRenderSegmentIdentity>()
let tailID = segments.last?.id
var segmentEstimates: [Int: TranscriptRenderSegmentEstimate] = [:]
segmentEstimates.reserveCapacity(segments.count)
for (ordinal, segment) in segments.enumerated() {
if ordinal.isMultiple(of: 64), Task.isCancelled { return nil }
let slice = result.items[segment.range]
let identity = TranscriptRenderSegmentIdentity(
lowerBound: segment.range.lowerBound,
rowCount: segment.length,
firstItemID: slice.first?.id ?? "",
lastItemID: slice.last?.id ?? "")
liveEstimateIdentities.insert(identity)
let estimate: TranscriptRenderSegmentEstimate
if segment.id != tailID, let cached = estimateCache[identity] {
estimate = cached
} else {
estimate = TranscriptRenderSegmentEstimate.measure(slice)
estimateCache[identity] = estimate
}
segmentEstimates[segment.id] = estimate
}
estimateCache = estimateCache.filter { liveEstimateIdentities.contains($0.key) }
guard !Task.isCancelled else { return nil }
let metadata = Self.metadata(in: projectionEvents, seededBy: settledMetadata)
guard !Task.isCancelled else { return nil }
let parts = (ContinuousClock.now - started).components
let milliseconds = Double(parts.seconds) * 1_000
+ Double(parts.attoseconds) / 1_000_000_000_000_000
if milliseconds >= 50 {
logger.notice(
"slow_projection events=\(projectionEvents.count) settled_events=\(settledHistory?.events.count ?? 0) settled_watermark=\(settledHistory?.watermarkSequence ?? 0) rows=\(result.items.count) render_weight=\(renderCost.totalWeight) max_row_weight=\(renderCost.maximumRowWeight) virtualized=\(usesVirtualizedLayout) elapsed_ms=\(milliseconds, format: .fixed(precision: 1))")
} else {
logger.debug(
"projection events=\(projectionEvents.count) settled_events=\(settledHistory?.events.count ?? 0) settled_watermark=\(settledHistory?.watermarkSequence ?? 0) rows=\(result.items.count) render_weight=\(renderCost.totalWeight) max_row_weight=\(renderCost.maximumRowWeight) virtualized=\(usesVirtualizedLayout) elapsed_ms=\(milliseconds, format: .fixed(precision: 1))")
}
let shapeStarted = ContinuousClock.now
let benchmarkStatistics = benchmark.isEnabled
? TranscriptRenderBenchmarkStatistics.measure(projectedItems)
: nil
guard !Task.isCancelled else { return nil }
if let stats = benchmarkStatistics {
let shapeParts = (ContinuousClock.now - shapeStarted).components
let shapeMilliseconds = Double(shapeParts.seconds) * 1_000
+ Double(shapeParts.attoseconds) / 1_000_000_000_000_000
logger.info(
"benchmark_shape variant=\(benchmark.variant.rawValue, privacy: .public) events=\(projectionEvents.count) settled_events=\(settledHistory?.events.count ?? 0) settled_watermark=\(settledHistory?.watermarkSequence ?? 0) projected_rows=\(stats.topLevelRowCount) rendered_rows=\(result.items.count) virtualized=\(usesVirtualizedLayout) render_weight=\(renderCost.totalWeight) max_row_weight=\(renderCost.maximumRowWeight) markdown_cache=\(renderCost.markdownCacheState.rawValue, privacy: .public) presentation_misses=\(renderCost.coldPresentationCount) nested_children=\(stats.nestedChildCount) max_depth=\(stats.maximumNestedDepth) tool_calls=\(stats.toolCallCount) max_group=\(stats.maximumGroupSize) visible_markdown_bytes=\(stats.visibleMarkdownBytes) shape_elapsed_ms=\(shapeMilliseconds, format: .fixed(precision: 1)) card_kinds=\(stats.cardKindSummary, privacy: .public)")
}
return Output(
items: result.items,
segments: segments,
segmentContentRevisions: segmentContentRevisions,
segmentEstimates: segmentEstimates,
lockLines: result.lockLines,
cardPresentations: cardPresentations,
renderCost: renderCost,
usesVirtualizedLayout: usesVirtualizedLayout,
metadata: metadata,
benchmarkStatistics: benchmarkStatistics)
}
/// Header/summary facts formerly rediscovered by several whole-event-array scans on every
/// SwiftUI body evaluation. Fold them once beside projection so long histories never leak an
/// O(events) main-actor cost back in through otherwise unrelated header rendering.
private static func firstEvent(after sequence: UInt64, in events: [AgentEvent]) -> Int {
var lower = 0
var upper = events.count
// Snapshot events may have non-monotone representative sequences, but every one is at or
// below the watermark and every canonical tail event is above it. The predicate therefore
// remains partitioned and binary search avoids rescanning a large canonical prefix.
while lower < upper {
let middle = lower + (upper - lower) / 2
if events[middle].seq <= sequence { lower = middle + 1 } else { upper = middle }
}
return lower
}
private static func metadata(
in events: [AgentEvent], seededBy seed: TranscriptProjectionMetadata = .empty
) -> TranscriptProjectionMetadata {
var hasResponse = seed.hasResponse
var tokenTotal = seed.sessionTokens ?? 0
var sawTokens = seed.sessionTokens != nil
var contextInputTokens = seed.contextInputTokens
var currentTurnStart = seed.currentTurnStart
var lastRunDurationMs = seed.lastRunDurationMs
for event in events {
switch event.kind {
case .turnCompleted(let turn):
hasResponse = true
if let usage = turn.usage {
tokenTotal += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
sawTokens = true
if let context = usage.contextInputTokens { contextInputTokens = context }
}
case .runFinished(let run):
hasResponse = true
if let duration = run.durationMs { lastRunDurationMs = duration }
case .usage(let usage):
if let context = usage.contextInputTokens { contextInputTokens = context }
case .userText:
currentTurnStart = event.at
default:
break
}
}
return TranscriptProjectionMetadata(
hasResponse: hasResponse,
sessionTokens: sawTokens ? tokenTotal : nil,
contextInputTokens: contextInputTokens,
currentTurnStart: currentTurnStart,
lastRunDurationMs: lastRunDurationMs)
}
}
/// Constant-cost reserved canvas for one unloaded native row. It deliberately does not typeset
/// fake prose, mask a gradient, blur layers, or run an infinite animation: all four happened for
/// every transient row during a fling and could cost more than rendering the real transcript.
private struct TranscriptChunkPlaceholder: View {
let seed: Int
let rowCount: Int
let fontSize: CGFloat
static func seed(for value: String?) -> Int {
var hash: UInt64 = 14_695_981_039_346_656_037
for byte in (value ?? "transcript").utf8 {
hash = (hash ^ UInt64(byte)) &* 1_099_511_628_211
}
return Int(truncatingIfNeeded: hash)
}
private func trailingInset(row: Int, line: Int) -> CGFloat {
let mixed = UInt(truncatingIfNeeded: seed &+ row &* 7_919 &+ line &* 1_009)
return CGFloat(44 + mixed % 164)
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(0..<max(1, min(rowCount, 3)), id: \.self) { row in
VStack(alignment: .leading, spacing: 7) {
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.16))
.frame(height: max(5, fontSize * 0.48))
.padding(.trailing, trailingInset(row: row, line: 0))
RoundedRectangle(cornerRadius: 2)
.fill(Color.secondary.opacity(0.10))
.frame(height: max(5, fontSize * 0.42))
.padding(.trailing, trailingInset(row: row, line: 1) + 70)
}
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.accessibilityHidden(true)
.allowsHitTesting(false)
}
}
/// A stable native-list slot whose readiness belongs to the recycled row, not the transcript root.
/// The placeholder and real content are mutually exclusive branches and swap without animation.
///
/// `mountsReady` is the caller's synchronous verdict (tail segment, completed warm pass, or a
/// segment that already mounted once, remembered in the unobserved `ProjectionCache`). A ready
/// mount realizes the row at its final height in one pass; the async placeholder→content swap —
/// each one a height correction that shifts the document under an in-flight scroll — remains only
/// for genuinely cold segments. Local `@State` still carries readiness earned by this row's own
/// prewarm across re-renders, because `mountsReady` was captured before that prewarm finished.
private struct TranscriptRenderSegmentSlot<Content: View>: View {
let seed: Int
let identity: TranscriptRenderSegmentIdentity
let length: Int
let fontSize: CGFloat
let reservedHeight: CGFloat
let mountsReady: Bool
let onMeasured: (CGFloat) -> Void
let onReady: () -> Void
let onPriorityMount: () async -> Bool
let content: Content
@State private var readyIdentity: TranscriptRenderSegmentIdentity?
init(
seed: Int,
identity: TranscriptRenderSegmentIdentity,
length: Int,
fontSize: CGFloat,
reservedHeight: CGFloat,
mountsReady: Bool,
onMeasured: @escaping (CGFloat) -> Void,
onReady: @escaping () -> Void,
onPriorityMount: @escaping () async -> Bool,
@ViewBuilder content: () -> Content
) {
self.seed = seed
self.identity = identity
self.length = length
self.fontSize = fontSize
self.reservedHeight = reservedHeight
self.mountsReady = mountsReady
self.onMeasured = onMeasured
self.onReady = onReady
self.onPriorityMount = onPriorityMount
self.content = content()
_readyIdentity = State(initialValue: mountsReady ? identity : nil)
}
private var isReady: Bool { mountsReady || readyIdentity == identity }
var body: some View {
Group {
if isReady {
content
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
onMeasured(height)
}
} else {
TranscriptChunkPlaceholder(seed: seed, rowCount: length, fontSize: fontSize)
.frame(height: max(reservedHeight, CGFloat(length)))
}
}
.task(id: identity) {
if isReady {
onReady()
return
}
guard await onPriorityMount(), !Task.isCancelled else { return }
var transaction = Transaction(animation: nil)
transaction.disablesAnimations = true
withTransaction(transaction) { readyIdentity = identity }
onReady()
}
}
}
/// One bounded batch inside the transcript's render container.
///
/// A short history lays every batch out eagerly; a long history uses each batch as one recyclable
/// native list row. SwiftUI compares at most two items before deciding that a settled segment's
/// row subtree and cached size can be reused. Closure identity is deliberately ignored; only
/// whether each optional control exists affects rendering, matching `TranscriptRow`'s Equatable
/// contract.
private struct TranscriptRenderSegmentView: View, Equatable {
let items: ArraySlice<TranscriptItem>
let cardPresentations: TranscriptCardPresentations
var benchmarkVariant = TranscriptRenderBenchmarkConfiguration.current.variant
var onRevert: ((UInt64) -> Void)?
var onLogin: (() -> Void)?
var onOpenVMMonitor: (() -> Void)?
var onSkip: ((ToolCall) -> Void)?
var onResolveStall: ((String, Bool) -> Void)?
var onOpenSubagents: (() -> Void)?
nonisolated static func == (
lhs: TranscriptRenderSegmentView, rhs: TranscriptRenderSegmentView
) -> Bool {
MainActor.assumeIsolated {
lhs.items.count == rhs.items.count
&& zip(lhs.items, rhs.items).allSatisfy { left, right in
left.hasSameViewIdentityAndText(as: right)
&& lhs.cardPresentations.revision(for: left)
== rhs.cardPresentations.revision(for: right)
}
&& lhs.benchmarkVariant == rhs.benchmarkVariant
&& (lhs.onRevert == nil) == (rhs.onRevert == nil)
&& (lhs.onLogin == nil) == (rhs.onLogin == nil)
&& (lhs.onOpenVMMonitor == nil) == (rhs.onOpenVMMonitor == nil)
&& (lhs.onSkip == nil) == (rhs.onSkip == nil)
&& (lhs.onResolveStall == nil) == (rhs.onResolveStall == nil)
&& (lhs.onOpenSubagents == nil) == (rhs.onOpenSubagents == nil)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(items) { item in
transcriptRow(item)
}
}
}
private func transcriptRow(_ item: TranscriptItem) -> some View {
TranscriptRow(
item: item,
presentationRevision: cardPresentations.revision(for: item),
benchmarkVariant: benchmarkVariant,
onRevert: onRevert,
onLogin: onLogin,
onOpenVMMonitor: onOpenVMMonitor,
onSkip: onSkip,
onResolveStall: onResolveStall,
onOpenSubagents: onOpenSubagents)
.equatable()
.id(item.id)
}
}
/// The floating "jump to the latest" chevron shown above the chat bar while the user
/// has scrolled up away from the bottom of the transcript.
private struct JumpToBottomButton: View {
@Environment(\.appPalette) private var palette
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "chevron.down")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 30, height: 30)
.background(.regularMaterial, in: .circle)
.overlay(Circle().strokeBorder(.separator, lineWidth: 0.5))
.shadow(color: .black.opacity(0.18), radius: 4, y: 1)
}
.buttonStyle(.plain)
.help("Jump to the latest transcript entry")
}
}
/// Composer card for a chat parked in the file-lock queue (LOCKING §4): names the contended files
/// and the chat(s) holding them, escalates to the attention style for a genuine deadlock, and
/// offers the manual escape hatch — releasing the holders' locks. It rides in the composer's glass
/// shell alongside permission prompts rather than at the transcript's tail, so the explanation sits
/// where the user is about to type into what otherwise looks like a frozen chat — and stays put
/// instead of scrolling away. The queued wait resolves itself when the holder's work lands; this
/// exists so that wait is never mistaken for a frozen app.
///
/// Everything here is derived from `wait`, which the store re-derives from the live lock table on
/// every reconcile pass — so when the current holder releases and the next chat in the queue is
/// granted the files, the named holder below follows it rather than freezing on whoever was
/// holding them at the moment this chat parked.
struct LockContentionBanner: View {
@Environment(\.appPalette) private var palette
let wait: AppStore.LockWaitInfo
let deadlocked: Bool
let onReleaseHolder: (AppStore.LockWaitInfo.Holder) -> Void
private var fileList: String {
let shown = wait.files.prefix(3).joined(separator: ", ")
return wait.files.count > 3 ? "\(shown), +\(wait.files.count - 3) more" : shown
}
private var holderList: String {
wait.holders.isEmpty
? "another active chat"
: wait.holders.map { "“\($0.title)”" }.joined(separator: ", ")
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Label(
deadlocked ? "Deadlocked on file locks" : "Waiting for file access",
systemImage: deadlocked ? "exclamationmark.triangle.fill" : "hourglass")
.font(.callout.weight(.semibold))
.foregroundStyle(deadlocked ? palette.attention : .secondary)
Text(
deadlocked
? "This chat and \(holderList) each hold a file the other needs "
+ "(\(fileList)) — neither can proceed until one releases."
: "\(fileList) is held by \(holderList). This chat resumes automatically "
+ "when that work lands.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if !wait.holders.isEmpty {
// One button for the whole escape hatch. The holder is named in the sentence
// above — and can change under this card as the queue advances — so baking a
// name into the label only risked it disagreeing with the text beside it.
Button("Release locks") { wait.holders.forEach(onReleaseHolder) }
.buttonStyle(.bordered)
.controlSize(.small)
.help(
"Force-release every file lock \(holderList) holds so this one can "
+ "proceed. Its unmerged work stays intact; the lock re-arms if it "
+ "keeps editing.")
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
deadlocked ? palette.attention.opacity(0.08) : AppTheme.composerFill,
in: .rect(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(
deadlocked ? palette.attention.opacity(0.35) : AppTheme.composerStroke,
lineWidth: 1))
}
}
/// A live "the agent is working" row shown at the foot of the transcript while a
/// turn is in flight, so a sent message visibly has somewhere to land.
struct WorkingIndicator: View {
let text: String
/// The label minus a single trailing ellipsis ("Waiting for answers…" → "Waiting
/// for answers"); the dots are rendered separately so they can animate.
private var base: String {
text.hasSuffix("…") ? String(text.dropLast()) : text
}
private var hasEllipsis: Bool { text.hasSuffix("…") }
var body: some View {
HStack(spacing: 8) {
ProgressView().controlSize(.small).tint(.secondary)
HStack(spacing: 0) {
Text(base)
if hasEllipsis { AnimatedEllipsis() }
}
.font(.callout).foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.transition(.opacity)
}
}
/// A three-dot ellipsis that cycles ·, ·· , ··· — a "still waiting" pulse. All three
/// dots are always laid out (only their opacity changes) so the label never reflows.
/// Driven by `TimelineView` (pure SwiftUI) so there's no Combine timer to manage.
private struct AnimatedEllipsis: View {
private static let step = 0.4
var body: some View {
TimelineView(.periodic(from: .now, by: Self.step)) { context in
let phase = Int(context.date.timeIntervalSinceReferenceDate / Self.step) % 4
HStack(spacing: 0) {
ForEach(0..<3, id: \.self) { index in
Text(".").opacity(index < phase ? 1 : 0.2)
}
}
}
}
}
struct ApprovalBar: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let request: ApprovalRequest
/// When set, the request belongs to a specific (off-screen) session — the ephemeral popup
/// window — so answers route there rather than to the single open session. nil = open session.
var sessionID: SessionID? = nil
/// The on-device model's cross-check of a host_exec reason against its command, filled in
/// asynchronously by the `.task` below. Nil while the check is in flight (the card shows
/// "Checking…") and for any non-host-exec request.
@State private var reasonAudit: HostExecReasonAudit?
/// `ExitPlanMode` is a three-way review rather than a generic permission prompt. Choosing
/// Revise reveals this feedback field; submitting it denies the transition while returning
/// the requested changes to Claude so it stays in plan mode.
@State private var revisingPlan = false
@State private var planRevision = ""
/// The full, untruncated content being granted (full command / path / URL / input).
/// `request.title` is only a one-line summary capped at 80 chars; the user must be
/// able to read everything before allowing, so the detail body is shown verbatim.
private var detail: String {
RiskClassifier.detail(toolName: request.toolName, input: request.input)
}
/// A git commit pipeline, when this request is one — rendered as a structured card
/// (subject + body + steps) instead of a raw command blob. Nil for any other command.
private var gitBlock: GitCommandSummary.Block? {
guard request.toolName == "Bash",
let command = request.input["command"]?.stringValue,
let block = GitCommandSummary.block(for: command), block.isCommit
else { return nil }
return block
}
/// A non-commit `rm`/`git` pipeline, when this request is one — rendered as a step list
/// (with destructive deletes flagged) instead of a raw command blob. Nil when a commit
/// card already covers it, or when there's nothing worth a card: a single non-destructive
/// op reads fine in the title, so a card is shown only for a multi-step pipeline or any
/// destructive delete.
private var shellBlock: ShellCommandSummary.Block? {
guard gitBlock == nil,
request.toolName == "Bash",
let command = request.input["command"]?.stringValue,
let block = ShellCommandSummary.block(for: command),
block.steps.count >= 2 || block.steps.contains(where: { $0.destructive })
else { return nil }
return block
}
/// The shell command of a `host_exec` request, when this is one — rendered as a structured
/// card (tool identity + pretty command block) instead of the raw `{"command": …}` JSON the
/// generic detail box would otherwise show. Nil for any other tool. The command escapes the
/// sandbox onto the host, so naming the tool and showing the command in full both matter.
/// Nil for a host-command *conflict* prompt — that has its own card (`hostCommandConflict`).
private var hostExecCommand: String? {
guard request.toolName == MCPApprovalServer.qualifiedHostExecToolName,
hostCommandConflict == nil,
let command = request.input["command"]?.stringValue,
!command.isEmpty
else { return nil }
// Expand `$PWD`-style substitutions so the card shows what the command actually resolves
// to on the host, rather than an opaque `$PWD` the user can't verify.
return HostCommandSummary.expand(command, environment: hostExecEnvironment)
}
/// The environment used to expand `$PWD`-style substitutions in a `host_exec` command: the host
/// process environment with `PWD` pinned to the command's working directory the request carries,
/// since a host_exec command runs with its cwd set to that workdir (so `$PWD` resolves there).
private var hostExecEnvironment: [String: String] {
HostCommandSummary.hostDisplayEnvironment(workingDirectory: request.input["cwd"]?.stringValue)
}
/// The agent's stated justification for the `host_exec` call, when one is present. Shown on the
/// card as an *unverified claim* — the command breakdown beside it is the ground truth, so the
/// user can weigh what the agent says against what the command actually does.
private var hostExecReason: String? {
guard let reason = request.input["reason"]?.stringValue,
!reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
return reason
}
/// The host-command conflict payload of a `host_exec` override prompt, when this request is one
/// (HOST_EXEC §concurrency): another session is already running a *similar* command. Drives the
/// dedicated conflict card + "Run anyway" / "Cancel" buttons. Nil for an ordinary host-exec gate.
private var hostCommandConflict: HostCommandConflictInfo? {
guard request.toolName == MCPApprovalServer.qualifiedHostExecToolName,
let entries = request.input["host_command_conflict"]?["with"]?.arrayValue,
!entries.isEmpty
else { return nil }
let others = entries.compactMap { entry -> HostCommandConflictInfo.Other? in
guard let session = entry["session"]?.stringValue else { return nil }
return .init(
session: session,
command: entry["command"]?.stringValue ?? "",
reason: entry["reason"]?.stringValue ?? "")
}
guard !others.isEmpty else { return nil }
return .init(command: request.input["command"]?.stringValue ?? "", others: others)
}
/// Whether this is a `mac_vm_request_operator` request — the agent asking the user to operate the
/// session's macOS VM by hand (CAPTCHA / login). Rendered as its own card with an "Open viewer &
/// help" action, rather than the raw `{"instructions": …}` JSON the generic detail box would show.
private var isOperatorAssist: Bool {
request.toolName == MCPApprovalServer.qualifiedMacVMOperatorToolName
}
/// The agent's instructions for an operator-assist request — what the user should do in the VM.
private var operatorInstructions: String? {
guard isOperatorAssist,
let s = request.input["instructions"]?.stringValue,
!s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
return s
}
/// The agent's stated reason a human is required, for an operator-assist request (a claim).
private var operatorReason: String? {
guard isOperatorAssist,
let s = request.input["reason"]?.stringValue,
!s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
else { return nil }
return s
}
/// The plan of an `ExitPlanMode` request, when this is one — the agent presenting the plan it
/// wrote and asking to leave plan mode. Rendered as the formatted-Markdown plan card instead of
/// the escaped-JSON blob the generic detail box would show. Nil for any other tool.
private var planToApprove: ExitPlanMode.Plan? {
guard request.toolName == ExitPlanMode.toolName else { return nil }
return ExitPlanMode.plan(from: request.input)
}
/// Whether a structured card carries the request's detail — when one does, the title row
/// just states the gate and the card supplies the specifics.
private var showsCard: Bool {
gitBlock != nil || shellBlock != nil || hostExecCommand != nil || hostCommandConflict != nil
|| isOperatorAssist || planToApprove != nil
}
/// Kept separate from `planToApprove`: newer Claude builds may omit the Markdown from the
/// permission payload. The review must still expose only Accept / Deny / Revise and must never
/// fall back to generic Allow Always behavior just because there is no renderable plan body.
private var isExitPlanMode: Bool { request.toolName == ExitPlanMode.toolName }
var body: some View {
// Title and actions are stacked vertically so the title (often a long file
// path) can wrap to as many lines as it needs instead of being truncated to
// share one row with the buttons.
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "pause.circle.fill").foregroundStyle(palette.attention).font(.title)
VStack(alignment: .leading, spacing: 2) {
// A commit headline / step list lives in the card below, so when one shows
// the title row just states the gate; everything else shows its own
// one-line summary.
// A plan card keeps the request's own "Ready to code?" title (the card below
// is the plan, not a separate gate summary); every other card reads as a gate.
Text(showsCard && planToApprove == nil ? "Permission requested" : request.title)
.font(.title3).bold()
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.textSelection(.enabled)
// The host-exec and conflict cards name the tool in their own headers, so the
// generic `mcp__…__host_exec · hostExec` line would only repeat it less legibly;
// the plan card likewise reads on its own without an "ExitPlanMode · readOnly" line.
if hostExecCommand == nil, hostCommandConflict == nil, !isOperatorAssist,
planToApprove == nil {
// The gate reads as what it grants — "macOS VM", "linux_container" —
// rather than as the `mcp__nucleic__…` wire name. The fully-qualified
// name stays one hover away, since this is the surface where knowing
// exactly which tool is being allowed matters most.
Text("\(SandboxToolDisplay.gateLabel(for: request.toolName)) · \(request.risk.rawValue)")
.font(.subheadline).foregroundStyle(.secondary)
.help(request.toolName)
}
}
Spacer(minLength: 0)
}
if let gitBlock {
// The whole git pipeline as a GUI element; the literal command stays one tap
// away under "Show command" so the user can still verify exactly what's granted.
GitBlockCard(block: gitBlock, rawCommand: detail, tint: palette.attention)
} else if let shellBlock {
// A mixed `rm`/`git` pipeline as a step list, destructive deletes flagged; the
// literal command stays one tap away under "Show command".
CommandStepsCard(block: shellBlock, rawCommand: detail, tint: palette.attention)
} else if let hostCommandConflict {
// A host-command conflict: another session is already running a similar command.
// Name which session(s) and why, so the user can spot a true clash — or override a
// false positive — at a glance (HOST_EXEC §concurrency).
HostCommandConflictCard(info: hostCommandConflict, tint: palette.attention)
} else if let hostExecCommand {
// A `host_exec` call: name the tool being invoked and show the command it wants
// to run on the host, pretty-formatted, instead of the raw `{"command": …}` JSON.
HostExecCard(
command: hostExecCommand, toolName: request.toolName,
reason: hostExecReason, reasonAudit: reasonAudit, tint: palette.attention,
projectRoot: store.openSession.flatMap { store.project($0.projectID)?.rootPath },
worktreeRoot: store.openSession?.worktreePath)
.task(id: request.id) {
// Cross-check the agent's reason against the command on-device, so a reason
// that doesn't match what the command does is flagged before approval. Runs
// only when a reason was actually given; the verdict comes from a separate
// local model, not the agent, so it can't be spoofed.
guard let reason = hostExecReason else { return }
reasonAudit = await store.intelligence.auditHostExecReason(
command: hostExecCommand, reason: reason)
}
} else if isOperatorAssist {
// A `mac_vm_request_operator` call: the agent needs the user to operate the VM by hand.
// Show what to do + why, instead of the raw `{"instructions": …}` JSON; the buttons
// below open the interactive viewer.
MacVMOperatorRequestCard(
instructions: operatorInstructions ?? "",
reason: operatorReason, tint: palette.attention)
} else if let planToApprove {
// An `ExitPlanMode` call: render the plan the agent presents as formatted Markdown
// (the same card the transcript shows), instead of the raw `{"plan": …}` JSON.
ExitPlanModeCard(plan: planToApprove)
} else if !detail.isEmpty, !request.title.contains(detail) {
// Full, untruncated content in a monospaced block — the command wraps and
// the box fits its content, only scrolling vertically once the content is
// genuinely tall, so the user can always see exactly what they are granting
// without a giant half-empty box. Shown only when it adds detail beyond the
// title (a short command the title already shows in full needs no echo).
ApprovalDetailBox(text: detail)
}
if isExitPlanMode, revisingPlan {
VStack(alignment: .leading, spacing: 6) {
Text("What should Claude change?")
.font(.subheadline.weight(.semibold))
TextField(
"Describe the changes you want in the plan…",
text: $planRevision,
axis: .vertical)
.lineLimit(2...6)
.textFieldStyle(.roundedBorder)
}
}
HStack(spacing: 8) {
if isExitPlanMode {
if revisingPlan {
Button("Cancel") {
planRevision = ""
revisingPlan = false
}
Spacer()
Button("Send Revision") { submitPlanRevision() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(ExitPlanMode.revisionReason(feedback: planRevision) == nil)
} else {
Button("Deny") {
respond(.deny(reason: ExitPlanMode.rejectionReason))
}
Spacer()
Button("Revise") { revisingPlan = true }
Button("Accept") { respond(.allow()) }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
} else if hostCommandConflict != nil {
// A conflict override is a one-off judgement call, not a trust grant: "Cancel"
// (skip this command, tell the agent to retry), "Wait" (queue behind the running
// twin and auto-run once it's clear), or "Run anyway" (it's a false positive).
// Never a remembered allow (HOST_EXEC §concurrency).
Button("Cancel") { respond(.deny(reason: "Skipped to avoid a concurrent host command")) }
Spacer()
// "Wait" is a non-deny allow, tagged so the host-exec gate queues it (FIFO) instead
// of overriding — see HostCommandConflictSignal.waitInputKey.
Button("Wait") {
respond(.allow(updatedInput: .object([
HostCommandConflictSignal.waitInputKey: .bool(true)
])))
}
Button("Run anyway") { respond(.allow()) }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
} else if isOperatorAssist {
// Operator hand-off: "Decline" tells the agent the user won't help; "Open viewer &
// help" hands the user the interactive VM + a walkthrough popup. The popup — NOT
// this button — resolves the approval (with {completed, note}) once the user is done,
// so opening the viewer leaves the request pending on purpose.
Button("Decline") { respond(.deny(reason: "The user declined to help operate the VM.")) }
Spacer()
Button("Open viewer & help") {
MacVMOperatorAssistController.shared.begin(request: request, store: store)
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
} else {
Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
Spacer()
if request.risk == .hostExec {
// Host execution escapes the sandbox: the only "remember" option is a
// deliberate, session-scoped grant — never a blanket tool allow (HOST_EXEC).
Button("Allow for Session") { respond(.allowAlways(.session)) }
} else if request.risk != .destructive {
// A destructive action (rm, force-push, reset --hard, …) offers no
// remembered allow: every one must be a deliberate, one-off approval,
// never granted in a way that lets the next one through unseen.
Button("Allow Always") { respond(.allowAlways(.toolName)) }
}
Button("Allow") { respond(.allow()) }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
.animation(ComposerMotion.layout(reduceMotion), value: revisingPlan)
.onChange(of: request.id) { _, _ in
revisingPlan = false
planRevision = ""
}
}
private func respond(_ decision: Decision) {
let id = request.id
if let sessionID {
Task { await store.respondToApproval(id, decision, inSession: sessionID) }
} else {
Task { await store.respondToOpenApproval(id, decision) }
}
}
private func submitPlanRevision() {
guard let reason = ExitPlanMode.revisionReason(feedback: planRevision) else { return }
respond(.deny(reason: reason))
}
}
/// The full content of an approval, wrapped and selectable. The box sizes to its
/// content — a short command sits in a snug box — and only begins scrolling once the
/// content exceeds `maxHeight`, so a large paste stays bounded instead of pushing the
/// Allow/Deny buttons off-screen. The text wraps; it never scrolls horizontally.
private struct ApprovalDetailBox: View {
@Environment(\.appPalette) private var palette
let text: String
private let maxHeight: CGFloat = 220
@State private var contentHeight: CGFloat = 0
var body: some View {
ScrollView(.vertical) {
Text(text)
.font(.callout.monospaced())
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(GeometryReader { geo in
Color.clear.preference(key: ApprovalDetailHeightKey.self, value: geo.size.height)
})
}
// Until the first measurement lands, show the content at full height so it
// never flashes as a zero-height sliver.
.frame(height: contentHeight == 0 ? nil : min(contentHeight, maxHeight))
.onPreferenceChange(ApprovalDetailHeightKey.self) { contentHeight = $0 }
.background(palette.attention.opacity(0.06), in: .rect(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(palette.attention.opacity(0.15), lineWidth: 1))
}
}
private struct ApprovalDetailHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
private struct ComposerActionColumnHeightKey: PreferenceKey {
static var defaultValue: CGFloat { 0 }
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// Answer UI for the built-in `AskUserQuestion` tool. Unlike a permission gate,
/// this collects the user's choice per question and folds the selections into the
/// approval reply's `updatedInput` (see `AskUserQuestion`); replying with a plain
/// allow makes the CLI report "the user did not answer the questions".
///
/// The questions are presented one at a time as a wizard — Back/Next to move
/// between them and Submit on the final step — so a multi-question ask reads as a
/// focused sequence rather than one long stacked form.
struct AskUserQuestionBar: View {
@Environment(AppStore.self) private var store
@Environment(\.appPalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let request: ApprovalRequest
let questions: [AskUserQuestion.Question]
/// See `ApprovalBar.sessionID` — routes answers to the ephemeral popup's session. nil = open.
var sessionID: SessionID? = nil
/// Selected option labels per question text.
@State private var selected: [String: Set<String>] = [:]
/// Whether the free-text "Other" answer is active per question text.
@State private var otherActive: [String: Bool] = [:]
/// The free-text "Other" answer per question text.
@State private var otherText: [String: String] = [:]
/// Index of the question currently shown (the wizard step).
@State private var step = 0
var body: some View {
VStack(alignment: .leading, spacing: 14) {
if let question = currentQuestion {
VStack(alignment: .leading, spacing: 6) {
if questions.count > 1 {
Text("Question \(step + 1) of \(questions.count)")
.font(.caption).foregroundStyle(.secondary)
}
if let header = question.header, !header.isEmpty {
Text(header.uppercased())
.font(.caption).bold().foregroundStyle(.secondary)
}
Text(question.question).font(.title3).bold()
ForEach(question.options) { option in
optionRow(question, label: option.label, description: option.description)
}
otherRow(question)
}
.id(step)
.transition(.opacity)
}
HStack {
Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
Spacer()
if step > 0 {
Button("Back") { step -= 1 }
}
if isLastStep {
Button("Submit") { submit() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!allAnswered)
} else {
Button("Next") { step += 1 }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(!currentAnswered)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.attention.opacity(0.08), in: .rect(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(palette.attention.opacity(0.25), lineWidth: 1))
.animation(ComposerMotion.layout(reduceMotion), value: step)
}
/// The question for the active step, or `nil` if the index is somehow out of range.
private var currentQuestion: AskUserQuestion.Question? {
questions.indices.contains(step) ? questions[step] : nil
}
private var isLastStep: Bool { step >= questions.count - 1 }
/// Whether the question on the current step has at least one answer — gates `Next`.
private var currentAnswered: Bool {
guard let currentQuestion else { return false }
return !answers(for: currentQuestion).isEmpty
}
@ViewBuilder
private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?)
-> some View
{
let isOn = selected[question.question]?.contains(label) ?? false
Button { toggle(question, label) } label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
.foregroundStyle(isOn ? palette.attention : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.body)
.fixedSize(horizontal: false, vertical: true)
if let description, !description.isEmpty {
// Let the subtext wrap to as many lines as it needs — inside a Button
// label with a trailing Spacer, an unconstrained Text truncates to one
// line, hiding the rest of the answer. Matches AskUserQuestionResultCard.
Text(description).font(.subheadline).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private func otherRow(_ question: AskUserQuestion.Question) -> some View {
let isOn = otherActive[question.question] ?? false
HStack(alignment: .center, spacing: 8) {
Button { toggleOther(question) } label: {
Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
.foregroundStyle(isOn ? palette.attention : .secondary)
}
.buttonStyle(.plain)
TextField(
"Other…",
text: Binding(
get: { otherText[question.question] ?? "" },
set: { newValue in
otherText[question.question] = newValue
if !newValue.isEmpty { activateOther(question) }
})
)
.textFieldStyle(.roundedBorder)
}
}
private func symbol(multiSelect: Bool, on: Bool) -> String {
if multiSelect { return on ? "checkmark.square.fill" : "square" }
return on ? "largecircle.fill.circle" : "circle"
}
private func toggle(_ question: AskUserQuestion.Question, _ label: String) {
var set = selected[question.question] ?? []
if question.multiSelect {
if set.contains(label) { set.remove(label) } else { set.insert(label) }
} else {
set = set.contains(label) ? [] : [label]
otherActive[question.question] = false
}
selected[question.question] = set
}
private func toggleOther(_ question: AskUserQuestion.Question) {
let next = !(otherActive[question.question] ?? false)
otherActive[question.question] = next
if next, !question.multiSelect { selected[question.question] = [] }
}
private func activateOther(_ question: AskUserQuestion.Question) {
otherActive[question.question] = true
if !question.multiSelect { selected[question.question] = [] }
}
/// Chosen labels for a question: picked options plus any non-empty "Other".
private func answers(for question: AskUserQuestion.Question) -> [String] {
var labels = Array(selected[question.question] ?? [])
if otherActive[question.question] ?? false {
let custom = (otherText[question.question] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if !custom.isEmpty { labels.append(custom) }
}
return labels
}
private var allAnswered: Bool {
questions.allSatisfy { !answers(for: $0).isEmpty }
}
private func submit() {
var selections: [String: [String]] = [:]
for question in questions { selections[question.question] = answers(for: question) }
let updatedInput = AskUserQuestion.updatedInput(
original: request.input, selections: selections)
respond(.allow(updatedInput: updatedInput))
}
private func respond(_ decision: Decision) {
let id = request.id
if let sessionID {
Task { await store.respondToApproval(id, decision, inSession: sessionID) }
} else {
Task { await store.respondToOpenApproval(id, decision) }
}
}
}
private extension View {
/// Constrains a view to the transcript's centered reading column: capped at
/// `maxWidth`, padded by `inset`, then centered — so the composer, approval
/// bars, and their dividers line up with the chat text and never run wider.
///
/// `resizeWidth` is the live column width; keying an animation to it (with the same curve
/// and timing the transcript uses) eases these elements' width changes in lockstep with the
/// transcript's prose reflow during a horizontal window resize, so the composer never slides
/// ahead of the text above it. `settling` suppresses that easing while the chat is opening,
/// matching the transcript so a freshly opened chat snaps into place in one pass.
func chatColumn(maxWidth: CGFloat, inset: CGFloat, resizeWidth: CGFloat, settling: Bool) -> some View {
self
.frame(maxWidth: maxWidth, alignment: .leading)
.padding(.horizontal, inset)
.frame(maxWidth: .infinity)
.animation(settling ? nil : .easeOut(duration: 0.2), value: resizeWidth)
}
}