2026-06-13 01:12:55 -07:00
|
|
|
|
import SwiftUI
|
|
|
|
|
|
import NucleicProtocol
|
|
|
|
|
|
|
2026-07-05 01:12:45 -07:00
|
|
|
|
/// 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
|
2026-07-05 00:54:23 -07:00
|
|
|
|
/// appear, unsubscribes on disappear.
|
2026-06-13 01:12:55 -07:00
|
|
|
|
struct SessionDetailView: View {
|
|
|
|
|
|
@EnvironmentObject var store: RemoteStore
|
|
|
|
|
|
let sessionID: SessionID
|
2026-07-05 01:12:45 -07:00
|
|
|
|
// 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"
|
2026-06-13 01:12:55 -07:00
|
|
|
|
@State private var draft = ""
|
2026-06-13 02:20:53 -07:00
|
|
|
|
@State private var showRename = false
|
|
|
|
|
|
@State private var renameDraft = ""
|
|
|
|
|
|
@State private var showIntegrate = false
|
2026-07-02 03:43:13 -07:00
|
|
|
|
@State private var showDiscard = false
|
2026-07-06 00:32:57 -07:00
|
|
|
|
// 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
|
2026-06-13 01:12:55 -07:00
|
|
|
|
|
|
|
|
|
|
private var summary: WireSessionSummary? {
|
|
|
|
|
|
store.sessions.first { $0.sessionID == sessionID }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
2026-07-06 00:33:00 -07:00
|
|
|
|
TranscriptList(events: store.openEvents, scrollToBottomRequest: scrollToBottomRequest)
|
2026-07-05 01:12:57 -07:00
|
|
|
|
// 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)
|
2026-07-05 18:14:15 -07:00
|
|
|
|
// 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 toggling `store.openSessionID`, 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.
|
2026-07-05 01:12:57 -07:00
|
|
|
|
.toolbar {
|
|
|
|
|
|
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
2026-07-05 01:12:57 -07:00
|
|
|
|
.sheet(isPresented: $showDiff) { diffSheet }
|
2026-06-13 02:20:53 -07:00
|
|
|
|
.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) {}
|
|
|
|
|
|
}
|
2026-07-02 03:43:13 -07:00
|
|
|
|
.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) {}
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
.onAppear { store.open(sessionID) }
|
2026-07-03 23:59:29 -07:00
|
|
|
|
// Pass our own id 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.
|
|
|
|
|
|
.onDisappear { store.closeOpen(sessionID) }
|
2026-07-04 01:18:35 -07:00
|
|
|
|
.background { interruptShortcut }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// ⌘. 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)
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
|
/// 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) })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
|
|
|
|
|
|
/// from the transcript exactly as the Mac header does.
|
|
|
|
|
|
private var contextPercent: Int? {
|
|
|
|
|
|
let used = store.openEvents.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 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 model / effort / auto controls for the open session — the mobile echo of the Mac's
|
2026-07-05 00:53:23 -07:00
|
|
|
|
/// session header, folded into the glass chat bar so they don't cost a strip of transcript
|
|
|
|
|
|
/// height. Reads current state from the summary; each change is a control intent.
|
2026-06-21 04:03:53 -07:00
|
|
|
|
@ViewBuilder
|
2026-07-05 00:53:23 -07:00
|
|
|
|
private func controlRow(_ summary: WireSessionSummary) -> some View {
|
2026-06-21 04:03:53 -07:00
|
|
|
|
HStack(spacing: 10) {
|
|
|
|
|
|
// A session's backend is fixed at creation, so only same-backend models are offered.
|
|
|
|
|
|
ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
|
|
|
|
|
|
EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
|
|
|
|
|
|
// No explicit model yet → use the session 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,
|
2026-06-27 15:47:36 -07:00
|
|
|
|
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)
|
2026-06-21 04:03:53 -07:00
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
|
|
if let percent = contextPercent {
|
2026-07-05 01:14:09 -07:00
|
|
|
|
// Weight and primary ink (not `.secondary`) so the metric stays legible over
|
|
|
|
|
|
// whatever the Liquid Glass bar is blurring through.
|
2026-06-21 04:03:53 -07:00
|
|
|
|
Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
|
2026-07-05 01:14:09 -07:00
|
|
|
|
.font(.caption2.weight(.semibold)).foregroundStyle(.primary)
|
2026-06-21 04:03:53 -07:00
|
|
|
|
.help("Context window used")
|
|
|
|
|
|
}
|
2026-07-05 23:42:16 -07:00
|
|
|
|
// 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")
|
2026-06-21 04:03:53 -07:00
|
|
|
|
}
|
2026-07-05 01:14:09 -07:00
|
|
|
|
.toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold))
|
2026-07-05 23:42:16 -07:00
|
|
|
|
.disabled(controlled)
|
|
|
|
|
|
.help(controlled
|
|
|
|
|
|
? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
|
|
|
|
|
|
: "Auto-approve safe actions; destructive ones still ask.")
|
2026-06-21 04:03:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 01:13:16 -07:00
|
|
|
|
/// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions.
|
2026-06-13 02:20:53 -07:00
|
|
|
|
@ViewBuilder
|
2026-07-05 01:13:16 -07:00
|
|
|
|
private var sessionMenu: some View {
|
2026-06-13 02:20:53 -07:00
|
|
|
|
Menu {
|
2026-07-05 01:13:16 -07:00
|
|
|
|
Button { showDiff = true } label: {
|
|
|
|
|
|
Label(diffLabel, systemImage: "plus.forwardslash.minus")
|
|
|
|
|
|
}
|
|
|
|
|
|
if let summary, store.canControl {
|
|
|
|
|
|
Divider()
|
|
|
|
|
|
sessionActions(summary)
|
|
|
|
|
|
}
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
Image(systemName: "ellipsis.circle")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// "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 {
|
2026-07-05 01:13:49 -07:00
|
|
|
|
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")
|
2026-06-13 02:20:53 -07:00
|
|
|
|
}
|
2026-07-05 01:13:49 -07:00
|
|
|
|
}
|
|
|
|
|
|
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")
|
2026-06-13 02:20:53 -07:00
|
|
|
|
}
|
2026-07-05 01:13:49 -07:00
|
|
|
|
Button { store.setArchived(sessionID, !summary.archived) } label: {
|
|
|
|
|
|
Label(summary.archived ? "Unarchive" : "Archive",
|
|
|
|
|
|
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
2026-06-13 02:20:53 -07:00
|
|
|
|
}
|
2026-07-05 01:13:49 -07:00
|
|
|
|
}
|
|
|
|
|
|
Button(role: .destructive) { store.deleteSession(sessionID) } label: {
|
|
|
|
|
|
Label("Delete", systemImage: "trash")
|
2026-06-13 02:20:53 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 00:53:47 -07:00
|
|
|
|
/// 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.
|
2026-06-13 01:12:55 -07:00
|
|
|
|
private var actionArea: some View {
|
2026-07-05 22:32:28 -07:00
|
|
|
|
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 }
|
|
|
|
|
|
actionContent
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
|
.padding(.bottom, 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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 {
|
2026-07-05 00:53:47 -07:00
|
|
|
|
Group {
|
|
|
|
|
|
if let approval = store.openApprovals.first {
|
2026-07-05 02:35:57 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ApprovalCardView(approval: approval)
|
|
|
|
|
|
}
|
2026-07-05 00:53:47 -07:00
|
|
|
|
} 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-01 19:01:20 -07:00
|
|
|
|
}
|
2026-07-05 00:53:47 -07:00
|
|
|
|
if running {
|
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
|
ProgressView().controlSize(.small)
|
2026-07-05 01:14:38 -07:00
|
|
|
|
Text("Agent is working…").font(.footnote).foregroundStyle(.primary)
|
2026-07-05 00:53:47 -07:00
|
|
|
|
Spacer()
|
|
|
|
|
|
}
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
if store.canControl, let summary { controlRow(summary) }
|
|
|
|
|
|
if canCompose {
|
|
|
|
|
|
HStack(alignment: .bottom, spacing: 8) {
|
2026-07-05 01:13:59 -07:00
|
|
|
|
// 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`).
|
2026-07-05 00:53:47 -07:00
|
|
|
|
TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
|
|
|
|
|
|
text: $draft, axis: .vertical)
|
|
|
|
|
|
.textFieldStyle(.plain)
|
|
|
|
|
|
.lineLimit(1...4)
|
|
|
|
|
|
.padding(.vertical, 3)
|
|
|
|
|
|
Button {
|
|
|
|
|
|
store.sendInput(draft, to: sessionID)
|
|
|
|
|
|
draft = ""
|
|
|
|
|
|
} label: {
|
|
|
|
|
|
Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
|
|
|
|
|
|
.font(.title2)
|
|
|
|
|
|
}
|
|
|
|
|
|
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
|
|
|
|
|
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
|
|
|
|
|
|
// Mac — plain Return stays newline in the multiline field.
|
|
|
|
|
|
.keyboardShortcut(.return, modifiers: .command)
|
|
|
|
|
|
}
|
2026-06-15 19:19:57 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-05 00:53:47 -07:00
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
|
.padding(.vertical, 12)
|
|
|
|
|
|
.glassSurface(cornerRadius: 26)
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-01 19:01:20 -07:00
|
|
|
|
|
|
|
|
|
|
/// 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: ", ")
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct TranscriptList: View {
|
|
|
|
|
|
let events: [AgentEvent]
|
2026-07-06 00:32:51 -07:00
|
|
|
|
/// 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
|
2026-06-21 04:03:53 -07:00
|
|
|
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
|
|
|
|
|
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
|
|
|
|
|
|
2026-07-06 00:32:51 -07:00
|
|
|
|
/// True while the user is parked at (within a hair of) the bottom, so live output keeps
|
|
|
|
|
|
/// following; once they scroll up it flips false, following stops, and the jump-to-bottom
|
|
|
|
|
|
/// chevron appears. Starts true — a freshly opened chat is anchored at the bottom.
|
|
|
|
|
|
@State private var isScrolledToBottom = true
|
|
|
|
|
|
|
|
|
|
|
|
/// 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
|
|
|
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
|
private var items: [TranscriptItem] {
|
|
|
|
|
|
TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents)
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
|
2026-07-06 00:32:51 -07:00
|
|
|
|
/// 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
|
|
|
|
|
|
|
2026-06-13 01:12:55 -07:00
|
|
|
|
var body: some View {
|
|
|
|
|
|
ScrollViewReader { proxy in
|
|
|
|
|
|
ScrollView {
|
2026-07-05 00:54:14 -07:00
|
|
|
|
LazyVStack(alignment: .leading, spacing: 14) {
|
2026-06-21 04:03:53 -07:00
|
|
|
|
ForEach(items) { item in
|
|
|
|
|
|
TranscriptRow(item: item).id(item.id)
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding()
|
2026-07-03 23:59:29 -07:00
|
|
|
|
// 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()
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
2026-06-26 20:22:30 -07:00
|
|
|
|
// A drag on the transcript dismisses the keyboard, so a tall multiline composer can
|
|
|
|
|
|
// be put away without leaving the session.
|
|
|
|
|
|
.scrollDismissesKeyboard(.interactively)
|
2026-07-06 00:32:51 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
// Track the live scroll position straight from the scroll view's geometry: how much
|
|
|
|
|
|
// content still sits below the viewport bottom. Within the slack threshold means the
|
|
|
|
|
|
// user is parked at the end (live output keeps following); scrolling up flips this
|
|
|
|
|
|
// false, which drops the anchor (above) and reveals the jump-to-bottom chevron.
|
|
|
|
|
|
.onScrollGeometryChange(for: Bool.self) { geo in
|
|
|
|
|
|
geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y
|
|
|
|
|
|
<= bottomFollowThreshold
|
|
|
|
|
|
} action: { _, atBottom in
|
|
|
|
|
|
// While the chat is opening the layout grows over a few passes and the scroll
|
|
|
|
|
|
// offset lags each growth by a frame — sampling that frame reads "not at bottom"
|
|
|
|
|
|
// even though `.defaultScrollAnchor(.bottom)` is about to re-pin. So during the
|
|
|
|
|
|
// open window accept only "at bottom" readings; honor real scroll-ups after.
|
|
|
|
|
|
if transcriptSettling {
|
|
|
|
|
|
if atBottom { isScrolledToBottom = true }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
isScrolledToBottom = atBottom
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// 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 }
|
|
|
|
|
|
transcriptSettling = false
|
|
|
|
|
|
}
|
2026-06-21 04:03:53 -07:00
|
|
|
|
// Coalescing means item count lags event count; key the autoscroll on the raw stream
|
2026-07-06 00:32:51 -07:00
|
|
|
|
// 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.
|
2026-06-13 01:12:55 -07:00
|
|
|
|
.onChange(of: events.count) {
|
2026-07-06 00:32:51 -07:00
|
|
|
|
if isScrolledToBottom { scrollToLast(proxy, animated: !transcriptSettling) }
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
2026-07-06 00:32:51 -07:00
|
|
|
|
// An explicit jump — the chevron or sending a message — always wins.
|
|
|
|
|
|
.onChange(of: scrollToBottomRequest) { scrollToLast(proxy) }
|
|
|
|
|
|
// Floating chevron above the chat bar, shown only while scrolled up; tap to jump back
|
|
|
|
|
|
// to the latest output. Mirrors the Mac's `JumpToBottomButton`.
|
|
|
|
|
|
.overlay(alignment: .bottom) {
|
|
|
|
|
|
ZStack {
|
|
|
|
|
|
if !isScrolledToBottom {
|
|
|
|
|
|
JumpToBottomButton { scrollToLast(proxy) }
|
|
|
|
|
|
.padding(.bottom, 12)
|
|
|
|
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Scroll the last message to the bottom edge; `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 scrollToLast(_ proxy: ScrollViewProxy, animated: Bool = true) {
|
|
|
|
|
|
guard let last = items.last else { return }
|
|
|
|
|
|
if animated {
|
|
|
|
|
|
withAnimation { proxy.scrollTo(last.id, anchor: .bottom) }
|
|
|
|
|
|
} else {
|
|
|
|
|
|
proxy.scrollTo(last.id, anchor: .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)
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
2026-07-06 00:32:51 -07:00
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
|
.accessibilityLabel("Jump to the latest message")
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 01:14:51 -07:00
|
|
|
|
/// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
|
2026-07-03 23:59:29 -07:00
|
|
|
|
/// 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.
|
2026-07-02 04:20:25 -07:00
|
|
|
|
struct SessionDiffView: View {
|
|
|
|
|
|
let diff: WireSessionDiff?
|
|
|
|
|
|
let loading: Bool
|
2026-07-03 23:59:29 -07:00
|
|
|
|
/// 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
|
2026-07-02 04:20:25 -07:00
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
if let diff, diff.stat.filesChanged > 0 {
|
2026-07-03 23:59:29 -07:00
|
|
|
|
GeometryReader { geo in
|
|
|
|
|
|
if geo.size.width >= twoPaneMinWidth {
|
|
|
|
|
|
SplitDiffView(diff: diff)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
stacked(diff)
|
2026-07-02 04:20:25 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if loading {
|
|
|
|
|
|
ProgressView("Fetching diff…")
|
|
|
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
ContentUnavailableView("No changes yet", systemImage: "doc")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 23:59:29 -07:00
|
|
|
|
/// 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)
|
2026-07-04 00:48:56 -07:00
|
|
|
|
.hoverEffect(.highlight)
|
2026-07-02 04:20:25 -07:00
|
|
|
|
}
|
2026-07-03 23:59:29 -07:00
|
|
|
|
if diff.truncated { TruncatedPatchNote().padding(12) }
|
2026-07-02 04:20:25 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 23:59:29 -07:00
|
|
|
|
@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 {
|
2026-07-02 04:20:25 -07:00
|
|
|
|
switch status {
|
|
|
|
|
|
case "added", "untracked": "plus.circle"
|
|
|
|
|
|
case "deleted": "minus.circle"
|
|
|
|
|
|
case "renamed": "arrow.right.circle"
|
|
|
|
|
|
default: "pencil.circle"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-03 23:59:29 -07:00
|
|
|
|
static func color(_ status: String) -> Color {
|
2026-07-02 04:20:25 -07:00
|
|
|
|
switch status {
|
|
|
|
|
|
case "added", "untracked": Palette.success
|
|
|
|
|
|
case "deleted": Palette.danger
|
|
|
|
|
|
default: .secondary
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 23:59:29 -07:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 04:20:25 -07:00
|
|
|
|
/// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 01:12:55 -07:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|