Merge branch 'claude/competent-pasteur-a5bb90' into HEAD
This commit is contained in:
@@ -8,12 +8,38 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
BF00000000000000000001 /* NucleicProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = PD00000000000000000001 /* NucleicProtocol */; };
|
||||
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FR00000000000000000002 /* NucleicRemoteWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
E7D43FF12FF0806800BF2407 /* app-logo-file.icon in Resources */ = {isa = PBXBuildFile; fileRef = E7D43FF02FF0806800BF2407 /* app-logo-file.icon */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
CP00000000000000000001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = PJ00000000000000000001 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = TG00000000000000000002;
|
||||
remoteInfo = NucleicRemoteWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
BP00000000000000000005 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
BF00000000000000000002 /* NucleicRemoteWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = "app-logo-file.icon"; sourceTree = "<group>"; };
|
||||
FR00000000000000000001 /* NucleicRemote.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = NucleicRemote.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FR00000000000000000002 /* NucleicRemoteWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NucleicRemoteWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -24,6 +50,13 @@
|
||||
);
|
||||
target = TG00000000000000000001 /* NucleicRemote */;
|
||||
};
|
||||
EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = TG00000000000000000002 /* NucleicRemoteWidgets */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -35,6 +68,19 @@
|
||||
path = NucleicRemote;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
SG00000000000000000002 /* NucleicRemoteWidgets */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EX00000000000000000002 /* Exceptions for "NucleicRemoteWidgets" folder in "NucleicRemoteWidgets" target */,
|
||||
);
|
||||
path = NucleicRemoteWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
SG00000000000000000003 /* Shared */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -46,6 +92,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BP00000000000000000007 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -53,6 +106,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
SG00000000000000000001 /* NucleicRemote */,
|
||||
SG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||
SG00000000000000000003 /* Shared */,
|
||||
GR00000000000000000002 /* Products */,
|
||||
E7D43FF02FF0806800BF2407 /* app-logo-file.icon */,
|
||||
);
|
||||
@@ -62,6 +117,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FR00000000000000000001 /* NucleicRemote.app */,
|
||||
FR00000000000000000002 /* NucleicRemoteWidgets.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -76,13 +132,16 @@
|
||||
BP00000000000000000002 /* Sources */,
|
||||
BP00000000000000000003 /* Frameworks */,
|
||||
BP00000000000000000004 /* Resources */,
|
||||
BP00000000000000000005 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
TD00000000000000000001 /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
SG00000000000000000001 /* NucleicRemote */,
|
||||
SG00000000000000000003 /* Shared */,
|
||||
);
|
||||
name = NucleicRemote;
|
||||
packageProductDependencies = (
|
||||
@@ -92,8 +151,37 @@
|
||||
productReference = FR00000000000000000001 /* NucleicRemote.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
TG00000000000000000002 /* NucleicRemoteWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */;
|
||||
buildPhases = (
|
||||
BP00000000000000000006 /* Sources */,
|
||||
BP00000000000000000007 /* Frameworks */,
|
||||
BP00000000000000000008 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
SG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||
SG00000000000000000003 /* Shared */,
|
||||
);
|
||||
name = NucleicRemoteWidgets;
|
||||
productName = NucleicRemoteWidgets;
|
||||
productReference = FR00000000000000000002 /* NucleicRemoteWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
TD00000000000000000001 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = TG00000000000000000002 /* NucleicRemoteWidgets */;
|
||||
targetProxy = CP00000000000000000001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
PJ00000000000000000001 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
@@ -105,6 +193,9 @@
|
||||
TG00000000000000000001 = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
};
|
||||
TG00000000000000000002 = {
|
||||
CreatedOnToolsVersion = 27.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = CL00000000000000000001 /* Build configuration list for PBXProject "NucleicRemote" */;
|
||||
@@ -125,6 +216,7 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
TG00000000000000000001 /* NucleicRemote */,
|
||||
TG00000000000000000002 /* NucleicRemoteWidgets */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -138,6 +230,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BP00000000000000000008 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -148,6 +247,13 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BP00000000000000000006 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@@ -189,6 +295,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "app-logo-file";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = NucleicRemote/NucleicRemote.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -225,6 +332,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = "app-logo-file";
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = NucleicRemote/NucleicRemote.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -256,6 +364,66 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BC00000000000000000005 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L7UDTQ6F5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
BC00000000000000000006 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = L7UDTQ6F5W;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = NucleicRemoteWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = NucleicRemoteWidgets;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "xyz.blakeslee.nucleic-remote.widgets";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@@ -277,6 +445,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CL00000000000000000003 /* Build configuration list for PBXNativeTarget "NucleicRemoteWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
BC00000000000000000005 /* Debug */,
|
||||
BC00000000000000000006 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>_nucleic._tcp</string>
|
||||
</array>
|
||||
<key>NucleicChannel</key>
|
||||
<string>$(NUCLEIC_CHANNEL)</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import NucleicProtocol
|
||||
|
||||
/// Owns the one aggregate session Live Activity (UX_IOS §5.3): started when work exists,
|
||||
/// updated as sessions change, ended when everything is idle or the device unpairs. State
|
||||
/// flows in from `RemoteStore` on every session-list change; the widget extension renders it
|
||||
/// (`SessionLiveActivity`).
|
||||
@MainActor
|
||||
final class LiveActivityManager {
|
||||
static let shared = LiveActivityManager()
|
||||
private init() {}
|
||||
|
||||
private var activity: Activity<NucleicSessionAttributes>?
|
||||
|
||||
/// Reconcile the Activity with the current session set.
|
||||
func sync(hostName: String, sessions: [WireSessionSummary]) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
let live = sessions.filter { !$0.archived }
|
||||
let running = live.filter { $0.status == .running || $0.status == .provisioning }
|
||||
let needsYou = live.filter { $0.status.needsYou($0.disposition) }
|
||||
|
||||
guard !running.isEmpty || !needsYou.isEmpty else {
|
||||
end()
|
||||
return
|
||||
}
|
||||
|
||||
// The most urgent session headlines: the top waiter, else the freshest runner.
|
||||
let top = needsYou.max { $0.updatedAt < $1.updatedAt }
|
||||
?? running.max { $0.updatedAt < $1.updatedAt }
|
||||
let state = NucleicSessionAttributes.ContentState(
|
||||
runningCount: running.count,
|
||||
needsYouCount: needsYou.count,
|
||||
topTitle: top?.title,
|
||||
topNeedsYou: top.map { $0.status.needsYou($0.disposition) } ?? false)
|
||||
|
||||
if let activity {
|
||||
Task { await activity.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
} else {
|
||||
// Recover an Activity that survived an app relaunch before starting a new one.
|
||||
if let existing = Activity<NucleicSessionAttributes>.activities.first {
|
||||
activity = existing
|
||||
Task { await existing.update(ActivityContent(state: state, staleDate: nil)) }
|
||||
return
|
||||
}
|
||||
activity = try? Activity.request(
|
||||
attributes: NucleicSessionAttributes(hostName: hostName),
|
||||
content: ActivityContent(state: state, staleDate: nil))
|
||||
}
|
||||
}
|
||||
|
||||
/// End the Activity (all idle, or unpaired).
|
||||
func end() {
|
||||
guard let activity else { return }
|
||||
self.activity = nil
|
||||
Task {
|
||||
await activity.end(
|
||||
ActivityContent(state: activity.content.state, staleDate: nil),
|
||||
dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/* The relay's content-free APNS wake (cloud/nucleic-edge apns.ts `approvalPayload`):
|
||||
the push deliberately carries no approval detail (the relay can't read it — E2EE), so
|
||||
this localized alert is all the lock screen shows. The app pulls the real approval
|
||||
over the encrypted channel on open. */
|
||||
"approval.pending" = "A session is waiting for your approval";
|
||||
@@ -49,6 +49,83 @@ final class RemoteStore: ObservableObject {
|
||||
@Published private(set) var openSessionID: SessionID?
|
||||
@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
|
||||
/// user opens the Diff tab and the host advertises `canFetchDiff`. Nil until it arrives.
|
||||
@Published private(set) var openDiff: WireSessionDiff?
|
||||
@Published private(set) var diffLoading = false
|
||||
|
||||
/// 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()
|
||||
|
||||
/// A navigation request from outside the view hierarchy (notification tap → this session).
|
||||
/// `RootView` switches to the Sessions tab; `SessionsView` pushes it and clears.
|
||||
@Published var pendingRoute: SessionID?
|
||||
|
||||
/// Whether the app is foreground-active (scene phase), mirrored here so the store can
|
||||
/// decide which transitions deserve a notification.
|
||||
private(set) var isActive = true
|
||||
|
||||
func setScenePhaseActive(_ active: Bool) { isActive = active }
|
||||
|
||||
/// Route to a session from a notification tap (deep link).
|
||||
func route(to sessionID: SessionID) { pendingRoute = sessionID }
|
||||
|
||||
/// An Allow/Deny straight from a notification action (UX_IOS §5.1). Requires a live
|
||||
/// channel; if the socket dropped while backgrounded, reconnect and send once ready —
|
||||
/// but only briefly (a stale queued approval must never fire minutes later; see
|
||||
/// UX_IOS §11.5, and the host dedupes/`alreadyResolved`s a lost race anyway).
|
||||
func respondFromNotification(_ id: ApprovalID, allow: Bool) {
|
||||
let decision: Decision = allow ? .allow(updatedInput: nil) : .deny(reason: nil)
|
||||
if connectivity.isLive {
|
||||
send(.approvalRespond(id, decision))
|
||||
} else {
|
||||
pendingNotificationDecision = (id, decision, Date())
|
||||
reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/// At most one decision waits for reconnect, and it expires after 30s.
|
||||
private var pendingNotificationDecision: (ApprovalID, Decision, Date)?
|
||||
|
||||
private func flushPendingNotificationDecision() {
|
||||
guard let (id, decision, at) = pendingNotificationDecision else { return }
|
||||
pendingNotificationDecision = nil
|
||||
guard Date().timeIntervalSince(at) < 30 else { return } // stale — require the app
|
||||
send(.approvalRespond(id, decision))
|
||||
}
|
||||
|
||||
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 } }
|
||||
@@ -87,7 +164,8 @@ final class RemoteStore: ObservableObject {
|
||||
connectivity = .connected
|
||||
hostName = "Andrew's Mac"
|
||||
grantedScope = .control
|
||||
capabilities = WireCapabilities(canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName])
|
||||
capabilities = WireCapabilities(
|
||||
canModifyToolInput: true, allowAlwaysScopes: [.session, .toolName], canFetchDiff: true)
|
||||
modelCatalog = WireModelCatalog(
|
||||
groups: [
|
||||
[WireModelCatalog.Model(sku: "claude-opus-4-8[1m]", displayName: "Opus 4.8", backend: .claudeCode,
|
||||
@@ -143,12 +221,51 @@ final class RemoteStore: ObservableObject {
|
||||
],
|
||||
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()),
|
||||
projectID: p2, projectName: "website", status: .open, dispatchedSessionID: nil,
|
||||
triage: "high", 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()),
|
||||
projectID: p1, projectName: "nucleic", status: .open, dispatchedSessionID: nil,
|
||||
triage: "critical", updatedAt: Date()),
|
||||
WireTodo(id: TodoID(rawValue: "t3"), text: "Write release notes", summary: nil,
|
||||
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil, updatedAt: Date()),
|
||||
projectID: nil, projectName: nil, status: .open, dispatchedSessionID: nil,
|
||||
triage: "low", updatedAt: Date()),
|
||||
],
|
||||
usage: WireSubscriptionUsage(
|
||||
fiveHour: WireUsageWindow(utilization: 42, resetsAt: Date().addingTimeInterval(3 * 3600)),
|
||||
sevenDay: WireUsageWindow(utilization: 78, resetsAt: Date().addingTimeInterval(2.4 * 86_400))),
|
||||
statusFeeds: [
|
||||
WireStatusFeed(provider: "claude", providerName: "Claude", incidents: []),
|
||||
WireStatusFeed(provider: "openai", providerName: "OpenAI", incidents: [
|
||||
WireStatusIncident(
|
||||
id: "i1", title: "Elevated errors on Codex", url: URL(string: "https://status.openai.com"),
|
||||
updatedAt: Date(), state: "Monitoring", isResolved: false, components: ["Codex"]),
|
||||
]),
|
||||
WireStatusFeed(provider: "xai", providerName: "xAI", incidents: []),
|
||||
])
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
}
|
||||
|
||||
/// Offline diff fixture (NUCLEIC_DEMO) so the full-patch Diff tab renders without a host.
|
||||
private func demoDiff(_ sessionID: SessionID) -> WireSessionDiff {
|
||||
WireSessionDiff(
|
||||
sessionID: sessionID,
|
||||
stat: DiffStat(filesChanged: 2, added: 312, removed: 40),
|
||||
files: [
|
||||
WireFileDiff(path: "auth/middleware.ts", oldPath: nil, status: "modified", added: 290, removed: 38),
|
||||
WireFileDiff(path: "auth/session.ts", oldPath: nil, status: "added", added: 22, removed: 2),
|
||||
],
|
||||
patch: """
|
||||
diff --git a/auth/middleware.ts b/auth/middleware.ts
|
||||
--- a/auth/middleware.ts
|
||||
+++ b/auth/middleware.ts
|
||||
@@ -10,7 +10,9 @@ export function requireSession(req: Request) {
|
||||
- const token = req.headers.get("x-auth")
|
||||
+ const header = req.headers.get("authorization") ?? ""
|
||||
+ const token = header.replace(/^Bearer /, "")
|
||||
+ if (!token) throw new AuthError("missing bearer token")
|
||||
return verify(token)
|
||||
}
|
||||
""")
|
||||
}
|
||||
|
||||
/// Pair from a scanned QR (SYNC §4.2): connect (LAN hint first, else Bonjour), run XXpsk0,
|
||||
@@ -196,6 +313,8 @@ final class RemoteStore: ObservableObject {
|
||||
IdentityStore.clearPairedHost()
|
||||
connectivity = .unpaired
|
||||
sessions = []
|
||||
LiveActivityManager.shared.end()
|
||||
NotificationRouter.shared.updateBadge(0)
|
||||
}
|
||||
|
||||
// MARK: - Intents (UX_IOS §9)
|
||||
@@ -204,11 +323,29 @@ final class RemoteStore: ObservableObject {
|
||||
openSessionID = sessionID
|
||||
openEvents = []
|
||||
openApprovals = []
|
||||
openDiff = nil
|
||||
diffLoading = false
|
||||
seenSeq.removeAll()
|
||||
markOpened(sessionID)
|
||||
if demoMode { seedDemoTranscript(sessionID); return }
|
||||
send(.subscribe(Subscribe(sessionID: sessionID, sinceSeq: nil, verbosity: .full)))
|
||||
}
|
||||
|
||||
/// Ask the host for the open session's full patch (Diff tab). No-op when the host
|
||||
/// doesn't advertise the capability — the view falls back to the diffstat summary.
|
||||
func fetchDiff(_ sessionID: SessionID) {
|
||||
if demoMode { openDiff = demoDiff(sessionID); return }
|
||||
guard capabilities.canFetchDiff else { return }
|
||||
diffLoading = openDiff == nil
|
||||
send(.fetchDiff(sessionID))
|
||||
}
|
||||
|
||||
/// 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,10 +374,15 @@ 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 = []
|
||||
openDiff = nil
|
||||
diffLoading = false
|
||||
}
|
||||
|
||||
func respond(_ approval: ApprovalRequest, _ decision: Decision) {
|
||||
@@ -290,6 +432,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)) }
|
||||
@@ -346,11 +490,29 @@ final class RemoteStore: ObservableObject {
|
||||
send(.listSessions)
|
||||
send(.listDashboard)
|
||||
if let id = openSessionID { send(.subscribe(Subscribe(sessionID: id, sinceSeq: nil, verbosity: .full))) }
|
||||
flushPendingNotificationDecision()
|
||||
case .sessionList(let list):
|
||||
sessions = list
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
case .sessionUpdated(let summary):
|
||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) { sessions[i] = summary }
|
||||
else { sessions.append(summary) }
|
||||
let previous: WireSessionSummary?
|
||||
if let i = sessions.firstIndex(where: { $0.sessionID == summary.sessionID }) {
|
||||
previous = sessions[i]
|
||||
sessions[i] = summary
|
||||
} else {
|
||||
previous = nil
|
||||
sessions.append(summary)
|
||||
}
|
||||
// Notify on the transition into "waiting on you" / "finished" — only when the
|
||||
// app isn't foreground-active (in-app, the list's washes and badges carry it).
|
||||
let becameWaiting = summary.status == .awaitingInput
|
||||
&& previous?.status != .awaitingInput
|
||||
if becameWaiting, !isActive, !summary.archived {
|
||||
NotificationRouter.shared.postSessionUpdate(summary)
|
||||
}
|
||||
NotificationRouter.shared.updateBadge(needsYouCount)
|
||||
LiveActivityManager.shared.sync(hostName: hostName, sessions: sessions)
|
||||
case .dashboard(let snapshot):
|
||||
dashboard = snapshot
|
||||
case .snapshot(let snapshot):
|
||||
@@ -368,10 +530,23 @@ final class RemoteStore: ObservableObject {
|
||||
if req.sessionID == openSessionID, !openApprovals.contains(where: { $0.id == req.id }) {
|
||||
openApprovals.append(req)
|
||||
}
|
||||
// Always post; the router suppresses the banner when the user is already
|
||||
// looking at this session, and resolution (any device) withdraws it.
|
||||
let title = sessions.first { $0.sessionID == req.sessionID }?.title ?? "Approval"
|
||||
NotificationRouter.shared.postApproval(req, sessionTitle: title)
|
||||
case .approvalResolved(let resolved):
|
||||
openApprovals.removeAll { $0.id == resolved.id }
|
||||
case .wireError:
|
||||
break // surfaced contextually by callers; not fatal
|
||||
NotificationRouter.shared.withdrawApproval(resolved.id)
|
||||
case .sessionDiff(let diff):
|
||||
guard diff.sessionID == openSessionID else { break }
|
||||
openDiff = diff
|
||||
diffLoading = false
|
||||
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 +556,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
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
import UIKit
|
||||
import NucleicProtocol
|
||||
|
||||
/// The phone's notification pipeline (UX_IOS §5.1): local notifications for approvals and
|
||||
/// needs-input transitions while the app is backgrounded, actionable Allow/Deny for low-risk
|
||||
/// approvals, deep-link routing on tap, and the app-icon badge (= NEEDS YOU count).
|
||||
///
|
||||
/// Two arrival paths converge here:
|
||||
/// - **Local** (LAN): the app is backgrounded but its socket is briefly alive; `RemoteStore`
|
||||
/// posts a rich local notification (content composed on-device — nothing rides APNs).
|
||||
/// - **Remote** (relay, M5): a content-free `approval.pending` tickle wakes the phone; the
|
||||
/// alert text comes from Localizable.strings and the app pulls the real approval over the
|
||||
/// encrypted channel on open.
|
||||
@MainActor
|
||||
final class NotificationRouter: NSObject {
|
||||
static let shared = NotificationRouter()
|
||||
|
||||
/// The store notifications act on; set once at app start.
|
||||
weak var store: RemoteStore?
|
||||
|
||||
// Category / action identifiers (also referenced from the notification service side).
|
||||
static let approvalCategory = "NUCLEIC_APPROVAL"
|
||||
static let approvalActionableCategory = "NUCLEIC_APPROVAL_ACTIONABLE"
|
||||
static let inputCategory = "NUCLEIC_INPUT"
|
||||
static let allowAction = "NUCLEIC_ALLOW"
|
||||
static let denyAction = "NUCLEIC_DENY"
|
||||
|
||||
/// Install the delegate + categories. Call once at launch (before any notification can
|
||||
/// arrive, so actions are always registered).
|
||||
func install(store: RemoteStore) {
|
||||
self.store = store
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.delegate = self
|
||||
|
||||
// Low/medium-risk approvals resolve straight from the banner (UX_IOS §5.1);
|
||||
// Allow requires device auth so a pocket-tap can't grant. High-risk approvals use
|
||||
// the action-less category — they must open the app (Face ID gate on the card).
|
||||
let allow = UNNotificationAction(
|
||||
identifier: Self.allowAction, title: "Allow",
|
||||
options: [.authenticationRequired])
|
||||
let deny = UNNotificationAction(
|
||||
identifier: Self.denyAction, title: "Deny",
|
||||
options: [.destructive])
|
||||
let actionable = UNNotificationCategory(
|
||||
identifier: Self.approvalActionableCategory,
|
||||
actions: [deny, allow], intentIdentifiers: [])
|
||||
let plain = UNNotificationCategory(
|
||||
identifier: Self.approvalCategory, actions: [], intentIdentifiers: [])
|
||||
let input = UNNotificationCategory(
|
||||
identifier: Self.inputCategory, actions: [], intentIdentifiers: [])
|
||||
center.setNotificationCategories([actionable, plain, input])
|
||||
}
|
||||
|
||||
// MARK: - Posting (local path, composed on-device)
|
||||
|
||||
/// Post a local notification for a pending approval. Rich because it never leaves the
|
||||
/// device; the remote tickle stays content-free.
|
||||
func postApproval(_ approval: ApprovalRequest, sessionTitle: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = sessionTitle
|
||||
content.body = "\(approval.toolName) wants: \(approval.title)"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = approval.risk.isHigh
|
||||
? Self.approvalCategory : Self.approvalActionableCategory
|
||||
content.userInfo = [
|
||||
"sessionID": approval.sessionID.rawValue,
|
||||
"approvalID": approval.id.rawValue,
|
||||
]
|
||||
// One notification per approval; a re-post for the same id replaces, and resolution
|
||||
// (any device) withdraws it.
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "approval-\(approval.id.rawValue)", content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
/// Post a "session needs you / finished" transition notification.
|
||||
func postSessionUpdate(_ summary: WireSessionSummary) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = summary.title
|
||||
content.body = summary.status == .awaitingInput && summary.disposition == .completed
|
||||
? "Finished its work." : "Waiting for your next prompt."
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.inputCategory
|
||||
content.userInfo = ["sessionID": summary.sessionID.rawValue]
|
||||
let request = UNNotificationRequest(
|
||||
identifier: "input-\(summary.sessionID.rawValue)", content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
/// Withdraw an approval's notification once it's resolved (first-responder-wins — the
|
||||
/// Mac may have answered).
|
||||
func withdrawApproval(_ id: ApprovalID) {
|
||||
let identifier = "approval-\(id.rawValue)"
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.removeDeliveredNotifications(withIdentifiers: [identifier])
|
||||
center.removePendingNotificationRequests(withIdentifiers: [identifier])
|
||||
}
|
||||
|
||||
/// Keep the app-icon badge equal to the NEEDS YOU count (UX_IOS §8).
|
||||
func updateBadge(_ count: Int) {
|
||||
UNUserNotificationCenter.current().setBadgeCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
extension NotificationRouter: UNUserNotificationCenterDelegate {
|
||||
/// Foreground arrivals: show the banner unless the user is already looking at that
|
||||
/// session (the approval card is louder than a banner).
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
let sessionID = notification.request.content.userInfo["sessionID"] as? String
|
||||
let suppress = await MainActor.run {
|
||||
sessionID != nil && store?.openSessionID?.rawValue == sessionID
|
||||
}
|
||||
return suppress ? [] : [.banner, .sound, .badge]
|
||||
}
|
||||
|
||||
/// Taps and actions. A tap routes to the session; Allow/Deny resolve the approval over
|
||||
/// a live channel (reconnecting first if the socket dropped).
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse
|
||||
) async {
|
||||
let info = response.notification.request.content.userInfo
|
||||
let sessionID = (info["sessionID"] as? String).map(SessionID.init(rawValue:))
|
||||
let approvalID = (info["approvalID"] as? String).map(ApprovalID.init(rawValue:))
|
||||
let action = response.actionIdentifier
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
guard let store = self?.store else { return }
|
||||
switch action {
|
||||
case Self.allowAction:
|
||||
if let approvalID { store.respondFromNotification(approvalID, allow: true) }
|
||||
case Self.denyAction:
|
||||
if let approvalID { store.respondFromNotification(approvalID, allow: false) }
|
||||
default:
|
||||
// Plain tap (or a long-press open): route to the session.
|
||||
if let sessionID { store.route(to: sessionID) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,22 @@ struct NucleicRemoteApp: App {
|
||||
.environmentObject(store)
|
||||
.onAppear {
|
||||
store.onAppear()
|
||||
// Notification categories + delegate must be in place before any
|
||||
// notification can arrive, so actions/taps always route.
|
||||
NotificationRouter.shared.install(store: store)
|
||||
// Surface the notifications prompt + register for APNS. The token rides along
|
||||
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
|
||||
PushRegistrar.shared.requestAuthorizationAndRegister()
|
||||
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
|
||||
if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" {
|
||||
PushRegistrar.shared.requestAuthorizationAndRegister()
|
||||
}
|
||||
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
|
||||
Task { await HeartbeatReporter.reportIfDue() }
|
||||
}
|
||||
// Re-check on every foreground so a new active day is counted.
|
||||
// Re-check on every foreground so a new active day is counted; keep the store's
|
||||
// activity flag current so it only notifies for background transitions.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
store.setScenePhaseActive(phase == .active)
|
||||
if phase == .active { Task { await HeartbeatReporter.reportIfDue() } }
|
||||
}
|
||||
}
|
||||
@@ -30,27 +38,75 @@ struct RootView: View {
|
||||
@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
|
||||
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
|
||||
// Appearance, so both devices honor the same choices.
|
||||
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
|
||||
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
|
||||
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if store.isPaired {
|
||||
TabView(selection: $tab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
||||
SessionsView()
|
||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||||
.badge(store.needsYouCount).tag(1)
|
||||
ProjectsView()
|
||||
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
||||
TodosView()
|
||||
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
||||
VStack(spacing: 0) {
|
||||
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
|
||||
BuildBanner()
|
||||
Group {
|
||||
if store.isPaired {
|
||||
TabView(selection: $tab) {
|
||||
HomeView()
|
||||
.tabItem { Label("Home", systemImage: "house") }.tag(0)
|
||||
SessionsView()
|
||||
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
|
||||
.badge(store.needsYouCount).tag(1)
|
||||
ProjectsView()
|
||||
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
|
||||
TodosView()
|
||||
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
|
||||
SettingsView()
|
||||
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
|
||||
}
|
||||
} else {
|
||||
PairingIntroView()
|
||||
}
|
||||
} else {
|
||||
PairingIntroView()
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) { ErrorBubble() }
|
||||
// A notification tap routes to its session: jump to the Sessions tab, where
|
||||
// `SessionsView` consumes `pendingRoute` and pushes the detail.
|
||||
.onChange(of: store.pendingRoute) { _, route in
|
||||
if route != nil { tab = 1 }
|
||||
}
|
||||
.tint(Palette.accent)
|
||||
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
|
||||
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
|
||||
// The palette resolves against the stored color-vision mode at render time; rebuild
|
||||
// the hierarchy when it changes so every cached view picks up the remapped colors.
|
||||
.id(colorVisionRaw)
|
||||
}
|
||||
}
|
||||
|
||||
/// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error
|
||||
/// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap
|
||||
/// dismisses it immediately.
|
||||
private struct ErrorBubble: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
|
||||
var body: some View {
|
||||
if let error = store.lastError {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
Text(error.message).lineLimit(3)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "xmark").opacity(0.7)
|
||||
}
|
||||
.font(.footnote.weight(.medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.background(Palette.danger, in: RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 58) // clear the tab bar
|
||||
.onTapGesture { store.dismissError() }
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.animation(.easeInOut(duration: 0.2), value: store.lastError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`.
|
||||
/// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`,
|
||||
/// see scripts/ios-release.sh); unset builds fall back to runtime detection (Debug → local,
|
||||
/// TestFlight → beta, App Store → release).
|
||||
enum BuildChannel {
|
||||
/// The `dev` channel — red "Local Development Build" banner.
|
||||
case local
|
||||
/// The `canary` channel — canary-yellow "Canary" banner with the bird. The bleeding-edge
|
||||
/// distributed channel, cut before beta (see BUILD.md).
|
||||
case canary
|
||||
/// The `beta` channel — blue "Beta" banner.
|
||||
case beta
|
||||
/// The `rc` channel — gold "Release Candidate" banner.
|
||||
case releaseCandidate
|
||||
/// The `stable` channel — no banner.
|
||||
case release
|
||||
}
|
||||
|
||||
/// Build identity for the running app: which channel it came from and its build label.
|
||||
/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from
|
||||
/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion`
|
||||
/// plays the same role here.
|
||||
struct BuildInfo {
|
||||
let channel: BuildChannel
|
||||
let buildLabel: String
|
||||
/// `CFBundleShortVersionString` when present, else `nil`.
|
||||
let version: String?
|
||||
|
||||
/// Resolved once — none of this changes while the app is running.
|
||||
static let current = BuildInfo()
|
||||
|
||||
init() {
|
||||
let info = Bundle.main.infoDictionary
|
||||
version = info?["CFBundleShortVersionString"] as? String
|
||||
buildLabel = (info?["CFBundleVersion"] as? String) ?? "local"
|
||||
channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String)
|
||||
}
|
||||
|
||||
/// Prefer the archive-time stamp (mirrors the Mac's `NUCLEIC_CHANNEL` build setting);
|
||||
/// fall back to what the runtime can tell on its own.
|
||||
private static func detectChannel(stamped: String?) -> BuildChannel {
|
||||
switch stamped?.lowercased() {
|
||||
case "dev", "local": return .local
|
||||
case "canary": return .canary
|
||||
case "beta": return .beta
|
||||
case "rc": return .releaseCandidate
|
||||
case "stable", "release": return .release
|
||||
default: break
|
||||
}
|
||||
#if DEBUG
|
||||
return .local
|
||||
#else
|
||||
// TestFlight installs carry the sandbox receipt; App Store installs don't.
|
||||
if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { return .beta }
|
||||
return .release
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Header strip warning that this is a non-release build — the same channel colors, icons,
|
||||
/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue
|
||||
/// for beta, gold for a release candidate. The leading edge names the channel; the build
|
||||
/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for
|
||||
/// a shipping App Store release.
|
||||
struct BuildBanner: View {
|
||||
var info: BuildInfo = .current
|
||||
|
||||
var body: some View {
|
||||
if let style = Style(channel: info.channel) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: style.icon)
|
||||
.font(.system(size: 15, weight: .heavy))
|
||||
Text(style.label)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 12)
|
||||
Text(info.buildLabel)
|
||||
.font(.caption.monospaced())
|
||||
.opacity(0.85)
|
||||
Text("EXPERIMENTAL")
|
||||
.font(.caption2.weight(.heavy))
|
||||
.tracking(0.9)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.white.opacity(0.16), in: .capsule)
|
||||
}
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 5)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(style.tint.opacity(0.5))
|
||||
.accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel))
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-channel appearance — identical constants to the Mac. `nil` for `.release` so the
|
||||
/// banner disappears entirely.
|
||||
private struct Style {
|
||||
var tint: Color
|
||||
var label: String
|
||||
var icon: String
|
||||
|
||||
init?(channel: BuildChannel) {
|
||||
switch channel {
|
||||
case .local:
|
||||
tint = Color(red: 0.78, green: 0.18, blue: 0.18) // red
|
||||
label = "Local Development Build"
|
||||
icon = "hammer.fill"
|
||||
case .canary:
|
||||
tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow
|
||||
label = "Canary Build"
|
||||
icon = "bird.fill"
|
||||
case .beta:
|
||||
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
|
||||
label = "Engineering Beta"
|
||||
icon = "person.2.badge.gearshape.fill"
|
||||
case .releaseCandidate:
|
||||
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
|
||||
label = "Release Candidate"
|
||||
icon = "checkmark.seal.fill"
|
||||
case .release:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func tooltip(version: String?, build: String) -> String {
|
||||
let v = version.map { "\($0) " } ?? ""
|
||||
return "\(label) — \(v)build \(build). Not a release build."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ struct StartChatComposer: View {
|
||||
@State private var auto = false
|
||||
@State private var model: String?
|
||||
@State private var effort = MobileEfforts.fallback
|
||||
// Advanced options (the Mac new-session sheet's base branch + worktree fields).
|
||||
@State private var showOptions = false
|
||||
@State private var baseBranch = ""
|
||||
@State private var useWorktree = true
|
||||
|
||||
private var projects: [WireProject] { store.dashboard.projects }
|
||||
private var selected: WireProject? {
|
||||
@@ -42,6 +46,32 @@ struct StartChatComposer: View {
|
||||
ModelMenu(model: $model, catalog: store.modelCatalog, backend: nil)
|
||||
Spacer()
|
||||
EffortMenu(effort: $effort, catalog: store.modelCatalog, modelSKU: model, controlled: controlled)
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { showOptions.toggle() }
|
||||
} label: {
|
||||
Image(systemName: showOptions ? "chevron.up.circle" : "ellipsis.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if showOptions {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.triangle.branch").foregroundStyle(.secondary)
|
||||
TextField(selected?.defaultBranch ?? "Base branch", text: $baseBranch)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.caption)
|
||||
}
|
||||
Toggle(isOn: $useWorktree) {
|
||||
Label("Run in a worktree", systemImage: "square.stack.3d.up")
|
||||
.font(.caption)
|
||||
}
|
||||
.tint(Palette.accent)
|
||||
}
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField("Describe a task…", text: $draft, axis: .vertical)
|
||||
@@ -51,7 +81,11 @@ struct StartChatComposer: View {
|
||||
.keyboardDismissable()
|
||||
Button {
|
||||
if let project = selected {
|
||||
store.startChat(in: project.id, message: draft, model: model, effort: effort, auto: auto)
|
||||
let branch = baseBranch.trimmingCharacters(in: .whitespaces)
|
||||
store.startChat(
|
||||
in: project.id, message: draft, model: model, effort: effort,
|
||||
baseBranch: branch.isEmpty ? nil : branch,
|
||||
useWorktree: useWorktree, auto: auto)
|
||||
draft = ""
|
||||
}
|
||||
} label: {
|
||||
@@ -104,6 +138,7 @@ struct QuickTodos: View {
|
||||
}.buttonStyle(.plain)
|
||||
Text(todo.summary?.isEmpty == false ? todo.summary! : todo.text)
|
||||
.font(.subheadline).lineLimit(1)
|
||||
if let triage = todo.triage { TriageBadge(rawLevel: triage) }
|
||||
Spacer()
|
||||
if let name = todo.projectName {
|
||||
Text(name).font(.caption2).foregroundStyle(.secondary)
|
||||
|
||||
@@ -54,11 +54,19 @@ struct HomeView: View {
|
||||
Text(subtitle).font(.subheadline).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
if streak.streak > 0 { StreakBadge(days: streak.streak, freezes: streak.availableFreezes) }
|
||||
// The Mac's service-status pill, fed by the dashboard projection.
|
||||
StatusFeedPill(feeds: store.dashboard.statusFeeds)
|
||||
}
|
||||
}
|
||||
|
||||
statCards
|
||||
|
||||
// The Mac's usage gauges (5-hour / weekly windows); hidden when the host
|
||||
// doesn't project usage.
|
||||
QuotaCard(usage: store.dashboard.usage)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Activity").font(.headline)
|
||||
ActivityGrid(
|
||||
|
||||
@@ -6,11 +6,14 @@ import NucleicProtocol
|
||||
struct SessionDetailView: View {
|
||||
@EnvironmentObject var store: RemoteStore
|
||||
let sessionID: SessionID
|
||||
@State private var tab = 0
|
||||
// Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for
|
||||
// offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
|
||||
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0
|
||||
@State private var draft = ""
|
||||
@State private var showRename = false
|
||||
@State private var renameDraft = ""
|
||||
@State private var showIntegrate = false
|
||||
@State private var showDiscard = false
|
||||
|
||||
private var summary: WireSessionSummary? {
|
||||
store.sessions.first { $0.sessionID == sessionID }
|
||||
@@ -32,7 +35,11 @@ struct SessionDetailView: View {
|
||||
|
||||
if tab == 0 {
|
||||
TranscriptList(events: store.openEvents)
|
||||
} else if store.capabilities.canFetchDiff || store.openDiff != nil {
|
||||
SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
|
||||
.onAppear { store.fetchDiff(sessionID) }
|
||||
} else {
|
||||
// Older host with no on-demand diff — the stat summary is all it projects.
|
||||
DiffSummaryView(diffStat: summary?.diffStat)
|
||||
}
|
||||
|
||||
@@ -56,6 +63,13 @@ struct SessionDetailView: View {
|
||||
Button("Rebase") { store.integrate(sessionID, .rebase) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Discard this session's branch and worktree? Unmerged work is lost.",
|
||||
isPresented: $showDiscard, titleVisibility: .visible
|
||||
) {
|
||||
Button("Discard", role: .destructive) { store.discard(sessionID) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear { store.open(sessionID) }
|
||||
.onDisappear { store.closeOpen() }
|
||||
}
|
||||
@@ -147,6 +161,9 @@ struct SessionDetailView: View {
|
||||
}
|
||||
Section {
|
||||
Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") }
|
||||
Button(role: .destructive) { showDiscard = true } label: {
|
||||
Label("Discard…", systemImage: "xmark.bin")
|
||||
}
|
||||
Button { store.setArchived(sessionID, !summary.archived) } label: {
|
||||
Label(summary.archived ? "Unarchive" : "Archive",
|
||||
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
||||
@@ -293,6 +310,112 @@ struct TranscriptList: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// The Diff tab with the real patch (the Mac Diff tab, phone form): a per-file summary list
|
||||
/// over the unified patch, colored +/− per line, fetched on demand via `fetchDiff`.
|
||||
struct SessionDiffView: View {
|
||||
let diff: WireSessionDiff?
|
||||
let loading: Bool
|
||||
|
||||
var body: some View {
|
||||
if let diff, diff.stat.filesChanged > 0 {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
fileList(diff)
|
||||
PatchText(patch: diff.patch)
|
||||
if diff.truncated {
|
||||
Label("Patch truncated — open the Mac for the rest.",
|
||||
systemImage: "scissors")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
} else if loading {
|
||||
ProgressView("Fetching diff…")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ContentUnavailableView("No changes yet", systemImage: "doc")
|
||||
}
|
||||
}
|
||||
|
||||
private func fileList(_ diff: WireSessionDiff) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("\(diff.stat.filesChanged) file\(diff.stat.filesChanged == 1 ? "" : "s") changed · +\(diff.stat.added) −\(diff.stat.removed)")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
ForEach(diff.files) { file in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: statusIcon(file.status))
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusColor(file.status))
|
||||
Text(file.path)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1).truncationMode(.middle)
|
||||
Spacer(minLength: 8)
|
||||
Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
|
||||
Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
|
||||
}
|
||||
}
|
||||
}
|
||||
.card()
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: String) -> String {
|
||||
switch status {
|
||||
case "added", "untracked": "plus.circle"
|
||||
case "deleted": "minus.circle"
|
||||
case "renamed": "arrow.right.circle"
|
||||
default: "pencil.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "added", "untracked": Palette.success
|
||||
case "deleted": Palette.danger
|
||||
default: .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
|
||||
/// horizontally as a block so long lines don't wrap into noise.
|
||||
private struct PatchText: View {
|
||||
let patch: String
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
|
||||
Text(line.isEmpty ? " " : line)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(lineColor(line))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(lineBackground(line))
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
}
|
||||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func lineColor(_ line: String) -> Color {
|
||||
if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary }
|
||||
if line.hasPrefix("+") { return Palette.success }
|
||||
if line.hasPrefix("-") { return Palette.danger }
|
||||
if line.hasPrefix("@@") { return Palette.active }
|
||||
if line.hasPrefix("diff ") { return .secondary }
|
||||
return Palette.primaryText
|
||||
}
|
||||
|
||||
private func lineBackground(_ line: String) -> Color {
|
||||
if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear }
|
||||
if line.hasPrefix("+") { return Palette.success.opacity(0.10) }
|
||||
if line.hasPrefix("-") { return Palette.danger.opacity(0.10) }
|
||||
return .clear
|
||||
}
|
||||
}
|
||||
|
||||
struct DiffSummaryView: View {
|
||||
let diffStat: DiffStat?
|
||||
|
||||
|
||||
@@ -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,19 @@ 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))
|
||||
}
|
||||
consumeRoute()
|
||||
}
|
||||
// A notification tap while this tab is already up.
|
||||
.onChange(of: store.pendingRoute) { consumeRoute() }
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
@@ -77,14 +90,28 @@ struct SessionsView: View {
|
||||
.safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the session a notification tap asked for, then clear the request.
|
||||
private func consumeRoute() {
|
||||
guard let route = store.pendingRoute else { return }
|
||||
store.pendingRoute = nil
|
||||
path.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
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 +124,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 +159,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,35 @@ struct SettingsView: View {
|
||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
|
||||
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
|
||||
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
|
||||
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Theme", selection: $appearanceRaw) {
|
||||
ForEach(AppAppearance.allCases) { appearance in
|
||||
Text(appearance.label).tag(appearance.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Text size", selection: $textSizeRaw) {
|
||||
ForEach(AppTextSize.allCases) { size in
|
||||
Text(size.label).tag(size.rawValue)
|
||||
}
|
||||
}
|
||||
Picker("Color vision", selection: $colorVisionRaw) {
|
||||
ForEach(ColorVisionMode.allCases) { mode in
|
||||
Text(mode.label).tag(mode.rawValue)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Appearance")
|
||||
} footer: {
|
||||
Text("Color vision remaps status colors to hues that stay distinguishable. Every status also carries a text label, so color is never the only cue.")
|
||||
}
|
||||
|
||||
Section("Connection") {
|
||||
LabeledContent("Status", value: store.connectivity.label)
|
||||
if !store.hostName.isEmpty {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import SwiftUI
|
||||
import NucleicProtocol
|
||||
|
||||
/// The Mac's usage gauges and provider-status pill, rendered from the dashboard's wire
|
||||
/// projections (`DashboardSnapshot.usage` / `.statusFeeds`) — same thresholds, icons, and
|
||||
/// copy as `QuotaIndicator` / `StatusFeedIndicator` on desktop.
|
||||
|
||||
// MARK: - Shared formatting (mirrors the Mac's QuotaFormat)
|
||||
|
||||
enum QuotaFormat {
|
||||
static let fiveHourIcon = "clock"
|
||||
static let weeklyIcon = "calendar"
|
||||
|
||||
static func percent(_ value: Double) -> String { "\(Int(value.rounded()))%" }
|
||||
|
||||
/// "6d 23h" / "2h 14m" / "14m" — the Mac's countdown formatting.
|
||||
static func duration(_ interval: TimeInterval) -> String {
|
||||
let total = Int(interval)
|
||||
let days = total / 86_400
|
||||
let hours = (total % 86_400) / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
if days > 0 { return "\(days)d \(hours)h" }
|
||||
if hours > 0 { return "\(hours)h \(minutes)m" }
|
||||
if minutes > 0 { return "\(minutes)m" }
|
||||
return "<1m"
|
||||
}
|
||||
|
||||
static func resetCaption(_ window: WireUsageWindow, now: Date) -> String? {
|
||||
guard let resetsAt = window.resetsAt else { return nil }
|
||||
let remaining = resetsAt.timeIntervalSince(now)
|
||||
guard remaining > 0 else { return "resetting…" }
|
||||
return "resets in \(duration(remaining))"
|
||||
}
|
||||
|
||||
/// Calm until 75%, amber to 90%, red beyond — the Mac's escalation.
|
||||
static func color(forPercent value: Double) -> Color {
|
||||
switch value {
|
||||
case ..<75: return Palette.accent
|
||||
case ..<90: return Palette.attention
|
||||
default: return Palette.danger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Quota card (the Mac's QuotaCard, dashboard form)
|
||||
|
||||
/// Labeled progress bars for the 5-hour and weekly windows with reset countdowns.
|
||||
/// Renders nothing when the host hasn't projected usage (older host, logged out) so the
|
||||
/// home screen doesn't carry a permanently empty card.
|
||||
struct QuotaCard: View {
|
||||
let usage: WireSubscriptionUsage?
|
||||
|
||||
var body: some View {
|
||||
if let usage, usage.peakUtilization != nil {
|
||||
TimelineView(.periodic(from: .now, by: 30)) { context in
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "gauge.with.dots.needle.67percent").foregroundStyle(.secondary)
|
||||
Text("Usage").font(.headline)
|
||||
}
|
||||
windowRow("5-hour limit", icon: QuotaFormat.fiveHourIcon, usage.fiveHour, now: context.date)
|
||||
windowRow("Weekly limit", icon: QuotaFormat.weeklyIcon, usage.sevenDay, now: context.date)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.card()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func windowRow(_ label: String, icon: String, _ window: WireUsageWindow?, now: Date) -> some View {
|
||||
if let window {
|
||||
let utilization = window.utilization(at: now)
|
||||
let color = QuotaFormat.color(forPercent: utilization)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: icon).font(.caption).foregroundStyle(.secondary)
|
||||
Text(label).font(.subheadline)
|
||||
Spacer()
|
||||
Text(QuotaFormat.percent(utilization))
|
||||
.font(.subheadline.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
bar(fraction: utilization / 100, color: color)
|
||||
if let caption = QuotaFormat.resetCaption(window, now: now) {
|
||||
Text(caption).font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bar(fraction: Double, color: Color) -> some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.18))
|
||||
Capsule().fill(color)
|
||||
.frame(width: max(3, geo.size.width * min(1, max(0, fraction))))
|
||||
}
|
||||
}
|
||||
.frame(height: 6)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status pill (the Mac's StatusFeedIndicator)
|
||||
|
||||
/// A compact service-status pill: a calm green check while every watched provider is
|
||||
/// operational, amber naming the affected providers when any has an active incident.
|
||||
/// Tapping opens a sheet listing current incidents with links to the status pages.
|
||||
/// Renders nothing before the host projects any feed (older host / first poll pending).
|
||||
struct StatusFeedPill: View {
|
||||
let feeds: [WireStatusFeed]
|
||||
@State private var showingSheet = false
|
||||
|
||||
private var active: [WireStatusIncident] { feeds.flatMap(\.activeIncidents) }
|
||||
private var affected: [String] { feeds.filter(\.hasActiveIncident).map(\.providerName) }
|
||||
|
||||
var body: some View {
|
||||
if !feeds.isEmpty {
|
||||
Button { showingSheet = true } label: {
|
||||
if active.isEmpty {
|
||||
Label("Operational", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(Palette.success)
|
||||
} else {
|
||||
Label(affected.joined(separator: ", "), systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(Palette.attention)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption)
|
||||
.sheet(isPresented: $showingSheet) { StatusFeedSheet(feeds: feeds) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pill's detail: current incidents grouped by provider (the Mac popover, sheet-form).
|
||||
private struct StatusFeedSheet: View {
|
||||
let feeds: [WireStatusFeed]
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(feeds) { feed in
|
||||
Section(feed.providerName) {
|
||||
if feed.activeIncidents.isEmpty {
|
||||
Label("Operational", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(Palette.success)
|
||||
.font(.subheadline)
|
||||
} else {
|
||||
ForEach(feed.activeIncidents) { incident in
|
||||
incidentRow(incident)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Service status")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func incidentRow(_ incident: WireStatusIncident) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(incident.title).font(.subheadline.weight(.medium))
|
||||
HStack(spacing: 6) {
|
||||
if let state = incident.state {
|
||||
Text(state).font(.caption).foregroundStyle(Palette.attention)
|
||||
}
|
||||
if !incident.components.isEmpty {
|
||||
Text(incident.components.joined(separator: " · "))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
if let updated = incident.updatedAt {
|
||||
Text(updated.formatted(date: .omitted, time: .shortened))
|
||||
.font(.caption).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
if let url = incident.url {
|
||||
Link("Status page", destination: url).font(.caption)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,208 @@
|
||||
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.
|
||||
/// The user's preferred appearance (Settings → Appearance). `system` follows iOS.
|
||||
/// Same storage key and semantics as the Mac's `AppAppearance`.
|
||||
enum AppAppearance: String, CaseIterable, Identifiable {
|
||||
case system, light, dark
|
||||
|
||||
var id: String { rawValue }
|
||||
static let storageKey = "nucleic.appearance"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: "System"
|
||||
case .light: "Light"
|
||||
case .dark: "Dark"
|
||||
}
|
||||
}
|
||||
|
||||
/// `nil` means follow the system setting.
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: nil
|
||||
case .light: .light
|
||||
case .dark: .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// User-selectable global text size (Settings → Appearance), applied app-wide via
|
||||
/// `.dynamicTypeSize` so the semantic fonts the UI uses scale together. Mirrors the Mac's
|
||||
/// `AppTextSize` (same storage key and steps).
|
||||
enum AppTextSize: String, CaseIterable, Identifiable {
|
||||
case small, medium, large, xLarge, xxLarge
|
||||
|
||||
var id: String { rawValue }
|
||||
static let storageKey = "nucleic.textSize"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .small: "Small"
|
||||
case .medium: "Default"
|
||||
case .large: "Large"
|
||||
case .xLarge: "Larger"
|
||||
case .xxLarge: "Largest"
|
||||
}
|
||||
}
|
||||
|
||||
/// `.large` is the system default content size, so "Default" maps to it.
|
||||
var dynamicTypeSize: DynamicTypeSize {
|
||||
switch self {
|
||||
case .small: .small
|
||||
case .medium: .large
|
||||
case .large: .xLarge
|
||||
case .xLarge: .xxLarge
|
||||
case .xxLarge: .xxxLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Color-vision mode. Remaps the categorical palette (status dots, activity grid, accent) to
|
||||
/// hues that stay distinguishable under the common color-vision deficiencies — the same
|
||||
/// palettes as the Mac's `ColorVisionMode`/`AppPalette`, same storage key, so both devices
|
||||
/// read identically for the same user.
|
||||
enum ColorVisionMode: String, CaseIterable, Identifiable {
|
||||
case standard, deuteranopia, protanopia, tritanopia
|
||||
|
||||
var id: String { rawValue }
|
||||
static let storageKey = "nucleic.colorVision"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .standard: "Standard"
|
||||
case .deuteranopia: "Deuteranopia (red–green)"
|
||||
case .protanopia: "Protanopia (red–green)"
|
||||
case .tritanopia: "Tritanopia (blue–yellow)"
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted mode (Settings → Appearance).
|
||||
static var stored: ColorVisionMode {
|
||||
ColorVisionMode(
|
||||
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? "") ?? .standard
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved set of semantic colors for one `ColorVisionMode` — the mobile mirror of the
|
||||
/// Mac's `AppPalette`, including its night-softening: vivid in light mode, desaturated and
|
||||
/// dimmed in dark so status accents read calmly instead of glowing.
|
||||
struct ResolvedPalette {
|
||||
var accent: Color
|
||||
var neutral: Color
|
||||
var active: Color
|
||||
var attention: Color
|
||||
var success: Color
|
||||
var paused: Color
|
||||
var danger: Color
|
||||
var frozen: Color
|
||||
var activityBase: Color
|
||||
|
||||
/// The vivid, light-mode categorical colors (identical constants to the Mac's
|
||||
/// `AppPalette.base`); `make` night-softens them for dark mode.
|
||||
private static func base(_ mode: ColorVisionMode) -> ResolvedPalette {
|
||||
switch mode {
|
||||
case .standard:
|
||||
ResolvedPalette(
|
||||
accent: Color(red: 0.04, green: 0.52, blue: 0.50), // deep teal
|
||||
neutral: .secondary,
|
||||
active: Color(red: 0.20, green: 0.55, blue: 0.95), // blue
|
||||
attention: Color(red: 1.00, green: 0.65, blue: 0.15), // amber
|
||||
success: Color(red: 0.30, green: 0.80, blue: 0.45), // green
|
||||
paused: Color(red: 0.62, green: 0.49, blue: 0.93), // purple
|
||||
danger: Color(red: 0.92, green: 0.34, blue: 0.34), // red
|
||||
frozen: Color(red: 0.26, green: 0.64, blue: 0.96), // cold "iced" blue
|
||||
activityBase: Color(red: 0.30, green: 0.80, blue: 0.45))
|
||||
case .deuteranopia, .protanopia:
|
||||
// Okabe–Ito-derived: avoids relying on red↔green separation.
|
||||
ResolvedPalette(
|
||||
accent: Color(red: 0.00, green: 0.50, blue: 0.58), // deep teal
|
||||
neutral: .secondary,
|
||||
active: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
|
||||
attention: Color(red: 0.90, green: 0.62, blue: 0.00), // orange
|
||||
success: Color(red: 0.34, green: 0.71, blue: 0.91), // sky blue
|
||||
paused: Color(red: 0.80, green: 0.47, blue: 0.65), // reddish purple
|
||||
danger: Color(red: 0.84, green: 0.37, blue: 0.00), // vermillion
|
||||
frozen: Color(red: 0.20, green: 0.58, blue: 0.86), // cool sky blue
|
||||
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
|
||||
case .tritanopia:
|
||||
// Blue↔yellow weak; red↔green is intact, so lean on those plus magenta.
|
||||
ResolvedPalette(
|
||||
accent: Color(red: 0.46, green: 0.33, blue: 0.78), // indigo-violet
|
||||
neutral: .secondary,
|
||||
active: Color(red: 0.00, green: 0.62, blue: 0.45), // green-teal
|
||||
attention: Color(red: 0.85, green: 0.20, blue: 0.46), // magenta
|
||||
success: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
|
||||
paused: Color(red: 0.67, green: 0.36, blue: 0.66), // purple
|
||||
danger: Color(red: 0.79, green: 0.10, blue: 0.15), // red
|
||||
frozen: Color(red: 0.30, green: 0.72, blue: 0.85), // light cyan
|
||||
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
|
||||
}
|
||||
}
|
||||
|
||||
static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
|
||||
var p = base(mode)
|
||||
p.accent = Palette.nightSoft(p.accent)
|
||||
p.active = Palette.nightSoft(p.active)
|
||||
p.attention = Palette.nightSoft(p.attention)
|
||||
p.success = Palette.nightSoft(p.success)
|
||||
p.paused = Palette.nightSoft(p.paused)
|
||||
p.danger = Palette.nightSoft(p.danger)
|
||||
p.frozen = Palette.nightSoft(p.frozen)
|
||||
p.activityBase = Palette.nightSoft(p.activityBase)
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): the same
|
||||
/// categorical status palette per color-vision mode, night-softened in dark mode, and the
|
||||
/// same status→color mapping refined by turn disposition — so the two devices read
|
||||
/// identically. The accessors resolve against the persisted `ColorVisionMode` on each read;
|
||||
/// the resolved palettes themselves are built once.
|
||||
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
|
||||
private static let resolved: [ColorVisionMode: ResolvedPalette] = Dictionary(
|
||||
uniqueKeysWithValues: ColorVisionMode.allCases.map { ($0, ResolvedPalette.make($0)) })
|
||||
|
||||
static var current: ResolvedPalette { resolved[ColorVisionMode.stored] ?? resolved[.standard]! }
|
||||
|
||||
static var accent: Color { current.accent }
|
||||
static var active: Color { current.active }
|
||||
static var attention: Color { current.attention }
|
||||
static var success: Color { current.success }
|
||||
static var paused: Color { current.paused }
|
||||
static var danger: Color { current.danger }
|
||||
/// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's
|
||||
/// `AppPalette.frozen`.
|
||||
static let frozen = Color(red: 0.26, green: 0.64, blue: 0.96)
|
||||
static var frozen: Color { current.frozen }
|
||||
static let neutral = Color.secondary
|
||||
/// The orchestra (orchestration mode) signature gold — mirrors the Mac's
|
||||
/// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the
|
||||
/// composer's golden ring.
|
||||
static let orchestra = Color(red: 0.90, green: 0.66, blue: 0.16)
|
||||
/// The orchestra (orchestration mode) signature gold — the exact appearance-aware pair the
|
||||
/// Mac's `AppTheme.orchestra` uses: a rich amber-gold in light (dark enough to read on
|
||||
/// white), a bright warm gold in dark so it glows without burning. A brand color, not a
|
||||
/// categorical status, so it doesn't remap with color-vision mode.
|
||||
static let orchestra = dynamic(
|
||||
light: UIColor(red: 0.80, green: 0.57, blue: 0.09, alpha: 1),
|
||||
dark: UIColor(red: 1.0, green: 0.82, blue: 0.34, alpha: 1))
|
||||
/// Primary prose color for chat text — the Mac's `AppTheme.primaryText`: a soft off-white
|
||||
/// on the deep dark background (reads calmly at night) and a near-black in light.
|
||||
static let primaryText = dynamic(
|
||||
light: UIColor(red: 0.24, green: 0.24, blue: 0.27, alpha: 1),
|
||||
dark: UIColor(red: 0.80, green: 0.81, blue: 0.83, alpha: 1))
|
||||
|
||||
/// A `Color` that resolves to `light`/`dark` with the active trait collection (driven by
|
||||
/// `.preferredColorScheme`) — the UIKit twin of the Mac's `NSColor(name:)` provider.
|
||||
private static func dynamic(light: UIColor, dark: UIColor) -> Color {
|
||||
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light })
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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.68, brightness: min(b, 0.80) * 0.82, alpha: a)
|
||||
return dynamic(light: ui, dark: dark)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -39,9 +222,9 @@ enum Palette {
|
||||
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
|
||||
case 1: current.activityBase.opacity(0.4)
|
||||
case 2: current.activityBase.opacity(0.7)
|
||||
default: current.activityBase
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +232,7 @@ enum Palette {
|
||||
/// activity, outliers excluded — see `ActivityScale`). `0` is an empty day's neutral gray.
|
||||
/// Mirrors the Mac's `AppPalette.activity(intensity:)`.
|
||||
static func activity(intensity: Double) -> Color {
|
||||
intensity <= 0 ? Color.secondary.opacity(0.15) : success.opacity(intensity)
|
||||
intensity <= 0 ? Color.secondary.opacity(0.15) : current.activityBase.opacity(intensity)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,8 +65,13 @@ private struct TodoRow: View {
|
||||
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)
|
||||
HStack(spacing: 6) {
|
||||
if let triage = todo.triage {
|
||||
TriageBadge(rawLevel: triage)
|
||||
}
|
||||
if todo.status == .dispatched {
|
||||
Text("Dispatched").font(.caption2).foregroundStyle(Palette.active)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -99,3 +104,56 @@ private struct TodoRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A small colored badge showing an idea's triage level — the Mac's `TriageBadge`, fed by
|
||||
/// the wire's raw level name. Honors the encouraging/classic labeling setting (same storage
|
||||
/// key as the Mac); an unrecognized level from a newer host renders no badge.
|
||||
struct TriageBadge: View {
|
||||
let rawLevel: String
|
||||
@AppStorage("nucleic.triageEncouragingLabels") private var encouraging = true
|
||||
|
||||
private var label: String? {
|
||||
switch (rawLevel, encouraging) {
|
||||
case ("critical", true): "Max Impact"
|
||||
case ("high", true): "High Impact"
|
||||
case ("medium", true): "Worthwhile"
|
||||
case ("low", true): "Someday"
|
||||
case ("critical", false): "Critical"
|
||||
case ("high", false): "High"
|
||||
case ("medium", false): "Medium"
|
||||
case ("low", false): "Low"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The Mac's badge hues: a red→orange→yellow→gray urgency scale for classic labels, a
|
||||
/// magenta→blue→green→slate reward scale for the encouraging framing.
|
||||
private var tint: Color? {
|
||||
switch (rawLevel, encouraging) {
|
||||
case ("critical", false): .red
|
||||
case ("high", false): .orange
|
||||
case ("medium", false): .yellow
|
||||
case ("low", false): .gray
|
||||
case ("critical", true): Color(red: 0.95, green: 0.33, blue: 0.78) // vivid magenta
|
||||
case ("high", true): Color(red: 0.26, green: 0.54, blue: 1.00) // bright blue
|
||||
case ("medium", true): Color(red: 0.18, green: 0.78, blue: 0.45) // green
|
||||
case ("low", true): Color(red: 0.56, green: 0.60, blue: 0.67) // calm slate
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let label, let tint {
|
||||
HStack(spacing: 4) {
|
||||
Circle().fill(tint).frame(width: 7, height: 7)
|
||||
Text(label)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(tint.opacity(0.14), in: .capsule)
|
||||
.overlay(Capsule().strokeBorder(tint.opacity(0.35), lineWidth: 0.5))
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// A lightweight Markdown renderer for chat messages — the exact port of the Mac's
|
||||
/// `MarkdownText`: fenced code blocks, headings, bullet/numbered lists, GitHub-style tables,
|
||||
/// and inline emphasis/links/`code`. Not a full CommonMark implementation — just the
|
||||
/// constructs agents actually emit — so a reply renders identically on both devices.
|
||||
struct MarkdownText: View {
|
||||
let markdown: String
|
||||
/// Base prose size; every other size (headings, code, tables) is derived from it so a
|
||||
/// response renders on one consistent scale. Scaled with the user's text-size setting
|
||||
/// (which drives `.dynamicTypeSize`), anchored to the Mac's transcript prose size.
|
||||
@ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = 15
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(Self.parse(markdown).enumerated()), id: \.offset) { _, block in
|
||||
switch block {
|
||||
case .code(let code):
|
||||
codeBlock(code)
|
||||
case .text(let text):
|
||||
textBlock(text)
|
||||
case .table(let rows):
|
||||
tableView(rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Anchor the whole response to one base size; inline code and body text
|
||||
// inherit it, so nothing drifts smaller than the prose around it.
|
||||
.font(.system(size: bodySize))
|
||||
// Soft off-white (vs. pure white) so chat prose reads calmly at night.
|
||||
.foregroundStyle(Palette.primaryText)
|
||||
}
|
||||
|
||||
// MARK: - blocks
|
||||
|
||||
private enum Block { case code(String), text(String), table([[String]]) }
|
||||
|
||||
/// Parsed-block cache. The same message string is re-parsed on every body re-evaluation
|
||||
/// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing
|
||||
/// is independent of `bodySize` — so the source string is a complete key. Bounded;
|
||||
/// `NSCache` also evicts under memory pressure.
|
||||
private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } }
|
||||
private static let blockCache: NSCache<NSString, ParsedBlocks> = {
|
||||
let cache = NSCache<NSString, ParsedBlocks>()
|
||||
cache.countLimit = 2048
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static func parse(_ markdown: String) -> [Block] {
|
||||
let key = markdown as NSString
|
||||
if let hit = blockCache.object(forKey: key) { return hit.blocks }
|
||||
let blocks = parseUncached(markdown)
|
||||
blockCache.setObject(ParsedBlocks(blocks), forKey: key)
|
||||
return blocks
|
||||
}
|
||||
|
||||
private static func parseUncached(_ markdown: String) -> [Block] {
|
||||
var blocks: [Block] = []
|
||||
var textBuffer: [String] = []
|
||||
func flush() {
|
||||
let joined = textBuffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !joined.isEmpty { blocks.append(.text(joined)) }
|
||||
textBuffer = []
|
||||
}
|
||||
let lines = markdown.components(separatedBy: "\n")
|
||||
var index = 0
|
||||
while index < lines.count {
|
||||
if lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
flush()
|
||||
var code: [String] = []
|
||||
index += 1
|
||||
while index < lines.count,
|
||||
!lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
code.append(lines[index])
|
||||
index += 1
|
||||
}
|
||||
blocks.append(.code(code.joined(separator: "\n")))
|
||||
index += 1 // consume closing fence
|
||||
} else if isTableStart(lines, index) {
|
||||
flush()
|
||||
var rows: [[String]] = [tableCells(lines[index])]
|
||||
index += 2 // header row + the |---|--- separator
|
||||
while index < lines.count, lines[index].contains("|"),
|
||||
!lines[index].trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
rows.append(tableCells(lines[index]))
|
||||
index += 1
|
||||
}
|
||||
blocks.append(.table(rows))
|
||||
} else {
|
||||
textBuffer.append(lines[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// MARK: - tables
|
||||
|
||||
/// A GitHub-style table: a `|`-bearing header line immediately followed by a
|
||||
/// `|---|:--:|` separator line.
|
||||
private static func isTableStart(_ lines: [String], _ index: Int) -> Bool {
|
||||
guard lines[index].contains("|"), index + 1 < lines.count else { return false }
|
||||
return isSeparatorRow(lines[index + 1])
|
||||
}
|
||||
|
||||
private static func isSeparatorRow(_ line: String) -> Bool {
|
||||
let cells = tableCells(line)
|
||||
guard !cells.isEmpty else { return false }
|
||||
return cells.allSatisfy { cell in
|
||||
!cell.isEmpty && cell.allSatisfy { $0 == "-" || $0 == ":" } && cell.contains("-")
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a table row into trimmed cells, dropping the empties created by the
|
||||
/// leading/trailing pipes.
|
||||
private static func tableCells(_ line: String) -> [String] {
|
||||
var trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.hasPrefix("|") { trimmed.removeFirst() }
|
||||
if trimmed.hasSuffix("|") { trimmed.removeLast() }
|
||||
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
}
|
||||
|
||||
private func tableView(_ rows: [[String]]) -> some View {
|
||||
let columns = rows.map(\.count).max() ?? 0
|
||||
return Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 6) {
|
||||
ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
|
||||
GridRow {
|
||||
ForEach(0..<columns, id: \.self) { col in
|
||||
inline(col < row.count ? row[col] : "")
|
||||
.font(.system(size: bodySize, weight: rowIndex == 0 ? .semibold : .regular))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
if rowIndex == 0, columns > 0 {
|
||||
Divider().gridCellColumns(columns)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(.quaternary.opacity(0.25), in: .rect(cornerRadius: 8))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary, lineWidth: 1))
|
||||
}
|
||||
|
||||
private func codeBlock(_ code: String) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
Text(code)
|
||||
// Same point size as body/inline code, just monospaced — so code
|
||||
// doesn't shrink relative to the prose.
|
||||
.font(.system(size: bodySize, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private func textBlock(_ text: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
ForEach(Array(text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, raw in
|
||||
lineView(raw)
|
||||
}
|
||||
}
|
||||
// Breathing room between the visual lines of a single wrapped paragraph,
|
||||
// which the per-line VStack spacing above can't reach.
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func lineView(_ raw: String) -> some View {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
Color.clear.frame(height: 3)
|
||||
} else if trimmed.hasPrefix("### ") {
|
||||
// Headings scale relative to the base prose size so the hierarchy holds
|
||||
// at any base and never collapses to the body size.
|
||||
inline(String(trimmed.dropFirst(4))).font(.system(size: bodySize * 1.13, weight: .semibold))
|
||||
} else if trimmed.hasPrefix("## ") {
|
||||
inline(String(trimmed.dropFirst(3))).font(.system(size: bodySize * 1.28, weight: .bold))
|
||||
} else if trimmed.hasPrefix("# ") {
|
||||
inline(String(trimmed.dropFirst(2))).font(.system(size: bodySize * 1.5, weight: .bold))
|
||||
} else if let bullet = Self.bulletContent(trimmed) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("•").foregroundStyle(.secondary)
|
||||
inline(bullet)
|
||||
}
|
||||
} else {
|
||||
inline(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil.
|
||||
private static func bulletContent(_ trimmed: String) -> String? {
|
||||
for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) {
|
||||
return String(trimmed.dropFirst(marker.count))
|
||||
}
|
||||
// Numbered list: "12. text"
|
||||
let parts = trimmed.split(separator: " ", maxSplits: 1)
|
||||
if let first = parts.first, parts.count == 2,
|
||||
first.hasSuffix("."), Int(first.dropLast()) != nil {
|
||||
return String(parts[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func inline(_ string: String) -> Text {
|
||||
Text(Self.attributedInline(string))
|
||||
}
|
||||
|
||||
/// Inline-Markdown cache. `AttributedString(markdown:)` is the dominant per-row cost
|
||||
/// when a transcript first lays out — it runs once per prose line — and the same lines
|
||||
/// recur across re-renders and reopens, so memoize the parsed result. Independent of
|
||||
/// `bodySize` (callers apply the font), so the source string is a complete key.
|
||||
private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } }
|
||||
private static let inlineCache: NSCache<NSString, InlineBox> = {
|
||||
let cache = NSCache<NSString, InlineBox>()
|
||||
cache.countLimit = 16384
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static func attributedInline(_ string: String) -> AttributedString {
|
||||
let key = string as NSString
|
||||
if let hit = inlineCache.object(forKey: key) { return hit.value }
|
||||
let options = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
// Fall back to the plain string on parse failure — `Text(AttributedString(string))`
|
||||
// renders identically to `Text(string)`, so callers see no behavioral change.
|
||||
let parsed = (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
|
||||
inlineCache.setObject(InlineBox(parsed), forKey: key)
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
@@ -60,8 +60,9 @@ struct TranscriptRow: View {
|
||||
private func modelLabel(_ model: String) -> String { model.isEmpty ? "agent" : model }
|
||||
}
|
||||
|
||||
/// Render an assistant/user message as Markdown (inline bold/italic/code/links), preserving the
|
||||
/// line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
||||
/// Render an assistant/user message with the full Markdown renderer the Mac transcript uses
|
||||
/// (fenced code blocks, headings, lists, tables, and inline emphasis/links/`code`), preserving
|
||||
/// the line breaks of multi-paragraph replies. Falls back to plain text if parsing fails.
|
||||
struct MessageBubble: View {
|
||||
let role: TranscriptItem.Role
|
||||
let text: String
|
||||
@@ -69,7 +70,7 @@ struct MessageBubble: View {
|
||||
var body: some View {
|
||||
HStack {
|
||||
if role == .user { Spacer(minLength: 40) }
|
||||
Text(Self.markdown(text))
|
||||
MarkdownText(markdown: text)
|
||||
.textSelection(.enabled)
|
||||
.padding(10)
|
||||
.background(role == .user ? Palette.accent.opacity(0.15) : Color(.secondarySystemBackground),
|
||||
@@ -79,6 +80,8 @@ struct MessageBubble: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline-only Markdown for one-line contexts (host-exec purpose lines etc.), where the
|
||||
/// block renderer above would be overkill.
|
||||
static func markdown(_ string: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: string, options: .init(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,9 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct NucleicRemoteWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
SessionLiveActivity()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Lock-screen / Dynamic Island rendering of the aggregate session Activity (UX_IOS §5.3):
|
||||
/// glance = running count + needs-you count + the most urgent session; tap opens the app
|
||||
/// (straight to the approval, via the notification-style route). Self-contained colors — the
|
||||
/// extension doesn't link the app's Theme, so the two palette constants it needs live here
|
||||
/// (the same teal/amber/blue as `Palette`).
|
||||
struct SessionLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: NucleicSessionAttributes.self) { context in
|
||||
// Lock screen banner.
|
||||
LockScreenView(state: context.state, hostName: context.attributes.hostName)
|
||||
.activityBackgroundTint(Color.black.opacity(0.55))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Label("\(context.state.runningCount)", systemImage: "circle.lefthalf.filled")
|
||||
.font(.headline)
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if context.state.needsYouCount > 0 {
|
||||
Label("\(context.state.needsYouCount)", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(ActivityPalette.attention)
|
||||
} else {
|
||||
Label("0", systemImage: "checkmark.circle")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if let title = context.state.topTitle {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: context.state.topNeedsYou
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.foregroundStyle(context.state.topNeedsYou
|
||||
? ActivityPalette.attention : ActivityPalette.active)
|
||||
Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
|
||||
Spacer()
|
||||
Text(statusLine(context.state))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
Text(statusLine(context.state))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "circle.lefthalf.filled")
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
} compactTrailing: {
|
||||
if context.state.needsYouCount > 0 {
|
||||
Text("\(context.state.needsYouCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(ActivityPalette.attention)
|
||||
} else {
|
||||
Text("\(context.state.runningCount)")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(ActivityPalette.active)
|
||||
}
|
||||
} minimal: {
|
||||
Image(systemName: context.state.needsYouCount > 0
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.foregroundStyle(context.state.needsYouCount > 0
|
||||
? ActivityPalette.attention : ActivityPalette.active)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusLine(_ state: NucleicSessionAttributes.ContentState) -> String {
|
||||
var parts: [String] = []
|
||||
if state.runningCount > 0 { parts.append("\(state.runningCount) running") }
|
||||
if state.needsYouCount > 0 { parts.append("\(state.needsYouCount) need you") }
|
||||
return parts.isEmpty ? "Idle" : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
private struct LockScreenView: View {
|
||||
let state: NucleicSessionAttributes.ContentState
|
||||
let hostName: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: state.needsYouCount > 0
|
||||
? "exclamationmark.triangle.fill" : "circle.lefthalf.filled")
|
||||
.font(.title2)
|
||||
.foregroundStyle(state.needsYouCount > 0
|
||||
? ActivityPalette.attention : ActivityPalette.active)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let title = state.topTitle {
|
||||
Text(title).font(.headline).lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
if state.runningCount > 0 {
|
||||
Text("\(state.runningCount) running").foregroundStyle(ActivityPalette.active)
|
||||
}
|
||||
if state.runningCount > 0 && state.needsYouCount > 0 { Text("·") }
|
||||
if state.needsYouCount > 0 {
|
||||
Text("\(state.needsYouCount) waiting for you")
|
||||
.foregroundStyle(ActivityPalette.attention)
|
||||
}
|
||||
if state.runningCount == 0 && state.needsYouCount == 0 {
|
||||
Text("Idle").foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
Text(hostName)
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(14)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
/// The two categorical colors the Activity needs — same constants as the app's `Palette`.
|
||||
private enum ActivityPalette {
|
||||
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // running blue
|
||||
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // needs-you amber
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
/// The one aggregate Live Activity (UX_IOS §5.3, aggregate per §11.4 — a single Activity
|
||||
/// respects iOS's budget better than one per session): "N running, M waiting" plus the most
|
||||
/// urgent session's title. Shared between the app (which starts/updates it) and the widget
|
||||
/// extension (which renders it).
|
||||
struct NucleicSessionAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
/// Sessions actively working a turn.
|
||||
var runningCount: Int
|
||||
/// Sessions blocked on the user (approval or next prompt) — the loud number.
|
||||
var needsYouCount: Int
|
||||
/// The most urgent session's title: the top waiting one, else the busiest running.
|
||||
var topTitle: String?
|
||||
/// Whether `topTitle` is waiting on the user (amber) vs just running (blue).
|
||||
var topNeedsYou: Bool
|
||||
}
|
||||
|
||||
/// The paired Mac's name (static for the Activity's lifetime).
|
||||
var hostName: String
|
||||
}
|
||||
Reference in New Issue
Block a user