import SwiftUI import NucleicProtocol /// One session: a segmented Transcript/Diff read view over a status-driven action area /// (UX_IOS §4). Subscribes on appear, unsubscribes on disappear. struct SessionDetailView: View { @EnvironmentObject var store: RemoteStore let sessionID: SessionID // Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for // offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0 @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 { Group { if tab == 0 { TranscriptList(events: store.openEvents) } else 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) } } // 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) .toolbar { // Transcript/Diff lives in the navigation bar (icon segments) rather than a // dedicated row — it's a mode flip, not content, and shouldn't cost chat height. ToolbarItem(placement: .topBarTrailing) { Picker("View", selection: $tab) { Image(systemName: "text.bubble").tag(0) .accessibilityLabel("Transcript") Image(systemName: "plus.forwardslash.minus").tag(1) .accessibilityLabel("Diff") } .pickerStyle(.segmented) .fixedSize() } if let summary, store.canControl { ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) } } } .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 { Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent") .font(.caption2).foregroundStyle(.secondary) .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) } .padding(.horizontal) .padding(.bottom, 6) } @ViewBuilder private func sessionMenu(_ summary: WireSessionSummary) -> some View { Menu { 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") } } label: { Image(systemName: "ellipsis.circle") } } @ViewBuilder private var actionArea: some View { Divider() if let approval = store.openApprovals.first { ApprovalCardView(approval: approval) .padding() } else if let status = summary?.status { let running = status == .running let canCompose = running || status == .awaitingInput || status.isTerminal VStack(spacing: 8) { // 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 let queued = summary?.queuedMessages, !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(.secondary) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) } if canCompose { HStack(spacing: 8) { TextField(running ? "Queue a follow-up…" : "Send a follow-up…", text: $draft, axis: .vertical) .textFieldStyle(.roundedBorder) .lineLimit(1...4) .keyboardDismissable() 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() } } /// 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: 10) { 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 tab, 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() } }