iOS remote: transcript fidelity + mid-session controls & model catalog
Bring NucleicRemote closer to desktop parity in two areas (the core sync loop was already at parity — shared protocol, control scope). Transcript fidelity (iOS): a client-side TranscriptProjection coalesces streaming text by messageID and folds each tool call's lifecycle (start/deltas/complete/result/fileChange) into one expandable card — fixing the duplicate started+completed rows. Adds Markdown bubbles, the gold Orchestra card for Task/Agent spawns, and the previously-dropped usage/cost, rate-limit, file-change, turn-boundary and session-started rows, plus a context-window % header badge. Mid-session controls + model catalog (protocol/host/iOS): project the host ModelCatalog over the wire as WireModelCatalog (in Welcome); add 5 control-scope setters (setSessionModel/Effort/Auto/AutoShip/ShipBranch) backed by the existing AppStore.mutateSession + SessionController hooks; enrich WireSessionSummary with model/effort/auto/autoShip/shipBranch/ contextInputTokens (all forward-compatible). The composer gains a model picker and a catalog-driven effort menu (per-backend caps: Codex→xhigh, Grok→auto), and the session header gains a model/effort/auto/autoship control bar. Tests: CBOR round-trips for the new messages, Welcome.modelCatalog, the new summary fields, forward-compat decode of old bytes, and the setters reaching the host. Verified in the Simulator (NUCLEIC_DEMO=1). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e3b6ccc297
commit
e6ef59e830
@@ -26,6 +26,8 @@ struct SessionDetailView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
if store.canControl, let summary { controlBar(summary) }
|
||||
|
||||
Divider()
|
||||
|
||||
if tab == 0 {
|
||||
@@ -58,6 +60,67 @@ struct SessionDetailView: View {
|
||||
.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)
|
||||
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 {
|
||||
@@ -68,6 +131,14 @@ struct SessionDetailView: View {
|
||||
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") }
|
||||
}
|
||||
@@ -140,19 +211,27 @@ struct SessionDetailView: View {
|
||||
|
||||
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(events, id: \.seq) { event in
|
||||
TranscriptRow(event: event).id(event.seq)
|
||||
ForEach(items) { item in
|
||||
TranscriptRow(item: item).id(item.id)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
// 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 = events.last { withAnimation { proxy.scrollTo(last.seq, anchor: .bottom) } }
|
||||
if let last = items.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user