diff --git a/NucleicRemote/NucleicRemote/LiveActivityManager.swift b/NucleicRemote/NucleicRemote/LiveActivityManager.swift index e0c137a..4816211 100644 --- a/NucleicRemote/NucleicRemote/LiveActivityManager.swift +++ b/NucleicRemote/NucleicRemote/LiveActivityManager.swift @@ -400,7 +400,7 @@ final class LiveActivityManager { case .codex, .codexExec: .codex case .grok: .grok // 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 } } diff --git a/NucleicRemote/NucleicRemote/Models/HostConnection.swift b/NucleicRemote/NucleicRemote/Models/HostConnection.swift index f5da36c..136f7aa 100644 --- a/NucleicRemote/NucleicRemote/Models/HostConnection.swift +++ b/NucleicRemote/NucleicRemote/Models/HostConnection.swift @@ -113,6 +113,9 @@ final class HostConnection { var macPairRequested: (WireMacPairRequest) -> Void = { _ in } /// The forwarded Mac-pair (by deviceID) was answered/withdrawn — dismiss the prompt. 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 @@ -640,6 +643,10 @@ final class HostConnection { case .macPairResolved(let deviceID, _): // Answered on this Mac or another device (or timed out) — dismiss the prompt. 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: // 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 diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index e62632e..7841000 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -120,6 +120,22 @@ final class RemoteStore: ObservableObject { /// its pairing window. 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 /// 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. @@ -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() { connectivity = .connected(.lan) hostName = "Andrew's Mac" @@ -887,6 +950,18 @@ final class RemoteStore: ObservableObject { self.pendingMacPairRequest = 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 } @@ -1290,14 +1365,30 @@ final class RemoteStore: ObservableObject { // MARK: - Intents (UX_IOS §9) - func open(_ sessionID: SessionID) { - openSessionID = sessionID + /// Ownership token for the open-session state: `open()` bumps and returns it, and the detail + /// 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* // 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 // 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. 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 // the host-specific projected values follow it. openSessionHostID = connection(owningSession: sessionID)?.hostID @@ -1306,8 +1397,7 @@ final class RemoteStore: ObservableObject { openApprovals = [] openDiff = nil diffLoading = false - markOpened(sessionID) - if demoMode { seedDemoTranscript(sessionID); return } + if demoMode { seedDemoTranscript(sessionID); return openGeneration } // 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). 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 // this the transcript would start at the connection point with no events from before it. 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()` @@ -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. 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). func markOpened(_ sessionID: SessionID) { 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 /// 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). - 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 } + 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)) markOpened(target) // everything up to now has been seen guard openSessionID == target else { return } @@ -1504,10 +1652,12 @@ final class RemoteStore: ObservableObject { 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) - guard !trimmed.isEmpty else { return } - send(.sendInput(sessionID, AgentInput(text: trimmed))) + // An attachment-only follow-up (files, no typed text) is a valid turn — the host folds the + // 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) } @@ -1516,12 +1666,15 @@ final class RemoteStore: ObservableObject { func startChat(in projectID: ProjectID, message: String, model: 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) - 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( 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?) { @@ -1674,11 +1827,13 @@ final class RemoteStore: ObservableObject { // `requestPairingCode()`/`cancelPairingCode()`, not through this owner-routing switch. // 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. + // `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, .registerLiveActivity, .endLiveActivity, .registerPushToStartToken, .setForeground, .transferOffer, .transferChunk, .transferCommit, .transferCancel, .fetchTranscript, .requestPairingCode, .cancelPairingCode, .respondMacPair, - .intelligenceResult, .credentialManifest, .credentialProvision: + .intelligenceResult, .credentialManifest, .credentialProvision, .createProject: break } } @@ -1867,7 +2022,9 @@ final class RemoteStore: ObservableObject { // the demo path short-circuits in `requestPairingCode()` with a stand-in code. .requestPairingCode, .cancelPairingCode, .respondMacPair, // 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) } } diff --git a/NucleicRemote/NucleicRemote/PushRegistrar.swift b/NucleicRemote/NucleicRemote/PushRegistrar.swift index 1e9e16a..7381eb8 100644 --- a/NucleicRemote/NucleicRemote/PushRegistrar.swift +++ b/NucleicRemote/NucleicRemote/PushRegistrar.swift @@ -46,7 +46,7 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { // 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 /// 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 @@ -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 /// until the token is registered (or the background window closes), so the process isn't /// 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. func application( _ application: UIApplication, @@ -68,6 +71,14 @@ final class PushAppDelegate: NSObject, UIApplicationDelegate { } 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 { // Fire-and-forget on the main actor (NotificationRouter is @MainActor); the removal is a // quick UNUserNotificationCenter call, so we can report .noData right away. diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 843dbef..aae3fcd 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -25,6 +25,10 @@ struct StartChatComposer: View { @State private var showOptions = false @State private var baseBranch = "" @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 } /// 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)) } + // 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) { + // 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 // boxed form control. TextField("Describe a task…", text: $draft, axis: .vertical) @@ -121,14 +136,18 @@ struct StartChatComposer: View { store.startChat( in: project.id, message: draft, model: model, effort: effort, baseBranch: branch.isEmpty ? nil : branch, - useWorktree: useWorktree, auto: effectiveAuto) + useWorktree: useWorktree, auto: effectiveAuto, + attachments: attachments.wireAttachments) draft = "" + attachments = [] + attachmentsOverflowed = false onStart?() } } label: { 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) } if !store.canControl { diff --git a/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift b/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift new file mode 100644 index 0000000..4a50880 --- /dev/null +++ b/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift @@ -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) + } +} diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 23f85e9..49b6983 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -5,13 +5,23 @@ import NucleicProtocol /// counts; tap through to that project's sessions + a scoped composer. struct ProjectsView: View { @EnvironmentObject var store: RemoteStore + @State private var showAddProject = false var body: some View { NavigationStack { Group { if store.dashboard.projects.isEmpty { - ContentUnavailableView("No projects", systemImage: "folder", - description: Text("Add a project on the Mac to see it here.")) + ContentUnavailableView { + 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 { List(store.dashboard.projects) { project in NavigationLink { @@ -40,10 +50,107 @@ struct ProjectsView: View { } .navigationTitle("Projects") .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 { @EnvironmentObject var store: RemoteStore @Environment(\.horizontalSizeClass) private var sizeClass diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index ea3f2e1..81e6278 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -11,6 +11,11 @@ struct SessionDetailView: View { // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1" @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 renameDraft = "" @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 // past the screen edge and push its Allow/Deny (or Submit) buttons out of reach. @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? { store.sessions.first { $0.sessionID == sessionID } @@ -78,10 +88,11 @@ struct SessionDetailView: View { Button("Discard", role: .destructive) { store.discard(sessionID) } Button("Cancel", role: .cancel) {} } - .onAppear { store.open(sessionID) } - // Pass our own id so an iPad split-view A→B switch (which may mount B before A - // disappears) unsubscribes A without tearing down B's just-opened state. - .onDisappear { store.closeOpen(sessionID) } + .onAppear { openToken = store.open(sessionID) } + // Pass our own id + token so an iPad split-view A→B switch (which may mount B + // before A disappears) unsubscribes A without tearing down B's just-opened state — + // and so a covered duplicate of the *same* session tears down nothing at all. + .onDisappear { store.closeOpen(sessionID, token: openToken) } .background { interruptShortcut } // 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 @@ -398,7 +409,24 @@ struct SessionDetailView: View { } if store.canControl, let summary { controlRow(summary) } 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) { + // 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 // over the glass bar on iOS 26) — a drag on the transcript // dismisses the keyboard instead (`scrollDismissesKeyboard`). @@ -420,14 +448,18 @@ struct SessionDetailView: View { .accessibilityLabel("Stop") } Button { - store.sendInput(draft, to: sessionID) + store.sendInput(draft, attachments: attachments.wireAttachments, + to: sessionID) draft = "" + attachments = [] + attachmentsOverflowed = false scrollToBottomRequest += 1 } label: { - Image(systemName: running ? "clock.arrow.circlepath" : "arrow.up.circle.fill") + Image(systemName: "arrow.up.circle.fill") .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 // Mac — plain Return stays newline in the multiline field. .keyboardShortcut(.return, modifiers: .command) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 9b6ef9d..ad9694a 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -1,15 +1,18 @@ import SwiftUI import NucleicProtocol -/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE. Archived -/// chats are hidden (matching the Mac sidebar); disposition splits "needs you" from "done". +/// Attention-first home (UX_IOS §3): NEEDS YOU pinned at top, then RUNNING, then DONE, then a +/// collapsible ARCHIVED group at the bottom (mirrors the Mac sidebar); disposition splits "needs +/// you" from "done". struct SessionsView: View { @EnvironmentObject var store: RemoteStore - @State private var showArchived = false - @State private var path = NavigationPath() + @State private var archivedExpanded = false + // 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])] { - let pool = (showArchived ? store.sessions : store.liveSessions) + let pool = store.liveSessions .sorted(by: StatusStyle.attentionThenRecency) // One pass, first bucket wins — the old `done = pool.filter { !needs.contains($0) … }` // 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 } } + /// 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 { NavigationStack(path: $path) { Group { - if store.liveSessions.isEmpty && !showArchived { + if store.liveSessions.isEmpty && archivedSessions.isEmpty { ContentUnavailableView( "No sessions", systemImage: "square.stack.3d.up", description: Text(store.connectivity.isLive @@ -54,29 +64,33 @@ struct SessionsView: View { ForEach(grouped, id: \.title) { group in Section(group.title.uppercased()) { ForEach(group.rows, id: \.sessionID) { summary in - 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) + sessionRow(summary) + } + } + } + // Archived chats live in their own collapsible section, pinned below + // "Done" (mirrors the Mac sidebar's "Archived Projects" group). + if !archivedSessions.isEmpty { + Section { + if archivedExpanded { + ForEach(archivedSessions, id: \.sessionID) { summary in + sessionRow(summary) } } + } 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. private func consumeRoute() { guard let route = store.pendingRoute else { return } 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) } } diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift index 2b60f2e..71f7be0 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/TranscriptProjection.swift @@ -42,15 +42,27 @@ struct TranscriptItem: Identifiable, Equatable { } /// 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 - /// during tool-run coalescing so it doesn't split an otherwise-contiguous run of calls into - /// two cards with a gap. Mirrors the desktop projection's `rendersNothing`. - var rendersNothing: Bool { + /// a finalized empty thinking item whenever the reasoning itself is redacted). `ThinkingRow` + /// renders no content for it, so `flatItems` drops it outright rather than carrying a phantom + /// row into the transcript. + /// + /// 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 { return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } 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. @@ -409,10 +421,13 @@ enum TranscriptProjection { /// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`. /// - /// Items that draw nothing (empty `.thinking` blocks — see `rendersNothing`) never break a - /// run: an invisible row landing between two tool calls must not split them into two cards - /// with a gap. Such items are held aside and re-emitted right after the block, so they still - /// render in place while the calls stay one contiguous block. Mirrors the desktop projection. + /// Items that draw nothing (see `rendersNothing`) never break a run: an invisible row landing + /// between two tool calls must not split them into two cards with a gap. Such items are held + /// aside and re-emitted right after the block, so the calls stay one contiguous block. + /// 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] { var out: [TranscriptItem] = [] var run: [TranscriptItem] = [] // consecutive `.tool` items @@ -510,7 +525,12 @@ enum TranscriptProjection { 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