2325 lines
124 KiB
Swift
2325 lines
124 KiB
Swift
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)
|
||
}
|
||
}
|