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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user