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:
@@ -50,6 +50,42 @@ final class RemoteStore: ObservableObject {
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
|
||||
/// A transient host-reported error (the mobile echo of the Mac's last-error bubble):
|
||||
/// shown as a red bubble at the bottom of the screen, auto-dismissed after a few seconds.
|
||||
struct LastError: Equatable, Identifiable {
|
||||
let id = UUID()
|
||||
let message: String
|
||||
let sessionID: SessionID?
|
||||
}
|
||||
@Published var lastError: LastError?
|
||||
private var errorDismissTask: Task<Void, Never>?
|
||||
|
||||
/// When each session was last opened on this device, for the green "finished while you
|
||||
/// weren't looking" wash on the session list (the Mac's unseen-completion marker; the wire
|
||||
/// doesn't carry the host's flag, so the phone tracks its own view locally).
|
||||
@Published private(set) var lastOpenedAt: [SessionID: Date] = RemoteStore.loadLastOpened()
|
||||
|
||||
private static let lastOpenedKey = "nucleic.lastOpenedAt"
|
||||
private static func loadLastOpened() -> [SessionID: Date] {
|
||||
guard let raw = UserDefaults.standard.dictionary(forKey: lastOpenedKey) else { return [:] }
|
||||
return raw.reduce(into: [:]) { result, entry in
|
||||
if let date = entry.value as? Date { result[SessionID(rawValue: entry.key)] = date }
|
||||
}
|
||||
}
|
||||
private func persistLastOpened() {
|
||||
let raw = lastOpenedAt.reduce(into: [String: Date]()) { $0[$1.key.rawValue] = $1.value }
|
||||
UserDefaults.standard.set(raw, forKey: Self.lastOpenedKey)
|
||||
}
|
||||
|
||||
/// Whether `summary` completed its work after the user last looked at it on this device.
|
||||
func unseenCompletion(_ summary: WireSessionSummary) -> Bool {
|
||||
let done = summary.status == .finished
|
||||
|| (summary.status == .awaitingInput && summary.disposition == .completed)
|
||||
guard done, summary.sessionID != openSessionID else { return false }
|
||||
guard let opened = lastOpenedAt[summary.sessionID] else { return true }
|
||||
return summary.updatedAt > opened
|
||||
}
|
||||
|
||||
/// Non-archived sessions (archived chats are hidden, matching the Mac sidebar).
|
||||
var liveSessions: [WireSessionSummary] { sessions.filter { !$0.archived } }
|
||||
|
||||
@@ -205,10 +241,17 @@ final class RemoteStore: ObservableObject {
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
seenSeq.removeAll()
|
||||
markOpened(sessionID)
|
||||
if demoMode { seedDemoTranscript(sessionID); return }
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||
func markOpened(_ sessionID: SessionID) {
|
||||
lastOpenedAt[sessionID] = Date()
|
||||
persistLastOpened()
|
||||
}
|
||||
|
||||
/// Offline transcript fixture (NUCLEIC_DEMO) so the richer transcript surfaces — grouped
|
||||
/// tools, Orchestra card, usage/cost, file changes, run outcome — render without a host.
|
||||
private func seedDemoTranscript(_ sessionID: SessionID) {
|
||||
@@ -237,7 +280,10 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
func closeOpen() {
|
||||
if let id = openSessionID { send(.unsubscribe(id)) }
|
||||
if let id = openSessionID {
|
||||
send(.unsubscribe(id))
|
||||
markOpened(id) // everything up to now has been seen
|
||||
}
|
||||
openSessionID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
@@ -290,6 +336,8 @@ final class RemoteStore: ObservableObject {
|
||||
if id == openSessionID { closeOpen() }
|
||||
}
|
||||
func integrate(_ id: SessionID, _ mode: IntegrationMode) { send(.integrate(id, mode)) }
|
||||
/// Throw away the session's branch/worktree without landing it (the Mac's Discard…).
|
||||
func discard(_ id: SessionID) { send(.discard(id)) }
|
||||
func interrupt(_ id: SessionID) { send(.interrupt(id)) }
|
||||
/// Cancel one queued (not-yet-sent) follow-up by id — the phone's per-message ✕.
|
||||
func cancelQueuedMessage(_ id: SessionID, _ messageID: UUID) { send(.cancelQueuedMessage(id, messageID)) }
|
||||
@@ -370,8 +418,12 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
case .approvalResolved(let resolved):
|
||||
openApprovals.removeAll { $0.id == resolved.id }
|
||||
case .wireError:
|
||||
break // surfaced contextually by callers; not fatal
|
||||
case .wireError(let error):
|
||||
// Not fatal — surface as a transient bubble (the Mac's last-error overlay).
|
||||
// Losing an approval race isn't an error worth interrupting for; the card
|
||||
// collapses on the matching `approvalResolved`.
|
||||
guard error.code != .alreadyResolved else { break }
|
||||
showError(error.message, sessionID: error.sessionID)
|
||||
case .failed(let message):
|
||||
connectivity = .failed(message)
|
||||
scheduleRetry()
|
||||
@@ -381,6 +433,24 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a transient error bubble, replacing any current one; auto-dismisses after 6s
|
||||
/// (matching the Mac's last-error overlay cadence).
|
||||
private func showError(_ message: String, sessionID: SessionID?) {
|
||||
let error = LastError(message: message, sessionID: sessionID)
|
||||
lastError = error
|
||||
errorDismissTask?.cancel()
|
||||
errorDismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
guard let self, self.lastError == error else { return }
|
||||
self.lastError = nil
|
||||
}
|
||||
}
|
||||
|
||||
func dismissError() {
|
||||
errorDismissTask?.cancel()
|
||||
lastError = nil
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
guard isPaired else { return }
|
||||
reconnectAttempts += 1
|
||||
|
||||
Reference in New Issue
Block a user