Merge branch 'dev' into canary
This commit is contained in:
@@ -400,7 +400,7 @@ final class LiveActivityManager {
|
|||||||
case .codex, .codexExec: .codex
|
case .codex, .codexExec: .codex
|
||||||
case .grok: .grok
|
case .grok: .grok
|
||||||
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
|
// ACP wrapper agents share the generic "Agent" tag/tint in the Live Activity for now.
|
||||||
case .opencode, .hermes, .cursorAgent: .other
|
case .opencode, .openclaw, .hermes, .cursorAgent, .acp: .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ final class HostConnection {
|
|||||||
var macPairRequested: (WireMacPairRequest) -> Void = { _ in }
|
var macPairRequested: (WireMacPairRequest) -> Void = { _ in }
|
||||||
/// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt.
|
/// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt.
|
||||||
var macPairResolved: (String) -> Void = { _ in }
|
var macPairResolved: (String) -> Void = { _ in }
|
||||||
|
/// The outcome of a `createProject` this phone sent (CLOUD_RUNTIME §4.3), correlated by
|
||||||
|
/// requestID — settle the Add Project sheet (the project row rides the dashboard push).
|
||||||
|
var projectCreated: (WireProjectCreated) -> Void = { _ in }
|
||||||
}
|
}
|
||||||
private let callbacks: Callbacks
|
private let callbacks: Callbacks
|
||||||
|
|
||||||
@@ -640,6 +643,10 @@ final class HostConnection {
|
|||||||
case .macPairResolved(let deviceID, _):
|
case .macPairResolved(let deviceID, _):
|
||||||
// Answered on this Mac or another device (or timed out) — dismiss the prompt.
|
// Answered on this Mac or another device (or timed out) — dismiss the prompt.
|
||||||
callbacks.macPairResolved(deviceID)
|
callbacks.macPairResolved(deviceID)
|
||||||
|
case .projectCreated(let outcome):
|
||||||
|
// The host settled a createProject we sent — hand it up so the Add Project sheet
|
||||||
|
// resolves (success or failure). Correlation by requestID happens in RemoteStore.
|
||||||
|
callbacks.projectCreated(outcome)
|
||||||
case .intelligenceRequest, .credentialNeeded, .credentialUpdate:
|
case .intelligenceRequest, .credentialNeeded, .credentialUpdate:
|
||||||
// Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating
|
// Antimatter runner verbs (docs/ANTIMATTER_RUNNER.md §5–6): a runner host delegating
|
||||||
// intelligence work or asking for / mirroring sealed credentials. Inert here until the
|
// intelligence work or asking for / mirroring sealed credentials. Inert here until the
|
||||||
|
|||||||
@@ -120,6 +120,22 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// its pairing window.
|
/// its pairing window.
|
||||||
private var pairingMintHostID: String?
|
private var pairingMintHostID: String?
|
||||||
|
|
||||||
|
/// "Add a project" (Projects tab): the phone asks a connected host advertising
|
||||||
|
/// `canCreateProjects` to clone a git URL and register it (CLOUD_RUNTIME §4.3) — how a
|
||||||
|
/// fresh Antimatter runner gets its first project. The outcome arrives asynchronously as
|
||||||
|
/// `HostMsg.projectCreated`, correlated by the request id below; the project row itself
|
||||||
|
/// rides the dashboard push.
|
||||||
|
enum AddProjectState: Equatable {
|
||||||
|
case idle
|
||||||
|
case creating // waiting on the host's `projectCreated` reply
|
||||||
|
case created(String) // success — the new project's display name
|
||||||
|
case failed(String) // the host's human-readable failure (clone error, bad URL, …)
|
||||||
|
}
|
||||||
|
@Published private(set) var addProject: AddProjectState = .idle
|
||||||
|
/// The in-flight request's id — replies are matched on it, so a late/stale `projectCreated`
|
||||||
|
/// (the user dismissed the sheet and started another) can't settle the wrong request.
|
||||||
|
private var createProjectRequestID: String?
|
||||||
|
|
||||||
/// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
|
/// A Mac trying to join via a code this phone shared, awaiting the user's allow/deny — the Mac
|
||||||
/// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
|
/// forwarded its pairing confirm here (`HostMsg.macPairRequested`) so it can be approved from
|
||||||
/// the phone. The "add a device" sheet renders an allow/deny dialog for it.
|
/// the phone. The "add a device" sheet renders an allow/deny dialog for it.
|
||||||
@@ -579,6 +595,53 @@ final class RemoteStore: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle the host's silent "reconnect" nudge (SYNC_PROTOCOL §3.2): the Mac noticed our LAN link
|
||||||
|
/// drop and is asking us to re-dial over a non-local transport before the connection silently
|
||||||
|
/// goes dark. Called from `PushAppDelegate` on a `content-available` background push, which may
|
||||||
|
/// relaunch the app with no scene, so this does the setup `onAppear` normally would. Re-running
|
||||||
|
/// `reconnect()` walks each host's LAN→tailnet→relay candidate loop — LAN just failed, so it
|
||||||
|
/// lands on tailnet or relay from wherever the phone now is. Holds a background-task assertion
|
||||||
|
/// until a host is live again or the window closes. Best-effort: iOS may deny background runtime
|
||||||
|
/// (budget, force-quit), in which case the reconnect simply happens on the next foreground.
|
||||||
|
func handleReconnectWake(completion: @escaping () -> Void) {
|
||||||
|
guard !demoMode, isPaired else { completion(); return }
|
||||||
|
// A silent wake means we're background (possibly cold-launched with no scene, where
|
||||||
|
// `isForeground`'s default would read stale-true); record that so the reconnect reports
|
||||||
|
// background and the host keeps owning the Live Activity via push-to-start (UX_IOS §5.3).
|
||||||
|
setForegroundState(false)
|
||||||
|
startNetworkingIfNeeded()
|
||||||
|
#if canImport(UIKit)
|
||||||
|
var bgTask: UIBackgroundTaskIdentifier = .invalid
|
||||||
|
var finished = false
|
||||||
|
let finish = {
|
||||||
|
guard !finished else { return }
|
||||||
|
finished = true
|
||||||
|
if bgTask != .invalid {
|
||||||
|
UIApplication.shared.endBackgroundTask(bgTask)
|
||||||
|
bgTask = .invalid
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
bgTask = UIApplication.shared.beginBackgroundTask(withName: "nucleic.sync.reconnect") {
|
||||||
|
finish() // the OS reclaimed the grant before we finished — report what we have
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
let finish = completion
|
||||||
|
#endif
|
||||||
|
pathMonitor.refresh()
|
||||||
|
reconnect()
|
||||||
|
// Poll until at least one host is live again (the dial loop found a working path), or we run
|
||||||
|
// out of background runtime.
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
for _ in 0..<50 { // ~25s at a 0.5s cadence, inside iOS's ~30s background grant
|
||||||
|
guard let self, !self.connections.values.contains(where: { $0.connectivity.isLive })
|
||||||
|
else { break }
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func seedDemo() {
|
private func seedDemo() {
|
||||||
connectivity = .connected(.lan)
|
connectivity = .connected(.lan)
|
||||||
hostName = "Andrew's Mac"
|
hostName = "Andrew's Mac"
|
||||||
@@ -887,6 +950,18 @@ final class RemoteStore: ObservableObject {
|
|||||||
self.pendingMacPairRequest = nil
|
self.pendingMacPairRequest = nil
|
||||||
self.pendingMacPairHostID = nil
|
self.pendingMacPairHostID = nil
|
||||||
}
|
}
|
||||||
|
cb.projectCreated = { [weak self] outcome in
|
||||||
|
guard let self else { return }
|
||||||
|
// Only the reply to the request in flight settles the sheet — a stale one (the user
|
||||||
|
// dismissed and retried, or another device's create) is dropped.
|
||||||
|
guard let pending = self.createProjectRequestID, outcome.requestID == pending else { return }
|
||||||
|
self.createProjectRequestID = nil
|
||||||
|
if let error = outcome.error {
|
||||||
|
self.addProject = .failed(error)
|
||||||
|
} else {
|
||||||
|
self.addProject = .created(outcome.name ?? "Project")
|
||||||
|
}
|
||||||
|
}
|
||||||
return cb
|
return cb
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1290,14 +1365,30 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Intents (UX_IOS §9)
|
// MARK: - Intents (UX_IOS §9)
|
||||||
|
|
||||||
func open(_ sessionID: SessionID) {
|
/// Ownership token for the open-session state: `open()` bumps and returns it, and the detail
|
||||||
openSessionID = sessionID
|
/// view hands its own back to `closeOpen`. Two detail views for the *same* session can be
|
||||||
|
/// mounted at once — a Live Activity deep link onto an already-open chat pushes a duplicate,
|
||||||
|
/// and a tab switch can remount the open session in another tab's stack. `closeOpen`'s
|
||||||
|
/// session-id guard can't tell those copies apart, so without the token the covered copy's
|
||||||
|
/// teardown would wipe the state the copy on screen is using (blank transcript, dead
|
||||||
|
/// subscription, tab bar restored over the chat bar).
|
||||||
|
private var openGeneration = 0
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func open(_ sessionID: SessionID) -> Int {
|
||||||
|
openGeneration += 1
|
||||||
// Hide the compact shell's floating tab bar the instant a session view opens — from *any*
|
// Hide the compact shell's floating tab bar the instant a session view opens — from *any*
|
||||||
// entry point (Sessions, Home, Projects, a notification tap), since every one funnels
|
// entry point (Sessions, Home, Projects, a notification tap), since every one funnels
|
||||||
// through here. Tied to the session view opening rather than the Sessions list being
|
// through here. Tied to the session view opening rather than the Sessions list being
|
||||||
// navigated away from, so the bar slides out even when the chat wasn't launched from that
|
// navigated away from, so the bar slides out even when the chat wasn't launched from that
|
||||||
// list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
|
// list. `SessionsView` still clears it early on back-swipe for a responsive re-show.
|
||||||
compactDetailPresented = true
|
compactDetailPresented = true
|
||||||
|
markOpened(sessionID)
|
||||||
|
// A second mount of the already-open session (see `openGeneration`): the transcript,
|
||||||
|
// approvals, and wire subscription are live and already this session's — keep them,
|
||||||
|
// don't wipe and reload. The new mount just takes ownership via the fresh generation.
|
||||||
|
guard openSessionID != sessionID else { return openGeneration }
|
||||||
|
openSessionID = sessionID
|
||||||
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
// Record which Mac owns this session (mesh P3) so its connection forwards the transcript and
|
||||||
// the host-specific projected values follow it.
|
// the host-specific projected values follow it.
|
||||||
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
openSessionHostID = connection(owningSession: sessionID)?.hostID
|
||||||
@@ -1306,8 +1397,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
openApprovals = []
|
openApprovals = []
|
||||||
openDiff = nil
|
openDiff = nil
|
||||||
diffLoading = false
|
diffLoading = false
|
||||||
markOpened(sessionID)
|
if demoMode { seedDemoTranscript(sessionID); return openGeneration }
|
||||||
if demoMode { seedDemoTranscript(sessionID); return }
|
|
||||||
// Seed from the on-device cache so the transcript shows instantly — including fully offline,
|
// Seed from the on-device cache so the transcript shows instantly — including fully offline,
|
||||||
// where the subscribe below is a no-op. Live events merge on top by seq (dedup).
|
// where the subscribe below is a no-op. Live events merge on top by seq (dedup).
|
||||||
loadCachedTranscript(sessionID)
|
loadCachedTranscript(sessionID)
|
||||||
@@ -1319,6 +1409,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
// Pull the full history too — the subscribe above only returns a 200-event tail, so without
|
// Pull the full history too — the subscribe above only returns a 200-event tail, so without
|
||||||
// this the transcript would start at the connection point with no events from before it.
|
// this the transcript would start at the connection point with no events from before it.
|
||||||
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
|
connection(owningSession: sessionID)?.fetchFullTranscript(sessionID)
|
||||||
|
return openGeneration
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()`
|
/// Bind the open session to its owning Mac once that Mac is live — the deferred half of `open()`
|
||||||
@@ -1400,6 +1491,49 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
/// A stand-in join code for the offline demo so the QR/copy sheet renders without a Mac.
|
||||||
private static let demoPairingCode = "nucleic://pair?d=demo"
|
private static let demoPairingCode = "nucleic://pair?d=demo"
|
||||||
|
|
||||||
|
// MARK: - Add a project (CLOUD_RUNTIME §4.3)
|
||||||
|
|
||||||
|
/// Hosts that can register a project right now (live + advertising `canCreateProjects`) —
|
||||||
|
/// the Add Project sheet's destination picker. Name-sorted for a stable picker.
|
||||||
|
var projectCreationHosts: [(hostID: String, name: String)] {
|
||||||
|
connections.values
|
||||||
|
.filter { $0.connectivity.isLive && $0.capabilities.canCreateProjects }
|
||||||
|
.map { ($0.hostID, $0.hostName) }
|
||||||
|
.sorted { $0.name < $1.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the Add Project affordance should appear: a capable host is reachable (or demo).
|
||||||
|
var canCreateProject: Bool { demoMode || !projectCreationHosts.isEmpty }
|
||||||
|
|
||||||
|
/// Ask `hostID` (or the first capable host) to clone `gitURL` and register it as a project.
|
||||||
|
/// The outcome arrives asynchronously as `.created`/`.failed` via the host connection; the
|
||||||
|
/// new project row follows on the dashboard push.
|
||||||
|
func createProject(gitURL: String, name: String?, branch: String?, onHost hostID: String?) {
|
||||||
|
if demoMode {
|
||||||
|
addProject = .created(name?.isEmpty == false ? name! : "Project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let conn = hostID.flatMap { connections[$0] }
|
||||||
|
?? connections.values.first { $0.connectivity.isLive && $0.capabilities.canCreateProjects }
|
||||||
|
guard let conn, conn.connectivity.isLive, conn.capabilities.canCreateProjects else {
|
||||||
|
addProject = .failed("No connected host can add projects right now.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestID = UUID().uuidString
|
||||||
|
createProjectRequestID = requestID
|
||||||
|
addProject = .creating
|
||||||
|
conn.send(.createProject(WireCreateProjectRequest(
|
||||||
|
gitURL: gitURL, branch: branch?.isEmpty == false ? branch : nil,
|
||||||
|
name: name?.isEmpty == false ? name : nil, requestID: requestID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Add Project sheet closed — drop any in-flight correlation (a late reply is ignored)
|
||||||
|
/// and reset the state for the next open.
|
||||||
|
func resetAddProject() {
|
||||||
|
createProjectRequestID = nil
|
||||||
|
addProject = .idle
|
||||||
|
}
|
||||||
|
|
||||||
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
/// Record that the user looked at this session now (clears its unseen-completion wash).
|
||||||
func markOpened(_ sessionID: SessionID) {
|
func markOpened(_ sessionID: SessionID) {
|
||||||
lastOpenedAt[sessionID] = Date()
|
lastOpenedAt[sessionID] = Date()
|
||||||
@@ -1466,8 +1600,22 @@ final class RemoteStore: ObservableObject {
|
|||||||
/// unsubscribe the named session but only tear down the shared open-state when it still
|
/// unsubscribe the named session but only tear down the shared open-state when it still
|
||||||
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
|
/// belongs to that session — otherwise we'd wipe B's freshly-loaded transcript. Called with
|
||||||
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
|
/// no argument it closes whatever is currently open (the iPhone push/pop path, unchanged).
|
||||||
func closeOpen(_ id: SessionID? = nil) {
|
///
|
||||||
|
/// `token` is the value the closing view got from its `open()` call. A stale token means
|
||||||
|
/// another detail has since taken ownership — possibly of the *same* session (a Live Activity
|
||||||
|
/// deep link onto the already-open chat mounts a duplicate detail): then even the unsubscribe
|
||||||
|
/// must be skipped, because the wire subscription now belongs to the copy still on screen.
|
||||||
|
func closeOpen(_ id: SessionID? = nil, token: Int? = nil) {
|
||||||
guard let target = id ?? openSessionID else { return }
|
guard let target = id ?? openSessionID else { return }
|
||||||
|
if let token, token != openGeneration {
|
||||||
|
// Stale mount unmounting after a newer `open()`. Same session: everything belongs to
|
||||||
|
// the current owner — full no-op. Different session (the iPad A→B switch): it still
|
||||||
|
// unsubscribes itself, exactly as before.
|
||||||
|
guard target != openSessionID else { return }
|
||||||
|
send(.unsubscribe(target))
|
||||||
|
markOpened(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
send(.unsubscribe(target))
|
send(.unsubscribe(target))
|
||||||
markOpened(target) // everything up to now has been seen
|
markOpened(target) // everything up to now has been seen
|
||||||
guard openSessionID == target else { return }
|
guard openSessionID == target else { return }
|
||||||
@@ -1504,10 +1652,12 @@ final class RemoteStore: ObservableObject {
|
|||||||
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
openApprovals.removeAll { $0.id == approval.id } // optimistic dismiss; host confirms
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendInput(_ text: String, to sessionID: SessionID) {
|
func sendInput(_ text: String, attachments: [WireAttachment] = [], to sessionID: SessionID) {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
// An attachment-only follow-up (files, no typed text) is a valid turn — the host folds the
|
||||||
send(.sendInput(sessionID, AgentInput(text: trimmed)))
|
// file references in as the message body.
|
||||||
|
guard !trimmed.isEmpty || !attachments.isEmpty else { return }
|
||||||
|
send(.sendInput(sessionID, AgentInput(text: trimmed, attachments: attachments)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func refreshSessions() { send(.listSessions); send(.listDashboard) }
|
func refreshSessions() { send(.listSessions); send(.listDashboard) }
|
||||||
@@ -1516,12 +1666,15 @@ final class RemoteStore: ObservableObject {
|
|||||||
|
|
||||||
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
|
func startChat(in projectID: ProjectID, message: String, model: String? = nil,
|
||||||
effort: String? = nil, baseBranch: String? = nil,
|
effort: String? = nil, baseBranch: String? = nil,
|
||||||
useWorktree: Bool = true, auto: Bool? = nil) {
|
useWorktree: Bool = true, auto: Bool? = nil,
|
||||||
|
attachments: [WireAttachment] = []) {
|
||||||
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !text.isEmpty else { return }
|
// An attachment-only opening message is allowed — the host materializes the files into the
|
||||||
|
// new working tree and uses their references as the first prompt.
|
||||||
|
guard !text.isEmpty || !attachments.isEmpty else { return }
|
||||||
send(.startChat(StartChatRequest(
|
send(.startChat(StartChatRequest(
|
||||||
projectID: projectID, message: text, model: model, effort: effort,
|
projectID: projectID, message: text, model: model, effort: effort,
|
||||||
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto)))
|
baseBranch: baseBranch, useWorktree: useWorktree, auto: auto, attachments: attachments)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func captureTodo(_ text: String, projectID: ProjectID?) {
|
func captureTodo(_ text: String, projectID: ProjectID?) {
|
||||||
@@ -1674,11 +1827,13 @@ final class RemoteStore: ObservableObject {
|
|||||||
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
// `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch.
|
||||||
// The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will
|
// The runner verbs (intelligence results, credential mesh — ANTIMATTER_RUNNER §5–6) will
|
||||||
// ride their own executor/vault loops when the phone side lands; nothing routes them here.
|
// ride their own executor/vault loops when the phone side lands; nothing routes them here.
|
||||||
|
// `createProject` (CLOUD_RUNTIME §4.3) will go straight to a user-chosen host when the
|
||||||
|
// phone grows that UI — a brand-new project has no owner to route by.
|
||||||
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
case .hello, .ping, .listPeers, .addressUpdate, .meshRoster,
|
||||||
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
.registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground,
|
||||||
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
.transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript,
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
.intelligenceResult, .credentialManifest, .credentialProvision:
|
.intelligenceResult, .credentialManifest, .credentialProvision, .createProject:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1867,7 +2022,9 @@ final class RemoteStore: ObservableObject {
|
|||||||
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
// the demo path short-circuits in `requestPairingCode()` with a stand-in code.
|
||||||
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
.requestPairingCode, .cancelPairingCode, .respondMacPair,
|
||||||
// Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host.
|
// Antimatter runner verbs (ANTIMATTER_RUNNER §5–6) — demo has no runner host.
|
||||||
.intelligenceResult, .credentialManifest, .credentialProvision:
|
.intelligenceResult, .credentialManifest, .credentialProvision,
|
||||||
|
// Remote project creation (CLOUD_RUNTIME §4.3) — demo has no host to clone on.
|
||||||
|
.createProject:
|
||||||
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
break // passive / already handled by the seeded fixtures (demo has no mesh peers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
// Best-effort: no token → the phone still works on LAN, just no remote wake.
|
// Best-effort: no token → the phone still works on LAN, just no remote wake.
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Silent pushes (content-available). Two kinds route here:
|
/// Silent pushes (content-available). Three kinds route here:
|
||||||
/// - `clear`: recall the "waiting for your approval" tickle once the approval is resolved on
|
/// - `clear`: recall the "waiting for your approval" tickle once the approval is resolved on
|
||||||
/// another device — wake briefly to withdraw the stale notification.
|
/// another device — wake briefly to withdraw the stale notification.
|
||||||
/// - `adopt`: a Mac push-started a Live Activity while the app was closed; come online long
|
/// - `adopt`: a Mac push-started a Live Activity while the app was closed; come online long
|
||||||
@@ -54,6 +54,9 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
/// resume without the user opening the app (UX_IOS §5.3). This one holds the completion handler
|
/// resume without the user opening the app (UX_IOS §5.3). This one holds the completion handler
|
||||||
/// until the token is registered (or the background window closes), so the process isn't
|
/// until the token is registered (or the background window closes), so the process isn't
|
||||||
/// suspended mid-connect.
|
/// suspended mid-connect.
|
||||||
|
/// - `reconnect`: the Mac noticed our LAN link drop and is nudging us to re-dial over a non-local
|
||||||
|
/// transport (tailnet/relay). Come online long enough to re-run the candidate loop so the
|
||||||
|
/// connection follows the phone off-LAN without the user opening the app (SYNC_PROTOCOL §3.2).
|
||||||
/// Other pushes (e.g. the approval wake tickle, which is an alert) don't route here.
|
/// Other pushes (e.g. the approval wake tickle, which is an alert) don't route here.
|
||||||
func application(
|
func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
@@ -68,6 +71,14 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if userInfo["reconnect"] != nil {
|
||||||
|
// The store holds its own background-task assertion while it re-dials; report .newData
|
||||||
|
// once a link is back up (or the background window closed).
|
||||||
|
Task { @MainActor in
|
||||||
|
RemoteStore.shared.handleReconnectWake { completionHandler(.newData) }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if userInfo["clear"] != nil {
|
if userInfo["clear"] != nil {
|
||||||
// Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a
|
// Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a
|
||||||
// quick UNUserNotificationCenter call, so we can report .noData right away.
|
// quick UNUserNotificationCenter call, so we can report .noData right away.
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ struct StartChatComposer: View {
|
|||||||
@State private var showOptions = false
|
@State private var showOptions = false
|
||||||
@State private var baseBranch = ""
|
@State private var baseBranch = ""
|
||||||
@State private var useWorktree = true
|
@State private var useWorktree = true
|
||||||
|
// Files/images attached to the opening message (see `ComposerAttachments`) — shipped as bytes
|
||||||
|
// and materialized into the new session's working tree host-side.
|
||||||
|
@State private var attachments: [StagedAttachment] = []
|
||||||
|
@State private var attachmentsOverflowed = false
|
||||||
|
|
||||||
private var projects: [WireProject] { store.dashboard.projects }
|
private var projects: [WireProject] { store.dashboard.projects }
|
||||||
/// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a
|
/// The picker defaults to "No project" (nil) rather than auto-selecting the first project, so a
|
||||||
@@ -106,7 +110,18 @@ struct StartChatComposer: View {
|
|||||||
}
|
}
|
||||||
.transition(.move(edge: .top).combined(with: .opacity))
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
// Staged attachments ride above the field, matching the in-session composer.
|
||||||
|
if !attachments.isEmpty {
|
||||||
|
StagedAttachmentBar(attachments: $attachments)
|
||||||
|
}
|
||||||
|
if attachmentsOverflowed {
|
||||||
|
Text("Some files were too large to attach.")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
// Attach photos/files to the opening message.
|
||||||
|
AttachMenuButton(attachments: $attachments, overflowed: $attachmentsOverflowed)
|
||||||
// Plain, borderless field — matching the open session's chat bar rather than a
|
// Plain, borderless field — matching the open session's chat bar rather than a
|
||||||
// boxed form control.
|
// boxed form control.
|
||||||
TextField("Describe a task…", text: $draft, axis: .vertical)
|
TextField("Describe a task…", text: $draft, axis: .vertical)
|
||||||
@@ -121,14 +136,18 @@ struct StartChatComposer: View {
|
|||||||
store.startChat(
|
store.startChat(
|
||||||
in: project.id, message: draft, model: model, effort: effort,
|
in: project.id, message: draft, model: model, effort: effort,
|
||||||
baseBranch: branch.isEmpty ? nil : branch,
|
baseBranch: branch.isEmpty ? nil : branch,
|
||||||
useWorktree: useWorktree, auto: effectiveAuto)
|
useWorktree: useWorktree, auto: effectiveAuto,
|
||||||
|
attachments: attachments.wireAttachments)
|
||||||
draft = ""
|
draft = ""
|
||||||
|
attachments = []
|
||||||
|
attachmentsOverflowed = false
|
||||||
onStart?()
|
onStart?()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "arrow.up.circle.fill").font(.title)
|
Image(systemName: "arrow.up.circle.fill").font(.title)
|
||||||
}
|
}
|
||||||
.disabled(selected == nil || draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.canControl)
|
// An attachment-only opening message is allowed (a project must still be chosen).
|
||||||
|
.disabled(selected == nil || (draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.canControl)
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
}
|
}
|
||||||
if !store.canControl {
|
if !store.canControl {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import PhotosUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
|
/// A file or image the user has staged in a composer but not yet sent. Holds the (already
|
||||||
|
/// compressed, for images) bytes plus a display name and, when it's an image, a small preview for
|
||||||
|
/// the chip. Converted to a `WireAttachment` at send time and shipped to the host, which
|
||||||
|
/// materializes it into the session's working tree (see `AttachmentMaterializer`). The phone can't
|
||||||
|
/// reach the host's tree, so it carries the bytes itself.
|
||||||
|
struct StagedAttachment: Identifiable, Equatable {
|
||||||
|
let id = UUID()
|
||||||
|
var filename: String
|
||||||
|
var data: Data
|
||||||
|
/// A downscaled preview for the chip; `nil` for non-image files (they show a doc glyph).
|
||||||
|
var thumbnail: UIImage?
|
||||||
|
|
||||||
|
var wire: WireAttachment { WireAttachment(filename: filename, data: data) }
|
||||||
|
|
||||||
|
static func == (lhs: StagedAttachment, rhs: StagedAttachment) -> Bool { lhs.id == rhs.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == StagedAttachment {
|
||||||
|
/// The wire form to hand to `RemoteStore.sendInput` / `startChat`.
|
||||||
|
var wireAttachments: [WireAttachment] { map(\.wire) }
|
||||||
|
var totalBytes: Int { reduce(0) { $0 + $1.data.count } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Staging helpers: turn picked photos / documents into `StagedAttachment`s, compressing images so
|
||||||
|
/// a phone photo (often 5–15 MB) rides the wire comfortably under the 16 MB frame cap.
|
||||||
|
enum ComposerAttachmentLoader {
|
||||||
|
/// Longest edge an attached image is scaled down to before sending — keeps big camera-roll
|
||||||
|
/// shots small while staying legible when the agent reads them.
|
||||||
|
static let maxImageDimension: CGFloat = 2048
|
||||||
|
/// A soft ceiling on the combined size of one message's attachments, leaving headroom under the
|
||||||
|
/// wire frame limit (`WireFraming.maxFrameSize`, 16 MB) for the rest of the envelope.
|
||||||
|
static let maxTotalBytes = 12 * 1024 * 1024
|
||||||
|
|
||||||
|
/// Load and compress picked photo-library items into staged image attachments. Runs off the main
|
||||||
|
/// actor (decoding/encoding is heavy); returns in pick order, skipping any that fail to load.
|
||||||
|
static func stage(photoItems items: [PhotosPickerItem]) async -> [StagedAttachment] {
|
||||||
|
var staged: [StagedAttachment] = []
|
||||||
|
for (index, item) in items.enumerated() {
|
||||||
|
guard let data = try? await item.loadTransferable(type: Data.self),
|
||||||
|
let attachment = stageImageData(data, index: index) else { continue }
|
||||||
|
staged.append(attachment)
|
||||||
|
}
|
||||||
|
return staged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load picked documents into staged attachments (images are compressed like photos; other file
|
||||||
|
/// types pass through verbatim). Reads each URL under its security scope.
|
||||||
|
static func stage(fileURLs urls: [URL]) -> [StagedAttachment] {
|
||||||
|
var staged: [StagedAttachment] = []
|
||||||
|
for (index, url) in urls.enumerated() {
|
||||||
|
let scoped = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
guard let data = try? Data(contentsOf: url) else { continue }
|
||||||
|
let name = url.lastPathComponent
|
||||||
|
if let image = stageImageData(data, index: index, preferredName: name) {
|
||||||
|
staged.append(image)
|
||||||
|
} else {
|
||||||
|
staged.append(StagedAttachment(filename: name, data: data, thumbnail: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return staged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compress image bytes (downscale to `maxImageDimension`, re-encode as JPEG) into a staged
|
||||||
|
/// attachment with a thumbnail. Returns `nil` if the bytes aren't a decodable image, so callers
|
||||||
|
/// can fall back to shipping the file verbatim.
|
||||||
|
private static func stageImageData(
|
||||||
|
_ data: Data, index: Int, preferredName: String? = nil
|
||||||
|
) -> StagedAttachment? {
|
||||||
|
guard let image = UIImage(data: data) else { return nil }
|
||||||
|
let scaled = downscale(image, maxDimension: maxImageDimension)
|
||||||
|
guard let jpeg = scaled.jpegData(compressionQuality: 0.8) else { return nil }
|
||||||
|
// Keep the picked file's name when we have one (retargeted to .jpg since we re-encoded);
|
||||||
|
// photo-library items carry no filename, so synthesize a stable, unique one.
|
||||||
|
let base = preferredName.map { ($0 as NSString).deletingPathExtension } ?? "image-\(shortToken())"
|
||||||
|
return StagedAttachment(filename: "\(base).jpg", data: jpeg, thumbnail: scaled)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scale `image` down so its longest edge is at most `maxDimension`, preserving aspect ratio.
|
||||||
|
/// Returns the image untouched if it's already small enough.
|
||||||
|
private static func downscale(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||||
|
let longest = max(image.size.width, image.size.height)
|
||||||
|
guard longest > maxDimension, longest > 0 else { return image }
|
||||||
|
let scale = maxDimension / longest
|
||||||
|
let target = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||||
|
let format = UIGraphicsImageRendererFormat.default()
|
||||||
|
format.scale = 1 // target is already in pixels; don't multiply by screen scale
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: target, format: format)
|
||||||
|
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shortToken() -> String { String(UUID().uuidString.prefix(8)).lowercased() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The paperclip button: a menu offering Photos (PhotosPicker) or Files (document picker), each
|
||||||
|
/// appending compressed/verbatim attachments to `attachments`. Shows a spinner while staging.
|
||||||
|
struct AttachMenuButton: View {
|
||||||
|
@Binding var attachments: [StagedAttachment]
|
||||||
|
/// Surfaced to the parent so it can show a note when an add is refused for exceeding the cap.
|
||||||
|
@Binding var overflowed: Bool
|
||||||
|
var tint: Color = Palette.accent
|
||||||
|
|
||||||
|
@State private var photoItems: [PhotosPickerItem] = []
|
||||||
|
@State private var showPhotos = false
|
||||||
|
@State private var showFiles = false
|
||||||
|
@State private var staging = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Menu {
|
||||||
|
Button { showPhotos = true } label: { Label("Photos", systemImage: "photo") }
|
||||||
|
Button { showFiles = true } label: { Label("Files", systemImage: "folder") }
|
||||||
|
} label: {
|
||||||
|
if staging {
|
||||||
|
ProgressView().controlSize(.small).frame(width: 24, height: 24)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "paperclip")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 24, height: 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(staging)
|
||||||
|
.accessibilityLabel("Attach")
|
||||||
|
.photosPicker(
|
||||||
|
isPresented: $showPhotos, selection: $photoItems,
|
||||||
|
maxSelectionCount: 10, matching: .images)
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true
|
||||||
|
) { result in
|
||||||
|
guard case .success(let urls) = result else { return }
|
||||||
|
append(ComposerAttachmentLoader.stage(fileURLs: urls))
|
||||||
|
}
|
||||||
|
.onChange(of: photoItems) { _, items in
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
staging = true
|
||||||
|
Task {
|
||||||
|
let staged = await ComposerAttachmentLoader.stage(photoItems: items)
|
||||||
|
await MainActor.run {
|
||||||
|
append(staged)
|
||||||
|
photoItems = []
|
||||||
|
staging = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append newly staged items, dropping any that would push the message over the total-size cap
|
||||||
|
/// (and flagging `overflowed` so the composer can say so).
|
||||||
|
private func append(_ staged: [StagedAttachment]) {
|
||||||
|
var running = attachments.totalBytes
|
||||||
|
for item in staged {
|
||||||
|
if running + item.data.count > ComposerAttachmentLoader.maxTotalBytes {
|
||||||
|
overflowed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
running += item.data.count
|
||||||
|
attachments.append(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The horizontal strip of staged-attachment chips shown above a composer's text field. Image
|
||||||
|
/// attachments show a thumbnail; other files show a doc glyph. Each chip has a remove button.
|
||||||
|
struct StagedAttachmentBar: View {
|
||||||
|
@Binding var attachments: [StagedAttachment]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(attachments) { attachment in
|
||||||
|
chip(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func chip(_ attachment: StagedAttachment) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let thumbnail = attachment.thumbnail {
|
||||||
|
Image(uiImage: thumbnail)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
.clipShape(.rect(cornerRadius: 5))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "doc")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 28, height: 28)
|
||||||
|
}
|
||||||
|
Text(attachment.filename)
|
||||||
|
.font(.caption2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
.frame(maxWidth: 120)
|
||||||
|
Button {
|
||||||
|
attachments.removeAll { $0.id == attachment.id }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.leading, 4).padding(.trailing, 6).padding(.vertical, 4)
|
||||||
|
.background(.quaternary.opacity(0.5), in: .capsule)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,23 @@ import NucleicProtocol
|
|||||||
/// counts; tap through to that project's sessions + a scoped composer.
|
/// counts; tap through to that project's sessions + a scoped composer.
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@State private var showAddProject = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if store.dashboard.projects.isEmpty {
|
if store.dashboard.projects.isEmpty {
|
||||||
ContentUnavailableView("No projects", systemImage: "folder",
|
ContentUnavailableView {
|
||||||
description: Text("Add a project on the Mac to see it here."))
|
Label("No projects", systemImage: "folder")
|
||||||
|
} description: {
|
||||||
|
Text(store.canCreateProject
|
||||||
|
? "Add a project here, or on the Mac."
|
||||||
|
: "Add a project on the Mac to see it here.")
|
||||||
|
} actions: {
|
||||||
|
if store.canCreateProject {
|
||||||
|
Button("Add Project") { showAddProject = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
List(store.dashboard.projects) { project in
|
List(store.dashboard.projects) { project in
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -40,10 +50,107 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle("Projects")
|
.navigationTitle("Projects")
|
||||||
.refreshable { store.refreshSessions() }
|
.refreshable { store.refreshSessions() }
|
||||||
|
.toolbar {
|
||||||
|
// "Add a project" (CLOUD_RUNTIME §4.3): clone a git URL on a connected host —
|
||||||
|
// how a fresh Antimatter runner gets its first project. Hidden when no live
|
||||||
|
// host advertises `canCreateProjects` (an older Mac would reject the verb).
|
||||||
|
if store.canCreateProject {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Button { showAddProject = true } label: {
|
||||||
|
Label("Add Project", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddProject) {
|
||||||
|
AddProjectSheet()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clone-and-register a project on a connected host: git URL (+ optional name/branch) and,
|
||||||
|
/// with more than one capable host, a destination picker. The request settles asynchronously
|
||||||
|
/// (`RemoteStore.addProject`); the sheet shows progress, the failure message, or dismisses on
|
||||||
|
/// success — the new project row arrives via the dashboard push.
|
||||||
|
private struct AddProjectSheet: View {
|
||||||
|
@EnvironmentObject var store: RemoteStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var gitURL = ""
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var branch = ""
|
||||||
|
@State private var hostID: String?
|
||||||
|
|
||||||
|
private var hosts: [(hostID: String, name: String)] { store.projectCreationHosts }
|
||||||
|
private var creating: Bool { store.addProject == .creating }
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
!gitURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !creating
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Git URL", text: $gitURL, prompt: Text("https://github.com/you/repo"))
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} footer: {
|
||||||
|
Text("The host clones this repository and manages the checkout itself.")
|
||||||
|
}
|
||||||
|
Section("Options") {
|
||||||
|
TextField("Name (optional)", text: $name)
|
||||||
|
TextField("Branch (optional)", text: $branch)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
if hosts.count > 1 {
|
||||||
|
Section("Create on") {
|
||||||
|
Picker("Host", selection: $hostID) {
|
||||||
|
ForEach(hosts, id: \.hostID) { host in
|
||||||
|
Text(host.name).tag(Optional(host.hostID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if case .failed(let message) = store.addProject {
|
||||||
|
Section {
|
||||||
|
Label(message, systemImage: "exclamationmark.triangle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Project")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.interactiveDismissDisabled(creating)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }.disabled(creating)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
if creating {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Button("Create") {
|
||||||
|
store.createProject(
|
||||||
|
gitURL: gitURL.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
name: name, branch: branch, onHost: hostID)
|
||||||
|
}
|
||||||
|
.disabled(!canSubmit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { if hostID == nil { hostID = hosts.first?.hostID } }
|
||||||
|
.onChange(of: store.addProject) { _, state in
|
||||||
|
if case .created = state { dismiss() }
|
||||||
|
}
|
||||||
|
.onDisappear { store.resetAddProject() }
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectDetailView: View {
|
struct ProjectDetailView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@Environment(\.horizontalSizeClass) private var sizeClass
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ struct SessionDetailView: View {
|
|||||||
// matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
|
// matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
|
||||||
@State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
|
@State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
|
||||||
@State private var draft = ""
|
@State private var draft = ""
|
||||||
|
// Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and
|
||||||
|
// materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick
|
||||||
|
// refused for exceeding the per-message size cap.
|
||||||
|
@State private var attachments: [StagedAttachment] = []
|
||||||
|
@State private var attachmentsOverflowed = false
|
||||||
@State private var showRename = false
|
@State private var showRename = false
|
||||||
@State private var renameDraft = ""
|
@State private var renameDraft = ""
|
||||||
@State private var showIntegrate = false
|
@State private var showIntegrate = false
|
||||||
@@ -25,6 +30,11 @@ struct SessionDetailView: View {
|
|||||||
// itself to the screen: a long host command or a long list of options must never grow the card
|
// itself to the screen: a long host command or a long list of options must never grow the card
|
||||||
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
|
// past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
|
||||||
@State private var availableHeight: CGFloat = 0
|
@State private var availableHeight: CGFloat = 0
|
||||||
|
// This mount's claim on the store's open-session state (`RemoteStore.openGeneration`). Two
|
||||||
|
// details for the same session can be mounted at once (a Live Activity deep link onto the
|
||||||
|
// already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
|
||||||
|
// of wiping the transcript the visible copy is showing.
|
||||||
|
@State private var openToken = 0
|
||||||
|
|
||||||
private var summary: WireSessionSummary? {
|
private var summary: WireSessionSummary? {
|
||||||
store.sessions.first { $0.sessionID == sessionID }
|
store.sessions.first { $0.sessionID == sessionID }
|
||||||
@@ -78,10 +88,11 @@ struct SessionDetailView: View {
|
|||||||
Button("Discard", role: .destructive) { store.discard(sessionID) }
|
Button("Discard", role: .destructive) { store.discard(sessionID) }
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear { store.open(sessionID) }
|
.onAppear { openToken = store.open(sessionID) }
|
||||||
// Pass our own id so an iPad split-view A→B switch (which may mount B before A
|
// Pass our own id + token so an iPad split-view A→B switch (which may mount B
|
||||||
// disappears) unsubscribes A without tearing down B's just-opened state.
|
// before A disappears) unsubscribes A without tearing down B's just-opened state —
|
||||||
.onDisappear { store.closeOpen(sessionID) }
|
// and so a covered duplicate of the *same* session tears down nothing at all.
|
||||||
|
.onDisappear { store.closeOpen(sessionID, token: openToken) }
|
||||||
.background { interruptShortcut }
|
.background { interruptShortcut }
|
||||||
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
|
// Publish the parent-determined height to the cards. `initial: true` seeds it on the
|
||||||
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
|
// first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
|
||||||
@@ -398,7 +409,24 @@ struct SessionDetailView: View {
|
|||||||
}
|
}
|
||||||
if store.canControl, let summary { controlRow(summary) }
|
if store.canControl, let summary { controlRow(summary) }
|
||||||
if canCompose {
|
if canCompose {
|
||||||
|
// Staged attachments ride above the field, matching the queued-message
|
||||||
|
// chips and the Mac composer.
|
||||||
|
if !attachments.isEmpty {
|
||||||
|
StagedAttachmentBar(attachments: $attachments)
|
||||||
|
}
|
||||||
|
if attachmentsOverflowed {
|
||||||
|
Text("Some files were too large to attach.")
|
||||||
|
.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
|
// Attach photos/files to the next turn (control scope only — a
|
||||||
|
// view-only device can't send).
|
||||||
|
if store.canControl {
|
||||||
|
AttachMenuButton(
|
||||||
|
attachments: $attachments, overflowed: $attachmentsOverflowed)
|
||||||
|
.disabled(!store.connectivity.isLive)
|
||||||
|
}
|
||||||
// No keyboard-accessory Done button here (it floats awkwardly
|
// No keyboard-accessory Done button here (it floats awkwardly
|
||||||
// over the glass bar on iOS 26) — a drag on the transcript
|
// over the glass bar on iOS 26) — a drag on the transcript
|
||||||
// dismisses the keyboard instead (`scrollDismissesKeyboard`).
|
// dismisses the keyboard instead (`scrollDismissesKeyboard`).
|
||||||
@@ -420,14 +448,18 @@ struct SessionDetailView: View {
|
|||||||
.accessibilityLabel("Stop")
|
.accessibilityLabel("Stop")
|
||||||
}
|
}
|
||||||
Button {
|
Button {
|
||||||
store.sendInput(draft, to: sessionID)
|
store.sendInput(draft, attachments: attachments.wireAttachments,
|
||||||
|
to: sessionID)
|
||||||
draft = ""
|
draft = ""
|
||||||
|
attachments = []
|
||||||
|
attachmentsOverflowed = false
|
||||||
scrollToBottomRequest += 1
|
scrollToBottomRequest += 1
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || !store.connectivity.isLive)
|
// An attachment-only follow-up (files, no typed text) is sendable.
|
||||||
|
.disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty) || !store.connectivity.isLive)
|
||||||
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
|
// Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
|
||||||
// Mac — plain Return stays newline in the multiline field.
|
// Mac — plain Return stays newline in the multiline field.
|
||||||
.keyboardShortcut(.return, modifiers: .command)
|
.keyboardShortcut(.return, modifiers: .command)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import NucleicProtocol
|
import NucleicProtocol
|
||||||
|
|
||||||
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived
|
/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, then a
|
||||||
/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done".
|
/// collapsible ARCHIVED group at the bottom (mirrors the Mac sidebar); disposition splits "needs
|
||||||
|
/// you" from "done".
|
||||||
struct SessionsView: View {
|
struct SessionsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showArchived = false
|
@State private var archivedExpanded = false
|
||||||
@State private var path = NavigationPath()
|
// Typed (not `NavigationPath`) so `consumeRoute` can see what's already pushed — a deep link
|
||||||
|
// to the session on top must land on it, not stack a duplicate detail over it.
|
||||||
|
@State private var path: [SessionID] = []
|
||||||
|
|
||||||
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
private var grouped: [(title: String, rows: [WireSessionSummary])] {
|
||||||
let pool = (showArchived ? store.sessions : store.liveSessions)
|
let pool = store.liveSessions
|
||||||
.sorted(by: StatusStyle.attentionThenRecency)
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
|
// One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }`
|
||||||
// ran O(rows²) full-summary equality scans on every body evaluation.
|
// ran O(rows²) full-summary equality scans on every body evaluation.
|
||||||
@@ -23,10 +26,17 @@ struct SessionsView: View {
|
|||||||
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
return [("Needs you", needs), ("Running", running), ("Done", done)].filter { !$0.rows.isEmpty }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Archived chats, shown in their own collapsible section below "Done". These are excluded
|
||||||
|
/// from `liveSessions` (and therefore from `grouped`), so they can only appear here.
|
||||||
|
private var archivedSessions: [WireSessionSummary] {
|
||||||
|
store.sessions.filter { $0.archived }
|
||||||
|
.sorted(by: StatusStyle.attentionThenRecency)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack(path: $path) {
|
NavigationStack(path: $path) {
|
||||||
Group {
|
Group {
|
||||||
if store.liveSessions.isEmpty && !showArchived {
|
if store.liveSessions.isEmpty && archivedSessions.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
"No sessions", systemImage: "square.stack.3d.up",
|
"No sessions", systemImage: "square.stack.3d.up",
|
||||||
description: Text(store.connectivity.isLive
|
description: Text(store.connectivity.isLive
|
||||||
@@ -54,29 +64,33 @@ struct SessionsView: View {
|
|||||||
ForEach(grouped, id: \.title) { group in
|
ForEach(grouped, id: \.title) { group in
|
||||||
Section(group.title.uppercased()) {
|
Section(group.title.uppercased()) {
|
||||||
ForEach(group.rows, id: \.sessionID) { summary in
|
ForEach(group.rows, id: \.sessionID) { summary in
|
||||||
NavigationLink(value: summary.sessionID) {
|
sessionRow(summary)
|
||||||
SessionRow(summary: summary)
|
}
|
||||||
}
|
}
|
||||||
.listRowBackground(SessionRowWash(summary: summary))
|
}
|
||||||
.swipeActions(edge: .leading) {
|
// Archived chats live in their own collapsible section, pinned below
|
||||||
Button {
|
// "Done" (mirrors the Mac sidebar's "Archived Projects" group).
|
||||||
store.setFavorite(summary.sessionID, !summary.favorite)
|
if !archivedSessions.isEmpty {
|
||||||
} label: {
|
Section {
|
||||||
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
|
if archivedExpanded {
|
||||||
}.tint(.yellow)
|
ForEach(archivedSessions, id: \.sessionID) { summary in
|
||||||
}
|
sessionRow(summary)
|
||||||
.swipeActions(edge: .trailing) {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
store.deleteSession(summary.sessionID)
|
|
||||||
} label: { Label("Delete", systemImage: "trash") }
|
|
||||||
Button {
|
|
||||||
store.setArchived(summary.sessionID, !summary.archived)
|
|
||||||
} label: {
|
|
||||||
Label(summary.archived ? "Unarchive" : "Archive",
|
|
||||||
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
|
||||||
}.tint(.gray)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} header: {
|
||||||
|
Button {
|
||||||
|
withAnimation(.easeInOut(duration: 0.22)) { archivedExpanded.toggle() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Archived (\(archivedSessions.count))")
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption2.weight(.semibold))
|
||||||
|
.rotationEffect(.degrees(archivedExpanded ? 90 : 0))
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -113,10 +127,42 @@ struct SessionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One tappable session row with its favorite / delete / archive swipe actions. Shared by
|
||||||
|
/// the live sections (Needs you / Running / Done) and the collapsible Archived section.
|
||||||
|
@ViewBuilder
|
||||||
|
private func sessionRow(_ summary: WireSessionSummary) -> some View {
|
||||||
|
NavigationLink(value: summary.sessionID) {
|
||||||
|
SessionRow(summary: summary)
|
||||||
|
}
|
||||||
|
.listRowBackground(SessionRowWash(summary: summary))
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
store.setFavorite(summary.sessionID, !summary.favorite)
|
||||||
|
} label: {
|
||||||
|
Label("Favorite", systemImage: summary.favorite ? "star.slash" : "star.fill")
|
||||||
|
}.tint(.yellow)
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .trailing) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
store.deleteSession(summary.sessionID)
|
||||||
|
} label: { Label("Delete", systemImage: "trash") }
|
||||||
|
Button {
|
||||||
|
store.setArchived(summary.sessionID, !summary.archived)
|
||||||
|
} label: {
|
||||||
|
Label(summary.archived ? "Unarchive" : "Archive",
|
||||||
|
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
||||||
|
}.tint(.gray)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Push the session a notification tap asked for, then clear the request.
|
/// Push the session a notification tap asked for, then clear the request.
|
||||||
private func consumeRoute() {
|
private func consumeRoute() {
|
||||||
guard let route = store.pendingRoute else { return }
|
guard let route = store.pendingRoute else { return }
|
||||||
store.pendingRoute = nil
|
store.pendingRoute = nil
|
||||||
|
// Already showing this session (the app was backgrounded with the chat open and a Live
|
||||||
|
// Activity tap deep-linked back to it): pushing again would mount a duplicate detail whose
|
||||||
|
// eventual teardown races the visible one's state. The open chat is the destination.
|
||||||
|
guard path.last != route else { return }
|
||||||
path.append(route)
|
path.append(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,15 +42,27 @@ struct TranscriptItem: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
|
/// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
|
||||||
/// a finalized empty thinking item whenever the reasoning itself is redacted). Held aside
|
/// a finalized empty thinking item whenever the reasoning itself is redacted). `ThinkingRow`
|
||||||
/// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into
|
/// renders no content for it, so `flatItems` drops it outright rather than carrying a phantom
|
||||||
/// two cards with a gap. Mirrors the desktop projection's `rendersNothing`.
|
/// row into the transcript.
|
||||||
var rendersNothing: Bool {
|
///
|
||||||
|
/// It has to be dropped, not merely rendered as an empty view: the transcript is a
|
||||||
|
/// `VStack(spacing: 14)` over `TranscriptRow`s, and a row whose body resolves to nothing is
|
||||||
|
/// still a *child* of that stack — SwiftUI can't elide an opaque `View` struct the way it
|
||||||
|
/// elides a literal `EmptyView`, so it still claims its 14pt inter-row gap. A long tool run
|
||||||
|
/// interleaved with redacted thinking (one empty block per call) held them all aside and
|
||||||
|
/// re-emitted them after the block, stacking one gap each into a large band of blank space
|
||||||
|
/// between the card and the next row. Mirrors the desktop projection's `isEmptyThinking`.
|
||||||
|
var isEmptyThinking: Bool {
|
||||||
if case .thinking(let text) = kind {
|
if case .thinking(let text) = kind {
|
||||||
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Items that draw no visible row. They must not break a run of tool calls when coalescing:
|
||||||
|
/// an invisible row sitting between two calls would otherwise split them into two cards.
|
||||||
|
var rendersNothing: Bool { isEmptyThinking }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
|
||||||
@@ -409,10 +421,13 @@ enum TranscriptProjection {
|
|||||||
|
|
||||||
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
|
/// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
|
||||||
///
|
///
|
||||||
/// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a
|
/// Items that draw nothing (see `rendersNothing`) never break a run: an invisible row landing
|
||||||
/// run: an invisible row landing between two tool calls must not split them into two cards
|
/// between two tool calls must not split them into two cards with a gap. Such items are held
|
||||||
/// with a gap. Such items are held aside and re-emitted right after the block, so they still
|
/// aside and re-emitted right after the block, so the calls stay one contiguous block.
|
||||||
/// render in place while the calls stay one contiguous block. Mirrors the desktop projection.
|
/// Mirrors the desktop projection.
|
||||||
|
///
|
||||||
|
/// `flatItems` already drops the only kind the phone has (empty redacted thinking), so the
|
||||||
|
/// hold path is a guard for a caller passing unfiltered items — not a live case.
|
||||||
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
|
static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
|
||||||
var out: [TranscriptItem] = []
|
var out: [TranscriptItem] = []
|
||||||
var run: [TranscriptItem] = [] // consecutive `.tool` items
|
var run: [TranscriptItem] = [] // consecutive `.tool` items
|
||||||
@@ -510,7 +525,12 @@ enum TranscriptProjection {
|
|||||||
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
|
kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items
|
// A redacted (empty) thinking block draws no row but would still cost one inter-row gap
|
||||||
|
// in the transcript's spaced VStack — see `isEmptyThinking`. Drop it here, after the
|
||||||
|
// coalescing upserts, so a *streaming* thinking item is judged on its accumulated text
|
||||||
|
// rather than on the empty first delta. A thinking message never straddles a segment
|
||||||
|
// seam (`buildSegment`'s precondition), so this stays fold-equivalent per segment.
|
||||||
|
return items.filter { !$0.isEmptyThinking }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Coalescing helpers
|
// MARK: - Coalescing helpers
|
||||||
|
|||||||
Reference in New Issue
Block a user