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
@@ -47,6 +47,7 @@ struct ProjectDetailView: View {
@EnvironmentObject var store: RemoteStore
let project: WireProject
@State private var draft = ""
@State private var model: String?
@State private var effort = MobileEfforts.fallback
private var sessions: [WireSessionSummary] {
@@ -60,15 +61,17 @@ struct ProjectDetailView: View {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
EffortMenu(effort: $effort, controlled: project.isNucleicControlled)
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
Spacer()
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model,
controlled: project.isNucleicControlled)
}
HStack(alignment: .bottom, spacing: 8) {
TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical)
.textFieldStyle(.roundedBorder).lineLimit(1...4)
.orchestraGlow(active: MobileEfforts.isOrchestra(effort) && project.isNucleicControlled)
Button {
store.startChat(in: project.id, message: draft, effort: effort)
store.startChat(in: project.id, message: draft, model: model, effort: effort)
draft = ""
} label: { Image(systemName: "arrow.up.circle.fill").font(.title2) }
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
@@ -88,5 +91,12 @@ struct ProjectDetailView: View {
}
.navigationTitle(project.name)
.navigationBarTitleDisplayMode(.inline)
.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
}
}
}
}