Merge branch 'dev' into canary
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"colors" : [ { "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" } ],
|
||||
"colors" : [
|
||||
{ "color" : { "color-space" : "srgb", "components" : { "red" : "0.118", "green" : "0.565", "blue" : "0.553", "alpha" : "1.000" } }, "idiom" : "universal" },
|
||||
{ "appearances" : [ { "appearance" : "luminosity", "value" : "dark" } ], "color" : { "color-space" : "srgb", "components" : { "red" : "0.366", "green" : "0.850", "blue" : "0.834", "alpha" : "1.000" } }, "idiom" : "universal" }
|
||||
],
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
@Published private(set) var connectivity: Connectivity = .unpaired
|
||||
/// A representative host name for the aggregate — the open session's Mac when a transcript is
|
||||
/// open, otherwise a live host. Sessions/projects from *every* connected Mac are merged into the
|
||||
/// flat state above (mesh P3), so no single "active host" is chosen; per-row provenance comes
|
||||
/// from `hostName(forSession:)` instead.
|
||||
@Published private(set) var hostName: String = ""
|
||||
/// The paired Mac this connection currently reflects (mesh P3), by its `HostID` (fingerprint).
|
||||
/// `hostName`/`sessions`/etc. above are that host's projection; switching hosts re-points them
|
||||
/// (live: reconnect to a different paired Mac; demo: swap the mock host). `nil` until connected.
|
||||
@Published private(set) var activeHostID: String?
|
||||
@Published private(set) var sessions: [WireSessionSummary] = []
|
||||
@Published private(set) var capabilities = WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
@Published private(set) var grantedScope: DeviceScope = .approve
|
||||
@@ -60,6 +60,10 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
// Open session projection.
|
||||
@Published private(set) var openSessionID: SessionID?
|
||||
/// The paired Mac that owns the open session (mesh P3). The open transcript's intents route to
|
||||
/// it, and the host-specific projected values (capabilities/scope/catalog/connectivity) follow
|
||||
/// it while a session is open. `nil` when nothing is open.
|
||||
private var openSessionHostID: String?
|
||||
@Published private(set) var openEvents: [AgentEvent] = []
|
||||
@Published private(set) var openApprovals: [ApprovalRequest] = []
|
||||
/// The open session's full diff (the Mac Diff tab's patch), fetched on demand when the
|
||||
@@ -180,13 +184,57 @@ final class RemoteStore: ObservableObject {
|
||||
/// Proactive network-path awareness (shared across every host connection): flips transports the
|
||||
/// instant Wi-Fi drops or returns, instead of waiting for a dead LAN socket to time out.
|
||||
let pathMonitor = NetworkPathMonitor()
|
||||
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once; the flat
|
||||
/// `sessions`/`connectivity`/`capabilities`/… above mirror the *active* one (`activeHostID`), so
|
||||
/// switching hosts is instant (no reconnect). Empty in demo mode (demo seeds state directly).
|
||||
/// One live connection per paired Mac (mesh P3 multiplexer). All connected at once, and the flat
|
||||
/// `sessions`/`dashboard` above are the *merged* projection across every one of them — the phone
|
||||
/// shows all your Macs together, with no active-host selector. Intents route to the Mac that owns
|
||||
/// the target session/project (see `send`). Empty in demo mode (demo seeds state directly).
|
||||
private var connections: [String: HostConnection] = [:]
|
||||
/// The active connection — the Mac whose projection the flat state reflects + whose transcript
|
||||
/// is open.
|
||||
private var activeConnection: HostConnection? { activeHostID.flatMap { connections[$0] } }
|
||||
|
||||
/// The connection whose projection drives the host-specific flat values (capabilities, scope,
|
||||
/// catalog, connectivity, host name): the open session's Mac while a transcript is open, else a
|
||||
/// representative live host (one with a populated catalog, falling back to any live/any host).
|
||||
private var contextConnection: HostConnection? {
|
||||
if let id = openSessionHostID, let conn = connections[id] { return conn }
|
||||
return connections.values.first { $0.connectivity.isLive && !$0.modelCatalog.groups.isEmpty }
|
||||
?? connections.values.first { $0.connectivity.isLive }
|
||||
?? connections.values.first
|
||||
}
|
||||
|
||||
/// A live connection to fall back on for host-agnostic intents (e.g. a project-less to-do
|
||||
/// capture, which has no owning Mac).
|
||||
private var firstLiveConnection: HostConnection? {
|
||||
connections.values.first { $0.connectivity.isLive } ?? connections.values.first
|
||||
}
|
||||
|
||||
// MARK: Intent routing (mesh P3) — resolve the Mac that owns a session / project / to-do.
|
||||
|
||||
private func connection(owningSession id: SessionID) -> HostConnection? {
|
||||
connections.values.first { $0.sessions.contains { $0.sessionID == id } }
|
||||
}
|
||||
private func connection(owningProject id: ProjectID) -> HostConnection? {
|
||||
connections.values.first { $0.dashboard.projects.contains { $0.id == id } }
|
||||
}
|
||||
private func connection(owningTodo id: TodoID) -> HostConnection? {
|
||||
connections.values.first { $0.dashboard.todos.contains { $0.id == id } }
|
||||
}
|
||||
/// Send to every live connection — for host-agnostic pulls (list sessions/dashboard) and as the
|
||||
/// safety fallback for an approval whose owning Mac isn't known (a notification-driven decision).
|
||||
private func broadcastLive(_ msg: ClientMsg) {
|
||||
for conn in connections.values where conn.connectivity.isLive { conn.send(msg) }
|
||||
}
|
||||
|
||||
/// How many Macs are currently connected — the Sessions list shows a per-row host tag only when
|
||||
/// more than one, so a single-Mac view stays clean.
|
||||
var connectedHostCount: Int {
|
||||
if demoMode { return 1 }
|
||||
return connections.values.filter { $0.connectivity.isLive }.count
|
||||
}
|
||||
|
||||
/// The name of the Mac that owns a session, for the per-row host tag. `nil` in demo (no
|
||||
/// connections) or when the session isn't found on any connected Mac.
|
||||
func hostName(forSession id: SessionID) -> String? {
|
||||
connection(owningSession: id)?.hostName
|
||||
}
|
||||
/// The phone's embedded Tailscale node state, for Settings (nil = not running). Shared across all
|
||||
/// connections (one embedded node, many dials).
|
||||
@Published private(set) var tailnetStatus: String?
|
||||
@@ -207,75 +255,6 @@ final class RemoteStore: ObservableObject {
|
||||
/// In-flight simulated-run tasks (canned streaming), cancelled on `exitDemo()`.
|
||||
private var demoTasks: [Task<Void, Never>] = []
|
||||
|
||||
// MARK: - Multi-host switching (mesh P3)
|
||||
|
||||
/// One selectable Mac for the host switcher — the paired Macs (live) or the mock hosts (demo).
|
||||
struct HostChoice: Identifiable, Equatable {
|
||||
let id: String // HostID (fingerprint), or a demo id
|
||||
let name: String
|
||||
let isActive: Bool
|
||||
}
|
||||
|
||||
/// The Macs this phone can switch between. Live: the paired-host registry. Demo: the mock hosts.
|
||||
/// One entry ⇒ the switcher hides itself and the app reads single-host. Today the phone holds one
|
||||
/// *live* connection at a time (switching reconnects); simultaneous connections are the deferred
|
||||
/// multiplexer step.
|
||||
var hostChoices: [HostChoice] {
|
||||
if demoMode {
|
||||
return demoHosts.map { HostChoice(id: $0.id, name: $0.name, isActive: $0.id == activeHostID) }
|
||||
}
|
||||
return IdentityStore.pairedHosts().map {
|
||||
HostChoice(id: $0.fingerprint, name: $0.hostName, isActive: $0.fingerprint == activeHostID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch which paired Mac is active. Live: every Mac is already connected, so this is instant —
|
||||
/// close the current transcript, re-point at the target connection, and mirror its state. Demo:
|
||||
/// swap the mock host's projection, preserving in-demo edits.
|
||||
func switchHost(to id: String) {
|
||||
guard id != activeHostID else { return }
|
||||
if demoMode {
|
||||
if let i = demoHosts.firstIndex(where: { $0.id == activeHostID }) {
|
||||
demoHosts[i].sessions = sessions // keep any in-demo mutations
|
||||
demoHosts[i].dashboard = dashboard
|
||||
}
|
||||
guard let next = demoHosts.first(where: { $0.id == id }) else { return }
|
||||
applyDemoHost(next)
|
||||
} else {
|
||||
// Close the transcript open on the old host.
|
||||
activeConnection?.openSessionID = nil
|
||||
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||
activeHostID = id
|
||||
// The target should already be connected; (re)dial only if it isn't.
|
||||
if let host = IdentityStore.pairedHost(id: id) {
|
||||
let conn = connection(for: host)
|
||||
if !conn.connectivity.isLive { conn.reconnect(to: host) }
|
||||
}
|
||||
mirrorActive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Mock hosts for the multi-host demo. Each carries its own name + sessions + dashboard;
|
||||
/// switching swaps the flat active-host projection. Empty outside demo.
|
||||
private struct DemoHost {
|
||||
let id: String
|
||||
let name: String
|
||||
var sessions: [WireSessionSummary]
|
||||
var dashboard: DashboardSnapshot
|
||||
}
|
||||
private var demoHosts: [DemoHost] = []
|
||||
|
||||
/// Project a mock host onto the flat active-host state (demo host switch).
|
||||
private func applyDemoHost(_ host: DemoHost) {
|
||||
activeHostID = host.id
|
||||
hostName = host.name
|
||||
sessions = host.sessions
|
||||
dashboard = host.dashboard
|
||||
openSessionID = nil; openEvents = []; openApprovals = []; openDiff = nil
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
}
|
||||
|
||||
var isPaired: Bool { demoMode || IdentityStore.loadPairedHost() != nil }
|
||||
var deviceFingerprint: String { identity.fingerprint }
|
||||
|
||||
@@ -411,11 +390,13 @@ final class RemoteStore: ObservableObject {
|
||||
fiveHour: WireUsageWindow(utilization: 18, resetsAt: Date().addingTimeInterval(2 * 3600)),
|
||||
sevenDay: WireUsageWindow(utilization: 40, resetsAt: nil)),
|
||||
statusFeeds: [])
|
||||
demoHosts = [
|
||||
DemoHost(id: "demo-1", name: "Andrew's Mac", sessions: host1Sessions, dashboard: host1Dashboard),
|
||||
DemoHost(id: "demo-2", name: "Studio Mac", sessions: host2Sessions, dashboard: host2Dashboard),
|
||||
]
|
||||
applyDemoHost(demoHosts[0])
|
||||
// Mesh P3: seed BOTH demo Macs' worlds merged into the flat aggregate, exactly as the app
|
||||
// presents real paired Macs — every host's sessions/projects/to-dos shown together, no
|
||||
// switcher. `demoHandle` then mutates this aggregate directly for the interactive demo.
|
||||
sessions = host1Sessions + host2Sessions
|
||||
dashboard = DashboardSnapshot.merged([host1Dashboard, host2Dashboard])
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
}
|
||||
|
||||
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
||||
@@ -461,8 +442,7 @@ final class RemoteStore: ObservableObject {
|
||||
connections[id]?.teardown()
|
||||
let conn = makeConnection(hostID: id, hostName: payload.hostName)
|
||||
connections[id] = conn
|
||||
activeHostID = id
|
||||
mirrorActive()
|
||||
rebuildAggregate()
|
||||
conn.pair(with: payload)
|
||||
}
|
||||
|
||||
@@ -481,13 +461,14 @@ final class RemoteStore: ObservableObject {
|
||||
}
|
||||
|
||||
/// The callbacks one `HostConnection` uses to drive shared/aggregate state. `hostID` is captured
|
||||
/// so a change updates the flat projection only for the active host, while badges, notifications,
|
||||
/// and the single open transcript are handled globally across hosts.
|
||||
/// so the open-transcript forwarding fires only for the Mac that owns the open session, while the
|
||||
/// merged list/dashboard, badges, and notifications are rebuilt across every host on any change.
|
||||
private func callbacks(for hostID: String) -> HostConnection.Callbacks {
|
||||
var cb = HostConnection.Callbacks()
|
||||
cb.didUpdate = { [weak self] in
|
||||
guard let self else { return }
|
||||
if hostID == self.activeHostID { self.mirrorActive() }
|
||||
// Any host's change re-merges the aggregate the flat state binds to (mesh P3).
|
||||
self.rebuildAggregate()
|
||||
self.refreshAggregate()
|
||||
self.flushPendingNotificationDecision()
|
||||
// A host that just connected (or reconnected) needs the current Live Activity token
|
||||
@@ -495,22 +476,22 @@ final class RemoteStore: ObservableObject {
|
||||
self.syncLiveActivityRegistration()
|
||||
}
|
||||
cb.openSnapshot = { [weak self] snap in
|
||||
guard let self, hostID == self.activeHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||
guard let self, hostID == self.openSessionHostID, snap.summary.sessionID == self.openSessionID else { return }
|
||||
self.openEvents = snap.recentEvents
|
||||
self.openApprovals = snap.pendingApprovals
|
||||
}
|
||||
cb.openEvents = { [weak self] batch in
|
||||
guard let self, hostID == self.activeHostID, batch.sessionID == self.openSessionID else { return }
|
||||
guard let self, hostID == self.openSessionHostID, batch.sessionID == self.openSessionID else { return }
|
||||
self.openEvents.append(contentsOf: batch.events)
|
||||
}
|
||||
cb.openDiff = { [weak self] diff in
|
||||
guard let self, hostID == self.activeHostID, diff.sessionID == self.openSessionID else { return }
|
||||
guard let self, hostID == self.openSessionHostID, diff.sessionID == self.openSessionID else { return }
|
||||
self.openDiff = diff
|
||||
self.diffLoading = false
|
||||
}
|
||||
cb.approvalRequested = { [weak self] req, title in
|
||||
guard let self else { return }
|
||||
if hostID == self.activeHostID, req.sessionID == self.openSessionID,
|
||||
if hostID == self.openSessionHostID, req.sessionID == self.openSessionID,
|
||||
!self.openApprovals.contains(where: { $0.id == req.id }) {
|
||||
self.openApprovals.append(req)
|
||||
}
|
||||
@@ -532,7 +513,7 @@ final class RemoteStore: ObservableObject {
|
||||
cb.didPair = { [weak self] host in
|
||||
guard let self else { return }
|
||||
IdentityStore.savePairedHost(host)
|
||||
self.activeHostID = host.fingerprint
|
||||
self.rebuildAggregate()
|
||||
}
|
||||
cb.meshRosterChanged = { [weak self] in
|
||||
// Mesh "join": a Mac was learned or revoked via gossip. Reconnect to every paired Mac
|
||||
@@ -546,32 +527,66 @@ final class RemoteStore: ObservableObject {
|
||||
return cb
|
||||
}
|
||||
|
||||
/// Mirror the active connection's projection onto the flat @Published state the UI binds to.
|
||||
private func mirrorActive() {
|
||||
guard let c = activeConnection else { return }
|
||||
connectivity = c.connectivity
|
||||
hostName = c.hostName
|
||||
sessions = c.sessions
|
||||
capabilities = c.capabilities
|
||||
grantedScope = c.grantedScope
|
||||
modelCatalog = c.modelCatalog
|
||||
dashboard = c.dashboard
|
||||
meshPeers = c.meshPeers
|
||||
/// Re-merge every connected Mac's projection into the flat @Published state the UI binds to
|
||||
/// (mesh P3): all hosts' sessions and dashboards shown together, with the host-specific values
|
||||
/// (host name, capabilities, catalog, connectivity, scope) following the open session's Mac —
|
||||
/// or a representative one. No-op in demo, which seeds the aggregate directly.
|
||||
private func rebuildAggregate() {
|
||||
guard !demoMode else { return }
|
||||
sessions = aggregatedSessions()
|
||||
dashboard = DashboardSnapshot.merged(connections.values.map(\.dashboard))
|
||||
meshPeers = connections.values.flatMap(\.meshPeers)
|
||||
|
||||
let ctx = contextConnection
|
||||
hostName = ctx?.hostName ?? ""
|
||||
capabilities = ctx?.capabilities
|
||||
?? WireCapabilities(canModifyToolInput: false, allowAlwaysScopes: [])
|
||||
modelCatalog = ctx?.modelCatalog ?? .empty
|
||||
|
||||
if let id = openSessionHostID, let conn = connections[id] {
|
||||
// While a transcript is open, the composer/controls act on *that* Mac — its connectivity
|
||||
// gates send and its scope drives the control affordances.
|
||||
connectivity = conn.connectivity
|
||||
grantedScope = conn.grantedScope
|
||||
} else {
|
||||
connectivity = aggregateConnectivity()
|
||||
// Optimistic new-chat gating: enabled if *any* Mac grants control (the owning Mac still
|
||||
// enforces scope when the intent lands there).
|
||||
grantedScope = connections.values
|
||||
.filter { $0.connectivity.isLive }.map(\.grantedScope).max() ?? .approve
|
||||
}
|
||||
}
|
||||
|
||||
/// Every connected Mac's live (non-archived) sessions — the aggregate the app-icon badge and
|
||||
/// Live Activity summarize across hosts.
|
||||
private var allLiveSessions: [WireSessionSummary] {
|
||||
if demoMode { return demoHosts.flatMap(\.sessions).filter { !$0.archived } }
|
||||
return connections.values.flatMap(\.sessions).filter { !$0.archived }
|
||||
/// The flat sessions list: every connected Mac's sessions, deduped by id (ids are globally
|
||||
/// unique). Views handle sorting/grouping.
|
||||
private func aggregatedSessions() -> [WireSessionSummary] {
|
||||
var seen = Set<SessionID>()
|
||||
var result: [WireSessionSummary] = []
|
||||
for conn in connections.values {
|
||||
for summary in conn.sessions where seen.insert(summary.sessionID).inserted {
|
||||
result.append(summary)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// One connectivity value for the whole app when no transcript is open (the chip + global
|
||||
/// gating): connected if any Mac is live, else the most-informative in-progress/offline state.
|
||||
private func aggregateConnectivity() -> Connectivity {
|
||||
let all = connections.values.map(\.connectivity)
|
||||
guard !all.isEmpty else { return .unpaired }
|
||||
if let live = all.first(where: { $0.isLive }) { return live }
|
||||
if all.contains(where: { if case .connecting = $0 { return true } else { return false } }) { return .connecting }
|
||||
if all.contains(where: { if case .reconnecting = $0 { return true } else { return false } }) { return .reconnecting }
|
||||
if all.contains(.hostOffline) { return .hostOffline }
|
||||
return all.first ?? .unpaired
|
||||
}
|
||||
|
||||
/// Refresh cross-host aggregates: the app-icon badge (needs-you across *all* Macs) + the Live
|
||||
/// Activity summary.
|
||||
/// Activity summary. `sessions`/`liveSessions` are already the merged aggregate.
|
||||
private func refreshAggregate() {
|
||||
let needs = allLiveSessions.filter { $0.status.needsYou($0.disposition) }.count
|
||||
NotificationRouter.shared.updateBadge(needs)
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: allLiveSessions)
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: liveSessions)
|
||||
}
|
||||
|
||||
/// Tear down every live connection (leaving the paired registry intact) — for entering demo.
|
||||
@@ -594,33 +609,43 @@ final class RemoteStore: ObservableObject {
|
||||
let conn = connection(for: host)
|
||||
if !conn.connectivity.isLive { conn.reconnect(to: host) }
|
||||
}
|
||||
if activeHostID == nil || connections[activeHostID!] == nil {
|
||||
activeHostID = hosts.last?.fingerprint
|
||||
}
|
||||
mirrorActive()
|
||||
rebuildAggregate()
|
||||
refreshAggregate()
|
||||
}
|
||||
|
||||
|
||||
/// Unpair this device entirely — drop every paired Mac and its connection (the "Unpair this
|
||||
/// device" button). Nothing left to show, so the app returns to the pairing intro.
|
||||
func unpair() {
|
||||
// Forget + drop the active Mac's connection specifically (the switcher may have made it not
|
||||
// the last). Other Macs' connections keep running.
|
||||
if let id = activeHostID {
|
||||
connections[id]?.teardown()
|
||||
connections[id] = nil
|
||||
IdentityStore.removePairedHost(id: id)
|
||||
} else {
|
||||
for (id, conn) in connections { conn.teardown(); connections[id] = nil }
|
||||
for host in IdentityStore.pairedHosts() { IdentityStore.removePairedHost(id: host.fingerprint) }
|
||||
IdentityStore.clearPairedHost()
|
||||
finishUnpairIfEmpty()
|
||||
}
|
||||
activeHostID = nil
|
||||
// Mesh P3: if other Macs remain paired, switch to one (already connected) rather than unpairing.
|
||||
if let next = IdentityStore.pairedHosts().last {
|
||||
activeHostID = next.fingerprint
|
||||
reconnect()
|
||||
return
|
||||
|
||||
/// Forget one paired Mac (the Settings ▸ Mesh per-row remove): drop just its connection +
|
||||
/// registry record. Other Macs keep running and stay in the merged view.
|
||||
func unpair(_ hostID: String) {
|
||||
connections[hostID]?.teardown()
|
||||
connections[hostID] = nil
|
||||
IdentityStore.removePairedHost(id: hostID)
|
||||
if IdentityStore.pairedHosts().isEmpty {
|
||||
finishUnpairIfEmpty()
|
||||
} else {
|
||||
rebuildAggregate()
|
||||
refreshAggregate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Wind the app back to the unpaired state once no Macs remain: clear the open transcript + flat
|
||||
/// aggregate and spin the embedded Tailscale node down.
|
||||
private func finishUnpairIfEmpty() {
|
||||
openSessionID = nil
|
||||
openSessionHostID = nil
|
||||
openEvents = []; openApprovals = []; openDiff = nil; diffLoading = false
|
||||
connectivity = .unpaired
|
||||
sessions = []
|
||||
dashboard = .empty
|
||||
// Nothing left to dial — spin the embedded Tailscale node down if it was running.
|
||||
Task { await TailnetNode.shared.stop() }
|
||||
tailnetStatus = nil
|
||||
@@ -645,9 +670,8 @@ final class RemoteStore: ObservableObject {
|
||||
UserDefaults.standard.set(false, forKey: Self.demoModeKey)
|
||||
demoTasks.forEach { $0.cancel() }
|
||||
demoTasks.removeAll()
|
||||
demoHosts = []
|
||||
activeHostID = nil
|
||||
openSessionID = nil
|
||||
openSessionHostID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
openDiff = nil
|
||||
@@ -667,15 +691,19 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
func open(_ sessionID: SessionID) {
|
||||
openSessionID = sessionID
|
||||
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
||||
// the host-specific projected values follow it.
|
||||
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
openDiff = nil
|
||||
diffLoading = false
|
||||
markOpened(sessionID)
|
||||
if demoMode { seedDemoTranscript(sessionID); return }
|
||||
// The open transcript belongs to the active host; tell its connection so it forwards the
|
||||
// snapshot/events (and dedupes them), then subscribe.
|
||||
activeConnection?.openSessionID = sessionID
|
||||
// Tell the owning connection so it forwards the snapshot/events (and dedupes them), re-derive
|
||||
// the context host (capabilities/scope/catalog/connectivity now follow it), then subscribe.
|
||||
connection(owningSession: sessionID)?.openSessionID = sessionID
|
||||
rebuildAggregate()
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
@@ -759,16 +787,25 @@ final class RemoteStore: ObservableObject {
|
||||
send(.unsubscribe(target))
|
||||
markOpened(target) // everything up to now has been seen
|
||||
guard openSessionID == target else { return }
|
||||
activeConnection?.openSessionID = nil
|
||||
connection(owningSession: target)?.openSessionID = nil
|
||||
openSessionID = nil
|
||||
openSessionHostID = nil
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
openDiff = nil
|
||||
diffLoading = false
|
||||
// Context reverts to the aggregate now that no transcript is open.
|
||||
if !demoMode { rebuildAggregate() }
|
||||
}
|
||||
|
||||
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
||||
send(.approvalRespond(approval.id, decision))
|
||||
// Route to the Mac that owns the approval's session (the approval message carries no
|
||||
// sessionID of its own). Demo resolves locally.
|
||||
if demoMode {
|
||||
demoHandle(.approvalRespond(approval.id, decision))
|
||||
} else {
|
||||
connection(owningSession: approval.sessionID)?.send(.approvalRespond(approval.id, decision))
|
||||
}
|
||||
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
||||
}
|
||||
|
||||
@@ -830,12 +867,54 @@ final class RemoteStore: ObservableObject {
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
/// Route an intent to the active host's connection (mesh P3). Every current intent — subscribe,
|
||||
/// send-input, approvals, favorite/archive/delete, start-chat — targets a session or project on
|
||||
/// the Mac whose list is on screen, i.e. the active one.
|
||||
/// Route an intent to the Mac that owns its target (mesh P3). Sessions/projects/to-dos are shown
|
||||
/// merged across every connected Mac, so an intent goes to the connection that owns the session,
|
||||
/// project, or to-do it names — not to a single "active" host. Host-agnostic pulls (list
|
||||
/// sessions/dashboard) broadcast to every live Mac; a project-less to-do capture (no owner) goes
|
||||
/// to the first live Mac. Demo has no connections and mutates the seeded aggregate directly.
|
||||
private func send(_ msg: ClientMsg) {
|
||||
if demoMode { demoHandle(msg); return }
|
||||
activeConnection?.send(msg)
|
||||
switch msg {
|
||||
// Session-owning intents → the Mac that has this session.
|
||||
case .subscribe(let s):
|
||||
connection(owningSession: s.sessionID)?.send(msg)
|
||||
case .unsubscribe(let id), .interrupt(let id), .deleteSession(let id), .discard(let id),
|
||||
.integrate(let id, _), .renameSession(let id, _), .setFavorite(let id, _),
|
||||
.setArchived(let id, _), .setSessionModel(let id, _), .setSessionEffort(let id, _),
|
||||
.setSessionAuto(let id, _), .setSessionAutoShip(let id, _), .setSessionShipBranch(let id, _),
|
||||
.sendInput(let id, _), .cancelQueuedMessage(let id, _), .fetchDiff(let id):
|
||||
connection(owningSession: id)?.send(msg)
|
||||
|
||||
// Project-owning intents → the Mac that has this project.
|
||||
case .startChat(let req):
|
||||
connection(owningProject: req.projectID)?.send(msg)
|
||||
case .dispatchTodo(_, let projectID):
|
||||
connection(owningProject: projectID)?.send(msg)
|
||||
|
||||
// To-do-owning intents → the Mac that has this to-do.
|
||||
case .setTodoStatus(let id, _), .deleteTodo(let id):
|
||||
connection(owningTodo: id)?.send(msg)
|
||||
|
||||
// A capture with a project goes to that Mac; a project-less one has no owner → first live.
|
||||
case .captureTodo(let req):
|
||||
if let pid = req.projectID, let conn = connection(owningProject: pid) { conn.send(msg) }
|
||||
else { firstLiveConnection?.send(msg) }
|
||||
|
||||
// Approvals carry no sessionID — `respond(_:_:)` routes by the approval's session directly;
|
||||
// a stray one here (or a notification-driven decision) broadcasts and the owner resolves it.
|
||||
case .approvalRespond:
|
||||
broadcastLive(msg)
|
||||
|
||||
// Host-agnostic pulls → every live Mac.
|
||||
case .listSessions, .listDashboard:
|
||||
broadcastLive(msg)
|
||||
|
||||
// Never originated from here (connection-internal, or handled by dedicated loops).
|
||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||
.registerLiveActivity, .endLiveActivity,
|
||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activity push registration (UX_IOS §5.3)
|
||||
@@ -1109,6 +1188,59 @@ extension Data {
|
||||
}
|
||||
}
|
||||
|
||||
extension DashboardSnapshot {
|
||||
/// Merge every connected Mac's dashboard into one aggregate projection (mesh P3), so Home,
|
||||
/// Projects, and To-Dos show all your Macs together: projects and to-dos concatenated, activity
|
||||
/// summed per day, counts summed (active-days recomputed from the merged activity), subscription
|
||||
/// usage taken from the first Mac that reports it (a user's Macs share one account, so their
|
||||
/// windows match — summing would double-count), and provider status feeds unioned by provider
|
||||
/// (provider status is global, not per-Mac).
|
||||
static func merged(_ snaps: [DashboardSnapshot]) -> DashboardSnapshot {
|
||||
guard snaps.count != 1 else { return snaps[0] }
|
||||
guard !snaps.isEmpty else { return .empty }
|
||||
|
||||
let projects = snaps.flatMap(\.projects)
|
||||
let todos = snaps.flatMap(\.todos)
|
||||
|
||||
// Activity summed per start-of-day.
|
||||
var byDay: [Date: (count: Int, tokens: Int)] = [:]
|
||||
for snap in snaps {
|
||||
for day in snap.activity {
|
||||
let key = Calendar.current.startOfDay(for: day.day)
|
||||
var entry = byDay[key] ?? (0, 0)
|
||||
entry.count += day.count
|
||||
entry.tokens += day.tokens
|
||||
byDay[key] = entry
|
||||
}
|
||||
}
|
||||
let activity = byDay
|
||||
.map { ActivityDay(day: $0.key, count: $0.value.count, tokens: $0.value.tokens) }
|
||||
.sorted { $0.day < $1.day }
|
||||
|
||||
let counts = DashboardCounts(
|
||||
projects: projects.count,
|
||||
chats: snaps.reduce(0) { $0 + $1.counts.chats },
|
||||
activeChats: snaps.reduce(0) { $0 + $1.counts.activeChats },
|
||||
messages: snaps.reduce(0) { $0 + $1.counts.messages },
|
||||
activeDays: activity.filter { $0.count > 0 || $0.tokens > 0 }.count,
|
||||
tokens: snaps.reduce(0) { $0 + $1.counts.tokens })
|
||||
|
||||
let usage = snaps.compactMap(\.usage).first
|
||||
|
||||
var seenProviders = Set<String>()
|
||||
var statusFeeds: [WireStatusFeed] = []
|
||||
for snap in snaps {
|
||||
for feed in snap.statusFeeds where seenProviders.insert(feed.provider).inserted {
|
||||
statusFeeds.append(feed)
|
||||
}
|
||||
}
|
||||
|
||||
return DashboardSnapshot(
|
||||
counts: counts, activity: activity, projects: projects,
|
||||
todos: todos, usage: usage, statusFeeds: statusFeeds)
|
||||
}
|
||||
}
|
||||
|
||||
extension WireSessionSummary {
|
||||
/// A copy with selected fields overridden — the demo simulator's only way to "mutate" a
|
||||
/// summary, whose stored properties are all `let`. Nullable fields use a double optional so a
|
||||
|
||||
@@ -62,7 +62,11 @@ final class NotificationRouter: NSObject {
|
||||
content.title = sessionTitle
|
||||
content.body = "\(approval.toolName) wants: \(approval.title)"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = approval.risk.isHigh
|
||||
// AskUserQuestion collects answers, it doesn't gate — a banner Allow would reply with no
|
||||
// selection (updatedInput: nil) and the CLI reports the question went unanswered. So, like
|
||||
// a high-risk approval, it uses the action-less category: it must open the app and be
|
||||
// answered on the picker card.
|
||||
content.categoryIdentifier = approval.risk.isHigh || approval.toolName == AskUserQuestion.toolName
|
||||
? Self.approvalCategory : Self.approvalActionableCategory
|
||||
content.userInfo = [
|
||||
"sessionID": approval.sessionID.rawValue,
|
||||
|
||||
@@ -169,7 +169,7 @@ private struct SplitSidebar: View {
|
||||
Section("Sessions") {
|
||||
Text(store.connectivity.isLive
|
||||
? "Start a session from Home or the Mac to see it here."
|
||||
: "Waiting to connect to \(store.hostName)…")
|
||||
: "Waiting to connect to your Mac…")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
@@ -203,7 +203,9 @@ private struct SplitSidebar: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle(store.hostName.isEmpty ? "Nucleic" : store.hostName)
|
||||
// Every Mac's sessions are merged into this sidebar (mesh P3), so a single host name would
|
||||
// misrepresent it — title it for the app.
|
||||
.navigationTitle("Nucleic")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// Answer UI for Claude Code's built-in `AskUserQuestion` tool — the mobile mirror of
|
||||
/// the Mac's `AskUserQuestionBar`. Unlike a permission gate, this *collects* the user's
|
||||
/// choice per question and folds the selections into the approval reply's `updatedInput`
|
||||
/// (see `AskUserQuestion`); replying with a plain allow makes the CLI report "the user
|
||||
/// did not answer the questions", which is exactly the bug the generic `ApprovalCardView`
|
||||
/// produced here before.
|
||||
///
|
||||
/// Questions are shown one at a time as a wizard — Back/Next between them, Submit on the
|
||||
/// last — so a multi-question ask reads as a focused sequence rather than one long form.
|
||||
struct AskUserQuestionCardView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let approval: ApprovalRequest
|
||||
let questions: [AskUserQuestion.Question]
|
||||
|
||||
/// Selected option labels per question text.
|
||||
@State private var selected: [String: Set<String>] = [:]
|
||||
/// Whether the free-text "Other" answer is active per question text.
|
||||
@State private var otherActive: [String: Bool] = [:]
|
||||
/// The free-text "Other" answer per question text.
|
||||
@State private var otherText: [String: String] = [:]
|
||||
/// Index of the question currently shown (the wizard step).
|
||||
@State private var step = 0
|
||||
|
||||
private var submitEnabled: Bool { store.connectivity.isLive }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "questionmark.bubble.fill").foregroundStyle(Palette.attention)
|
||||
Text(questions.count > 1 ? "Questions" : "Question")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
|
||||
if let question = currentQuestion {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if questions.count > 1 {
|
||||
Text("Question \(step + 1) of \(questions.count)")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if let header = question.header, !header.isEmpty {
|
||||
Text(header.uppercased())
|
||||
.font(.caption).bold().foregroundStyle(.secondary)
|
||||
}
|
||||
Text(question.question).font(.headline)
|
||||
ForEach(question.options) { option in
|
||||
optionRow(question, label: option.label, description: option.description)
|
||||
}
|
||||
otherRow(question)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(role: .destructive) {
|
||||
respond(.deny(reason: nil))
|
||||
} label: {
|
||||
Text("Deny").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
if step > 0 {
|
||||
Button("Back") { step -= 1 }
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if isLastStep {
|
||||
Button {
|
||||
submit()
|
||||
} label: {
|
||||
Text("Submit").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!allAnswered || !submitEnabled)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
} else {
|
||||
Button {
|
||||
step += 1
|
||||
} label: {
|
||||
Text("Next").frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!currentAnswered)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.glassSurface(cornerRadius: 20)
|
||||
}
|
||||
|
||||
// MARK: - Wizard state
|
||||
|
||||
/// The question for the active step, or `nil` if the index is somehow out of range.
|
||||
private var currentQuestion: AskUserQuestion.Question? {
|
||||
questions.indices.contains(step) ? questions[step] : nil
|
||||
}
|
||||
|
||||
private var isLastStep: Bool { step >= questions.count - 1 }
|
||||
|
||||
/// Whether the question on the current step has at least one answer — gates `Next`.
|
||||
private var currentAnswered: Bool {
|
||||
guard let currentQuestion else { return false }
|
||||
return !answers(for: currentQuestion).isEmpty
|
||||
}
|
||||
|
||||
private var allAnswered: Bool {
|
||||
questions.allSatisfy { !answers(for: $0).isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
@ViewBuilder
|
||||
private func optionRow(_ question: AskUserQuestion.Question, label: String, description: String?)
|
||||
-> some View
|
||||
{
|
||||
let isOn = selected[question.question]?.contains(label) ?? false
|
||||
Button { toggle(question, label) } label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
|
||||
.foregroundStyle(isOn ? Palette.attention : .secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label).font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let description, !description.isEmpty {
|
||||
// Let the subtext wrap freely — inside a Button label with a trailing
|
||||
// Spacer, an unconstrained Text truncates to one line and hides the rest.
|
||||
Text(description).font(.subheadline).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func otherRow(_ question: AskUserQuestion.Question) -> some View {
|
||||
let isOn = otherActive[question.question] ?? false
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Button { toggleOther(question) } label: {
|
||||
Image(systemName: symbol(multiSelect: question.multiSelect, on: isOn))
|
||||
.foregroundStyle(isOn ? Palette.attention : .secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
TextField(
|
||||
"Other…",
|
||||
text: Binding(
|
||||
get: { otherText[question.question] ?? "" },
|
||||
set: { newValue in
|
||||
otherText[question.question] = newValue
|
||||
if !newValue.isEmpty { activateOther(question) }
|
||||
})
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private func symbol(multiSelect: Bool, on: Bool) -> String {
|
||||
if multiSelect { return on ? "checkmark.square.fill" : "square" }
|
||||
return on ? "largecircle.fill.circle" : "circle"
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
private func toggle(_ question: AskUserQuestion.Question, _ label: String) {
|
||||
var set = selected[question.question] ?? []
|
||||
if question.multiSelect {
|
||||
if set.contains(label) { set.remove(label) } else { set.insert(label) }
|
||||
} else {
|
||||
set = set.contains(label) ? [] : [label]
|
||||
otherActive[question.question] = false
|
||||
}
|
||||
selected[question.question] = set
|
||||
}
|
||||
|
||||
private func toggleOther(_ question: AskUserQuestion.Question) {
|
||||
let next = !(otherActive[question.question] ?? false)
|
||||
otherActive[question.question] = next
|
||||
if next, !question.multiSelect { selected[question.question] = [] }
|
||||
}
|
||||
|
||||
private func activateOther(_ question: AskUserQuestion.Question) {
|
||||
otherActive[question.question] = true
|
||||
if !question.multiSelect { selected[question.question] = [] }
|
||||
}
|
||||
|
||||
/// Chosen labels for a question: picked options plus any non-empty "Other".
|
||||
private func answers(for question: AskUserQuestion.Question) -> [String] {
|
||||
var labels = Array(selected[question.question] ?? [])
|
||||
if otherActive[question.question] ?? false {
|
||||
let custom = (otherText[question.question] ?? "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !custom.isEmpty { labels.append(custom) }
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// MARK: - Submit
|
||||
|
||||
private func submit() {
|
||||
var selections: [String: [String]] = [:]
|
||||
for question in questions { selections[question.question] = answers(for: question) }
|
||||
let updatedInput = AskUserQuestion.updatedInput(
|
||||
original: approval.input, selections: selections)
|
||||
respond(.allow(updatedInput: updatedInput))
|
||||
}
|
||||
|
||||
private func respond(_ decision: Decision) {
|
||||
store.respond(approval, decision)
|
||||
}
|
||||
}
|
||||
@@ -221,7 +221,17 @@ struct SessionDetailView: View {
|
||||
private var actionArea: some View {
|
||||
Group {
|
||||
if let approval = store.openApprovals.first {
|
||||
// AskUserQuestion isn't a permission gate — it collects the user's answers and
|
||||
// needs them folded back into `updatedInput`. Render the picker (matching the
|
||||
// Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would
|
||||
// reply with no selection and make the CLI report the question went unanswered.
|
||||
if approval.toolName == AskUserQuestion.toolName,
|
||||
let questions = AskUserQuestion.questions(from: approval.input)
|
||||
{
|
||||
AskUserQuestionCardView(approval: approval, questions: questions)
|
||||
} else {
|
||||
ApprovalCardView(approval: approval)
|
||||
}
|
||||
} else if let status = summary?.status {
|
||||
let running = status == .running
|
||||
let canCompose = running || status == .awaitingInput || status.isTerminal
|
||||
|
||||
@@ -28,7 +28,7 @@ struct SessionsView: View {
|
||||
"No sessions", systemImage: "square.stack.3d.up",
|
||||
description: Text(store.connectivity.isLive
|
||||
? "Start a session from Home or the Mac to see it here."
|
||||
: "Waiting to connect to \(store.hostName)…"))
|
||||
: "Waiting to connect to your Mac…"))
|
||||
} else {
|
||||
List {
|
||||
ForEach(grouped, id: \.title) { group in
|
||||
@@ -79,27 +79,6 @@ struct SessionsView: View {
|
||||
// A notification tap while this tab is already up.
|
||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||
.toolbar {
|
||||
// Host switcher (mesh P3): pick which paired Mac to view. Hidden with a single Mac.
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if store.hostChoices.count > 1 {
|
||||
Menu {
|
||||
ForEach(store.hostChoices) { choice in
|
||||
Button {
|
||||
store.switchHost(to: choice.id)
|
||||
} label: {
|
||||
Label(choice.name,
|
||||
systemImage: choice.isActive ? "checkmark" : "desktopcomputer")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "desktopcomputer")
|
||||
Text(store.hostName).lineLimit(1)
|
||||
Image(systemName: "chevron.down").font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
showArchived.toggle()
|
||||
@@ -172,9 +151,15 @@ struct SessionRow: View {
|
||||
systemImage: "arrow.down.forward")
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
} else if showProjectName {
|
||||
// With more than one Mac connected, tag the owning Mac so the merged list stays
|
||||
// distinguishable (mesh P3); a single-Mac view just shows the project.
|
||||
if store.connectedHostCount > 1, let host = store.hostName(forSession: summary.sessionID) {
|
||||
Text("\(summary.projectName) · \(host)").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(summary.projectName).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
if summary.pendingApprovalCount > 0 {
|
||||
|
||||
@@ -55,9 +55,6 @@ struct SettingsView: View {
|
||||
|
||||
Section("Connection") {
|
||||
LabeledContent("Status", value: store.connectivity.label)
|
||||
if !store.hostName.isEmpty {
|
||||
LabeledContent("Mac", value: store.hostName)
|
||||
}
|
||||
// What this pairing can dial — the live transport is in Status above.
|
||||
if let host = IdentityStore.loadPairedHost() {
|
||||
LabeledContent("Transports", value: Self.transportsLabel(host))
|
||||
@@ -66,14 +63,13 @@ struct SettingsView: View {
|
||||
.disabled(!store.isPaired)
|
||||
}
|
||||
|
||||
// Mesh P3: every Mac this phone is paired with. Today the connection uses the
|
||||
// "Active" one (most-recently paired); the multiplexer will connect to all. Removing
|
||||
// the active Mac unpairs the live connection; removing another just forgets it.
|
||||
// Mesh P3: every Mac this phone is paired with — all connected at once and shown
|
||||
// together in the app (no active-host selector). Removing one forgets just that Mac
|
||||
// and drops its connection; the others keep running.
|
||||
let pairedHosts = IdentityStore.pairedHosts()
|
||||
if !pairedHosts.isEmpty {
|
||||
Section("Mesh") {
|
||||
ForEach(pairedHosts, id: \.fingerprint) { host in
|
||||
let isActive = host.fingerprint == pairedHosts.last?.fingerprint
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "desktopcomputer").foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -82,15 +78,8 @@ struct SettingsView: View {
|
||||
.font(.footnote.monospaced()).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isActive {
|
||||
Text("Active").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if isActive {
|
||||
store.unpair()
|
||||
} else {
|
||||
IdentityStore.removePairedHost(id: host.fingerprint)
|
||||
}
|
||||
store.unpair(host.fingerprint)
|
||||
pairedHostsToken = UUID()
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
|
||||
@@ -142,7 +142,10 @@ struct ResolvedPalette {
|
||||
|
||||
static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
|
||||
var p = base(mode)
|
||||
p.accent = Palette.nightSoft(p.accent)
|
||||
// The accent is the interactive tint (buttons, menus, toggles, links), not a calm
|
||||
// status hue — night-softening the already-deep teal made it vanish on dark
|
||||
// backgrounds, so dark mode brightens it instead.
|
||||
p.accent = Palette.nightBright(p.accent)
|
||||
p.active = Palette.nightSoft(p.active)
|
||||
p.attention = Palette.nightSoft(p.attention)
|
||||
p.success = Palette.nightSoft(p.success)
|
||||
@@ -194,6 +197,17 @@ enum Palette {
|
||||
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light })
|
||||
}
|
||||
|
||||
/// The inverse of `nightSoft`, for the interactive accent: light mode keeps `base`; dark
|
||||
/// mode gets a brighter, lightly desaturated variant so the tint stays legible against
|
||||
/// deep backgrounds and glass instead of sinking into them.
|
||||
static func nightBright(_ base: Color) -> Color {
|
||||
let ui = UIColor(base)
|
||||
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
|
||||
let dark = UIColor(hue: h, saturation: s * 0.72, brightness: max(b, 0.85), alpha: a)
|
||||
return dynamic(light: ui, dark: dark)
|
||||
}
|
||||
|
||||
/// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same
|
||||
/// night-softening curve as the Mac's `AppPalette.nightSoft`.
|
||||
static func nightSoft(_ base: Color) -> Color {
|
||||
|
||||
@@ -109,10 +109,23 @@ private struct LockScreenView: View {
|
||||
// MARK: - Shared pieces
|
||||
|
||||
/// One session: state glyph + title + "project · Backend" + a right-aligned status/diff.
|
||||
///
|
||||
/// Each row is its own tap target: wrapped in a `Link` to `nucleic://session/<id>`, so tapping a
|
||||
/// specific session on the lock screen or expanded Dynamic Island opens *that* session — overriding
|
||||
/// the activity-wide `widgetURL` (which still governs the header, churn footer, and empty space, and
|
||||
/// remains the only tap target the compact/minimal presentations can offer).
|
||||
private struct SessionRow: View {
|
||||
let line: NucleicSessionAttributes.SessionLine
|
||||
|
||||
var body: some View {
|
||||
if let url = NucleicDeepLink.session(line.id) {
|
||||
Link(destination: url) { rowContent }
|
||||
} else {
|
||||
rowContent
|
||||
}
|
||||
}
|
||||
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ActivityPalette.glyph(line.kind))
|
||||
.font(.footnote)
|
||||
|
||||
Reference in New Issue
Block a user