|
|
|
@@ -145,9 +145,18 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
private var seenSeq: Set<UInt64> = []
|
|
|
|
|
private var reconnectAttempts = 0
|
|
|
|
|
|
|
|
|
|
/// Offline UI-preview mode (set `NUCLEIC_DEMO=1`): seeds mock state so the surfaces render
|
|
|
|
|
/// without a paired Mac. No effect on real use.
|
|
|
|
|
private let demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|
|
|
|
|
/// Offline demo mode: seeds mock state and simulates the agent locally so every surface
|
|
|
|
|
/// renders — and the core loops (send, approve, start chat, to-dos) actually respond —
|
|
|
|
|
/// without a paired Mac. Reachable in two ways: the `NUCLEIC_DEMO=1` env var (dev /
|
|
|
|
|
/// screenshots, forced on at launch) and a persisted in-app toggle
|
|
|
|
|
/// (`enterDemo()` / `exitDemo()`) so an App Store reviewer with no Mac can exercise the app
|
|
|
|
|
/// (App Review Guideline 2.1). `@Published` so the UI can show a DEMO indicator and the
|
|
|
|
|
/// "Leave demo" affordance.
|
|
|
|
|
private static let demoModeKey = "nucleic.demoMode"
|
|
|
|
|
@Published private(set) var demoMode = ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] == "1"
|
|
|
|
|
|| UserDefaults.standard.bool(forKey: RemoteStore.demoModeKey)
|
|
|
|
|
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
|
|
|
|
private var demoTasks: [Task<Void, Never>] = []
|
|
|
|
|
|
|
|
|
|
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
|
|
|
|
var deviceFingerprint: String { identity.fingerprint }
|
|
|
|
@@ -317,6 +326,40 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
NotificationRouter.shared.updateBadge(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Enter the in-app demo (the "Explore a demo" button): drop any live connection, persist the
|
|
|
|
|
/// flag so it survives relaunch (a reviewer may relaunch), and seed the mock world. `isPaired`
|
|
|
|
|
/// then returns true, so `RootView` shows the full TabView.
|
|
|
|
|
func enterDemo() {
|
|
|
|
|
teardown()
|
|
|
|
|
demoMode = true
|
|
|
|
|
UserDefaults.standard.set(true, forKey: Self.demoModeKey)
|
|
|
|
|
reconnectAttempts = 0
|
|
|
|
|
seedDemo()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Leave the demo (Settings ▸ Leave demo): cancel any simulated runs, clear the mock world,
|
|
|
|
|
/// and return to the real state — reconnect if a Mac is actually paired, else the pairing intro.
|
|
|
|
|
func exitDemo() {
|
|
|
|
|
demoMode = false
|
|
|
|
|
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
|
|
|
|
demoTasks.forEach { $0.cancel() }
|
|
|
|
|
demoTasks.removeAll()
|
|
|
|
|
openSessionID = nil
|
|
|
|
|
openEvents = []
|
|
|
|
|
openApprovals = []
|
|
|
|
|
openDiff = nil
|
|
|
|
|
diffLoading = false
|
|
|
|
|
sessions = []
|
|
|
|
|
dashboard = .empty
|
|
|
|
|
LiveActivityManager.shared.end()
|
|
|
|
|
NotificationRouter.shared.updateBadge(0)
|
|
|
|
|
if IdentityStore.loadPairedHost() != nil {
|
|
|
|
|
reconnect()
|
|
|
|
|
} else {
|
|
|
|
|
connectivity = .unpaired
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Intents (UX_IOS §9)
|
|
|
|
|
|
|
|
|
|
func open(_ sessionID: SessionID) {
|
|
|
|
@@ -371,6 +414,21 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
event(14, .usage(Usage(inputTokens: 84_300, outputTokens: 2_140, costUSD: 0.0421, contextInputTokens: 84_300))),
|
|
|
|
|
event(15, .runFinished(RunFinished(outcome: .completed, finalText: "Done."))),
|
|
|
|
|
]
|
|
|
|
|
// If this session is blocked on a human, surface a real approval card so the
|
|
|
|
|
// Allow/Deny loop is exercisable in the demo (the seeded `a1` session).
|
|
|
|
|
if sessions.first(where: { $0.sessionID == sessionID })?.status == .awaitingApproval {
|
|
|
|
|
openApprovals = [ApprovalRequest(
|
|
|
|
|
id: Self.demoApprovalID(for: sessionID),
|
|
|
|
|
sessionID: sessionID, toolCallID: "t-appr", toolName: "Bash",
|
|
|
|
|
input: ["command": "npm run deploy"], title: "Run npm run deploy",
|
|
|
|
|
risk: .execute, createdAt: Date())]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Deterministic approval id for a demo session's seeded approval, so re-opening the same
|
|
|
|
|
/// session doesn't stack duplicate cards.
|
|
|
|
|
private static func demoApprovalID(for sessionID: SessionID) -> ApprovalID {
|
|
|
|
|
ApprovalID(rawValue: "demo-appr-\(sessionID.rawValue)")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func closeOpen() {
|
|
|
|
@@ -449,10 +507,191 @@ final class RemoteStore: ObservableObject {
|
|
|
|
|
// MARK: - Plumbing
|
|
|
|
|
|
|
|
|
|
private func send(_ msg: ClientMsg) {
|
|
|
|
|
if demoMode { demoHandle(msg); return }
|
|
|
|
|
guard let client else { return }
|
|
|
|
|
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,
|
|
|
|
|
.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. Each step
|
|
|
|
|
/// re-checks demo mode and cancellation so `exitDemo()` stops it cleanly. Runs entirely on the
|
|
|
|
|
/// main actor (the store is `@MainActor`, and a `Task` in an isolated method inherits it).
|
|
|
|
|
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 toolID = UUID().uuidString
|
|
|
|
|
let task = Task { [weak self] in
|
|
|
|
|
guard let self else { return }
|
|
|
|
|
@MainActor func pause(_ ms: Int) async -> Bool {
|
|
|
|
|
try? await Task.sleep(for: .milliseconds(ms))
|
|
|
|
|
if Task.isCancelled { return false }
|
|
|
|
|
return self.demoMode
|
|
|
|
|
}
|
|
|
|
|
guard await pause(600) else { return }
|
|
|
|
|
self.demoAppend(sessionID, .assistantText(TextChunk(
|
|
|
|
|
messageID: UUID().uuidString, text: "On it — let me take a look.", isPartial: false)))
|
|
|
|
|
guard await pause(700) else { return }
|
|
|
|
|
self.demoAppend(sessionID, .toolCallStarted(ToolCall(
|
|
|
|
|
toolCallID: toolID, name: "Bash", input: ["command": "npm test"])))
|
|
|
|
|
guard await pause(900) else { return }
|
|
|
|
|
self.demoAppend(sessionID, .toolResult(ToolResult(
|
|
|
|
|
toolCallID: toolID, content: "42 passing\n0 failing", isError: false)))
|
|
|
|
|
guard await pause(700) else { return }
|
|
|
|
|
self.demoAppend(sessionID, .assistantText(TextChunk(
|
|
|
|
|
messageID: UUID().uuidString,
|
|
|
|
|
text: "All green — tests pass and the change is in place. Anything else?",
|
|
|
|
|
isPartial: false)))
|
|
|
|
|
self.demoAppend(sessionID, .usage(Usage(
|
|
|
|
|
inputTokens: 12_400, outputTokens: 640, costUSD: 0.0088, contextInputTokens: 12_400)))
|
|
|
|
|
self.demoAppend(sessionID, .runFinished(RunFinished(outcome: .completed, finalText: "Done.")))
|
|
|
|
|
self.demoUpdateSession(sessionID) {
|
|
|
|
|
$0.demoCopy(status: .awaitingInput, disposition: .some(.completed))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
demoTasks.append(task)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
@@ -599,3 +838,41 @@ extension Data {
|
|
|
|
|
DeviceIdentity.fingerprint(ofStaticKey: self)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
extension WireSessionSummary {
|
|
|
|
|
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
|
|
|
|
|
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
|
|
|
|
|
/// caller can distinguish "keep" (omit) from "set to nil" (`.some(nil)`). `updatedAt` bumps
|
|
|
|
|
/// to now unless given. Only the fields the simulator touches are exposed.
|
|
|
|
|
func demoCopy(
|
|
|
|
|
status: SessionStatus? = nil,
|
|
|
|
|
disposition: TurnDisposition?? = nil,
|
|
|
|
|
title: String? = nil,
|
|
|
|
|
favorite: Bool? = nil,
|
|
|
|
|
archived: Bool? = nil,
|
|
|
|
|
pendingApprovalCount: Int? = nil,
|
|
|
|
|
diffStat: DiffStat?? = nil,
|
|
|
|
|
model: String?? = nil,
|
|
|
|
|
effort: String?? = nil,
|
|
|
|
|
auto: Bool? = nil,
|
|
|
|
|
autoShip: Bool? = nil,
|
|
|
|
|
shipBranch: String?? = nil,
|
|
|
|
|
updatedAt: Date? = nil
|
|
|
|
|
) -> WireSessionSummary {
|
|
|
|
|
WireSessionSummary(
|
|
|
|
|
sessionID: sessionID, projectID: projectID, projectName: projectName, backend: backend,
|
|
|
|
|
status: status ?? self.status,
|
|
|
|
|
disposition: disposition ?? self.disposition,
|
|
|
|
|
title: title ?? self.title, branch: branch, lastSeq: lastSeq,
|
|
|
|
|
diffStat: diffStat ?? self.diffStat,
|
|
|
|
|
pendingApprovalCount: pendingApprovalCount ?? self.pendingApprovalCount,
|
|
|
|
|
favorite: favorite ?? self.favorite,
|
|
|
|
|
archived: archived ?? self.archived,
|
|
|
|
|
queuedMessage: queuedMessage, queuedMessages: queuedMessages,
|
|
|
|
|
model: model ?? self.model, effort: effort ?? self.effort,
|
|
|
|
|
auto: auto ?? self.auto, autoShip: autoShip ?? self.autoShip,
|
|
|
|
|
shipBranch: shipBranch ?? self.shipBranch,
|
|
|
|
|
contextInputTokens: contextInputTokens,
|
|
|
|
|
updatedAt: updatedAt ?? Date())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|