1487 lines
83 KiB
Swift
1487 lines
83 KiB
Swift
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()
|
||
}
|
||
}
|