Review Feature Access
Nucleic-Session: 85A6233A-F3F6-44B2-AD0B-261D49EE9C35 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user