2026-06-13 01:12:55 -07:00
|
|
|
|
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
|
|
|
|
|
|
@State private var tab = 0
|
|
|
|
|
|
@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-06-13 01:12:55 -07:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-06-21 04:03:53 -07:00
|
|
|
|
if store.canControl, let summary { controlBar(summary) }
|
|
|
|
|
|
|
2026-06-13 01:12:55 -07:00
|
|
|
|
Divider()
|
|
|
|
|
|
|
|
|
|
|
|
if tab == 0 {
|
|
|
|
|
|
TranscriptList(events: store.openEvents)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
DiffSummaryView(diffStat: summary?.diffStat)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
actionArea
|
|
|
|
|
|
}
|
|
|
|
|
|
.navigationTitle(summary?.title ?? "Session")
|
|
|
|
|
|
.navigationBarTitleDisplayMode(.inline)
|
2026-06-13 02:20:53 -07:00
|
|
|
|
.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) {}
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
.onAppear { store.open(sessionID) }
|
|
|
|
|
|
.onDisappear { store.closeOpen() }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
/// 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,
|
|
|
|
|
|
controlled: controlled)
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 02:20:53 -07:00
|
|
|
|
@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")
|
|
|
|
|
|
}
|
2026-06-21 04:03:53 -07:00
|
|
|
|
// 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
|
|
|
|
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 { 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")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 01:12:55 -07:00
|
|
|
|
@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) {
|
|
|
|
|
|
// A follow-up submitted mid-turn, held by the host to send when the current
|
|
|
|
|
|
// turn finishes.
|
|
|
|
|
|
if let queued = summary?.queuedMessage, !queued.isEmpty {
|
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
|
Image(systemName: "clock").foregroundStyle(.secondary)
|
|
|
|
|
|
Text(queued).lineLimit(1).truncationMode(.tail)
|
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
|
|
}
|
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.padding()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
struct TranscriptList: View {
|
|
|
|
|
|
let events: [AgentEvent]
|
2026-06-21 04:03:53 -07:00
|
|
|
|
@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)
|
|
|
|
|
|
}
|
2026-06-13 01:12:55 -07:00
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
|
ScrollViewReader { proxy in
|
|
|
|
|
|
ScrollView {
|
|
|
|
|
|
LazyVStack(alignment: .leading, spacing: 10) {
|
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-06-21 04:03:53 -07:00
|
|
|
|
// 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.
|
2026-06-13 01:12:55 -07:00
|
|
|
|
.onChange(of: events.count) {
|
2026-06-21 04:03:53 -07:00
|
|
|
|
if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } }
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|