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 = "" @State private var showRename = false @State private var renameDraft = "" @State private var showIntegrate = false @State private var showDiscard = false private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } } var body: some View { TranscriptList(events: store.openEvents) // 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 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. .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 { store.open(sessionID) } // 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) } .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) } } /// 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 { Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) }) } private var effortBinding: Binding { 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 /// 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. @ViewBuilder private func controlRow(_ summary: WireSessionSummary) -> some View { 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, 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) Spacer(minLength: 4) 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") } Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") } .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold)) } } /// The ⋯ menu: the diff viewer for everyone, then the control-scope 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() 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 { 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 } 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 { 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) } else { ApprovalCardView(approval: approval) } } 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("Agent is working…").font(.footnote).foregroundStyle(.primary) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) } if store.canControl, let summary { controlRow(summary) } if canCompose { HStack(alignment: .bottom, spacing: 8) { // 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(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) } } } .padding(.horizontal, 14) .padding(.vertical, 12) .glassSurface(cornerRadius: 26) } } } .padding(.horizontal, 12) .padding(.bottom, 8) } /// 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: ", ") } } struct TranscriptList: View { let events: [AgentEvent] @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true private var items: [TranscriptItem] { TranscriptProjection.build(events, showRaw: showRaw, showLockEvents: showLockEvents) } var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 14) { ForEach(items) { item in TranscriptRow(item: item).id(item.id) } } .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) // 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. .onChange(of: events.count) { if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } } } } /// 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() } }