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

442 lines
19 KiB
Swift
Raw Normal View History

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 {
VStack(spacing: 0) {
Picker("", selection: $tab) {
Text("Transcript").tag(0)
Text("Diff").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.bottom, 8)
if store.canControl, let summary { controlBar(summary) }
Divider()
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)
}
actionArea
}
.navigationTitle(summary?.title ?? "Session")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
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) }
.onDisappear { store.closeOpen() }
}
/// 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
/// session header. Reads current state from the summary; each change is a control intent.
@ViewBuilder
private func controlBar(_ 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,
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)
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()
2026-06-15 19:19:57 -07:00
} 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)
}
2026-06-15 19:19:57 -07:00
}
}
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)
2026-06-26 20:22:24 -07:00
.keyboardDismissable()
2026-06-15 19:19:57 -07:00
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)
}
}
}
.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()
}
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)
// 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 with the real patch (the Mac Diff tab, phone form): a per-file summary list
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`.
struct SessionDiffView: View {
let diff: WireSessionDiff?
let loading: Bool
var body: some View {
if let diff, diff.stat.filesChanged > 0 {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
fileList(diff)
PatchText(patch: diff.patch)
if diff.truncated {
Label("Patch truncated — open the Mac for the rest.",
systemImage: "scissors")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding()
}
} else if loading {
ProgressView("Fetching diff…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ContentUnavailableView("No changes yet", systemImage: "doc")
}
}
private func fileList(_ diff: WireSessionDiff) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
.font(.subheadline.weight(.semibold))
ForEach(diff.files) { file in
HStack(spacing: 8) {
Image(systemName: statusIcon(file.status))
.font(.caption)
.foregroundStyle(statusColor(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)
}
}
}
.card()
}
private func statusIcon(_ status: String) -> String {
switch status {
case "added", "untracked": "plus.circle"
case "deleted": "minus.circle"
case "renamed": "arrow.right.circle"
default: "pencil.circle"
}
}
private func statusColor(_ status: String) -> Color {
switch status {
case "added", "untracked": Palette.success
case "deleted": Palette.danger
default: .secondary
}
}
}
/// 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()
}
}