From 360e359613b5802a4fde5f219527ee7326239ace Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Thu, 2 Jul 2026 19:19:09 -0700 Subject: [PATCH] Review Feature Access Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic --- .../NucleicRemote/Models/RemoteStore.swift | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 381fed6..2fef85f 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -512,6 +512,191 @@ final class RemoteStore: ObservableObject { Task { await client.send(msg) } } + // MARK: - Demo simulator (offline, interactive) + // + // In demo mode there's no host, so writes can't go over the wire. Instead they mutate the + // already-`@Published` projection directly and (for the agent loop) stream a short canned run, + // so a reviewer can send messages, approve actions, start chats, and manage to-dos and see the + // UI respond — the read-only fixtures (`seedDemo`/`seedDemoTranscript`/`demoDiff`) already + // cover the passive surfaces. + + private func demoHandle(_ msg: ClientMsg) { + switch msg { + case .sendInput(let id, let input): + demoRun(id, userText: input.plainText ?? "") + case .startChat(let req): + demoStartChat(req) + case .approvalRespond(let id, let decision): + demoResolveApproval(id, decision) + case .captureTodo(let req): + demoCaptureTodo(req) + case .setTodoStatus(let id, let status): + demoSetTodoStatus(id, status) + case .deleteTodo(let id): + demoMutateTodos { $0.filter { $0.id != id } } + case .dispatchTodo(let id, _): + demoSetTodoStatus(id, .dispatched) + case .renameSession(let id, let title): + demoUpdateSession(id) { $0.demoCopy(title: title) } + case .setFavorite(let id, let favorite): + demoUpdateSession(id) { $0.demoCopy(favorite: favorite) } + case .setArchived(let id, let archived): + demoUpdateSession(id) { $0.demoCopy(archived: archived) } + case .deleteSession(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + case .discard(let id): + sessions.removeAll { $0.sessionID == id } + NotificationRouter.shared.updateBadge(needsYouCount) + case .interrupt(let id): + demoUpdateSession(id) { $0.demoCopy(status: .interrupted, disposition: .some(nil)) } + case .integrate(let id, _): + demoAppend(id, .note(NoteEvent(text: "nvrsion: Landed to trunk.", icon: "arrow.triangle.branch"))) + demoUpdateSession(id) { $0.demoCopy(status: .finished, disposition: .some(.completed)) } + case .setSessionModel(let id, let model): + demoUpdateSession(id) { $0.demoCopy(model: .some(model)) } + case .setSessionEffort(let id, let effort): + demoUpdateSession(id) { $0.demoCopy(effort: .some(effort)) } + case .setSessionAuto(let id, let auto): + demoUpdateSession(id) { $0.demoCopy(auto: auto) } + case .setSessionAutoShip(let id, let autoShip): + demoUpdateSession(id) { $0.demoCopy(autoShip: autoShip) } + case .setSessionShipBranch(let id, let branch): + demoUpdateSession(id) { $0.demoCopy(shipBranch: .some(branch)) } + case .hello, .listSessions, .listDashboard, .subscribe, .unsubscribe, + .interrupt, .ping, .cancelQueuedMessage, .fetchDiff: + break // passive / already handled by the seeded fixtures + } + } + + /// Append a transcript event to the open session (no-op if it isn't the one on screen). + private func demoAppend(_ sessionID: SessionID, _ kind: AgentEvent.Kind) { + guard sessionID == openSessionID else { return } + let seq = (openEvents.map(\.seq).max() ?? 0) + 1 + let backend = sessions.first { $0.sessionID == sessionID }?.backend ?? .claudeCode + openEvents.append(AgentEvent( + sessionID: sessionID, seq: seq, at: Date(), backend: backend, nativeType: nil, kind: kind)) + } + + /// Replace a session summary in the list, keeping the badge / Live Activity in sync. + private func demoUpdateSession(_ id: SessionID, _ transform: (WireSessionSummary) -> WireSessionSummary) { + guard let i = sessions.firstIndex(where: { $0.sessionID == id }) else { return } + sessions[i] = transform(sessions[i]) + NotificationRouter.shared.updateBadge(needsYouCount) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + } + + /// Stream a short, believable canned turn for a session: assistant prose, a tool call + + /// result, usage, and a finish — flipping the summary running → awaiting-input. + private func demoRun(_ sessionID: SessionID, userText: String?) { + if let userText, !userText.isEmpty { + demoAppend(sessionID, .userText(TextChunk(messageID: UUID().uuidString, text: userText, isPartial: false))) + } + demoUpdateSession(sessionID) { $0.demoCopy(status: .running, disposition: .some(nil)) } + let task = Task { [weak self] in + let toolID = UUID().uuidString + guard await self?.demoStep(sessionID, delayMs: 600, { + $0.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 700, { + $0.demoAppend(sessionID, .toolCallStarted(ToolCall( + toolCallID: toolID, name: "Bash", input: ["command": "npm test"]))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 900, { + $0.demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: toolID, content: "42 passing\n0 failing", isError: false))) + }) == true else { return } + guard await self?.demoStep(sessionID, delayMs: 700, { + $0.demoAppend(sessionID, .assistantText(TextChunk( + messageID: UUID().uuidString, + text: "All green — tests pass and the change is in place. Anything else?", + isPartial: false))) + $0.demoAppend(sessionID, .usage(Usage( + inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400))) + $0.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))) + }) == true else { return } + await self?.demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.completed)) + } + } + demoTasks.append(task) + } + + /// One step of a simulated run: sleep, then (if still in demo and not cancelled) apply a + /// mutation on the main actor. Returns false to abort the remaining steps. + private func demoStep(_ sessionID: SessionID, delayMs: UInt64, _ body: @MainActor (RemoteStore) -> Void) async -> Bool { + try? await Task.sleep(for: .milliseconds(Int(delayMs))) + guard !Task.isCancelled, demoMode else { return false } + body(self) + return true + } + + private func demoStartChat(_ req: StartChatRequest) { + let project = dashboard.projects.first { $0.id == req.projectID } + let id = SessionID(rawValue: "demo-\(UUID().uuidString.prefix(8))") + let title = String(req.message.prefix(48)) + let summary = WireSessionSummary( + sessionID: id, projectID: req.projectID.rawValue, + projectName: project?.name ?? "project", backend: BackendID.forModel(req.model) ?? .claudeCode, + status: .running, disposition: nil, title: title.isEmpty ? "New chat" : title, + branch: "nucleic/\(id.rawValue)", lastSeq: 0, diffStat: nil, + pendingApprovalCount: 0, favorite: false, archived: false, + model: req.model, effort: req.effort, auto: req.auto ?? false, + updatedAt: Date()) + sessions.insert(summary, at: 0) + LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions) + demoRun(id, userText: req.message) + } + + private func demoResolveApproval(_ id: ApprovalID, _ decision: Decision) { + openApprovals.removeAll { $0.id == id } + NotificationRouter.shared.withdrawApproval(id) + guard let sessionID = openSessionID else { return } + switch decision { + case .deny, .cancelRun: + demoAppend(sessionID, .note(NoteEvent(text: "You denied the command.", icon: "hand.raised"))) + demoUpdateSession(sessionID) { + $0.demoCopy(status: .awaitingInput, disposition: .some(.awaitingInput), pendingApprovalCount: 0) + } + case .allow, .allowAlways: + demoAppend(sessionID, .toolResult(ToolResult( + toolCallID: "t-appr", content: "Deployed successfully.", isError: false))) + demoUpdateSession(sessionID) { $0.demoCopy(pendingApprovalCount: 0) } + demoRun(sessionID, userText: nil) // wrap up the turn after the approved tool runs + } + } + + private func demoCaptureTodo(_ req: CaptureTodoRequest) { + let project = req.projectID.flatMap { pid in dashboard.projects.first { $0.id == pid } } + let todo = WireTodo( + id: .generate(), text: req.text, summary: nil, projectID: req.projectID, + projectName: project?.name, status: .open, dispatchedSessionID: nil, + triage: nil, updatedAt: Date()) + demoMutateTodos { [todo] + $0 } + } + + private func demoSetTodoStatus(_ id: TodoID, _ status: TodoStatus) { + demoMutateTodos { todos in + todos.map { todo in + todo.id == id + ? WireTodo(id: todo.id, text: todo.text, summary: todo.summary, + projectID: todo.projectID, projectName: todo.projectName, + status: status, dispatchedSessionID: todo.dispatchedSessionID, + triage: todo.triage, updatedAt: Date()) + : todo + } + } + } + + /// Rebuild the dashboard with a transformed to-do list (its fields are `let`). + private func demoMutateTodos(_ transform: ([WireTodo]) -> [WireTodo]) { + dashboard = DashboardSnapshot( + counts: dashboard.counts, activity: dashboard.activity, projects: dashboard.projects, + todos: transform(dashboard.todos), usage: dashboard.usage, statusFeeds: dashboard.statusFeeds) + } + private func makeChannel(_ endpoint: NWEndpoint) -> NWFrameChannel { NWFrameChannel(endpoint: endpoint) }