M4+: mirror the desktop design language onto mobile (Home/Projects/To-Dos/Sessions)
Design system: Palette (teal accent + the Mac's categorical status colors, status→color refined by turn disposition), StatusStyle glyphs/labels, a card surface modifier, app-wide teal tint. Fixes the reported bug: Sessions now hides archived chats and uses disposition to split NEEDS YOU from DONE (a finished-the-work turn no longer shows as needing you). Rows gain status dots, favorite star, approval-count badge, diffstat; swipe to favorite/archive/delete. New surfaces, full control-scope parity with the Mac: - Home: greeting + day-streak flame, GitHub-style activity grid, stat cards, quick to-dos, and a start-chat composer (project picker + Auto). - Projects: per-project session counts → project detail with a scoped composer. - To-dos: capture, group by project, complete/dispatch/delete. - Session detail: a control menu (rename, favorite, interrupt, integrate merge/squash/rebase, archive, delete). RemoteStore handles the dashboard event + exposes all control intents; claims control scope. A NUCLEIC_DEMO/NUCLEIC_TAB env seam seeds mock state for offline UI preview. iOS app builds for the iOS 27 simulator; Home + Sessions verified rendering via screenshots. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -39,13 +39,24 @@ final class RemoteStore: ObservableObject {
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
|
||||
/// Home / Projects / To-Dos state — the dashboard projection.
|
||||
@Published private(set) var dashboard = DashboardSnapshot.empty
|
||||
|
||||
// Open session projection.
|
||||
@Published private(set) var openSessionID: SessionID?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
|
||||
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section).
|
||||
var needsYouCount: Int { sessions.filter { $0.status == .awaitingApproval }.count }
|
||||
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
|
||||
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
|
||||
|
||||
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section). Uses turn
|
||||
/// disposition so a finished-the-work session doesn't count, and excludes archived.
|
||||
var needsYouCount: Int {
|
||||
liveSessions.filter { $0.status.needsYou($0.disposition) }.count
|
||||
}
|
||||
|
||||
var canControl: Bool { grantedScope >= .control }
|
||||
|
||||
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||
let discovery = LANDiscovery()
|
||||
@@ -54,16 +65,67 @@ final class RemoteStore: ObservableObject {
|
||||
private var seenSeq: Set<UInt64> = []
|
||||
private var reconnectAttempts = 0
|
||||
|
||||
var isPaired: Bool { IdentityStore.loadPairedHost() != nil }
|
||||
/// 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"
|
||||
|
||||
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
||||
var deviceFingerprint: String { identity.fingerprint }
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func onAppear() {
|
||||
if demoMode { seedDemo(); return }
|
||||
discovery.start()
|
||||
if isPaired { reconnect() }
|
||||
}
|
||||
|
||||
private func seedDemo() {
|
||||
connectivity = .connected
|
||||
hostName = "Andrew's Mac"
|
||||
grantedScope = .control
|
||||
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
|
||||
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,
|
||||
fav: Bool = false, arch: Bool = false, add: Int = 0, rem: Int = 0) -> WireSessionSummary {
|
||||
WireSessionSummary(
|
||||
sessionID: SessionID(rawValue: id), projectID: project.rawValue, projectName: name,
|
||||
backend: .claudeCode, status: status, disposition: disp, title: title,
|
||||
branch: "nucleic/\(id)", lastSeq: 10,
|
||||
diffStat: add + rem > 0 ? DiffStat(filesChanged: 2, added: add, removed: rem) : nil,
|
||||
pendingApprovalCount: approvals, favorite: fav, archived: arch,
|
||||
updatedAt: Date())
|
||||
}
|
||||
sessions = [
|
||||
sum("a1", p1, "nucleic", "auth-refactor", .awaitingApproval, approvals: 1, add: 312, rem: 40),
|
||||
sum("a2", p1, "nucleic", "flaky-tests", .running, add: 88, rem: 12),
|
||||
sum("a3", p2, "website", "graphql-migration", .awaitingInput, .awaitingInput, fav: true),
|
||||
sum("a4", p2, "website", "docs-pass", .awaitingInput, .completed, add: 20, rem: 4),
|
||||
sum("a5", p1, "nucleic", "old-experiment", .finished, arch: true),
|
||||
]
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let activity = (0..<40).map { i -> ActivityDay in
|
||||
ActivityDay(day: cal.date(byAdding: .day, value: -i, to: today)!, count: (i * 7) % 6)
|
||||
}
|
||||
dashboard = DashboardSnapshot(
|
||||
counts: DashboardCounts(projects: 2, chats: 5, activeChats: 2, messages: 142, activeDays: 9),
|
||||
activity: activity,
|
||||
projects: [
|
||||
WireProject(id: p1, name: "nucleic", defaultBranch: "main", sessionCount: 3, activeCount: 2),
|
||||
WireProject(id: p2, name: "website", defaultBranch: "main", sessionCount: 2, activeCount: 1),
|
||||
],
|
||||
todos: [
|
||||
WireTodo(id: TodoID(rawValue: "t1"), text: "Add dark mode to settings", summary: "Dark mode in settings",
|
||||
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
|
||||
WireTodo(id: TodoID(rawValue: "t2"), text: "Investigate the memory leak in the sync server", summary: "Sync server memory leak",
|
||||
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil, updatedAt: Date()),
|
||||
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
|
||||
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, updatedAt: Date()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
||||
/// and on success pin the host key for future IK reconnects.
|
||||
func pair(with payload: PairingPayload) {
|
||||
@@ -138,7 +200,43 @@ final class RemoteStore: ObservableObject {
|
||||
send(.sendInput(sessionID, AgentInput(text: trimmed)))
|
||||
}
|
||||
|
||||
func refreshSessions() { send(.listSessions) }
|
||||
func refreshSessions() { send(.listSessions); send(.listDashboard) }
|
||||
|
||||
// MARK: - Control intents (control scope; the same actions the Mac can take)
|
||||
|
||||
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
|
||||
effort: String? = nil, baseBranch: String? = nil,
|
||||
useWorktree: Bool = true, auto: Bool? = nil) {
|
||||
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
send(.startChat(StartChatRequest(
|
||||
projectID: projectID, message: text, model: model, effort: effort,
|
||||
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
|
||||
}
|
||||
|
||||
func captureTodo(_ text: String, projectID: ProjectID?) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
send(.captureTodo(CaptureTodoRequest(text: trimmed, projectID: projectID)))
|
||||
}
|
||||
|
||||
func dispatchTodo(_ id: TodoID, in projectID: ProjectID) { send(.dispatchTodo(id, projectID)) }
|
||||
func completeTodo(_ id: TodoID) { send(.setTodoStatus(id, .done)) }
|
||||
func deleteTodo(_ id: TodoID) { send(.deleteTodo(id)) }
|
||||
|
||||
func renameSession(_ id: SessionID, to title: String) {
|
||||
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
send(.renameSession(id, trimmed))
|
||||
}
|
||||
func setFavorite(_ id: SessionID, _ favorite: Bool) { send(.setFavorite(id, favorite)) }
|
||||
func setArchived(_ id: SessionID, _ archived: Bool) { send(.setArchived(id, archived)) }
|
||||
func deleteSession(_ id: SessionID) {
|
||||
send(.deleteSession(id))
|
||||
if id == openSessionID { closeOpen() }
|
||||
}
|
||||
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
|
||||
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
@@ -181,12 +279,15 @@ final class RemoteStore: ObservableObject {
|
||||
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
||||
}
|
||||
send(.listSessions)
|
||||
send(.listDashboard)
|
||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||
case .sessionList(let list):
|
||||
sessions = list
|
||||
case .sessionUpdated(let summary):
|
||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
||||
else { sessions.append(summary) }
|
||||
case .dashboard(let snapshot):
|
||||
dashboard = snapshot
|
||||
case .snapshot(let snapshot):
|
||||
guard snapshot.summary.sessionID == openSessionID else { break }
|
||||
seenSeq = Set(snapshot.recentEvents.map(\.seq))
|
||||
|
||||
Reference in New Issue
Block a user