Merge branch 'dev' into nucleic/plucky-umber-viper
# Conflicts: # docs/ANTIMATTER_RUNNER.md
This commit is contained in:
@@ -595,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"
|
||||
@@ -1575,10 +1622,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) }
|
||||
@@ -1587,12 +1636,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?) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -398,7 +403,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 +442,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)
|
||||
|
||||
Reference in New Issue
Block a user