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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user