Files
nucleic/Sources/NucleicApp/SessionDetailView.swift
T

2325 lines
124 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import AppKit
import NucleicCore
/// 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
/// 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 = ""
/// 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
@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
/// 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()
/// 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 eager 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
/// 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 eager 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
@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
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
/// 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 = 900
/// 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 }
/// Another device's live typing in this chat's composer (mesh composer streaming). While
/// present, this composer is locked and renders the streamed draft; it unlocks when the
/// typer sends or goes idle (their tombstone — or the local expiry guard — clears it).
private var remoteTyping: ComposerTypingState? {
guard let id = session?.id else { return nil }
return store.composerTypingBySession[id]
}
// 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)
// 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 = store.openTranscript.reversed().lazy.compactMap { event -> Int? in
switch event.kind {
case .turnCompleted(let turn): return turn.usage?.contextInputTokens
case .usage(let usage): return usage.contextInputTokens
default: return nil
}
}.first
guard let used, used > 0 else { return nil }
return ContextWindowUsage(
usedTokens: used, windowTokens: ModelCatalog.contextWindow(for: effectiveModel))
}
/// Shown when the open session lives on a peer Mac (mesh session sync): the transcript is
/// mirrored live, and messages, approvals, and the header's session controls (rename,
/// model/effort, Auto/Ship, interrupt) route to the owning Mac. Worktree-rooted chrome
/// (files/terminal/editor panels, Build/Run, git ops) stays local-only.
@ViewBuilder
private var meshViewingBanner: some View {
HStack(spacing: 6) {
Image(systemName: "globe")
Text("On another Mac — it runs there; your messages, approvals, and controls go to it.")
.font(.caption)
Spacer()
}
.foregroundStyle(.secondary)
.padding(.horizontal, 12).padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(.regularMaterial)
}
var body: some View {
VStack(spacing: 0) {
header
if store.openHostID != nil { meshViewingBanner }
Divider()
transcript
if let approval = store.openApprovals.first {
Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
Group {
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionBar(request: approval, questions: questions)
} else {
ApprovalBar(request: approval)
}
}
.chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
}
Divider().chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
composer
}
// 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
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
}
// 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()
}
}
.toolbar {
ToolbarItemGroup {
if isBusy {
Button { Task { await store.interruptOpenSession() } } label: {
Label("Stop", systemImage: "stop.circle")
}
.help("Interrupt the running turn")
}
Button { Task { await store.refreshOpenStatus() } } label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
if let session, let project = store.project(session.projectID) {
BuildRunControls(
project: project,
workingDirectory: session.worktreePath,
compact: true)
}
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")
}
.tint(.white)
.disabled(integrating)
.help("Merge, or open this chat's worktree for any git command")
Button { Task { await exportSession() } } label: {
Label("Export", systemImage: "square.and.arrow.up")
}
.help("Export the full chat (with backend/debug info) to a file")
Button(role: .destructive) { Task { await store.discardOpenSession() } } label: {
Label("Discard", systemImage: "trash")
}
}
}
.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 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(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, 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 {
isOrchestra && !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), id: \.self) { level in
Button { Task { await store.setOpenSessionEffort(level) } } label: {
choiceLabel(ModelCatalog.effortDisplayName(level),
isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
}
// Orchestra is a one-way latch: once it's on for a chat it can't be turned off, so
// the ordinary levels are disabled (the host ignores the switch anyway — see
// `SessionController.setEffort`). Keeps the menu honest rather than a silent no-op.
.disabled(isOrchestra)
}
// 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). It's
// fixed at creation (its supervisor model is chosen when the chat is born), so it can't
// be switched on mid-chat: the row is disabled unless the chat is already in Orchestra,
// where it shows the locked-on state. Also gated to Control projects (with the reason).
Divider()
Button { Task { await store.setOpenSessionEffort(ModelCatalog.orchestraEffort) } } label: {
orchestraMenuItem(available: isControlProject)
}
.disabled(!isControlProject || !isOrchestra)
.help(!isControlProject
? ModelCatalog.orchestraRequiresControlHelp
: (isOrchestra ? ModelCatalog.orchestraLockedHelp : ModelCatalog.orchestraStartOnlyHelp))
} 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(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(
isOrchestra ? AnyShapeStyle(AppTheme.orchestra.opacity(0.6)) : AnyShapeStyle(.quaternary),
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)
}
}
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)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(locked)
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
.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)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.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 {
Divider().frame(height: 16)
shipDestinationMenu(inherited: inherited, override: override, current: current)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(.quaternary, lineWidth: 1))
.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)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.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? {
var total = 0
var sawAny = false
for event in store.openTranscript {
if case .turnCompleted(let turn) = event.kind, let usage = turn.usage {
total += (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
sawAny = true
}
}
return sawAny ? total : nil
}
/// 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? {
let fromTranscript = store.openTranscript.last { event in
if case .userText = event.kind { return true }
return false
}?.at
return fromTranscript ?? 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? {
store.openTranscript.reversed().lazy.compactMap { event -> Int? in
if case .runFinished(let run) = event.kind { return run.durationMs }
return nil
}.first
}
/// 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 live, coalesced transcript: streaming deltas merged into single rows so
/// thoughts, tool calls, and assistant text show as they're produced. Lock-lifecycle notes are
/// folded onto the edit's tool card they bracket (LOCKING §4); `lockLines` carries them keyed by
/// tool-call id for the rows to render, and a note that matched no card stays a standalone row
/// (and is still gated by the "Show lock events" filter below).
private func projectedTranscript() -> (items: [TranscriptItem], lockLines: [String: [NoteLock]]) {
// The projection is an O(n) partition/fold/coalesce over the whole transcript, and
// `body` re-evaluates far more often than the transcript actually changes — every
// scroll moves a preference (viewport height, bottom edge), and hover/selection also
// re-run it. Memoize on a token that captures exactly the inputs: the open session,
// its monotonic transcript version (bumps on any content change, see AppStore), the
// worktree root, and the two display filters. Identical token ⇒ identical projection.
let token = ProjectionToken(
session: store.openSessionID,
version: store.openTranscriptVersion,
worktree: store.openSession?.worktreePath,
showDebug: showDebugLines,
showLock: showLockEvents)
if projectionCache.token == token { return projectionCache.value }
var result = TranscriptProjection.items(
store.openTranscript, worktreeRoot: store.openSession?.worktreePath)
if !showDebugLines { result.items = result.items.filter { !$0.isDebug } }
if !showLockEvents { result.items = result.items.filter { !$0.isLockEvent } }
projectionCache.token = token
projectionCache.value = result
return result
}
/// A token that changes iff `projectedTranscript`'s inputs change.
private struct ProjectionToken: Equatable {
let session: SessionID?
let version: Int
let worktree: String?
let showDebug: Bool
let showLock: Bool
}
/// 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.
private final class ProjectionCache {
var token: ProjectionToken?
var value: (items: [TranscriptItem], lockLines: [String: [NoteLock]]) = ([], [:])
}
/// 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 {
store.openTranscript.contains { event in
switch event.kind {
case .turnCompleted, .runFinished: return true
default: return false
}
}
}
/// 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 open session's transcript has been loaded (it's cleared
/// synchronously on open, then filled asynchronously by `reloadOpen`).
private var transcriptLoaded: Bool { !store.openTranscript.isEmpty }
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 content exists
// means it first appears with the full, eagerly-laid-out transcript present, so the
// bottom anchor initializes at the true end in one layout pass — no scrolling at all.
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)
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 {
// Brief placeholder while the (fast, local) reload fills the transcript.
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)
}
}
}
// Hold the whole transcript (prose + summary card) invisible until its eager layout
// has pinned to the bottom, then reveal it in one shot. The settle happens off-screen,
// so the chat appears already at the tail — no visible scroll, no prose composited
// mid-move (the thin/fuzzy look). The flip is set outside any animation, so it snaps
// rather than fading (a fade would re-introduce the compositing blur it's avoiding).
.opacity(transcriptRevealed ? 1 : 0)
// Until the chat is revealed, suppress *every* implicit animation inside the
// transcript — the tool-summary cross-fades (TranscriptRow) and the scroll-anchor
// re-pin that fire as the layout settles on open. Animating any of those rasterizes
// the affected text into an offscreen layer, where macOS drops font-smoothing — the
// thin, fuzzy, shimmering look — until the animation lands. With them forced off, the
// settle is instant and the text is drawn straight to the window, so it stays crisp.
.transaction { txn in
if !transcriptRevealed { txn.animation = nil }
}
}
// A freshly opened chat must land at the bottom against its OWN geometry, not
// the previous chat's last-measured position. Reset to the at-bottom default,
// and hold the settling guard up while the new transcript performs its first
// layout passes — long enough to cover the open, short enough that subsequent
// live output animates normally.
.task(id: store.openSessionID) {
transcriptSettling = true
transcriptRevealed = false
transcriptContentHeight = 0
isScrolledToBottom = true
try? await Task.sleep(for: .milliseconds(300))
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))
transcriptRevealed = true
}
}
/// How close (pts) the bottom of the content must be to the viewport bottom to
/// still count as "scrolled to the bottom" — a little slack so sub-pixel rest
/// positions and the in-flight growth of a streaming line keep following live output.
private let bottomFollowThreshold: CGFloat = 24
private func scrollableTranscript(reserveCardSpace: Bool, containerWidth: CGFloat) -> some View {
let projection = projectedTranscript()
return ScrollViewReader { proxy in
ScrollView {
// Eager VStack (not Lazy): the whole transcript is already in memory,
// and eager layout gives the ScrollView the true total content height
// up front. That's what lets `.defaultScrollAnchor(.bottom)` *initialize*
// at the real bottom in a single pass — no scrolling. A LazyVStack only
// realizes rows as they're scrolled into view, so the bottom anchor lands
// on an estimated (partial) height and drifts as rows resolve, which is
// what made opening land partway down or render blank.
VStack(alignment: .leading, spacing: 6) {
// An invisible twin of the floating summary card: it occupies the
// card's exact height at the top so the first message is pushed
// down below the (pinned, right-aligned) visible card rather than
// being covered by it. Same binding ⇒ identical height. Skipped when
// the card is docked in the margin, where it overlaps no content.
if reserveCardSpace {
summaryCardView()
.hidden()
.frame(maxWidth: .infinity, alignment: .trailing)
}
ForEach(TranscriptRow.hidingResolvedAuthArtifacts(projection.items)) { item in
// 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, where messages are read-only until unarchived.
// `.equatable()` diffs rows by content (TranscriptRow.==): the settled
// bulk of a long chat skips re-rendering on every streaming update,
// and only the rows whose item actually changed re-evaluate.
TranscriptRow(
item: item,
onRevert: (isBusy || isArchived) ? nil : { revertSeq = $0 },
onLogin: startClaudeLogin,
onOpenVMMonitor: { panels.revealVMMonitor() },
// Skip is offered for a local session's own running exec calls; a
// peer-owned (mesh-viewed) session runs its exec on the owner, and there's
// no remote-Skip command yet, so it stays hidden there.
onSkip: (isArchived || store.openHostID != nil)
? nil
: { call in Task { await store.skipOpenSessionToolCall(call) } },
// Same gating as Skip: a hung-host-command alert's Kill/Keep-waiting act on
// the local session's own in-flight command; hidden for archived or
// peer-owned (mesh-viewed) sessions.
onResolveStall: (isArchived || store.openHostID != nil)
? nil
: { stallID, kill in
Task { await store.resolveOpenSessionStall(stallID, kill: kill) }
})
.equatable()
.id(item.id)
}
if isBusy {
WorkingIndicator(text: progressText).id(Self.workingID)
}
// The bottom padding is a real trailing element AND the scroll
// target, so scrolling to the end reveals the full gap above the
// composer — not just the last message's bottom edge (which left
// this padding hidden just below the viewport on open).
Color.clear
.frame(height: transcriptBottomPadding)
.id(Self.bottomAnchorID)
}
.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)
.frame(maxWidth: contentMaxWidth, alignment: .leading)
.padding(.horizontal, transcriptInset)
.padding(.top, 14)
.frame(maxWidth: .infinity)
// Smooth the prose reflow during a horizontal window resize. Keying the
// animation to the container width (rather than a blanket `.animation()`)
// makes SwiftUI re-lay the wrapping text at each *interpolated* width
// across the easing curve, so words glide across line breaks instead of
// snapping frame-by-frame on a fast drag. Scoping it to `containerWidth`
// leaves streaming text and newly appended messages instant — those don't
// change the width, so this modifier never touches them. Suppressed
// while the chat is opening: the first layout passes settle the width,
// and easing those into place is the open-time reflow jitter — let them
// snap, and animate only genuine resizes once the chat has landed.
.animation(transcriptSettling ? nil : .easeOut(duration: 0.2), value: 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.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.scrollContentBackground(.hidden)
// Track the live scroll position straight from the scroll view's geometry:
// how much content still sits below the viewport bottom. Within the slack
// threshold means the user is parked at the end, so live output keeps
// following; scrolling up flips this false, which drops the anchor (above)
// and reveals the jump-to-bottom chevron. (Replaces a coordinate-space /
// preference measurement that never updated mid-scroll, so `isScrolledToBottom`
// was stuck true — the chat always re-followed and the chevron never showed.)
.onScrollGeometryChange(for: Bool.self) { geo in
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
<= bottomFollowThreshold
} action: { _, atBottom in
// While the chat is opening, the eager layout grows over a few passes and
// the scroll offset lags each growth by a frame — sampling that frame reads
// "not at bottom" even though `.defaultScrollAnchor(.bottom)` is about to
// re-pin. Honoring it drops the anchor to `nil` mid-re-pin and strands the
// chat slightly above its true bottom (recovering only if the gap happens to
// fall back under the slack threshold — the "appears higher, then scrolls
// down, sometimes sticks" open glitch). So during the open window accept only
// "at bottom" readings: stay pinned until the layout is stable. Once it
// clears, honor real scroll-ups so history reading and the chevron work.
if transcriptSettling {
if atBottom { isScrolledToBottom = true }
} else {
isScrolledToBottom = atBottom
}
}
// Reveal the freshly opened transcript only once its content height has come to
// rest. On open the eager 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 }
}
.task(id: transcriptContentHeight) {
guard !transcriptRevealed, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
transcriptRevealed = true
}
// Live output appended during an active session follows the bottom only when
// the user is already parked there — scrolling up to read history is never
// yanked back down. (Neither fires on open: this view is created with the
// transcript already in place, so there's no 0→N change to animate.)
// During the open-settle window these land the bottom instantly (animated:
// false) so a chat that's still settling doesn't ease into place; once the
// window clears, live output follows the bottom with the usual animation.
.onChange(of: store.openTranscript.count) { _, _ in if isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } }
.onChange(of: isBusy) { _, busy in if busy && isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) } }
// 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.
.onChange(of: store.openApprovals.first?.id) { _, id in
if id != nil && isScrolledToBottom { scrollToEnd(proxy, animated: !transcriptSettling) }
}
// An explicit jump — the down arrow or sending a message — always wins.
.onChange(of: scrollToBottomRequest) { _, _ in scrollToEnd(proxy) }
// 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 { scrollToEnd(proxy) }
.padding(.bottom, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
}
}
}
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
// last message 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; every other gate is an approval decision.
if let approval = store.openApprovals.first {
return approval.toolName == AskUserQuestion.toolName
? "Waiting for answers…"
: "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…"
default: return "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
}
/// Whether there's something to send: typed text, or staged attachments on their own (an
/// attachment-only message is allowed — the references become the message body).
private var canSend: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
}
private var composer: some View {
VStack(alignment: .leading, spacing: 8) {
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, 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(alignment: .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 })
.frame(height: composerHeight)
.padding(8)
.background(.quaternary.opacity(0.5), 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)
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.
Button(action: unarchive) {
Text("Unarchive")
.font(.callout.weight(.medium))
.fixedSize()
}
.buttonStyle(.borderedProminent)
.help("Unarchive this chat to send messages and interact with it")
} else if isBusy && !canSend {
Button { Task { await store.interruptOpenSession() } } label: {
Image(systemName: "stop.circle.fill").font(.title2)
}
.buttonStyle(.plain)
.frame(width: sendButtonWidth)
.help("Interrupt the running turn")
} else {
Button(action: send) {
SubmitKeyIcon(mode: submitMode)
}
.buttonStyle(.plain)
.frame(width: sendButtonWidth)
.help(isBusy
? "Queue this message — it sends when the agent finishes its turn"
: submitMode.detail)
// 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)
}
}
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()
modelMenu
effortMenu
}
.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)
.padding(.trailing, sendButtonWidth + 8)
// The mode/model controls go inert (and dim) alongside the field in an
// archived chat — only Unarchive stays live.
.disabled(isArchived)
}
.padding(.top, 22)
.padding(.bottom, 12)
// Match the transcript's centered column so the input area and its
// accessories never extend wider than the chat text above them.
.chatColumn(maxWidth: contentMaxWidth, inset: transcriptInset, resizeWidth: columnWidth, settling: transcriptSettling)
// 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()` — ends the typing and unlocks them.
.onChange(of: draft) { _, text in
guard let id = session?.id, !isArchived else { return }
store.composerDraftChanged(id, text: text)
}
// Switching chats (or closing to none) ends any streaming typing in the one we left.
.onChange(of: session?.id) { old, _ in
if let old { store.composerDraftEnded(old) }
}
.onDisappear {
if let id = session?.id { store.composerDraftEnded(id) }
}
}
/// The friendly label for a remote typer, never blank.
private func typerName(_ typing: ComposerTypingState) -> String {
typing.deviceName.isEmpty ? "Another device" : typing.deviceName
}
/// 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(.quaternary.opacity(0.5), in: .rect(cornerRadius: 12))
.help("Someone is composing a message for this chat on another device. The composer unlocks when they send or stop.")
}
/// 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(.quaternary.opacity(0.5), 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) }
}
}
private func send() {
// 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 trimmed = 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
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
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"
guard panel.runModal() == .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)"
}
}
}
/// 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 message")
}
}
/// 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
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?
/// 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
}
/// 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
}
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.
Text(showsCard ? "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.
if hostExecCommand == nil, hostCommandConflict == nil, !isOperatorAssist {
Text("\(request.toolName) · \(request.risk.rawValue)")
.font(.subheadline).foregroundStyle(.secondary)
}
}
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 !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)
}
HStack(spacing: 8) {
Spacer()
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")) }
// "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.")) }
Button("Open viewer & help") {
MacVMOperatorAssistController.shared.begin(request: request, store: store)
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
} else {
Button("Deny") { respond(.deny(reason: "Denied from Nucleic")) }
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))
}
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) }
}
}
}
/// 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())
}
}
/// 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
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)
}
}
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))
}
/// 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)
}
}