4977 lines
259 KiB
Swift
4977 lines
259 KiB
Swift
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)
|
||
}
|
||
}
|