Merge branch 'dev' into canary

This commit is contained in:
2026-07-10 17:23:28 -07:00
10 changed files with 674 additions and 62 deletions
@@ -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