Merge mobile control-scope parity + design language into main
iPhone client gains full control-scope parity with the Mac (start chats, manage to-dos, manage/integrate sessions), the desktop design language (teal palette, status colors, cards), and Home/Projects/To-Dos surfaces. Fixes archived chats leaking into Needs You. See nucleic-m4-state. # Conflicts: # ios/NucleicRemote/NucleicRemote.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
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 capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
@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.
|
// Open session projection.
|
||||||
@Published private(set) var openSessionID: SessionID?
|
@Published private(set) var openSessionID: SessionID?
|
||||||
@Published private(set) var openEvents: [AgentEvent] = []
|
@Published private(set) var openEvents: [AgentEvent] = []
|
||||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||||
|
|
||||||
/// Sessions needing a human (drives the app-icon badge + NEEDS YOU section).
|
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
|
||||||
var needsYouCount: Int { sessions.filter { $0.status == .awaitingApproval }.count }
|
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()
|
private let identity = IdentityStore.loadOrCreateIdentity()
|
||||||
let discovery = LANDiscovery()
|
let discovery = LANDiscovery()
|
||||||
@@ -54,16 +65,67 @@ final class RemoteStore: ObservableObject {
|
|||||||
private var seenSeq: Set<UInt64> = []
|
private var seenSeq: Set<UInt64> = []
|
||||||
private var reconnectAttempts = 0
|
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 }
|
var deviceFingerprint: String { identity.fingerprint }
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func onAppear() {
|
func onAppear() {
|
||||||
|
if demoMode { seedDemo(); return }
|
||||||
discovery.start()
|
discovery.start()
|
||||||
if isPaired { reconnect() }
|
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,
|
/// 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.
|
/// and on success pin the host key for future IK reconnects.
|
||||||
func pair(with payload: PairingPayload) {
|
func pair(with payload: PairingPayload) {
|
||||||
@@ -138,7 +200,43 @@ final class RemoteStore: ObservableObject {
|
|||||||
send(.sendInput(sessionID, AgentInput(text: trimmed)))
|
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
|
// MARK: - Plumbing
|
||||||
|
|
||||||
@@ -181,12 +279,15 @@ final class RemoteStore: ObservableObject {
|
|||||||
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
lanHost: payload.lanHost, lanPort: payload.lanPort))
|
||||||
}
|
}
|
||||||
send(.listSessions)
|
send(.listSessions)
|
||||||
|
send(.listDashboard)
|
||||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||||
case .sessionList(let list):
|
case .sessionList(let list):
|
||||||
sessions = list
|
sessions = list
|
||||||
case .sessionUpdated(let summary):
|
case .sessionUpdated(let summary):
|
||||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
||||||
else { sessions.append(summary) }
|
else { sessions.append(summary) }
|
||||||
|
case .dashboard(let snapshot):
|
||||||
|
dashboard = snapshot
|
||||||
case .snapshot(let snapshot):
|
case .snapshot(let snapshot):
|
||||||
guard snapshot.summary.sessionID == openSessionID else { break }
|
guard snapshot.summary.sessionID == openSessionID else { break }
|
||||||
seenSeq = Set(snapshot.recentEvents.map(\.seq))
|
seenSeq = Set(snapshot.recentEvents.map(\.seq))
|
||||||
|
|||||||
@@ -15,18 +15,29 @@ struct NucleicRemoteApp: App {
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@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 {
|
var body: some View {
|
||||||
|
Group {
|
||||||
if store.isPaired {
|
if store.isPaired {
|
||||||
TabView {
|
TabView(selection: $tab) {
|
||||||
|
HomeView()
|
||||||
|
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
||||||
SessionsView()
|
SessionsView()
|
||||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||||||
.badge(store.needsYouCount)
|
.badge(store.needsYouCount).tag(1)
|
||||||
|
ProjectsView()
|
||||||
|
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
||||||
|
TodosView()
|
||||||
|
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.tabItem { Label("Settings", systemImage: "gearshape") }
|
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PairingIntroView()
|
PairingIntroView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.tint(Palette.accent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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..<weeks, id: \.self) { col in
|
||||||
|
VStack(spacing: gap) {
|
||||||
|
ForEach(0..<7, id: \.self) { row in
|
||||||
|
let date = cal.date(byAdding: .day, value: col * 7 + row, to: gridStart)!
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(date > 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ struct SessionDetailView: View {
|
|||||||
let sessionID: SessionID
|
let sessionID: SessionID
|
||||||
@State private var tab = 0
|
@State private var tab = 0
|
||||||
@State private var draft = ""
|
@State private var draft = ""
|
||||||
|
@State private var showRename = false
|
||||||
|
@State private var renameDraft = ""
|
||||||
|
@State private var showIntegrate = false
|
||||||
|
|
||||||
private var summary: WireSessionSummary? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
@@ -35,10 +38,54 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(summary?.title ?? "Session")
|
.navigationTitle(summary?.title ?? "Session")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.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) }
|
.onAppear { store.open(sessionID) }
|
||||||
.onDisappear { store.closeOpen() }
|
.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
|
@ViewBuilder
|
||||||
private var actionArea: some View {
|
private var actionArea: some View {
|
||||||
Divider()
|
Divider()
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, with a
|
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived
|
||||||
/// persistent connection chip. Tapping a row pushes detail.
|
/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done".
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@State private var showArchived = false
|
||||||
|
|
||||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||||
let sorted = store.sessions.sorted {
|
let pool = (showArchived ? store.sessions : store.liveSessions)
|
||||||
let a = StatusStyle.sortRank($0.status), b = StatusStyle.sortRank($1.status)
|
.sorted {
|
||||||
|
let a = StatusStyle.sortRank($0), b = StatusStyle.sortRank($1)
|
||||||
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
return a == b ? $0.updatedAt > $1.updatedAt : a < b
|
||||||
}
|
}
|
||||||
let needs = sorted.filter { $0.status == .awaitingApproval || $0.status == .awaitingInput }
|
let needs = pool.filter { $0.status.needsYou($0.disposition) }
|
||||||
let running = sorted.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
let running = pool.filter { $0.status == .running || $0.status == .provisioning || $0.status == .idle }
|
||||||
let done = sorted.filter { $0.status.isTerminal }
|
let done = pool.filter { !needs.contains($0) && !running.contains($0) }
|
||||||
return [
|
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
||||||
("Needs you", needs), ("Running", running), ("Done", done),
|
|
||||||
].filter { !$0.rows.isEmpty }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if store.sessions.isEmpty {
|
if store.liveSessions.isEmpty && !showArchived {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No sessions", systemImage: "square.stack.3d.up",
|
"No sessions", systemImage: "square.stack.3d.up",
|
||||||
description: Text(store.connectivity.isLive
|
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)…"))
|
: "Waiting to connect to \(store.hostName)…"))
|
||||||
} else {
|
} else {
|
||||||
List {
|
List {
|
||||||
@@ -38,6 +38,24 @@ struct SessionsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
SessionRow(summary: summary)
|
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,11 +65,18 @@ struct SessionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sessions")
|
.navigationTitle("Sessions")
|
||||||
.safeAreaInset(edge: .bottom) {
|
.toolbar {
|
||||||
ConnectionChip().padding(.bottom, 8)
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
showArchived.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionRow: View {
|
struct SessionRow: View {
|
||||||
@@ -59,21 +84,35 @@ struct SessionRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(systemName: StatusStyle.symbol(summary.status))
|
Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition))
|
||||||
.foregroundStyle(StatusStyle.color(summary.status))
|
.foregroundStyle(Palette.status(summary.status, disposition: summary.disposition))
|
||||||
.font(.title3)
|
.font(.title3)
|
||||||
.frame(width: 24)
|
.frame(width: 24)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
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.title).font(.body.weight(.medium)).lineLimit(1)
|
||||||
|
}
|
||||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
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 {
|
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||||||
Text("+\(diff.added)/−\(diff.removed)")
|
Text("+\(diff.added)/−\(diff.removed)")
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user