diff --git a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8b86550..c75bf3d 100644 --- a/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8150c8a8a78c15ae48059529aee5044ae234d717fb91b94b39df933cf2fafaa6", + "originHash" : "356ce51ce58b0487d13c561d75d0036cd69e1f96a76c7e633fa7c13161c1529e", "pins" : [ { "identity" : "grdb.swift", @@ -9,6 +9,24 @@ "revision" : "9ed8c8457e00ff9c7aedb3bf213f20a2cfdf509e", "version" : "7.11.0" } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm.git", + "state" : { + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" + } } ], "version" : 3 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index c9adf71..ce3da24 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -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 = [] 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)) diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 2aad284..450a3a3 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -15,18 +15,29 @@ struct NucleicRemoteApp: App { struct RootView: View { @EnvironmentObject var store: RemoteStore + // Initial tab; overridable via NUCLEIC_TAB for offline UI previews. + @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0 var body: some View { - if store.isPaired { - TabView { - SessionsView() - .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } - .badge(store.needsYouCount) - SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") } + Group { + if store.isPaired { + TabView(selection: $tab) { + HomeView() + .tabItem { Label("Home", systemImage: "house") }.tag(0) + SessionsView() + .tabItem { Label("Sessions", systemImage: "square.stack.3d.up") } + .badge(store.needsYouCount).tag(1) + ProjectsView() + .tabItem { Label("Projects", systemImage: "folder") }.tag(2) + TodosView() + .tabItem { Label("To-dos", systemImage: "checklist") }.tag(3) + SettingsView() + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) + } + } else { + PairingIntroView() } - } else { - PairingIntroView() } + .tint(Palette.accent) } } diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift new file mode 100644 index 0000000..05404c3 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -0,0 +1,101 @@ +import SwiftUI +import NucleicProtocol + +/// Start a new chat in a project, straight from Home (control scope — mirrors the Mac's home +/// composer). Picks a project, types a prompt, optionally toggles Auto. +struct StartChatComposer: View { + @EnvironmentObject var store: RemoteStore + @State private var projectID: ProjectID? + @State private var draft = "" + @State private var auto = false + + private var projects: [WireProject] { store.dashboard.projects } + private var selected: WireProject? { + projects.first { $0.id == projectID } ?? projects.first + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("New chat").font(.headline) + HStack { + Menu { + ForEach(projects) { project in + Button(project.name) { projectID = project.id } + } + } label: { + Label(selected?.name ?? "Select a project", systemImage: "folder") + .font(.subheadline) + } + Spacer() + Toggle(isOn: $auto) { + Label("Auto", systemImage: auto ? "bolt.fill" : "bolt.slash") + } + .toggleStyle(.button) + .tint(Palette.accent) + .font(.caption) + } + HStack(alignment: .bottom, spacing: 8) { + TextField("Describe a task…", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...5) + Button { + if let project = selected { + store.startChat(in: project.id, message: draft, auto: auto) + draft = "" + } + } label: { + Image(systemName: "arrow.up.circle.fill").font(.title) + } + .disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + } + if !store.canControl { + Text("This device is view-only.").font(.caption2).foregroundStyle(.secondary) + } + } + .card() + } +} + +/// A compact peek at open ideas with inline capture; the full inbox is the To-Dos tab. +struct QuickTodos: View { + @EnvironmentObject var store: RemoteStore + @State private var draft = "" + + private var open: [WireTodo] { store.dashboard.todos.filter { $0.status == .open } } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("To-dos").font(.headline) + Spacer() + if !open.isEmpty { Text("\(open.count)").foregroundStyle(.secondary) } + } + HStack { + TextField("Capture an idea…", text: $draft) + .textFieldStyle(.roundedBorder) + .onSubmit(capture) + Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) } + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + } + ForEach(open.prefix(3)) { todo in + HStack(spacing: 10) { + Button { store.completeTodo(todo.id) } label: { + Image(systemName: "circle").foregroundStyle(.secondary) + }.buttonStyle(.plain) + Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) + .font(.subheadline).lineLimit(1) + Spacer() + if let name = todo.projectName { + Text(name).font(.caption2).foregroundStyle(.secondary) + } + } + } + } + .card() + } + + private func capture() { + store.captureTodo(draft, projectID: nil) + draft = "" + } +} diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift new file mode 100644 index 0000000..f42395d --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -0,0 +1,169 @@ +import SwiftUI +import NucleicProtocol + +/// The mobile home dashboard — the phone's mirror of the Mac home (UX_MACOS §2): a greeting + +/// streak, a GitHub-style activity grid, rollup stat cards, a quick to-do peek, and a +/// start-chat composer (control scope). +struct HomeView: View { + @EnvironmentObject var store: RemoteStore + + private var counts: DashboardCounts { store.dashboard.counts } + private var activityByDay: [Date: Int] { + Dictionary(store.dashboard.activity.map { (Calendar.current.startOfDay(for: $0.day), $0.count) }, + uniquingKeysWith: +) + } + + private var streak: Int { + let cal = Calendar.current + var day = cal.startOfDay(for: Date()) + if (activityByDay[day] ?? 0) == 0 { day = cal.date(byAdding: .day, value: -1, to: day)! } + var count = 0 + while (activityByDay[day] ?? 0) > 0 { + count += 1 + day = cal.date(byAdding: .day, value: -1, to: day)! + } + return count + } + + private var greeting: String { + switch Calendar.current.component(.hour, from: Date()) { + case 5..<12: "Good morning" + case 12..<17: "Good afternoon" + case 17..<22: "Good evening" + default: "Hello" + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 2) { + Text(greeting).font(.largeTitle.bold()) + Text(subtitle).font(.subheadline).foregroundStyle(.secondary) + } + Spacer() + if streak > 0 { StreakBadge(days: streak) } + } + + statCards + + VStack(alignment: .leading, spacing: 10) { + Text("Activity").font(.headline) + ActivityGrid(activityByDay: activityByDay) + } + .frame(maxWidth: .infinity, alignment: .leading) + .card() + + QuickTodos() + + StartChatComposer() + } + .padding() + } + .navigationTitle("Home") + .navigationBarTitleDisplayMode(.inline) + .refreshable { store.refreshSessions() } + .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } + } + } + + private var subtitle: String { + if counts.chats == 0 { return "Start a chat below to get going." } + let p = counts.projects == 1 ? "project" : "projects" + return "\(counts.activeChats) active of \(counts.chats) chats · \(counts.projects) \(p)" + } + + private var statCards: some View { + HStack(spacing: 12) { + StatCard(value: counts.projects, label: "Projects", icon: "folder") + StatCard(value: counts.activeChats, label: "Active", icon: "bolt") + StatCard(value: counts.messages, label: "Messages", icon: "paperplane") + } + } +} + +private struct StatCard: View { + let value: Int + let label: String + let icon: String + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Image(systemName: icon).foregroundStyle(Palette.accent) + Text("\(value)").font(.title2.weight(.semibold).monospacedDigit()) + Text(label).font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .card(padding: 12) + } +} + +/// A flame whose heat grows with the streak (mirrors the Mac StreakBadge). +struct StreakBadge: View { + let days: Int + private var intensity: Double { min(1, Double(max(0, days - 1)) / 20) } + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "flame.fill") + .font(.system(size: 18 + 8 * intensity)) + .foregroundStyle( + LinearGradient(colors: [ + Color(hue: 0.03, saturation: 0.9, brightness: 1), + Color(hue: 0.10 + 0.04 * intensity, saturation: 0.9, brightness: 1), + ], startPoint: .top, endPoint: .bottom)) + VStack(alignment: .leading, spacing: -2) { + Text("\(days)").font(.headline.monospacedDigit()) + Text("day streak").font(.caption2).foregroundStyle(.secondary) + } + } + .padding(.horizontal, 12).padding(.vertical, 8) + .background(Color(.secondarySystemBackground), in: Capsule()) + } +} + +/// GitHub-style activity grid: columns = weeks (old→new), rows = Sun…Sat. +struct ActivityGrid: View { + let activityByDay: [Date: Int] + private let cell: CGFloat = 13 + private let gap: CGFloat = 3 + + var body: some View { + GeometryReader { geo in + let weeks = max(8, Int((geo.size.width + gap) / (cell + gap))) + grid(weeks: weeks) + } + .frame(height: cell * 7 + gap * 6) + } + + private func grid(weeks: Int) -> some View { + let cal = Calendar.current + let today = cal.startOfDay(for: Date()) + let weekday = cal.component(.weekday, from: today) + let startOfWeek = cal.date(byAdding: .day, value: -(weekday - 1), to: today)! + let gridStart = cal.date(byAdding: .day, value: -7 * (weeks - 1), to: startOfWeek)! + return HStack(alignment: .top, spacing: gap) { + ForEach(0.. today ? Color.clear : Palette.activity(level: Self.level(activityByDay[date] ?? 0))) + .frame(width: cell, height: cell) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + static func level(_ count: Int) -> Int { + switch count { + case 0: 0 + case 1...2: 1 + case 3...5: 2 + default: 3 + } + } +} diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift new file mode 100644 index 0000000..1473dd7 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -0,0 +1,84 @@ +import SwiftUI +import NucleicProtocol + +/// Projects overview (mirrors the Mac's per-project pages): each project with its session +/// counts; tap through to that project's sessions + a scoped composer. +struct ProjectsView: View { + @EnvironmentObject var store: RemoteStore + + var body: some View { + NavigationStack { + Group { + if store.dashboard.projects.isEmpty { + ContentUnavailableView("No projects", systemImage: "folder", + description: Text("Add a project on the Mac to see it here.")) + } else { + List(store.dashboard.projects) { project in + NavigationLink { + ProjectDetailView(project: project) + } label: { + HStack(spacing: 12) { + Image(systemName: "folder.fill").foregroundStyle(Palette.accent) + VStack(alignment: .leading, spacing: 2) { + Text(project.name).font(.body.weight(.medium)) + Text(project.defaultBranch).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if project.activeCount > 0 { + Text("\(project.activeCount) active") + .font(.caption2.bold()) + .padding(.horizontal, 7).padding(.vertical, 3) + .background(Palette.active.opacity(0.18), in: Capsule()) + .foregroundStyle(Palette.active) + } + Text("\(project.sessionCount)").foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle("Projects") + .refreshable { store.refreshSessions() } + } + } +} + +struct ProjectDetailView: View { + @EnvironmentObject var store: RemoteStore + let project: WireProject + @State private var draft = "" + + private var sessions: [WireSessionSummary] { + store.liveSessions + .filter { $0.projectID == project.id.rawValue } + .sorted { StatusStyle.sortRank($0) < StatusStyle.sortRank($1) } + } + + var body: some View { + List { + Section { + HStack(alignment: .bottom, spacing: 8) { + TextField("Start a chat in \(project.name)…", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(1...4) + Button { + store.startChat(in: project.id, message: draft) + draft = "" + } label: { Image(systemName: "arrow.up.circle.fill").font(.title2) } + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + } + } + Section("Sessions") { + if sessions.isEmpty { + Text("No sessions yet.").foregroundStyle(.secondary) + } + ForEach(sessions, id: \.sessionID) { summary in + NavigationLink { + SessionDetailView(sessionID: summary.sessionID) + } label: { SessionRow(summary: summary) } + } + } + } + .navigationTitle(project.name) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 1f1cc10..d7a535c 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -8,6 +8,9 @@ struct SessionDetailView: View { let sessionID: SessionID @State private var tab = 0 @State private var draft = "" + @State private var showRename = false + @State private var renameDraft = "" + @State private var showIntegrate = false private var summary: WireSessionSummary? { store.sessions.first { $0.sessionID == sessionID } @@ -35,10 +38,54 @@ struct SessionDetailView: View { } .navigationTitle(summary?.title ?? "Session") .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let summary, store.canControl { + ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) } + } + } + .alert("Rename chat", isPresented: $showRename) { + TextField("Title", text: $renameDraft) + Button("Cancel", role: .cancel) {} + Button("Rename") { store.renameSession(sessionID, to: renameDraft) } + } + .confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) { + Button("Merge") { store.integrate(sessionID, .merge) } + Button("Squash & merge") { store.integrate(sessionID, .squash) } + Button("Rebase") { store.integrate(sessionID, .rebase) } + Button("Cancel", role: .cancel) {} + } .onAppear { store.open(sessionID) } .onDisappear { store.closeOpen() } } + @ViewBuilder + private func sessionMenu(_ summary: WireSessionSummary) -> some View { + Menu { + Button { renameDraft = summary.title; showRename = true } label: { + Label("Rename", systemImage: "pencil") + } + Button { store.setFavorite(sessionID, !summary.favorite) } label: { + Label(summary.favorite ? "Unfavorite" : "Favorite", + systemImage: summary.favorite ? "star.slash" : "star") + } + if summary.status == .running { + Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } + } + Section { + Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } + Button { store.setArchived(sessionID, !summary.archived) } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") + } + } + Button(role: .destructive) { store.deleteSession(sessionID) } label: { + Label("Delete", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + @ViewBuilder private var actionArea: some View { Divider() diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 05d1658..85c0f90 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -1,32 +1,32 @@ import SwiftUI import NucleicProtocol -/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a -/// persistent connection chip. Tapping a row pushes detail. +/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived +/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done". struct SessionsView: View { @EnvironmentObject var store: RemoteStore + @State private var showArchived = false private var grouped: [(title: String, rows: [WireSessionSummary])] { - let sorted = store.sessions.sorted { - let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status) - return a == b ? $0.updatedAt > $1.updatedAt : a < b - } - let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput } - let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle } - let done = sorted.filter { $0.status.isTerminal } - return [ - ("Needs you", needs), ("Running", running), ("Done", done), - ].filter { !$0.rows.isEmpty } + let pool = (showArchived ? store.sessions : store.liveSessions) + .sorted { + let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1) + return a == b ? $0.updatedAt > $1.updatedAt : a < b + } + let needs = pool.filter { $0.status.needsYou($0.disposition) } + let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle } + let done = pool.filter { !needs.contains($0) && !running.contains($0) } + return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty } } var body: some View { NavigationStack { Group { - if store.sessions.isEmpty { + if store.liveSessions.isEmpty && !showArchived { ContentUnavailableView( "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive - ? "Start a session on your Mac to see it here." + ? "Start a session from Home or the Mac to see it here." : "Waiting to connect to \(store.hostName)…")) } else { List { @@ -38,6 +38,24 @@ struct SessionsView: View { } label: { SessionRow(summary: summary) } + .swipeActions(edge: .leading) { + Button { + store.setFavorite(summary.sessionID, !summary.favorite) + } label: { + Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill") + }.tint(.yellow) + } + .swipeActions(edge: .trailing) { + Button(role: .destructive) { + store.deleteSession(summary.sessionID) + } label: { Label("Delete", systemImage: "trash") } + Button { + store.setArchived(summary.sessionID, !summary.archived) + } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") + }.tint(.gray) + } } } } @@ -47,9 +65,16 @@ struct SessionsView: View { } } .navigationTitle("Sessions") - .safeAreaInset(edge: .bottom) { - ConnectionChip().padding(.bottom, 8) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showArchived.toggle() + } label: { + Image(systemName: showArchived ? "archivebox.fill" : "archivebox") + } + } } + .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } } @@ -59,19 +84,33 @@ struct SessionRow: View { var body: some View { HStack(spacing: 12) { - Image(systemName: StatusStyle.symbol(summary.status)) - .foregroundStyle(StatusStyle.color(summary.status)) + Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition)) + .foregroundStyle(Palette.status(summary.status, disposition: summary.disposition)) .font(.title3) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { - Text(summary.title).font(.body.weight(.medium)).lineLimit(1) + HStack(spacing: 5) { + if summary.favorite { + Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow) + } + Text(summary.title).font(.body.weight(.medium)).lineLimit(1) + } Text(summary.projectName).font(.caption).foregroundStyle(.secondary) } Spacer() - if let diff = summary.diffStat, diff.filesChanged > 0 { - Text("+\(diff.added)/−\(diff.removed)") - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + VStack(alignment: .trailing, spacing: 2) { + if summary.pendingApprovalCount > 0 { + Text("\(summary.pendingApprovalCount)") + .font(.caption2.bold()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Palette.attention, in: Capsule()) + .foregroundStyle(.white) + } + if let diff = summary.diffStat, diff.filesChanged > 0 { + Text("+\(diff.added)/−\(diff.removed)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } } } .padding(.vertical, 2) diff --git a/NucleicRemote/NucleicRemote/Views/StatusStyle.swift b/NucleicRemote/NucleicRemote/Views/StatusStyle.swift deleted file mode 100644 index 7a79cf0..0000000 --- a/NucleicRemote/NucleicRemote/Views/StatusStyle.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI -import NucleicProtocol - -/// Same status glyphs/semantics as the Mac (UX_MACOS §1 / UX_IOS §3) so the two devices read -/// identically. -enum StatusStyle { - static func symbol(_ status: SessionStatus) -> String { - switch status { - case .idle: "circle" - case .provisioning: "circle.dotted" - case .running: "circle.lefthalf.filled" - case .awaitingApproval: "exclamationmark.triangle.fill" - case .awaitingInput: "bubble.left" - case .finished: "checkmark.circle.fill" - case .interrupted: "stop.circle" - case .error: "xmark.octagon.fill" - } - } - - static func color(_ status: SessionStatus) -> Color { - switch status { - case .awaitingApproval: .orange - case .running, .provisioning: .blue - case .finished: .green - case .error: .red - case .awaitingInput: .purple - case .idle, .interrupted: .secondary - } - } - - /// Attention-first ordering for the session list (UX_IOS §3). - static func sortRank(_ status: SessionStatus) -> Int { - switch status { - case .awaitingApproval: 0 - case .awaitingInput: 1 - case .running, .provisioning: 2 - case .idle: 3 - case .finished, .interrupted, .error: 4 - } - } -} - -extension Risk { - var isHigh: Bool { self == .destructive || self == .network } - var label: String { rawValue } - var color: Color { - switch self { - case .destructive, .network: .red - case .execute: .orange - case .write: .yellow - case .readOnly: .secondary - case .unknown: .secondary - } - } -} diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift new file mode 100644 index 0000000..f1b6e76 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -0,0 +1,116 @@ +import SwiftUI +import NucleicProtocol + +/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): a deep +/// teal accent, the same categorical status palette, and the same status→color mapping +/// refined by turn disposition — so the two devices read identically. Pure SwiftUI Color +/// (no AppKit), adapting to light/dark via the system. +enum Palette { + static let accent = Color(red: 0.04, green: 0.52, blue: 0.50) // deep teal + static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // blue + static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // amber + static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green + static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple + static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red + static let neutral = Color.secondary + + /// Dot/accent color for a session's status, refined by the last turn's disposition — a + /// finished-the-work turn reads as "done" (success) rather than the calm "ready" accent. + static func status(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> Color { + if status == .awaitingInput, disposition == .completed { return success } + switch status { + case .idle, .provisioning: return neutral + case .running: return active + case .awaitingApproval: return attention + case .awaitingInput: return accent + case .finished: return success + case .interrupted: return paused + case .error: return danger + } + } + + static func activity(level: Int) -> Color { + switch level { + case 0: Color.secondary.opacity(0.15) + case 1: success.opacity(0.4) + case 2: success.opacity(0.7) + default: success + } + } +} + +/// Status glyph + label, matching the Mac semantics (UX_MACOS §1). +enum StatusStyle { + static func symbol(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String { + if status == .awaitingInput, disposition == .completed { return "checkmark.circle.fill" } + switch status { + case .idle: return "circle" + case .provisioning: return "circle.dotted" + case .running: return "circle.lefthalf.filled" + case .awaitingApproval: return "exclamationmark.triangle.fill" + case .awaitingInput: return "bubble.left.fill" + case .finished: return "checkmark.circle.fill" + case .interrupted: return "stop.circle" + case .error: return "xmark.octagon.fill" + } + } + + static func label(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String { + if status == .awaitingInput, disposition == .completed { return "Done" } + return status.displayName + } + + /// Attention-first ordering for the session list (UX_IOS §3). + static func sortRank(_ summary: WireSessionSummary) -> Int { + switch summary.status { + case .awaitingApproval: return 0 + case .awaitingInput: return summary.disposition == .completed ? 4 : 1 + case .running, .provisioning: return 2 + case .idle: return 3 + case .finished, .interrupted, .error: return 5 + } + } +} + +extension SessionStatus { + /// "Needs you" vs background work, mirroring the Mac sidebar's disposition refinement. + func needsYou(_ disposition: TurnDisposition?) -> Bool { + switch self { + case .awaitingApproval: return true + case .awaitingInput: return disposition != .completed + default: return false + } + } +} + +extension Risk { + var isHigh: Bool { self == .destructive || self == .network } + var label: String { rawValue } + var color: Color { + switch self { + case .destructive, .network: return Palette.danger + case .execute: return Palette.attention + case .write: return Color(red: 0.85, green: 0.75, blue: 0.2) + case .readOnly, .unknown: return .secondary + } + } +} + +/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`. +struct CardBackground: ViewModifier { + var padding: CGFloat = 14 + func body(content: Content) -> some View { + content + .padding(padding) + .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1)) + } +} + +extension View { + func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) } + /// Apply the teal accent app-wide. + func nucleicTint() -> some View { tint(Palette.accent) } +} diff --git a/NucleicRemote/NucleicRemote/Views/TodosView.swift b/NucleicRemote/NucleicRemote/Views/TodosView.swift new file mode 100644 index 0000000..c2cecf9 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/TodosView.swift @@ -0,0 +1,99 @@ +import SwiftUI +import NucleicProtocol + +/// The to-do inbox (mirrors the Mac's idea inbox): capture ideas, group by project, then +/// complete, dispatch (start an agent), or delete each — all control scope. +struct TodosView: View { + @EnvironmentObject var store: RemoteStore + @State private var draft = "" + + private var open: [WireTodo] { store.dashboard.todos.filter { $0.status != .done } } + + /// Group open todos by project name ("Unassigned" bucket last). + private var groups: [(name: String, todos: [WireTodo])] { + let byProject = Dictionary(grouping: open) { $0.projectName ?? "Unassigned" } + return byProject + .map { ($0.key, $0.value.sorted { $0.updatedAt > $1.updatedAt }) } + .sorted { ($0.name == "Unassigned" ? "zzz" : $0.name) < ($1.name == "Unassigned" ? "zzz" : $1.name) } + } + + var body: some View { + NavigationStack { + List { + Section { + HStack { + TextField("Capture an idea…", text: $draft) + .textFieldStyle(.roundedBorder) + .onSubmit(capture) + Button(action: capture) { Image(systemName: "plus.circle.fill").font(.title3) } + .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl) + } + } + if open.isEmpty { + ContentUnavailableView("Inbox zero", systemImage: "checkmark.circle", + description: Text("Capture an idea above to dispatch later.")) + } + ForEach(groups, id: \.name) { group in + Section(group.name.uppercased()) { + ForEach(group.todos) { todo in TodoRow(todo: todo) } + } + } + } + .navigationTitle("To-dos") + .refreshable { store.refreshSessions() } + } + } + + private func capture() { + store.captureTodo(draft, projectID: nil) + draft = "" + } +} + +private struct TodoRow: View { + @EnvironmentObject var store: RemoteStore + let todo: WireTodo + + var body: some View { + HStack(spacing: 10) { + Button { store.completeTodo(todo.id) } label: { + Image(systemName: todo.status == .dispatched ? "circle.dashed" : "circle") + .foregroundStyle(.secondary) + }.buttonStyle(.plain) + VStack(alignment: .leading, spacing: 2) { + Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text) + .font(.subheadline).lineLimit(2) + if todo.status == .dispatched { + Text("Dispatched").font(.caption2).foregroundStyle(Palette.active) + } + } + Spacer() + dispatchButton + } + .swipeActions { + Button(role: .destructive) { store.deleteTodo(todo.id) } label: { + Label("Delete", systemImage: "trash") + } + } + } + + /// Dispatch to the todo's project, or pick one if unassigned. + @ViewBuilder + private var dispatchButton: some View { + if todo.status == .open && store.canControl { + if let pid = todo.projectID { + Button { store.dispatchTodo(todo.id, in: pid) } label: { + Image(systemName: "play.circle.fill").foregroundStyle(Palette.accent) + }.buttonStyle(.plain) + } else if !store.dashboard.projects.isEmpty { + Menu { + ForEach(store.dashboard.projects) { project in + Button(project.name) { store.dispatchTodo(todo.id, in: project.id) } + } + } label: { + Image(systemName: "play.circle").foregroundStyle(Palette.accent) + } + } + } + } +}