nvrsion: Add review feature access for iOS remote: Enable reviewers to test all features in App Store (or TestFlight) reviews by updating BUILD.md, docs/APP_REVIEW_NOTES.md, RemoteStore.swift, RemoteApp.swift, BuildBanner.swift, and SettingsView.swift.
Nucleic-Promote: 1 Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user