Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
T

1487 lines
83 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicProtocol
/// One session: a full-height transcript with a status-driven action bar floating over it on
/// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on
/// appear, unsubscribes on disappear.
struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore
let sessionID: SessionID
// Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews,
// matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
@State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
@State private var draft = ""
// Mesh composer streaming bookkeeping for `draft`: the text this composer last took from the
// mesh (so a draft consumed on another device clears only what came from there, never what
// has been typed here since), and the same text held for one update as the echo to swallow —
// adopting the shared draft must not stream it straight back out and lock every other device.
@State private var meshDraft: String?
@State private var meshDraftEcho: String?
// Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and
// materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick
// refused for exceeding the per-message size cap.
@State private var attachments: [StagedAttachment] = []
@State private var attachmentsOverflowed = false
@State private var showRename = false
@State private var renameDraft = ""
@State private var showIntegrate = false
@State private var showDiscard = false
// Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to
// read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
@State private var scrollToBottomRequest = 0
// Owned here (not inside `TranscriptList`) so the jump-to-bottom chevron can ride in the chat
// bar just above the composer. `TranscriptList` drives it from the scroll geometry.
@State private var isScrolledToBottom = true
// The transcript's on-screen height, measured so the floating approval / question card can cap
// itself to the screen: a long host command or a long list of options must never grow the card
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
@State private var availableHeight: CGFloat = 0
// This mount's claim on the store's open-session state (`RemoteStore.openGeneration`). Two
// details for the same session can be mounted at once (a Live Activity deep link onto the
// already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
// of wiping the transcript the visible copy is showing.
@State private var openToken = 0
// The Intelligence rail's stop while a gesture is in flight. The committed value is read back
// from the session summary (the host owns it — it re-routes and applies the pair), so this is
// the only local level state: an optimistic stop shown until the host's answer lands, plus the
// live preview under the finger.
@State private var pendingIntelligence: Int?
@State private var intelligencePreview: Int?
private var summary: WireSessionSummary? {
store.sessions.first { $0.sessionID == sessionID }
}
/// This chat's shared composer draft as another device left it (mesh composer streaming) —
/// being edited there right now, or settled and waiting to be picked up here.
private var sharedComposerDraft: ComposerTypingState? {
store.composerTypingBySession[sessionID]
}
/// Another device is editing this chat's composer right now. While present, this composer is
/// locked and renders the incoming draft above the field; it unlocks when that device stops
/// — and the text it left settles into the field (`sharedComposerDraftChanged`).
private var remoteTyping: ComposerTypingState? {
sharedComposerDraft.flatMap { $0.editing ? $0 : nil }
}
/// This chat's shared draft changed on another device — apply it to the field.
///
/// A *settled* entry (the other device stopped typing) moves into the composer: the text
/// carries on where they left off, on every device, instead of vanishing with the lock. A
/// tombstone means the draft was consumed there (sent) or emptied, so this field empties too
/// — but only the part that came from the mesh; anything typed here since stands, and is
/// itself the newer shared draft. An `editing` entry applies nothing: it renders live in the
/// row above the (locked) field until it settles.
private func sharedComposerDraftChanged(
from old: ComposerTypingState?, to new: ComposerTypingState?
) {
guard let new else {
guard old != nil, draft == meshDraft else { return }
meshDraft = nil
applyMeshDraft("")
return
}
guard !new.editing else { return }
meshDraft = new.text
applyMeshDraft(new.text)
}
/// Put mesh-sourced text in the composer without streaming it back out (see the `draft`
/// `onChange` — `meshDraftEcho` is the one update it swallows).
private func applyMeshDraft(_ text: String) {
guard draft != text else { return }
meshDraftEcho = text
draft = text
}
/// The friendly label for a remote typer, never blank.
private func typerName(_ typing: ComposerTypingState) -> String {
typing.deviceName.isEmpty ? "Another device" : typing.deviceName
}
var body: some View {
// The detail's height comes from the enclosing GeometryReader — a value fixed by the parent
// (the navigation content area), never by anything inside it. The attention cards (approval /
// question) cap themselves to this so their buttons stay on-screen. Sourcing it from a
// GeometryReader — rather than measuring the transcript, whose height the cards' own bottom
// `safeAreaInset` resizes — makes the measurement categorically independent of the card, so
// the card can't feed its height back into the value it's sized from. That feedback was an
// unresolved layout cycle that pinned the main thread at 100% CPU during the push transition;
// it showed up intermittently because a feedback loop only diverges for some content/card-
// height combinations. It bit only the attention path (a card consumes this value; the plain
// composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung.
GeometryReader { proxy in
TranscriptList(events: store.openEvents,
scrollToBottomRequest: scrollToBottomRequest,
isScrolledToBottom: $isScrolledToBottom)
// The chat bar floats over the scrolling content on Liquid Glass instead of sitting
// in a boxed strip below it, so the transcript runs the full height of the screen.
.safeAreaInset(edge: .bottom) { actionArea }
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
// With a session open the floating chat bar owns the bottom edge, so the compact
// shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
// `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
// from any entry point, not just the Sessions list), not a per-navigation tab-bar
// toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
// the iPad split, which has no tab bar.
.toolbar {
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
}
.sheet(isPresented: $showDiff) { diffSheet }
.alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {}
Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
}
.confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
Button("Merge") { store.integrate(sessionID, .merge) }
Button("Squash & merge") { store.integrate(sessionID, .squash) }
Button("Rebase") { store.integrate(sessionID, .rebase) }
Button("Cancel", role: .cancel) {}
}
.confirmationDialog(
"Discard this session's branch and worktree? Unmerged work is lost.",
isPresented: $showDiscard, titleVisibility: .visible
) {
Button("Discard", role: .destructive) { store.discard(sessionID) }
Button("Cancel", role: .cancel) {}
}
.onAppear {
openToken = store.open(sessionID)
// Restore any unsent follow-up — the composer keeps what was typed across
// backgrounding, locking, or the view being torn down and remounted. With
// nothing kept here, pick up the chat's shared draft instead: another device
// may have left one settled while this phone was away.
let kept = store.sessionDraft(sessionID)
if kept.isEmpty, let shared = sharedComposerDraft, !shared.editing,
!shared.text.isEmpty {
meshDraft = shared.text
applyMeshDraft(shared.text)
} else {
draft = kept
}
}
// Pass our own id + token so an iPad split-view A→B switch (which may mount B
// before A disappears) unsubscribes A without tearing down B's just-opened state —
// and so a covered duplicate of the *same* session tears down nothing at all.
.onDisappear { store.closeOpen(sessionID, token: openToken) }
.background { interruptShortcut }
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
// resize). Because `proxy.size.height` never depends on the card, updating this can't
// re-drive the measurement — no cycle.
.onChange(of: proxy.size.height, initial: true) { _, height in
availableHeight = height
}
// Stream this composer's draft to the session's host (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 on send — consumes the
// shared draft and empties theirs too. Leaving the detail releases the lock
// while leaving the text as the chat's shared draft.
.onChange(of: draft) { _, text in
// Text this composer just took from the mesh is already the shared draft —
// echoing it back out would claim the editing lock on every other device
// for a change the user never made.
if meshDraftEcho == text {
meshDraftEcho = nil
} else {
meshDraftEcho = nil
store.composerDraftChanged(sessionID, text: text)
}
// Durably save the draft (cleared once the field empties, e.g. on send) so it
// survives leaving the app — see `RemoteStore.setSessionDraft`.
store.setSessionDraft(sessionID, text)
}
// The other device stopped typing (or its draft was consumed): move its text
// down into this field, or clear it — the composer's text is one draft shared
// across the mesh.
.onChange(of: sharedComposerDraft) { old, new in
sharedComposerDraftChanged(from: old, to: new)
}
.onDisappear { store.composerDraftEnded(sessionID) }
}
}
/// The live view of another device's in-progress draft for this chat, shown above the
/// (locked) field. Head-truncated: the tail is where the typing is happening, so it's the
/// part that must stay visible.
private func remoteTypingRow(_ 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(.footnote.weight(.semibold))
.foregroundStyle(Palette.accent)
if !typing.text.isEmpty {
Text(typing.text)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(3)
.truncationMode(.head)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8).padding(.vertical, 6)
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10))
}
/// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
/// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies.
@ViewBuilder
private var interruptShortcut: some View {
if store.canControl, summary?.status == .running {
Button("Interrupt") { store.interrupt(sessionID) }
.keyboardShortcut(".", modifiers: .command)
.opacity(0)
.accessibilityHidden(true)
}
}
/// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
private var controlled: Bool {
store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false
}
private var modelBinding: Binding<String?> {
Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
}
private var effortBinding: Binding<String> {
Binding(
get: {
let fallback = store.modelCatalog.fallbackEffort.isEmpty
? MobileEfforts.fallback : store.modelCatalog.fallbackEffort
return summary?.effort ?? fallback
},
set: { store.setSessionEffort(sessionID, $0) })
}
/// Memo for the context-occupancy scan below. The backward scan usually stops at the last
/// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole
/// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan
/// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as
/// in the projection cache; a class held in `@State` so updating it from `body` doesn't
/// itself invalidate the view.
private final class ContextScanCache {
var count = -1
var lastSeq: UInt64 = 0
var used: Int?
}
@State private var contextScanCache = ContextScanCache()
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
/// from the transcript exactly as the Mac header does. Only the token scan is memoized —
/// the window division stays live, so a model switch reflects immediately.
private var contextPercent: Int? {
let events = store.openEvents
let cache = contextScanCache
if cache.count != events.count || cache.lastSeq != (events.last?.seq ?? 0) {
cache.count = events.count
cache.lastSeq = events.last?.seq ?? 0
cache.used = events.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 { $0 > 0 }
}
guard let used = cache.used else { return nil }
let window = store.modelCatalog.contextWindow(summary?.model)
guard window > 0 else { return nil }
return min(100, Int((Double(used) / Double(window)) * 100))
}
/// The context-occupancy readout and the Auto toggle for the open session, folded into the
/// glass chat bar so they don't cost a strip of transcript height. The model / effort
/// selectors used to sit here too, but they moved into the ⋯ menu (`modelEffortMenu`) to keep
/// the bar compact. Reads current state from the summary; each change is a control intent.
@ViewBuilder
private func controlRow(_ summary: WireSessionSummary) -> some View {
HStack(spacing: 10) {
if let percent = contextPercent {
// Weight and primary ink (not `.secondary`) so the metric stays legible over
// whatever the Liquid Glass bar is blurring through.
Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
.font(.caption2.weight(.semibold)).foregroundStyle(.primary)
.help("Context window used")
}
Spacer(minLength: 4)
// Locked on for a Nucleic Control project — those chats always run autonomously, and
// the host keeps Auto on regardless of what the phone sends, so show it locked on to
// match rather than offering a toggle that wouldn't take.
Toggle(isOn: Binding(get: { controlled || summary.auto },
set: { store.setSessionAuto(sessionID, $0) })) {
Label("Auto", systemImage: (controlled || summary.auto) ? "bolt.fill" : "bolt.slash")
}
.toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold))
.disabled(controlled)
.help(controlled
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
: "Auto-approve safe actions; destructive ones still ask.")
}
}
/// The ⋯ menu: the diff viewer for everyone, then — at control scope — the next-turn model /
/// effort selectors (moved off the composer to keep it compact) and the session actions.
@ViewBuilder
private var sessionMenu: some View {
Menu {
Button { showDiff = true } label: {
Label(diffLabel, systemImage: "plus.forwardslash.minus")
}
if let summary, store.canControl {
Divider()
modelEffortMenu(summary)
Divider()
sessionActions(summary)
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
/// The model and effort selectors for the next turn. With routing on they are no longer the
/// control — the rail below the field is — so they drop out of the ⋯ menu entirely rather than
/// sitting there as a second, contradictory way to set the same thing; the route line under
/// the rail carries the manual pick instead. Without routing they stay exactly where they were.
@ViewBuilder
private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
if !store.routesIntelligence {
// Context Switch can replace the backend between turns. The summary is the live source
// of truth, and the explicit identity rebuilds a native Menu that happened to be open
// across the update instead of retaining its old provider's snapshot.
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
.id(summary.backend)
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
// No explicit model yet → use the current backend's default, so the menu
// shows that backend's effort range rather than collapsing to one level.
modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
controlled: controlled,
// Orchestra is a one-way latch for an existing chat — the host won't turn it
// off, so disable the other levels here to match.
lockWhenOrchestra: true)
}
}
// MARK: - The in-session Intelligence rail
/// The stop this chat's rail sits at. The host owns it — moving the rail sends the stop and
/// the host re-routes within the chat's current backend lane, then broadcasts the resulting
/// model/effort — so the committed value is *derived* from the summary rather than held here.
/// `pendingIntelligence` covers only the round trip, and `intelligencePreview` the drag.
private var sessionIntelligence: Int {
guard let summary else { return store.defaultIntelligenceLevel }
return pendingIntelligence ?? store.sessionIntelligenceLevel(summary)
}
private var sessionOrchestra: Bool {
store.modelCatalog.isOrchestra(summary?.effort)
}
/// The rail and route line for the open chat, sitting below the field where the thumb is.
/// Only where there's something to route: an archived or view-only chat, or a host that
/// doesn't route, keeps the composer as it was.
@ViewBuilder
private func intelligenceRow(_ summary: WireSessionSummary) -> some View {
if store.routesIntelligence, store.canControl {
let level = intelligencePreview ?? sessionIntelligence
VStack(alignment: .leading, spacing: 2) {
IntelligenceRail(
level: Binding(
get: { sessionIntelligence },
set: { next in
// Show the new stop at once and let the host answer with the pair it
// resolves to; `pendingIntelligence` clears when that summary lands.
pendingIntelligence = next
store.setSessionIntelligence(sessionID, level: next)
}),
orchestra: Binding(
get: { sessionOrchestra },
// Orchestra is a one-way latch for an existing chat — the host refuses to
// turn it off (`SessionController.setEffort`), so the rail only ever sends
// the engage, and a pull back toward Max is simply not acted on rather
// than being sent and silently ignored.
set: { on in if on { store.setSessionOrchestra(sessionID) } }),
catalog: store.intelligenceCatalog,
enabled: store.connectivity.isLive && !summary.archived,
// Always offered — no Nucleic Control gate. See the new-chat composer.
orchestraAvailable: store.intelligenceCatalog.orchestraAvailable,
composerText: draft,
routeDescription: sessionRouteDescription,
unavailableReason: summary.archived
? "This chat is archived."
: (store.connectivity.isLive ? nil : "The Mac that owns this chat is offline."),
onPreviewChanged: { intelligencePreview = $0 })
IntelligenceRouteLine(
level: level,
orchestra: sessionOrchestra,
catalog: store.intelligenceCatalog,
model: sessionPreviewPair(summary, level: level)?.model,
effort: sessionPreviewPair(summary, level: level)?.effort,
modelCatalog: store.modelCatalog)
}
// The host's answer is authoritative: drop the optimistic stop as soon as one lands,
// so the rail reads back what actually happened.
//
// Keyed on the recorded *level* as well as the pair: adjacent stops legitimately
// resolve to the same model and effort (Deep and Max share a cell on several purpose
// rows), so watching the pair alone would leave the optimistic value latched on
// exactly the moves that changed nothing visible — where it would then shadow the
// next change made from another device.
.onChange(of: summary.routedLevel) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.model) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.effort) { _, _ in pendingIntelligence = nil }
.onChange(of: summary.backend) { _, _ in pendingIntelligence = nil }
// A refused move — every candidate for that stop is inside a reached quota window —
// changes nothing at all; the host answers with an error instead. Without this the
// rail would stay parked on a stop the chat never reached, so it springs back here
// and the error banner says why.
.onChange(of: store.lastError) { _, error in
if error?.sessionID == sessionID { pendingIntelligence = nil }
}
}
}
/// The route description spoken to VoiceOver — the concrete pair this chat is on now, since
/// for an existing chat that's a fact rather than a prediction.
private var sessionRouteDescription: String? {
guard let summary, let model = summary.model, let effort = summary.effort else { return nil }
return "\(store.modelCatalog.displayName(model)), "
+ "\(store.modelCatalog.effortNoun(forModel: model).lowercased()) "
+ store.modelCatalog.effortDisplayName(effort)
}
/// The pair the route line names for an open chat.
///
/// At rest that is the session's **actual** model and effort as the host broadcast them — a
/// fact, not a prediction, and the same fact the Mac's own composer shows. Re-predicting it
/// here was the mismatch: this device classified the follow-up *draft* while the host routes
/// on what the chat is *for*, so the two named different models for the same stop.
///
/// Only mid-drag does it predict, and then through the session's recorded purpose
/// (`sessionIntelligenceRoute`) — so the number that appears under the thumb is the one the
/// host will actually resolve on release.
private func sessionPreviewPair(
_ summary: WireSessionSummary, level: Int
) -> (model: String, effort: String)? {
if intelligencePreview != nil || pendingIntelligence != nil {
if let route = store.sessionIntelligenceRoute(summary, level: level) {
return (route.model, route.effort)
}
}
guard let model = summary.model, let effort = summary.effort else { return nil }
return (model, effort)
}
/// "View changes" with the live +/− counts when the host projects them, so the menu hints
/// whether there's anything to look at before opening the sheet.
private var diffLabel: String {
guard let stat = summary?.diffStat, stat.filesChanged > 0 else { return "View changes" }
return "View changes · +\(stat.added) −\(stat.removed)"
}
/// The diff, presented as a sheet from the ⋯ menu — a viewer you visit, not a mode the
/// chat lives in.
private var diffSheet: some View {
NavigationStack {
Group {
if store.capabilities.canFetchDiff || store.openDiff != nil {
SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
.onAppear { store.fetchDiff(sessionID) }
} else {
// Older host with no on-demand diff — the stat summary is all it projects.
DiffSummaryView(diffStat: summary?.diffStat)
}
}
.navigationTitle("Changes")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { showDiff = false }
}
}
}
}
@ViewBuilder
private func sessionActions(_ summary: WireSessionSummary) -> some View {
// "Awaiting Input" that's actually done: flip it to "Done" by hand so a chat stuck on the
// wrong state clears without spending another turn on it. Offered only while the gate holds
// (`store.canMarkDone`), mirroring the Mac sidebar's own condition.
if store.canMarkDone(summary) {
Button { store.markSessionDone(sessionID) } label: {
Label("Mark Done", systemImage: "checkmark.circle")
}
}
Button { renameDraft = summary.title; showRename = true } label: {
Label("Rename", systemImage: "pencil")
}
Button { store.setFavorite(sessionID, !summary.favorite) } label: {
Label(summary.favorite ? "Unfavorite" : "Favorite",
systemImage: summary.favorite ? "star.slash" : "star")
}
// Autoship is a Nucleic Control capability — offered only for Control projects, and
// the host couples it with auto-approval (enabling it turns Auto on).
if controlled {
Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
}
}
if summary.status == .running {
Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") }
}
Section {
Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") }
Button(role: .destructive) { showDiscard = true } label: {
Label("Discard…", systemImage: "xmark.bin")
}
Button { store.setArchived(sessionID, !summary.archived) } label: {
Label(summary.archived ? "Unarchive" : "Archive",
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
}
}
Button(role: .destructive) { store.deleteSession(sessionID) } label: {
Label("Delete", systemImage: "trash")
}
}
/// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
/// indicator, the session controls, and the composer — or the approval card when a gate is
/// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
private var actionArea: some View {
VStack(spacing: 8) {
// Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
// disabled composer reads as "offline / read-only history" rather than broken.
if !store.connectivity.isLive { disconnectedBanner }
// The last run died on an auth failure and the host can broker a sign-in — the
// phone analogue of the Mac's in-transcript "Log in" row (docs/REMOTE_AGENT_LOGIN.md).
if let summary {
AgentAuthErrorBanner(sessionID: sessionID, backend: summary.backend)
}
actionContent
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
// The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately
// not a stack member. This whole action area is the transcript's bottom `safeAreaInset`,
// so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it
// appeared — and its visibility is *decided by* that same scroll geometry
// (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every
// frame near the follow threshold, re-layouting the whole eager transcript each time and
// pinning the main thread at 100% with the UI locked. An overlay contributes nothing to
// the inset height, so showing or hiding it can't move the scroll geometry at all.
// (It's not a transcript overlay either — that aligned to the scroll view's full-height
// bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the
// same scroll request the send button uses, so following resumes once the transcript
// reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
.overlay(alignment: .top) {
if !isScrolledToBottom {
JumpToBottomButton { scrollToBottomRequest += 1 }
// Hang the button fully *above* the bar: top-aligned, then shifted up by its
// own 30pt height plus an 8pt gap. A render-time offset (not an alignment
// guide, which misplaced it half-overlapping the bar's top edge) so the
// placement is exact and — critically — contributes nothing to the inset.
.offset(y: -38)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
}
/// The offline notice over the transcript: the chat's history is cached and readable, but the
/// composer + controls below stay disabled until the Mac that owns it is reachable again.
private var disconnectedBanner: some View {
HStack(spacing: 10) {
Image(systemName: "wifi.slash").foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text("Disconnected").font(.footnote.weight(.semibold))
Text("Showing saved history · reconnect to send")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer(minLength: 8)
Button("Reconnect") { store.reconnect() }
.font(.caption.weight(.semibold))
.buttonStyle(.borderless)
.disabled(!store.isPaired)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14).padding(.vertical, 10)
.glassSurface(cornerRadius: 20)
}
@ViewBuilder
private var actionContent: some View {
Group {
if let approval = store.openApprovals.first {
// AskUserQuestion isn't a permission gate — it collects the user's answers and
// needs them folded back into `updatedInput`. Render the picker (matching the
// Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would
// reply with no selection and make the CLI report the question went unanswered.
if approval.toolName == AskUserQuestion.toolName,
let questions = AskUserQuestion.questions(from: approval.input)
{
AskUserQuestionCardView(
approval: approval, questions: questions, availableHeight: availableHeight)
} else if approval.toolName == ExitPlanMode.toolName {
ExitPlanModeApprovalCardView(
approval: approval,
plan: ExitPlanMode.plan(from: approval.input),
availableHeight: availableHeight)
} else {
ApprovalCardView(approval: approval, availableHeight: availableHeight)
// So a host_exec approval's working-directory line abbreviates the "cd"
// target the same way the settled transcript card does.
.environment(\.transcriptWorktreeRoot,
TranscriptProjection.worktreeRoot(in: store.openEvents[...]))
}
} else if store.openApprovalLoading {
// Opened from a Live Activity / notification for a session blocked on approval, but
// the channel is still (re)connecting — bridge the gap with a placeholder so the tap
// lands on the request-in-progress, not a blank transcript. Replaced by the real
// ApprovalCardView the instant the snapshot arrives (sub-second reconnect).
approvalLoadingCard
} else if let status = summary?.status {
let running = status == .running
let canCompose = running || status == .awaitingInput || status.isTerminal
let queued = summary?.queuedMessages ?? []
if canCompose || store.canControl || !queued.isEmpty {
VStack(spacing: 10) {
// Follow-ups submitted mid-turn, held by the host to send when the current
// turn finishes. Each is shown as its own pill (with its attachments) and —
// at control scope — can be cancelled individually, matching the Mac
// composer. (They still send as one combined turn.)
if !queued.isEmpty {
VStack(spacing: 6) {
ForEach(queued) { message in
queuedMessageRow(message)
}
}
}
if running {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(progressText).font(.footnote).foregroundStyle(.primary)
// Cross-fade the label as the agent moves between steps, so a
// fast run of tool calls doesn't snap the row's text.
.contentTransition(.opacity)
.animation(.easeInOut(duration: 0.15), value: progressText)
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if store.canControl, let summary { controlRow(summary) }
if canCompose {
// 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, mirroring the Mac composer).
if let typing = remoteTyping {
remoteTypingRow(typing)
}
// Staged attachments ride above the field, matching the queued-message
// chips and the Mac composer.
if !attachments.isEmpty {
StagedAttachmentBar(attachments: $attachments)
}
if attachmentsOverflowed {
Text("Some files were too large to attach.")
.font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack(alignment: .bottom, spacing: 8) {
// Attach photos/files to the next turn (control scope only — a
// view-only device can't send).
if store.canControl {
AttachMenuButton(
attachments: $attachments, overflowed: $attachmentsOverflowed)
.disabled(!store.connectivity.isLive)
}
// No keyboard-accessory Done button here (it floats awkwardly
// over the glass bar on iOS 26) — a drag on the transcript
// dismisses the keyboard instead (`scrollDismissesKeyboard`).
TextField(
remoteTyping.map { "\(typerName($0)) is typing…" }
?? (running ? "Queue a follow-up…" : "Send a follow-up…"),
text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.lineLimit(1...4)
.padding(.vertical, 3)
// Locked while another device is typing here — the
// mesh-wide "one typer per session" contract.
.disabled(remoteTyping != nil)
// Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only
// while running and at control scope; send stays at the far right so
// its position never shifts. Mirrors `interruptShortcut`.
if running && store.canControl {
Button { store.interrupt(sessionID) } label: {
Image(systemName: "stop.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.disabled(!store.connectivity.isLive)
.accessibilityLabel("Stop")
}
Button {
store.sendInput(draft, attachments: attachments.wireAttachments,
to: sessionID)
draft = ""
attachments = []
attachmentsOverflowed = false
scrollToBottomRequest += 1
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.title2)
}
// An attachment-only follow-up (files, no typed text) is sendable —
// but never while another device is mid-draft here (locked).
.disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty)
|| !store.connectivity.isLive || remoteTyping != nil)
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
// Mac — plain Return stays newline in the multiline field.
.keyboardShortcut(.return, modifiers: .command)
}
// How much intelligence the next turn gets, directly under the field
// and closest to the thumb — the same placement (and the same
// rail-replaces-the-menus rule) as the new-chat composer, so the two
// entry points read identically.
if let summary { intelligenceRow(summary) }
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
// Orchestra's signature gold ring, as on the Mac's composer.
.orchestraGlow(active: sessionOrchestra && controlled, cornerRadius: 26)
}
}
}
}
/// The placeholder shown while an approval's details are still loading — mirrors the approval
/// card's frame (orange warning header, glass surface) so the real `ApprovalCardView` swaps in
/// without a jump. Non-actionable: the real approval id isn't known yet, so there's nothing to
/// Allow/Deny until the snapshot lands.
private var approvalLoadingCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
Text("Permission requested").font(.subheadline.weight(.semibold))
}
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Connecting to your Mac to load this approval…")
.font(.footnote).foregroundStyle(.secondary)
Spacer(minLength: 0)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14).padding(.vertical, 12)
.glassSurface(cornerRadius: 26)
}
/// A queued follow-up shown above the composer: its text (or attachment names) with the
/// attachments as chips, and — at control scope — a ✕ that cancels just this message.
@ViewBuilder
private func queuedMessageRow(_ message: QueuedMessage) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "clock").foregroundStyle(.secondary)
Text(queuedRowText(message)).lineLimit(1).truncationMode(.tail)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
if store.canControl {
Button {
// Refill the composer for editing (text only — the phone can't stage the
// host's files) unless the user has already started typing; then drop it.
if draft.trimmingCharacters(in: .whitespaces).isEmpty { draft = message.text }
store.cancelQueuedMessage(sessionID, message.id)
} label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.disabled(!store.connectivity.isLive)
}
}
.font(.footnote)
if !message.attachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(message.attachments) { attachment in
Label(attachment.filename, systemImage: "doc")
.font(.caption2)
.lineLimit(1)
.padding(.horizontal, 7).padding(.vertical, 3)
.background(.quaternary, in: .capsule)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8).padding(.vertical, 6)
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10))
}
/// The one-line label for a queued pill: the message text, or — for an attachment-only
/// message — the attachment names (the chips below repeat them), so it's never blank.
private func queuedRowText(_ message: QueuedMessage) -> String {
if !message.text.isEmpty { return message.text }
return message.attachments.map(\.filename).joined(separator: ", ")
}
/// A live, human label for what the agent is doing right now — "Reading…", "Running on a macOS
/// VM…", "Thinking…" — derived from the newest transcript event, replacing a fixed "Agent is
/// working…" that said nothing about a turn that may run for minutes.
///
/// Derived on-device from the host's own event stream rather than read off a field in the
/// summary: the phone already holds those events (they're what the transcript above renders),
/// so the label tracks the turn at streaming speed instead of at summary-refresh speed, needs
/// no wire field, and works against a host of any version. Mirrors the Mac's
/// `SessionDetailView.progressText`; keep the two in step.
private var progressText: String {
// Blocked on a human, not the agent. The composer's working row is hidden behind the
// approval card in that state, but a resolved-but-not-yet-cleared gate can flash through
// here — say what's actually being waited on rather than claiming the agent is working.
if let approval = store.openApprovals.first {
switch approval.toolName {
case AskUserQuestion.toolName: return "Waiting for answers…"
case ExitPlanMode.toolName: return "Waiting for plan review…"
default: return "Waiting for approval…"
}
}
for event in store.openEvents.reversed() {
// Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events
// during the model's reasoning pause — often the long gap after a tool result, before
// any visible output. Without this the walk skips every ping and lands back on the last
// tool call, freezing the row on e.g. "Running…" for the whole pause, which reads as a
// stall even though the model is working.
if Self.isThinkingPing(event) { 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…".
return "Working…"
case .userText:
// Nothing from the agent since the user's message.
return "Working…"
default:
continue
}
}
return "Working…"
}
/// Whether this is a Claude `system/thinking_tokens` progress ping — the mobile echo of
/// `TranscriptProjection.parseThinkingTokens`, reduced to the "is it one" half (the phone's
/// working row shows no token meter, so the counts themselves go unused).
private static func isThinkingPing(_ event: AgentEvent) -> Bool {
guard case .raw(let raw) = event.kind else { return false }
return event.nativeType == "system/thinking_tokens"
|| raw.native["subtype"]?.stringValue == "thinking_tokens"
}
/// Turns a tool name into a present-progressive status line — "Read" → "Reading…". Unknown
/// tools fall back to "Running <name>…" so we never guess a malformed gerund. Mirrors the Mac's
/// `SessionDetailView.gerund(for:)`.
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 HostCommandSummary.hostExecToolName: return "Running a command on host…"
// The VM/container tools name where the work is happening ("Running on a macOS VM…")
// rather than echoing their `mcp__nucleic__…` wire name.
default: return SandboxToolDisplay.gerund(for: name)
?? "Running \(name)…"
}
}
}
struct TranscriptList: View {
let events: [AgentEvent]
/// Bumped by the parent when the user sends a message — a deliberate "show me what happens
/// next" that jumps to the bottom even if they'd scrolled up to read history.
var scrollToBottomRequest: Int = 0
@AppStorage("nucleic.showRawEvents") private var showRaw = false
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
/// 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. Owned by
/// the parent so the chevron can live in the chat bar above the composer.
@Binding var isScrolledToBottom: Bool
/// True for a short window right after the transcript appears, while it runs its first
/// layout passes and its events fill in. During it we accept only "at bottom" scroll
/// readings, so the settling layout can't strand the chat above its true bottom or flash the
/// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`.
@State private var transcriptSettling = true
/// Total scroll-content height, tracked live from the scroll view's geometry, but only while
/// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest.
@State private var transcriptContentHeight: CGFloat = 0
/// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers:
/// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps
/// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against
/// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals
/// everything before the live turn once and re-folds only the tail, so a streaming delta
/// costs O(live-tail) instead of re-folding all N events (which made long sessions cost
/// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body`
/// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior).
@State private var projectionCache = IncrementalTranscriptProjection()
/// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes
/// it or the transcript goes away — so a long warm can't outlive the view or stack up behind
/// row churn.
@State private var prewarmTask: Task<Void, Never>?
/// How many projected rows the pre-warm has already covered, so each later trigger snapshots
/// only the *new* rows instead of re-walking (and re-hashing) the whole transcript on every
/// row that lands — which would quietly re-introduce an O(N) main-thread pass per row. Held
/// in a box (not `@State` value) because updating it from `body`-adjacent code must not
/// invalidate the view.
private final class PrewarmProgress { var count = 0 }
@State private var prewarmProgress = PrewarmProgress()
private var items: [TranscriptItem] {
projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents)
}
/// The session's worktree root — its `sessionStarted` cwd — read straight from the events (an
/// early-returning scan) and injected into the transcript so a host card's working-directory
/// line can abbreviate an absolute "cd" path to a short "<worktree name>/…" anchor.
private var worktreeRoot: String? {
TranscriptProjection.worktreeRoot(in: events[...])
}
/// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText`
/// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a
/// cost that lands on the main thread right as the session is pushed and the tab bar slides
/// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read
/// of the memoized projection), then parse them on a background task so that first layout finds
/// the caches already warm. Idempotent and self-cancelling; the parse results are the same
/// whichever thread fills the (thread-safe) caches. Incremental: only rows beyond the last
/// covered count are snapshotted (coalescing can shuffle nearby indices, but a missed body just
/// parses on first layout as before — the warm is an optimization, never a correctness gate).
private func prewarmMarkdown() {
let current = items
if current.count < prewarmProgress.count { prewarmProgress.count = 0 } // stream reset
let bodies: [String] = current[prewarmProgress.count...].compactMap {
if case .message(_, let text) = $0.kind { return text } else { return nil }
}
prewarmProgress.count = current.count
guard !bodies.isEmpty else { return }
// Chain batches instead of cancelling the in-flight one: each batch covers *new* rows
// only, so cancelling a predecessor (say, the big open batch, superseded by the first
// streamed row) would permanently drop its coverage. `onDisappear` cancels the head of
// the chain; a predecessor mid-parse just finishes its bounded batch into shared caches.
prewarmTask = Task.detached(priority: .utility) { [previous = prewarmTask] in
await previous?.value
for body in bodies {
if Task.isCancelled { return }
MarkdownText.prewarm(body)
}
}
}
/// How much content may still sit below the viewport's bottom edge and still count as "at
/// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors
/// the Mac's `bottomFollowThreshold`.
private let bottomFollowThreshold: CGFloat = 24
/// How far a *user* scroll must move away from the bottom before following disengages. Wider
/// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't
/// constant (composer lines grow, the working row appears, the keyboard dismisses
/// interactively), and every inset change perturbs the scroll geometry by tens of points. A
/// single threshold read both ways let one such perturbation flip the gate, whose reactions
/// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that
/// re-layouted the whole eager transcript every frame and pinned the main thread at 100%.
/// The band is wider than any bar-height delta, so only a deliberate scroll crosses it.
private let bottomUnfollowThreshold: CGFloat = 64
/// The live scroll phase, used to tell *user* scrolling (an active drag / flick decelerating)
/// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a
/// user-driven phase may disengage bottom-following — a programmatic perturbation can only
/// ever re-engage it — which structurally breaks every geometry→state→geometry feedback
/// cycle: no chain of layout reactions can take the gate false and sustain itself.
///
/// `.tracking` (finger down, no displacement yet) deliberately does NOT count: a finger
/// resting on the transcript scrolls nothing, but touching down *stops a live deceleration*,
/// and that stop emits a final far-from-bottom geometry reading under `.tracking`. Tapping
/// the chevron mid-deceleration raced exactly that emission against the tap's "follow again"
/// — when the stop reading landed after it, following disengaged right back and the chevron
/// stuck until a second tap. Real scroll-aways always pass through `.interacting`.
@State private var scrollPhase: ScrollPhase = .idle
private var isUserScrolling: Bool {
scrollPhase == .interacting || scrollPhase == .decelerating
}
/// True from an explicit jump (chevron / send) until the scroll actually reaches the bottom.
/// While set, the unfollow branch is suppressed entirely: phase changes and geometry
/// emissions are delivered on separate callbacks with no ordering guarantee, so a stopping
/// fling could emit one last far-from-bottom reading whose *recorded* phase was still
/// user-driven (`.decelerating`) — landing after the tap's "follow again" and disengaging it,
/// which left the chevron up until a second tap. The latch outlives any stale emission and
/// clears on arrival at the bottom, or the moment the user genuinely grabs the transcript
/// again (`.interacting`), so a mid-jump scroll-away still works.
@State private var jumpingToBottom = false
var body: some View {
ScrollViewReader { proxy in
ScrollView {
// Eager VStack (not Lazy): the whole transcript is in memory, and eager layout
// hands the ScrollView its true, *fixed* total content height up front. A
// LazyVStack instead realizes rows as they scroll into view, so its reported
// content height shifts mid-scroll as rows resolve — and that shifting height fed
// straight back through the geometry reader below (flip at-bottom → toggle the
// `.defaultScrollAnchor` → re-pin → new height → …), which is what made the whole
// transcript jitter up and down under a drag. A stable height breaks the loop.
// Matches the Mac transcript, for the same reason.
VStack(alignment: .leading, spacing: 14) {
ForEach(items) { item in
TranscriptRow(item: item).id(item.id)
}
// Fixed, zero-content scroll target for "jump to bottom". Scrolling to the last
// row's id instead would chase a moving target — the final row's identity
// changes as its streaming deltas coalesce — nudging the viewport mid-stream.
Color.clear.frame(height: 1).id(Self.bottomAnchorID)
}
.environment(\.transcriptWorktreeRoot, worktreeRoot)
.padding()
// Cap the transcript to a readable measure on a wide iPad so lines don't run
// edge-to-edge on a 13-inch screen; a no-op at phone width.
.readableColumn()
}
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
// be put away without leaving the session.
.scrollDismissesKeyboard(.interactively)
// 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. So gate the anchor itself: drop it to `nil` the moment
// they scroll away, which lets new content land off-screen below instead of dragging
// the viewport, and restore `.bottom` once they're back at the end.
.defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
.onScrollPhaseChange { _, newPhase in
scrollPhase = newPhase
// A real grab (drag displacement, not a mere touch-down) takes over from an
// in-flight programmatic jump: the user may scroll away again immediately.
if newPhase == .interacting { jumpingToBottom = false }
}
// Track the live scroll position straight from the scroll view's geometry: how much
// content still sits below the viewport bottom. Parked within the follow threshold
// means live output keeps following; a *user* scroll past the (wider) unfollow
// threshold flips it false, which drops the anchor (above) and reveals the chevron.
// Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
// programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
// animation) may re-engage following but can never disengage it, so no layout
// feedback cycle through this gate can sustain itself. Tracking the rounded distance
// (not a Bool) also means every scroll emits fresh values, so the gate can't latch
// against a stale reading (the old settle-window latch bug).
.onScrollGeometryChange(for: CGFloat.self) { geo in
(geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
} action: { _, distance in
if distance <= bottomFollowThreshold {
isScrolledToBottom = true
jumpingToBottom = false // arrived — the jump is complete
} else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling,
!jumpingToBottom {
// While the chat is opening the layout grows over a few passes and the offset
// lags each growth by a frame — those frames read "not at bottom" even though
// `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts
// only re-engagement (the `!transcriptSettling` above).
isScrolledToBottom = false
}
}
// Detect when the opening layout has come to rest: track the content height while
// settling and let `.task(id:)` debounce it — the task restarts on every change and
// only reaches its body once the height has held steady. Freezing the tracker after
// settling keeps live streaming (which changes the height every frame) from re-arming.
.onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in
if transcriptSettling { transcriptContentHeight = height }
}
.task(id: transcriptContentHeight) {
guard transcriptSettling, transcriptContentHeight > 0 else { return }
try? await Task.sleep(for: .milliseconds(80))
if Task.isCancelled { return }
// Pin to the true bottom before opening the gate. The settling layout lands a
// hair off the bottom, so the passive `.defaultScrollAnchor` alone leaves the
// scroll geometry reading "not at bottom" — and that stale reading latches
// `onScrollGeometryChange`'s tracked value at `false` while the gate above holds
// `isScrolledToBottom` at `true`. Because the callback only fires on a *change*,
// the user's first real scroll-up (still `false`) then never fires it: the chevron
// never appears and live output keeps yanking the view to the bottom, until a
// down-then-up round-trip finally re-emits the change. An explicit pin nudges the
// offset the last hair, forcing a fresh "at bottom" emission that re-syncs the
// tracker with the gate. Mirrors the Mac's reveal pin.
scrollToEnd(proxy, animated: false)
transcriptSettling = false
}
// Coalescing means item count lags event count; key the autoscroll on the raw stream
// so every streamed delta keeps the view pinned to the bottom — but only while the
// user is already parked there. Scrolling up to read history is never yanked down,
// and a finger actively on the transcript is never fought mid-drag (the drag that
// takes them past the unfollow threshold flips the gate; until then the native
// bottom anchor alone keeps content pinned, without a scroll grabbing the viewport
// back out of their hand). Never animated: while pinned, the bottom anchor provides
// the visual continuity and this call only closes the last few points — but a
// session still *opening* keeps receiving history merges (cached transcript,
// reconnect backfill) after the settle window closes, and animating those rode the
// viewport visibly down through the whole transcript instead of landing at the end.
.onChange(of: events.count) {
if isScrolledToBottom, !isUserScrolling {
scrollToEnd(proxy, animated: false)
}
}
// An explicit jump — the chevron or sending a message — always wins, and it *is* the
// user saying "follow again": re-engage the gate directly rather than waiting for the
// geometry to confirm. The animated scroll can come to rest a few points short of the
// follow threshold (content padding, inset rounding), which sat inside the hysteresis
// band — following never re-engaged and the chevron lingered until the user manually
// scrolled the last few points. Setting the gate here hides the chevron immediately
// and hands pinning back to the bottom anchor; a later real scroll-up still
// disengages it as usual.
.onChange(of: scrollToBottomRequest) {
isScrolledToBottom = true
jumpingToBottom = true
scrollToEnd(proxy)
}
// Warm the Markdown parse caches off the main thread whenever the row set grows —
// `initial: true` fires it for the batch that lands on open (the expensive case), and
// each later new row tops it up. Keyed on the row *count*, so streaming deltas into an
// existing row (which don't change the count) never re-arm it. `items.count` is O(1).
.onChange(of: items.count, initial: true) { _, _ in prewarmMarkdown() }
.onDisappear { prewarmTask?.cancel() }
}
}
/// Scroll the fixed bottom anchor into view; `animated: false` during the open-settle
/// window so a chat that's still laying out snaps to the tail instead of easing into place.
private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
if animated {
withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
} else {
proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
}
}
private static let bottomAnchorID = "nucleic.transcript-bottom"
}
/// 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. The mobile twin of the Mac's
/// `JumpToBottomButton`.
private struct JumpToBottomButton: View {
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)
.accessibilityLabel("Jump to the latest message")
}
}
/// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
/// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's
/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
/// wide detail column. Same wire, no new protocol.
struct SessionDiffView: View {
let diff: WireSessionDiff?
let loading: Bool
/// Below this available width the two-pane diff would leave the patch too cramped, so we
/// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the
/// stack on a phone or a narrow portrait split — based on real width, not just size class.
private let twoPaneMinWidth: CGFloat = 700
var body: some View {
if let diff, diff.stat.filesChanged > 0 {
GeometryReader { geo in
if geo.size.width >= twoPaneMinWidth {
SplitDiffView(diff: diff)
} else {
stacked(diff)
}
}
} else if loading {
ProgressView("Fetching diff…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
/// Phone form: the file summary over one scrolling unified patch.
private func stacked(_ diff: WireSessionDiff) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
DiffStatHeader(stat: diff.stat)
ForEach(diff.files) { DiffFileRow(file: $0) }
}
.card()
PatchText(patch: diff.patch)
if diff.truncated { TruncatedPatchNote() }
}
.padding()
.readableColumn()
}
}
}
/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected
/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose
/// single-select tap handling is unreliable outside an edit-mode / split-view context).
private struct SplitDiffView: View {
let diff: WireSessionDiff
@State private var selected: String?
/// The combined patch split into per-file sections, keyed by file path.
private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) }
private var current: String? { selected ?? diff.files.first?.path }
var body: some View {
HStack(spacing: 0) {
fileList
.frame(width: 280)
.background(Color(.secondarySystemBackground))
Divider()
patchPane
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { if selected == nil { selected = diff.files.first?.path } }
// Keep the selection valid as the diff refetches (files can appear/vanish between turns).
.onChange(of: diff.files) { _, files in
if selected == nil || !files.contains(where: { $0.path == selected }) {
selected = files.first?.path
}
}
}
private var fileList: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
DiffStatHeader(stat: diff.stat)
.padding(.horizontal, 12).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
Divider()
ForEach(diff.files) { file in
Button { selected = file.path } label: {
DiffFileRow(file: file)
.padding(.horizontal, 12).padding(.vertical, 9)
.frame(maxWidth: .infinity, alignment: .leading)
.background(current == file.path
? Palette.accent.opacity(0.15) : Color.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.hoverEffect(.highlight)
}
if diff.truncated { TruncatedPatchNote().padding(12) }
}
}
}
@ViewBuilder
private var patchPane: some View {
if let path = current, let patch = sections[path], !patch.isEmpty {
ScrollView { PatchText(patch: patch).padding() }
} else if current != nil {
// A file with no textual hunk (binary, or a section dropped by patch truncation).
ContentUnavailableView(
"No preview", systemImage: "doc",
description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").")
)
} else {
ContentUnavailableView("Select a file", systemImage: "sidebar.left")
}
}
}
/// The "N files changed · +A −R" header shared by both diff forms.
private struct DiffStatHeader: View {
let stat: DiffStat
var body: some View {
Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)")
.font(.subheadline.weight(.semibold))
}
}
/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the
/// iPad file list.
struct DiffFileRow: View {
let file: WireFileDiff
var body: some View {
HStack(spacing: 8) {
Image(systemName: DiffStatus.icon(file.status))
.font(.caption)
.foregroundStyle(DiffStatus.color(file.status))
Text(file.path)
.font(.caption.monospaced())
.lineLimit(1).truncationMode(.middle)
Spacer(minLength: 8)
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
}
}
}
enum DiffStatus {
static func icon(_ status: String) -> String {
switch status {
case "added", "untracked": "plus.circle"
case "deleted": "minus.circle"
case "renamed": "arrow.right.circle"
default: "pencil.circle"
}
}
static func color(_ status: String) -> Color {
switch status {
case "added", "untracked": Palette.success
case "deleted": Palette.danger
default: .secondary
}
}
}
private struct TruncatedPatchNote: View {
var body: some View {
Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view.
enum UnifiedPatch {
/// Map from file path to that file's slice of the unified patch. Sections begin at a
/// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally
/// (the host emits patch and files together, in order); otherwise we key each section by the
/// new path parsed from its header. Callers fall back to a "no preview" state on a miss.
static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] {
guard patch.contains("diff --git ") else {
// A single-file patch with no git header: attribute the whole thing to the sole file.
return files.count == 1 ? [files[0].path: patch] : [:]
}
var chunks: [String] = []
var current: [Substring] = []
for line in patch.split(separator: "\n", omittingEmptySubsequences: false) {
if line.hasPrefix("diff --git ") {
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
current = [line]
} else if !current.isEmpty {
current.append(line)
}
}
if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
var result: [String: String] = [:]
if chunks.count == files.count {
for (file, chunk) in zip(files, chunks) { result[file.path] = chunk }
} else {
for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk }
}
return result
}
/// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is
/// absent or the path is quoted/spaced in a way we don't split cleanly.
private static func newPath(_ chunk: String) -> String? {
guard let header = chunk.split(separator: "\n", maxSplits: 1).first,
header.hasPrefix("diff --git "),
let bRange = header.range(of: " b/") else { return nil }
let path = header[bRange.upperBound...]
return path.isEmpty ? nil : String(path)
}
}
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
/// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View {
let patch: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
Text(line.isEmpty ? " " : line)
.font(.caption.monospaced())
.foregroundStyle(lineColor(line))
.frame(maxWidth: .infinity, alignment: .leading)
.background(lineBackground(line))
}
}
.textSelection(.enabled)
.padding(8)
}
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
private func lineColor(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary }
if line.hasPrefix("+") { return Palette.success }
if line.hasPrefix("-") { return Palette.danger }
if line.hasPrefix("@@") { return Palette.active }
if line.hasPrefix("diff ") { return .secondary }
return Palette.primaryText
}
private func lineBackground(_ line: String) -> Color {
if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear }
if line.hasPrefix("+") { return Palette.success.opacity(0.10) }
if line.hasPrefix("-") { return Palette.danger.opacity(0.10) }
return .clear
}
}
struct DiffSummaryView: View {
let diffStat: DiffStat?
var body: some View {
VStack(spacing: 12) {
if let diff = diffStat, diff.filesChanged > 0 {
Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
.font(.headline)
HStack(spacing: 16) {
Text("+\(diff.added)").foregroundStyle(.green)
Text("−\(diff.removed)").foregroundStyle(.red)
}
.font(.title3.monospacedDigit())
Text("Open the Mac for the full patch.")
.font(.footnote).foregroundStyle(.secondary)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}