Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift
T
abkslmandNucleic 63054a4629 Message Queue Display
Nucleic-Session: 4714846A-A598-4711-AA38-640B40927DF4
Co-authored-by: Nucleic <[email protected]>
2026-07-01 18:34:18 -07:00

319 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = ""
@State private var showRename = false
@State private var renameDraft = ""
@State private var showIntegrate = 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 {
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) {}
}
.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,
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 { 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)
}
}
}
.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()
}
// 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) } }
}
}
}
}
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()
}
}