ios: full feature/appearance parity with the macOS app (client-side)
Port the Mac's MarkdownText renderer, AppPalette (color-vision modes + night-softening), appearance settings (theme/text size/color vision), and BuildBanner channel strip to the iPhone remote. Add session-row attention/unseen-completion washes and marker icons, Discard action, branch/worktree options in the new-chat composer, and a transient error bubble for host wire errors. Stamp the build channel via NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL, default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the notifications prompt so UI previews are scriptable. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -6,6 +6,7 @@ import NucleicProtocol
|
||||
struct SessionsView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
@State private var showArchived = false
|
||||
@State private var path = NavigationPath()
|
||||
|
||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
||||
@@ -20,7 +21,7 @@ struct SessionsView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
NavigationStack(path: $path) {
|
||||
Group {
|
||||
if store.liveSessions.isEmpty && !showArchived {
|
||||
ContentUnavailableView(
|
||||
@@ -33,11 +34,10 @@ struct SessionsView: View {
|
||||
ForEach(grouped, id: \.title) { group in
|
||||
Section(group.title.uppercased()) {
|
||||
ForEach(group.rows, id: \.sessionID) { summary in
|
||||
NavigationLink {
|
||||
SessionDetailView(sessionID: summary.sessionID)
|
||||
} label: {
|
||||
NavigationLink(value: summary.sessionID) {
|
||||
SessionRow(summary: summary)
|
||||
}
|
||||
.listRowBackground(SessionRowWash(summary: summary))
|
||||
.swipeActions(edge: .leading) {
|
||||
Button {
|
||||
store.setFavorite(summary.sessionID, !summary.favorite)
|
||||
@@ -65,6 +65,16 @@ struct SessionsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
.navigationDestination(for: SessionID.self) { id in
|
||||
SessionDetailView(sessionID: id)
|
||||
}
|
||||
// Offline UI previews (with NUCLEIC_DEMO): jump straight into one session.
|
||||
.onAppear {
|
||||
if let raw = ProcessInfo.processInfo.environment["NUCLEIC_DEMO_SESSION"],
|
||||
!raw.isEmpty, path.isEmpty {
|
||||
path.append(SessionID(rawValue: raw))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
@@ -80,11 +90,18 @@ struct SessionsView: View {
|
||||
}
|
||||
|
||||
struct SessionRow: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let summary: WireSessionSummary
|
||||
/// Show the project name as subtext. Suppressed when the row already lives inside
|
||||
/// its project's own section (e.g. `ProjectDetailView`), where the name is redundant.
|
||||
var showProjectName = true
|
||||
|
||||
/// Whether Orchestra is latched on (the effort carries the sentinel).
|
||||
private var orchestra: Bool {
|
||||
guard let effort = summary.effort else { return false }
|
||||
return effort.caseInsensitiveCompare(store.modelCatalog.orchestraSentinelOrFallback) == .orderedSame
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: StatusStyle.symbol(summary.status, disposition: summary.disposition))
|
||||
@@ -97,6 +114,17 @@ struct SessionRow: View {
|
||||
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
|
||||
}
|
||||
Text(summary.title).font(.body.weight(.medium)).lineLimit(1)
|
||||
// The same trailing markers the Mac sidebar row carries: auto-approval
|
||||
// bolt, Orchestra note, autoship box.
|
||||
if summary.auto {
|
||||
Image(systemName: "bolt.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
}
|
||||
if orchestra {
|
||||
Image(systemName: "music.note.list").font(.caption2).foregroundStyle(Palette.orchestra)
|
||||
}
|
||||
if summary.autoShip {
|
||||
Image(systemName: "shippingbox.fill").font(.caption2).foregroundStyle(Palette.accent)
|
||||
}
|
||||
}
|
||||
if showProjectName {
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
@@ -121,3 +149,31 @@ struct SessionRow: View {
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
|
||||
/// The Mac sidebar's row washes, mirrored: a breathing amber fill on a chat that's blocked on
|
||||
/// you, and a steady green fill on a chat that finished while you weren't looking. Applied as
|
||||
/// a `listRowBackground` so the wash sits under the whole row.
|
||||
struct SessionRowWash: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let summary: WireSessionSummary
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@State private var pulsing = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(.secondarySystemGroupedBackground) // the list row's normal surface
|
||||
if summary.status.needsYou(summary.disposition) {
|
||||
// Breathing amber (0.10 → 0.30 on a 1.3s ease, matching the Mac); a steady
|
||||
// mid-tint under Reduce Motion.
|
||||
Palette.attention.opacity(reduceMotion ? 0.18 : (pulsing ? 0.30 : 0.10))
|
||||
.animation(
|
||||
reduceMotion ? nil
|
||||
: .easeInOut(duration: 1.3).repeatForever(autoreverses: true),
|
||||
value: pulsing)
|
||||
.onAppear { pulsing = true }
|
||||
} else if store.unseenCompletion(summary) {
|
||||
Palette.success.opacity(0.18)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user