Merge nucleic/golden-lunar-raven into dev

This commit is contained in:
2026-07-10 16:20:17 -07:00
parent dea680cdc9
commit f4da2751cc
10 changed files with 407 additions and 16 deletions
+30 -2
View File
@@ -5502,6 +5502,23 @@ public final class AppStore: ConflictArbiter {
}
}
/// Materialize the attachment parts a remote (phone) turn carried into `id`'s working tree and
/// fold their paths into the message, returning the rewritten turn (text-only, ready for the
/// backend) and a `QueuedMessage` for display should the turn end up queued mid-flight. A turn
/// with no attachment parts comes back unchanged with a `nil` display (the controller then
/// derives its own from the plain text), so the ordinary text-only path is untouched.
private func materializeRemoteAttachments(
_ input: AgentInput, forSession id: SessionID
) async -> (AgentInput, QueuedMessage?) {
let wireAttachments = input.attachments
guard !wireAttachments.isEmpty else { return (input, nil) }
let text = input.plainText ?? ""
let worktree = await controllers[id]?.snapshot.session.worktreePath
let pending = wireAttachments.map { PendingAttachment(filename: $0.filename, data: $0.data) }
let (augmented, refs) = await augmentWithAttachments(text, pending, worktree: worktree)
return (AgentInput(text: augmented), QueuedMessage(text: text, attachments: refs))
}
/// Cancel one of the open chat's queued (not-yet-sent) follow-ups by id, returning the
/// removed message so the composer can refill its text and restore its attachment chips.
/// Returns `nil` if nothing matched.
@@ -8597,10 +8614,15 @@ extension AppStore: SyncHostBridge {
// reveal: false a chat started from another device (phone composer, a peer Mac's
// unified sidebar) must not yank THIS Mac's open view over to it; the initiator sees
// the new row through the creation broadcast instead.
// Attachment bytes the phone shipped along become host-side `PendingAttachment`s, which
// `createSession` materializes into the new working tree and folds into the first prompt.
let attachments = request.attachments.map {
PendingAttachment(filename: $0.filename, data: $0.data)
}
_ = await startChat(
in: project, message: request.message, model: request.model, effort: request.effort,
base: request.baseBranch.map { GitRef($0) }, useWorktree: request.useWorktree,
auto: request.auto, reveal: false)
auto: request.auto, attachments: attachments, reveal: false)
await pushDashboard()
}
@@ -8737,7 +8759,13 @@ extension AppStore: SyncHostBridge {
guard let controller = controllers[id] else {
throw WireError(code: .unknownSession, message: "no such session", sessionID: id)
}
try await controller.sendInput(input)
// A remote (phone) turn may carry attachment bytes it couldn't materialize itself it can't
// reach this Mac's working tree. Drop them into the session's tree here and fold their paths
// into the message (exactly as the Mac composer does in `sendToOpenSession`), so the agent's
// `Read` tool can open them. A turn with no attachments passes through untouched, so the
// ordinary text-only path is unaffected.
let (materialized, display) = await materializeRemoteAttachments(input, forSession: id)
try await controller.sendInput(materialized, display: display)
// Queueing a follow-up mid-turn emits no event, so push the refreshed summary
// (carrying `queuedMessage`) so the phone's composer reflects the queued state.
let snapshot = await controller.snapshot
@@ -2238,6 +2238,13 @@ public actor ClaudeCodeBackend: AgentBackend {
case .context(let label, let body):
blocks.append(
.object(["type": .string("text"), "text": .string("[\(label)]\n\(body)")]))
case .attachment(let attachment):
// Attachments are normally materialized to disk and folded into the text before a
// turn reaches any backend (`AppStore.materializeRemoteAttachments`), so this is a
// defensive fallback: reference the file by name rather than drop it silently.
blocks.append(.object([
"type": .string("text"),
"text": .string("[Attached file: \(attachment.filename)]")]))
}
}
let line = JSONValue.object([
@@ -719,6 +719,10 @@ public actor CodexAppServerBackend: AgentBackend {
items.append(textInput(text))
case .context(let label, let body):
items.append(textInput("[\(label)]\n\(body)"))
case .attachment(let attachment):
// Defensive: attachments are materialized + folded into text before reaching a
// backend (`AppStore.materializeRemoteAttachments`); reference by name if one leaks.
items.append(textInput("[Attached file: \(attachment.filename)]"))
}
}
return .array(items)
@@ -717,6 +717,10 @@ public actor ACPBackend: AgentBackend {
blocks.append(.object(["type": .string("text"), "text": .string(text)]))
case .context(let label, let body):
blocks.append(.object(["type": .string("text"), "text": .string("[\(label)]\n\(body)")]))
case .attachment(let attachment):
// Defensive: attachments are materialized + folded into text before reaching a
// backend (`AppStore.materializeRemoteAttachments`); reference by name if one leaks.
blocks.append(.object(["type": .string("text"), "text": .string("[Attached file: \(attachment.filename)]")]))
}
}
return .array(blocks)
+52 -3
View File
@@ -146,14 +146,39 @@ public enum TodoStatus: String, Sendable, Codable {
public typealias WorktreePath = String
// MARK: - WireAttachment (attachment bytes carried device host by `sendInput` / `startChat`)
/// A file or image the user attached in a composer, carried over the wire from a device (the iOS
/// remote) to the host, which materializes it into the session's working tree via
/// `AttachmentMaterializer`. Just bytes plus a display name the sender has already read the file
/// (or captured/compressed the picked image) before handing it over. The host equivalent is
/// `PendingAttachment` (NucleicCore); this is its on-the-wire twin so attachment bytes can travel
/// from a device that can't reach the host's working tree.
public struct WireAttachment: Sendable, Codable, Equatable {
/// The original (or synthesized) file name, e.g. `screenshot.png`. Sanitized host-side before it
/// hits disk never trusted as a path.
public let filename: String
public let data: Data
public init(filename: String, data: Data) {
self.filename = filename
self.data = data
}
}
// MARK: - AgentInput (user turn payload; carried over the wire by `sendInput`)
public struct AgentInput: Sendable, Codable, Equatable {
public enum Part: Sendable, Codable, Equatable {
case text(String)
case context(label: String, body: String)
/// A file/image attached on a device, carried as bytes for the host to materialize into the
/// working tree. Only ever *originated* by a remote (the phone) the host strips these out
/// and folds a path reference into the text before the backend ever sees the turn, so a
/// backend `Part` consumer never encounters `.attachment`.
case attachment(WireAttachment)
private enum CodingKeys: String, CodingKey { case type, text, label, body }
private enum CodingKeys: String, CodingKey { case type, text, label, body, filename, data }
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
@@ -164,6 +189,10 @@ public struct AgentInput: Sendable, Codable, Equatable {
self = .context(
label: try c.decode(String.self, forKey: .label),
body: try c.decode(String.self, forKey: .body))
case "attachment":
self = .attachment(WireAttachment(
filename: try c.decode(String.self, forKey: .filename),
data: try c.decode(Data.self, forKey: .data)))
case let other:
throw DecodingError.dataCorruptedError(
forKey: .type, in: c, debugDescription: "Unknown AgentInput.Part \(other)")
@@ -180,6 +209,10 @@ public struct AgentInput: Sendable, Codable, Equatable {
try c.encode("context", forKey: .type)
try c.encode(label, forKey: .label)
try c.encode(body, forKey: .body)
case .attachment(let attachment):
try c.encode("attachment", forKey: .type)
try c.encode(attachment.filename, forKey: .filename)
try c.encode(attachment.data, forKey: .data)
}
}
}
@@ -189,12 +222,28 @@ public struct AgentInput: Sendable, Codable, Equatable {
public init(parts: [Part]) { self.parts = parts }
public init(text: String) { self.parts = [.text(text)] }
/// Flattened user-facing text (for the injected `userText` transcript event).
/// Build a user turn from typed text plus attached files, dropping the text part when empty (an
/// attachment-only turn). Used by the iOS remote's composers.
public init(text: String, attachments: [WireAttachment]) {
var parts: [Part] = []
if !text.isEmpty { parts.append(.text(text)) }
parts.append(contentsOf: attachments.map { .attachment($0) })
self.parts = parts
}
/// The attachment parts, in order what the host materializes into the working tree.
public var attachments: [WireAttachment] {
parts.compactMap { if case .attachment(let a) = $0 { return a } else { return nil } }
}
/// Flattened user-facing text (for the injected `userText` transcript event). Attachment parts
/// contribute no text they're referenced by path once the host materializes them.
public var plainText: String? {
let pieces = parts.map { part -> String in
let pieces = parts.compactMap { part -> String? in
switch part {
case .text(let text): return text
case .context(let label, let body): return "[\(label)]\n\(body)"
case .attachment: return nil
}
}
let joined = pieces.joined(separator: "\n")
+37 -1
View File
@@ -195,10 +195,14 @@ public struct StartChatRequest: Sendable, Codable, Equatable {
public let baseBranch: String?
public let useWorktree: Bool
public let auto: Bool?
/// Files/images attached to the opening message, carried as bytes for the host to materialize
/// into the new session's working tree (see `AttachmentMaterializer`). Empty for a plain chat.
public let attachments: [WireAttachment]
public init(
projectID: ProjectID, message: String, model: String? = nil, effort: String? = nil,
baseBranch: String? = nil, useWorktree: Bool = true, auto: Bool? = nil
baseBranch: String? = nil, useWorktree: Bool = true, auto: Bool? = nil,
attachments: [WireAttachment] = []
) {
self.projectID = projectID
self.message = message
@@ -207,6 +211,38 @@ public struct StartChatRequest: Sendable, Codable, Equatable {
self.baseBranch = baseBranch
self.useWorktree = useWorktree
self.auto = auto
self.attachments = attachments
}
private enum CodingKeys: String, CodingKey {
case projectID, message, model, effort, baseBranch, useWorktree, auto, attachments
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
projectID = try c.decode(ProjectID.self, forKey: .projectID)
message = try c.decode(String.self, forKey: .message)
model = try c.decodeIfPresent(String.self, forKey: .model)
effort = try c.decodeIfPresent(String.self, forKey: .effort)
baseBranch = try c.decodeIfPresent(String.self, forKey: .baseBranch)
useWorktree = try c.decode(Bool.self, forKey: .useWorktree)
auto = try c.decodeIfPresent(Bool.self, forKey: .auto)
// Older senders omit `attachments` default to none rather than failing to decode.
attachments = try c.decodeIfPresent([WireAttachment].self, forKey: .attachments) ?? []
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(projectID, forKey: .projectID)
try c.encode(message, forKey: .message)
try c.encodeIfPresent(model, forKey: .model)
try c.encodeIfPresent(effort, forKey: .effort)
try c.encodeIfPresent(baseBranch, forKey: .baseBranch)
try c.encode(useWorktree, forKey: .useWorktree)
try c.encodeIfPresent(auto, forKey: .auto)
// Only emit the key when there's something to send, so a plain chat stays byte-identical
// to the pre-attachment wire form.
if !attachments.isEmpty { try c.encode(attachments, forKey: .attachments) }
}
}
@@ -1575,10 +1575,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 +1589,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?) {
@@ -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 515 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: "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)