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:
Nucleic
2026-06-21 04:03:53 -07:00
co-authored by Claude Opus 4.8
parent e3b6ccc297
commit e6ef59e830
9 changed files with 763 additions and 63 deletions
@@ -8,12 +8,14 @@ struct StartChatComposer: View {
@State private var projectID: ProjectID?
@State private var draft = ""
@State private var auto = false
@State private var model: String?
@State private var effort = MobileEfforts.fallback
private var projects: [WireProject] { store.dashboard.projects }
private var selected: WireProject? {
projects.first { $0.id == projectID } ?? projects.first
}
private var controlled: Bool { selected?.isNucleicControlled ?? false }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
@@ -28,7 +30,6 @@ struct StartChatComposer: View {
.font(.subheadline)
}
Spacer()
EffortMenu(effort: $effort, controlled: selected?.isNucleicControlled ?? false)
Toggle(isOn: $auto) {
Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash")
}
@@ -36,14 +37,20 @@ struct StartChatComposer: View {
.tint(Palette.accent)
.font(.caption)
}
HStack {
// The model also selects the backend, so the home composer offers every provider.
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
}
HStack(alignment: .bottom, spacing: 8) {
TextField("Describe a task…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...5)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && (selected?.isNucleicControlled ?? false))
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && controlled)
Button {
if let project = selected {
store.startChat(in: project.id, message: draft, effort: effort, auto: auto)
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
draft = ""
}
} label: {
@@ -56,6 +63,14 @@ struct StartChatComposer: View {
}
}
.card()
// Switching to a model with a lower effort cap can't leave an unsupported level selected.
.onChange(of: model) { _, newModel in
let levels = store.modelCatalog.offeredEfforts(forModel: newModel)
let sentinel = store.modelCatalog.orchestraSentinelOrFallback
if effort.caseInsensitiveCompare(sentinel) != .orderedSame, !levels.contains(effort) {
effort = levels.last ?? MobileEfforts.fallback
}
}
}
}