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
@@ -38,6 +38,9 @@ final class RemoteStore: ObservableObject {
@Published private(set) var sessions: [WireSessionSummary] = []
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
@Published private(set) var grantedScope: DeviceScope = .approve
/// The host's model/effort catalog (SYNC §5.2), driving the composer + session-header pickers.
/// `.empty` until `Welcome` arrives; the pickers fall back to the built-in effort list.
@Published private(set) var modelCatalog: WireModelCatalog = .empty
/// Home / Projects / To-Dos state — the dashboard projection.
@Published private(set) var dashboard = DashboardSnapshot.empty
@@ -85,6 +88,24 @@ final class RemoteStore: ObservableObject {
hostName = "Andrew's Mac"
grantedScope = .control
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
modelCatalog = WireModelCatalog(
groups: [
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
contextBadge: "1M", contextWindow: 1_000_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort"),
WireModelCatalog.Model(sku: "claude-sonnet-4-6", displayName: "Sonnet 4.6", backend: .claudeCode,
contextBadge: nil, contextWindow: 200_000,
efforts: ["low", "medium", "high", "xhigh", "max"], effortNoun: "Effort")],
[WireModelCatalog.Model(sku: "gpt-5.5", displayName: "GPT-5.5", backend: .codex,
contextBadge: nil, contextWindow: 350_000,
efforts: ["low", "medium", "high", "xhigh"], effortNoun: "Reasoning")],
[WireModelCatalog.Model(sku: "grok-build", displayName: "Grok Build", backend: .grok,
contextBadge: nil, contextWindow: 256_000,
efforts: ["auto"], effortNoun: "Reasoning")],
],
effortDisplayNames: ["auto": "Auto", "orchestra": "Orchestra"],
orchestraSentinel: "orchestra", orchestraRequiresControlNote: "Requires Nucleic Control",
fallbackModel: "claude-opus-4-8[1m]", fallbackEffort: "high")
let p1 = ProjectID(rawValue: "p1"), p2 = ProjectID(rawValue: "p2")
func sum(_ id: String, _ project: ProjectID, _ name: String, _ title: String,
_ status: SessionStatus, _ disp: TurnDisposition? = nil, approvals: Int = 0,
@@ -179,9 +200,37 @@ final class RemoteStore: ObservableObject {
openEvents = []
openApprovals = []
seenSeq.removeAll()
if demoMode { seedDemoTranscript(sessionID); return }
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
}
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
private func seedDemoTranscript(_ sessionID: SessionID) {
func event(_ seq: UInt64, _ kind: AgentEvent.Kind) -> AgentEvent {
AgentEvent(sessionID: sessionID, seq: seq, at: Date(), backend: .claudeCode,
nativeType: nil, kind: kind)
}
openEvents = [
event(1, .sessionStarted(SessionStarted(
backendSessionID: "demo", model: "claude-opus-4-8[1m]", cwd: "~/code/nucleic", toolNames: []))),
event(2, .userText(TextChunk(messageID: "u1", text: "Refactor the auth middleware and run the tests.", isPartial: false))),
event(3, .assistantText(TextChunk(messageID: "a1", text: "I'll update the auth middleware, then run the suite.\n\n**Plan:**\n- extract `requireSession`\n- add a `Bearer` check", isPartial: false))),
event(4, .toolCallStarted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(5, .toolCallCompleted(ToolCall(toolCallID: "t1", name: "Edit", input: ["file_path": "auth/middleware.ts"]))),
event(6, .fileChange(FileChange(path: "auth/middleware.ts", kind: .update, toolCallID: "t1"))),
event(7, .toolResult(ToolResult(toolCallID: "t1", content: "Applied 2 edits to auth/middleware.ts", isError: false))),
event(8, .toolCallStarted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(9, .toolCallCompleted(ToolCall(toolCallID: "t2", name: "Bash", input: ["command": "npm test"]))),
event(10, .toolResult(ToolResult(toolCallID: "t2", content: "42 passing\n0 failing", isError: false))),
event(11, .toolCallStarted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites", "prompt": "Find every caller of the old auth API."]))),
event(12, .toolCallCompleted(ToolCall(toolCallID: "t3", name: "Task", input: ["description": "Audit other call sites"]))),
event(13, .toolResult(ToolResult(toolCallID: "t3", content: "Checked 7 files; 1 stale caller updated.", isError: false))),
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
]
}
func closeOpen() {
if let id = openSessionID { send(.unsubscribe(id)) }
openSessionID = nil
@@ -238,6 +287,14 @@ final class RemoteStore: ObservableObject {
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
// Mid-session model / reasoning / automation — the same affordances the Mac header exposes.
// `nil` model/effort resets the session to the host/app default.
func setSessionModel(_ id: SessionID, _ model: String?) { send(.setSessionModel(id, model)) }
func setSessionEffort(_ id: SessionID, _ effort: String?) { send(.setSessionEffort(id, effort)) }
func setSessionAuto(_ id: SessionID, _ auto: Bool) { send(.setSessionAuto(id, auto)) }
func setSessionAutoShip(_ id: SessionID, _ autoShip: Bool) { send(.setSessionAutoShip(id, autoShip)) }
func setSessionShipBranch(_ id: SessionID, _ branch: String?) { send(.setSessionShipBranch(id, branch)) }
// MARK: - Plumbing
private func send(_ msg: ClientMsg) {
@@ -272,6 +329,7 @@ final class RemoteStore: ObservableObject {
hostName = welcome.host.hostName
capabilities = welcome.capabilities
grantedScope = welcome.grantedScope
modelCatalog = welcome.modelCatalog
if let payload = pairingPayload, let hostKey = await client?.hostKey() {
IdentityStore.savePairedHost(PairedHost(
deviceID: IdentityStore.deviceID(), hostName: welcome.host.hostName,