From 6268105fa383184e0fd773c6c7724ec629f848c8 Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 7 Sep 2026 09:45:00 -0700 Subject: [PATCH 1/2] fix(session): show a message sent mid-turn in the transcript A message sent while the agent is still replying never appeared in the transcript. The client does not decode feedback_submitted at all, so the event falls into the AcpEvent .unknown catch-all and the handler drops it. The message only surfaces once the turn ends and the transcript reconciles with the server, which projects a mid-turn user_message_chunk as its own user turn. And because no user turn lands between the two halves of the reply, the answer to that message keeps streaming into the same assistant bubble, so two separate replies read as one run-on paragraph. Decode the event, and when the note is already delivered on submission (the native steering channel, where the agent has the text as a user message rather than as a check_user_feedback tool result), splice it into the live turn at the point it arrived. That closes the run the agent was writing and starts the reply to it in a fresh one, which is the same split the server's own projection makes, so the live view and a reload agree. It is idempotent by note id, since the submit is broadcast to every attached client including the sender, and it is ignored outside a running turn, where there is nothing to split and appending would graft the message onto a finished reply. The waiting shimmer moves to the tail of the turn, so interrupting the agent no longer leaves the transcript looking stalled. The turn's transcript is replaced wholesale by the server's copy when it settles, so nothing doubles on reload or on re-entering the session. The unreconciled fallback splits its snapshot the same way rather than folding a whole interrupted reply into one assistant turn. iOS itself still cannot send mid-turn: send() refuses while a turn is in flight and the compose button is Stop. What this fixes today is the cross-client case, a message sent from desktop or web while the phone is watching the same turn. --- .../Features/SessionDetail/LiveTurn.swift | 101 +++++++++++++++--- .../SessionDetail/Rendering/ToolCallVM.swift | 11 ++ .../SessionDetailViewModel.swift | 33 ++++-- .../SessionDetail/Timeline/TimelineNode.swift | 15 ++- CodegiOS/Models/AcpEvent.swift | 42 +++++++- 5 files changed, 176 insertions(+), 26 deletions(-) diff --git a/CodegiOS/Features/SessionDetail/LiveTurn.swift b/CodegiOS/Features/SessionDetail/LiveTurn.swift index fd446bc..6bf8fdc 100644 --- a/CodegiOS/Features/SessionDetail/LiveTurn.swift +++ b/CodegiOS/Features/SessionDetail/LiveTurn.swift @@ -64,20 +64,38 @@ final class LiveToolCall: Identifiable { } } +/// A message the user sent WHILE the turn was running, delivered to the agent +/// over the live-steering channel. Not agent output: it marks the point in the +/// stream where the user interrupted, so the transcript can show it as a user +/// message there and start the reply to it as a fresh run. +struct LiveUserMessage { + /// The feedback note's id. Also the dedup key — the server broadcasts a + /// submission to every attached client, including the one that sent it. + let id: String + let text: String + /// When the note was created (its `created_at`), so the message is stamped + /// with the moment it was sent rather than the start of the reply it + /// interrupted. + let sentAt: Date +} + /// One ordered piece of an in-flight assistant turn. The live turn is a sequence -/// of these so text, reasoning, and tool calls render in arrival order, exactly -/// as a finalized transcript would. The associated reference types expose a -/// `nonisolated let id`, so the enum's `id` needs no actor hop. +/// of these so text, reasoning, tool calls, and any message the user sent +/// mid-turn render in arrival order, exactly as a finalized transcript would. +/// The associated reference types expose a `nonisolated let id`, so the enum's +/// `id` needs no actor hop. enum LiveSegment: Identifiable { case text(LiveTextRun) case thinking(LiveTextRun) case tool(LiveToolCall) + case userMessage(LiveUserMessage) var id: String { switch self { case .text(let run): return "t-\(run.id)" case .thinking(let run): return "k-\(run.id)" case .tool(let call): return "x-\(call.id)" + case .userMessage(let message): return "u-\(message.id)" } } } @@ -167,6 +185,9 @@ final class LiveTurn: Identifiable { private(set) var segments: [LiveSegment] = [] /// Tool-call lookup so `tool_call_update` finds its target in O(1). private var toolIndex: [String: LiveToolCall] = [:] + /// Feedback-note ids already spliced in as mid-turn user messages, so the + /// submit broadcast can be applied idempotently. + private var userMessageIDs: Set = [] /// The agent's live plan/TODO list (`plan_update`). Replaced wholesale per /// event (each carries the full list); rendered as a checklist above the turn. var livePlan: [PlanEntry] = [] @@ -184,6 +205,15 @@ final class LiveTurn: Identifiable { /// True before any content has streamed — used to show a "waiting" shimmer. var isEmpty: Bool { segments.isEmpty && errorMessage == nil && livePlan.isEmpty } + /// True while there is nothing from the agent to show yet: before the turn's + /// first content, and again right after a mid-turn user message until the + /// reply to it starts. Drives the "waiting" shimmer at the tail of the turn, + /// so interrupting the agent doesn't leave the transcript looking stalled. + var isAwaitingOutput: Bool { + if case .userMessage? = segments.last { return true } + return isEmpty + } + /// Replace the live plan from a `plan_update` event (or snapshot `plan` block). func updatePlan(_ entries: [PlanEntry]) { livePlan = entries @@ -209,6 +239,20 @@ final class LiveTurn: Identifiable { } } + /// Splice a message the user sent mid-turn into the stream at the point the + /// agent received it. It ends the run the agent was writing, so the reply to + /// it starts a fresh one instead of continuing the same paragraph — the same + /// split the persisted transcript makes for a mid-turn `user_message_chunk`, + /// so the live view and a reload agree. + /// + /// Idempotent by note id: the submit is broadcast to every attached client, + /// and one of them may be the sender. + func appendUserMessage(id: String, text: String, sentAt: Date) { + guard !text.isEmpty, !userMessageIDs.contains(id) else { return } + userMessageIDs.insert(id) + segments.append(.userMessage(LiveUserMessage(id: id, text: text, sentAt: sentAt))) + } + /// Flush every text/reasoning run's pending coalesce immediately. Called when /// the turn ends so the finalized render sees complete text (no trailing /// reflow) and a snapshot taken right after is whole. @@ -216,7 +260,7 @@ final class LiveTurn: Identifiable { for segment in segments { switch segment { case .text(let run), .thinking(let run): run.flushNow() - case .tool: break + case .tool, .userMessage: break } } } @@ -287,15 +331,37 @@ final class LiveTurn: Identifiable { return nil } - /// Snapshot this (finalized) turn as an immutable assistant `MessageTurn`, so a - /// reply that finished streaming but isn't yet in the server transcript can be - /// folded into the persisted list — surviving a subsequent send — until a - /// reconcile replaces it with the authoritative copy. Segment→block mapping - /// mirrors `MessageRender.adaptLive` so the folded copy renders the same; a - /// tool's diff `content` rides in the result preview (its only persisted slot) - /// when there's no separate output text. - func snapshotAsMessageTurn() -> MessageTurn { + /// Snapshot this (finalized) turn as immutable `MessageTurn`s, so a reply that + /// finished streaming but isn't yet in the server transcript can be folded into + /// the persisted list — surviving a subsequent send — until a reconcile replaces + /// it with the authoritative copy. Segment→block mapping mirrors + /// `MessageRender.adaptLive` so the folded copy renders the same; a tool's diff + /// `content` rides in the result preview (its only persisted slot) when there's + /// no separate output text. + /// + /// Usually one assistant turn. A message the user sent mid-turn splits it, the + /// way the server's own projection does, so the folded copy keeps the message + /// in place and the two halves of the reply don't run together. Turns with no + /// renderable blocks are omitted: a turn can be non-empty *solely* because of an + /// inline error / "Cancelled." message, which `ContentBlock` has no case for, + /// and folding in that zero-block turn would render as "No content". + func snapshotAsMessageTurns() -> [MessageTurn] { + var out: [MessageTurn] = [] var blocks: [ContentBlock] = [] + + func flushAssistant() { + guard !blocks.isEmpty else { return } + // The first turn keeps the live turn's own id so a `List` row that was + // rendering the stream keeps its identity across the handoff. + out.append(MessageTurn( + id: out.isEmpty ? self.id : "\(self.id)#\(out.count)", + role: .assistant, + blocks: blocks, + timestamp: Date() + )) + blocks = [] + } + for segment in segments { switch segment { case .text(let run): @@ -310,8 +376,17 @@ final class LiveTurn: Identifiable { blocks.append(.toolUse(id: call.id, name: call.title, inputPreview: call.rawInput, meta: call.meta)) let output = call.rawOutput.isEmpty ? call.content : call.rawOutput blocks.append(.toolResult(id: call.id, outputPreview: output, isError: call.isError)) + case .userMessage(let message): + flushAssistant() + out.append(MessageTurn( + id: "u-\(message.id)", + role: .user, + blocks: [.text(message.text)], + timestamp: message.sentAt + )) } } - return MessageTurn(id: id, role: .assistant, blocks: blocks, timestamp: Date()) + flushAssistant() + return out } } diff --git a/CodegiOS/Features/SessionDetail/Rendering/ToolCallVM.swift b/CodegiOS/Features/SessionDetail/Rendering/ToolCallVM.swift index 3ac86f8..820a477 100644 --- a/CodegiOS/Features/SessionDetail/Rendering/ToolCallVM.swift +++ b/CodegiOS/Features/SessionDetail/Rendering/ToolCallVM.swift @@ -93,6 +93,10 @@ enum RenderPart { case reasoning(text: String) // finalized reasoning case liveText(LiveTextRun, streaming: Bool) // streaming prose (plain, @Bindable) case liveReasoning(LiveTextRun, streaming: Bool) + /// A message the user sent while the reply was streaming, carried as the user + /// turn it renders as. It breaks the grouping runs below, so the answer to it + /// never folds into what the agent was saying before it arrived. + case userMessage(MessageTurn) case tool(ToolCallVM) case toolGroup(items: [ToolCallVM], streaming: Bool) /// A run of consecutive `get_delegation_status` polls, merged into one card. @@ -185,6 +189,13 @@ enum MessageRender { parts.append(.liveText(run, streaming: turn.isStreaming && isLast)) case .thinking(let run): parts.append(.liveReasoning(run, streaming: turn.isStreaming && isLast)) + case .userMessage(let message): + parts.append(.userMessage(MessageTurn( + id: "u-\(message.id)", + role: .user, + blocks: [.text(message.text)], + timestamp: message.sentAt + ))) case .tool(let call): let state: ToolCallState = call.isFinished ? (call.isError ? .error : .done) : .running // Same boundary-marker treatment as the persisted path: codex emits diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 1a23eb3..70d4dfd 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -1275,6 +1275,22 @@ final class SessionDetailViewModel { // The server echoes our own prompt; we already showed it optimistically. break + case .feedbackSubmitted(let note): + // A note that is already `delivered` on submission was pushed INTO the + // running turn over the native steering channel, so the agent has the + // text as a user message — show it as one, where it interrupted. A + // `pending` note is the cooperative `check_user_feedback` pull channel: + // the agent reads it as a tool result, never as a message, and it gets + // no user turn on reload either. + // + // `isTurnActive` is the "there is a turn to splice into" guard. The + // submission is recorded ungated, so a note can land just after the + // turn settled, and appending there would graft it onto a finished + // reply. The agent recorded it either way, so a reload still shows it. + guard isTurnActive, note.status == "delivered" else { break } + live.appendUserMessage(id: note.id, text: note.text, sentAt: note.createdAt ?? Date()) + requestScrollToBottom() + case .turnComplete(let stopReason): finalize(live: live, stopReason: stopReason) @@ -1675,16 +1691,15 @@ final class SessionDetailViewModel { guard liveTurn === live else { return } turns.append(contentsOf: pendingUserTurns) pendingUserTurns.removeAll() - // Only fold in an assistant turn that actually has renderable content. A + // Usually one assistant turn; a message the user sent mid-turn splits it + // into the same user/assistant sequence the server's projection produces. + // Turns with no renderable content are dropped by the snapshot itself: a // finalized turn can be non-empty *solely* because of an inline error / - // "Cancelled." message (which `snapshotAsMessageTurn` can't represent as a - // persisted block, since ContentBlock has no error case) — appending its - // zero-block snapshot would render as "No content". Such a transient error - // placeholder is simply dropped on the next send; the user turn is kept. - let snapshot = live.snapshotAsMessageTurn() - if !snapshot.blocks.isEmpty { - turns.append(snapshot) - } + // "Cancelled." message (which it can't represent as a persisted block, + // since ContentBlock has no error case) and appending that zero-block turn + // would render as "No content". Such a transient error placeholder is + // simply dropped on the next send; the user turn is kept. + turns.append(contentsOf: live.snapshotAsMessageTurns()) liveTurn = nil requestScrollToBottom() } diff --git a/CodegiOS/Features/SessionDetail/Timeline/TimelineNode.swift b/CodegiOS/Features/SessionDetail/Timeline/TimelineNode.swift index 859ce07..436d773 100644 --- a/CodegiOS/Features/SessionDetail/Timeline/TimelineNode.swift +++ b/CodegiOS/Features/SessionDetail/Timeline/TimelineNode.swift @@ -187,12 +187,15 @@ enum TranscriptTimeline { if !live.livePlan.isEmpty { out.append(TimelineNode(id: "\(live.id)#plan", content: .plan(live.livePlan, streaming: live.isStreaming), agent: agent)) } - if live.isEmpty && live.isStreaming { - out.append(TimelineNode(id: "\(live.id)#thinking", content: .thinking, agent: agent)) - } out.append(contentsOf: MessageRender.adaptLive(live).enumerated().map { idx, part in node(for: part, ownerID: live.id, index: idx, agent: agent) }) + // The "waiting" shimmer sits at the TAIL, after whatever has arrived so + // far: nothing has, or the user interrupted and the agent hasn't answered + // yet. (With no parts it lands exactly where it always did.) + if live.isStreaming && live.isAwaitingOutput { + out.append(TimelineNode(id: "\(live.id)#thinking", content: .thinking, agent: agent)) + } if let error = live.errorMessage { out.append(TimelineNode(id: "\(live.id)#error", content: .error(error), agent: agent)) } @@ -218,6 +221,12 @@ enum TranscriptTimeline { return TimelineNode(id: "t-\(run.id)", content: .liveText(run, streaming: streaming), agent: agent) case .liveReasoning(let run, let streaming): return TimelineNode(id: "k-\(run.id)", content: .liveReasoning(run, streaming: streaming), agent: agent) + case .userMessage(let turn): + // Keyed by the feedback note (`turn.id` is "u-"), not the + // part index, so the row keeps its identity as the reply grows past + // it. `startsGroup` matches a persisted user turn: interrupting the + // agent begins a new exchange. + return TimelineNode(id: turn.id, content: .user(turn), agent: agent, startsGroup: true) case .tool(let vm): return TimelineNode(id: "tool-\(vm.id)", content: .tool(vm), agent: agent) case .toolGroup(let items, let streaming): diff --git a/CodegiOS/Models/AcpEvent.swift b/CodegiOS/Models/AcpEvent.swift index da36bb1..b6b5b65 100644 --- a/CodegiOS/Models/AcpEvent.swift +++ b/CodegiOS/Models/AcpEvent.swift @@ -40,6 +40,34 @@ enum UserMessageBlock: Hashable, Sendable, Decodable { } } +/// A live-feedback note (Rust `FeedbackItem`) — a short message the user sent +/// while the agent was mid-turn. +/// +/// `status` decides what it *is*. A note that is already `delivered` when it is +/// submitted was pushed into the running turn over the native steering channel, +/// so the agent has the text as a user message. A `pending` one is waiting for +/// the agent to pull it with `check_user_feedback`, which returns it as a tool +/// result and never as a message. Kept as the raw string so an unknown future +/// status decodes instead of throwing. +struct FeedbackNote: Hashable, Sendable, Decodable { + let id: String + let text: String + let status: String + /// When the note was created, stamped on the machine the agent runs on. + /// Optional: an unparseable instant must not cost us the message. + let createdAt: Date? + + private enum CodingKeys: String, CodingKey { case id, text, status, createdAt } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + id = try c.decodeIfPresent(String.self, forKey: .id) ?? "" + text = try c.decodeIfPresent(String.self, forKey: .text) ?? "" + status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" + createdAt = (try? c.decodeIfPresent(Date.self, forKey: .createdAt)) ?? nil + } +} + /// A backend→client ACP event (Rust `AcpEvent`, internally tagged `type`). /// Decode-only. Unknown event types decode to `.unknown` so a new server event /// never breaks the stream. @@ -56,6 +84,10 @@ enum AcpEvent: Hashable, Sendable, Decodable { case usageUpdate(used: UInt64, size: UInt64) case userMessage(messageId: String, blocks: [UserMessageBlock]) case userPromptSent(textPreview: String) + /// The user submitted a live-feedback note while the agent is mid-turn. + /// Broadcast to every client watching the conversation, including the one + /// that sent it. + case feedbackSubmitted(note: FeedbackNote) case error(message: String, code: String?) /// Agent asks the user to approve a tool call before it runs. Also carries /// ExitPlanMode — the proposed plan rides inside `toolCall`. Resolve via @@ -84,7 +116,7 @@ enum AcpEvent: Hashable, Sendable, Decodable { case type, text, title, kind, status, content, meta case toolCallId, rawInput, rawOutput, rawOutputAppend case stopReason, sessionId, conversationId, folderId - case used, size, messageId, blocks, message, code, textPreview + case used, size, messageId, blocks, message, code, textPreview, item case requestId, toolCall, options, questionId, questions, entries case approvalId, planMarkdown } @@ -152,6 +184,14 @@ enum AcpEvent: Hashable, Sendable, Decodable { ) case "user_prompt_sent": self = .userPromptSent(textPreview: try c.decodeIfPresent(String.self, forKey: .textPreview) ?? "") + case "feedback_submitted": + // No sensible stand-in for a missing note, so an event without one + // degrades to `.unknown` rather than inventing an empty message. + if let note = try c.decodeIfPresent(FeedbackNote.self, forKey: .item) { + self = .feedbackSubmitted(note: note) + } else { + self = .unknown(type: type) + } case "error": self = .error( message: try c.decodeIfPresent(String.self, forKey: .message) ?? "Unknown error", From b0804484dcd04b08656e2e071116ec211ced438e Mon Sep 17 00:00:00 2001 From: Adam Dalloul <47503782+Adam-Dalloul@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:29:26 -0700 Subject: [PATCH 2/2] feat(session): send a message while the agent is still replying The composer refuses to send during a turn: send() guards on !isInFlight and the action button is Stop. So the phone can only watch a running turn, while desktop and web can talk into it. This adds the send half; the receive half (rendering a mid-turn message in the transcript) is the change this stacks on. Delivery is the server's decision, not ours. A connection advertises native_steering_available and feedback_tool_available in its attach snapshot, and submit_session_feedback refuses a session that has neither. The snapshot already carries both flags on the wire, so decode them and let them decide whether the affordance exists at all: no channel, no button, and the bar keeps its Stop-only form. The flags only ever upgrade, since a snapshot read while the agent was still coming up reports false, and they reset with the connection they describe. The one downgrade the server signals is a note that comes back pending on a session we believed was native, which proves it rerouted to the pull tool; that latches, so a stale snapshot cannot restore the promise. The two channels do different things and the composer says which. A native push lands in the reply being written and comes back delivered, so it appears in the transcript where it interrupted. A pulled note is only read when the agent next checks, gets no user turn there or on reload, and would otherwise vanish without a trace, so it reports itself in the notice banner. The line above the field carries the same distinction before the tap rather than after it. The whole affordance is also gated on the server's own Live feedback toggle, which is what that setting describes. Images take neither channel. The pull tool delivers plain text, and while the native wire can carry a full draft, a server without that support ignores the field rather than rejecting it, which would drop the image with no error anywhere. A draft holding one is therefore kept whole and sent as an ordinary prompt when the turn ends. The turn-end race takes the same route: the server answers no active turn, nothing was recorded, and the draft goes as a normal message the moment the stream catches up. There is no message queue here, so the composer is the queue, and the draft stays visible and editable either way. Every other failure keeps the draft and says what went wrong. --- .../Features/SessionDetail/ComposeBar.swift | 178 +++++++++++++--- .../SessionDetail/SessionDetailView.swift | 3 + .../SessionDetailViewModel.swift | 196 +++++++++++++++++- CodegiOS/Models/AcpEvent.swift | 11 + CodegiOS/Networking/APIError.swift | 13 ++ CodegiOS/Networking/CodegClient.swift | 18 ++ CodegiOS/Networking/WireRequests.swift | 13 ++ 7 files changed, 406 insertions(+), 26 deletions(-) diff --git a/CodegiOS/Features/SessionDetail/ComposeBar.swift b/CodegiOS/Features/SessionDetail/ComposeBar.swift index 1f1faa0..545f4e8 100644 --- a/CodegiOS/Features/SessionDetail/ComposeBar.swift +++ b/CodegiOS/Features/SessionDetail/ComposeBar.swift @@ -2,8 +2,11 @@ import SwiftUI import PhotosUI /// The pinned bottom compose bar. A leading "+" sits to the left of a growing -/// multiline field; a send button (which becomes Stop while a turn streams) sits -/// on the right. Attached-image thumbnails appear above the field. The "agent is +/// multiline field; a send button sits on the right, and becomes Stop while a +/// turn streams. Sessions whose agent can take a message mid-turn get a second +/// send beside Stop, with a line above the field saying which of the two things +/// it does — insert into the running turn, or leave a note for the agent's next +/// check. Attached-image thumbnails appear above the field. The "agent is /// working" state is shown as a node at the tail of the transcript timeline (a /// thinking tick, a running tool, a streaming reply) — not as a status line here. /// @@ -21,6 +24,14 @@ struct ComposeBar: View { let onNotice: (String) -> Void let onSend: () -> Void let onStop: () -> Void + /// How a message typed while the reply streams would reach the agent, or nil + /// when this session has no channel for one — the bar then keeps its historic + /// Stop-only form. Never inferred here: the view model reads it off the + /// connection's attach snapshot. + let midTurnChannel: SessionDetailViewModel.MidTurnChannel? + /// A mid-turn message is in flight; the button greys out until it settles. + let isSendingMidTurn: Bool + let onSendMidTurn: () -> Void let onDismissNotice: () -> Void /// Backs the "+" menu's text-insert pickers (quick messages / experts / commands). let insertModel: ComposeInsertModel @@ -38,13 +49,36 @@ struct ComposeBar: View { private var hasText: Bool { !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + private var hasDraft: Bool { + hasText || !attachments.isEmpty + } private var canSend: Bool { - (hasText || !attachments.isEmpty) && !isInFlight + hasDraft && !isInFlight + } + /// The mid-turn send greys out with nothing to send, exactly like Send does, + /// rather than appearing and disappearing as the user types. + private var canSendMidTurn: Bool { + hasDraft && !isSendingMidTurn } private var remainingSlots: Int { max(0, AttachmentPrep.maxCount - attachments.count) } + /// What sending right now would actually do, said before the tap. The two + /// channels promise different things and the composer must not blur them; a + /// draft carrying images promises neither, because it waits for the turn. + /// + /// Shown from the moment the user engages the field, which is also the only + /// way to reach a sendable draft — so the button is never actionable without + /// its explanation on screen, and an idle streaming turn keeps its layout. + private var midTurnHint: LocalizedStringKey? { + guard isInFlight, focused || hasDraft, let channel = midTurnChannel else { return nil } + guard attachments.isEmpty else { + return "Images can't join a running turn. Sending waits for the reply to finish." + } + return channel.composerHint + } + var body: some View { VStack(spacing: 8) { if let notice { @@ -56,6 +90,10 @@ struct ComposeBar: View { .transition(.opacity.combined(with: .move(edge: .bottom))) } + if let hint = midTurnHint { + MidTurnHint(text: hint) + } + GlassEffectContainer(spacing: 8) { HStack(alignment: .bottom, spacing: 8) { addButton @@ -119,6 +157,9 @@ struct ComposeBar: View { .animation(Theme.Motion.expand, value: isInFlight) .animation(Theme.Motion.expand, value: notice) .animation(Theme.Motion.expand, value: attachments) + // The mid-turn affordance arrives with the attach snapshot, which can land + // after the turn has started — fade it in rather than popping it in. + .animation(Theme.Motion.expand, value: midTurnChannel) // Width + keyboard-gap shift on focus change, kept just slightly slower // than the keyboard's own animation so the bar settles into place. .animation(.snappy(duration: 0.26), value: focused) @@ -170,38 +211,85 @@ struct ComposeBar: View { @ViewBuilder private var actionButton: some View { if isInFlight { - Button(action: onStop) { - Image(systemName: "stop.fill") - .font(.system(size: 16, weight: .bold)) - .frame(width: 26, height: 26) + // Stop keeps its place and its role; the mid-turn send joins it only + // on sessions that actually have somewhere to put the message, so a + // channel-less session stays pixel-identical to before. + HStack(spacing: 8) { + stopButton + if let channel = midTurnChannel { + midTurnSendButton(channel) + } } - .buttonStyle(.glassProminent) - .tint(Theme.danger) - .clipShape(Circle()) - .transition(.scale.combined(with: .opacity)) - .accessibilityLabel("Stop") } else { - Button(action: send) { - Image(systemName: "arrow.up") - .font(.system(size: 16, weight: .bold)) - .frame(width: 26, height: 26) - } - .buttonStyle(.glassProminent) - .tint(Theme.accent) - .clipShape(Circle()) - .disabled(!canSend) - .opacity(canSend ? 1 : 0.5) - .transition(.scale.combined(with: .opacity)) - .accessibilityLabel("Send") + sendButton } } + private var stopButton: some View { + Button(action: onStop) { + Image(systemName: "stop.fill") + .font(.system(size: 16, weight: .bold)) + .frame(width: 26, height: 26) + } + .buttonStyle(.glassProminent) + .tint(Theme.danger) + .clipShape(Circle()) + .transition(.scale.combined(with: .opacity)) + .accessibilityLabel("Stop") + } + + private var sendButton: some View { + Button(action: send) { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + .frame(width: 26, height: 26) + } + .buttonStyle(.glassProminent) + .tint(Theme.accent) + .clipShape(Circle()) + .disabled(!canSend) + .opacity(canSend ? 1 : 0.5) + .transition(.scale.combined(with: .opacity)) + .accessibilityLabel("Send") + } + + /// Send into the running turn. Same shape as Send, because it is a send — + /// the hint above the field carries what makes it different, and the + /// accessibility label spells it out per channel. + private func midTurnSendButton(_ channel: SessionDetailViewModel.MidTurnChannel) -> some View { + Button(action: sendMidTurn) { + Image(systemName: "arrow.up") + .font(.system(size: 16, weight: .bold)) + .frame(width: 26, height: 26) + } + .buttonStyle(.glassProminent) + .tint(Theme.accent) + .clipShape(Circle()) + .disabled(!canSendMidTurn) + .opacity(canSendMidTurn ? 1 : 0.5) + .transition(.scale.combined(with: .opacity)) + .accessibilityLabel(Text(midTurnLabel(channel))) + } + + /// The action's name for the draft as it stands: one with images is queued + /// for the end of the turn rather than delivered into it, so the label must + /// not claim to insert anything. + private func midTurnLabel(_ channel: SessionDetailViewModel.MidTurnChannel) -> LocalizedStringKey { + attachments.isEmpty ? channel.sendLabel : "Send when the current reply finishes" + } + private func send() { guard canSend else { return } sendHaptic &+= 1 onSend() } + private func sendMidTurn() { + guard canSendMidTurn else { return } + sendHaptic &+= 1 + onSendMidTurn() + } + // MARK: - Attachment intake private func handlePhotoItems(_ items: [PhotosPickerItem]) { @@ -252,6 +340,48 @@ struct ComposeBar: View { } } +private extension SessionDetailViewModel.MidTurnChannel { + /// What a message sent right now would do, in the user's terms. The native + /// push lands in the reply the agent is writing; the pull tool leaves a note + /// it collects whenever it next looks. Promising the first on a pull session + /// would be a lie, so the copy is keyed on the channel, never shared. + var composerHint: LocalizedStringKey { + switch self { + case .native: return "Messages you send now go into the current turn." + case .pull: return "Messages you send now wait for the agent's next check." + } + } + + /// The action's own name, for VoiceOver and the button's label. + var sendLabel: LocalizedStringKey { + switch self { + case .native: return "Send into the current turn" + case .pull: return "Send a note for the next check" + } + } +} + +/// A quiet line above the field explaining what sending during a reply does, +/// shown from the moment the user engages the composer — so the extra button +/// beside Stop is never actionable without its explanation on screen. +private struct MidTurnHint: View { + let text: LocalizedStringKey + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "bolt.horizontal") + .font(.system(size: 11, weight: .semibold)) + Text(text) + .font(.caption) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .foregroundStyle(Theme.textTertiary) + .padding(.horizontal, 14) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } +} + /// A dismissible non-fatal notice (e.g. "a turn is already running"). private struct NoticeBanner: View { let message: String diff --git a/CodegiOS/Features/SessionDetail/SessionDetailView.swift b/CodegiOS/Features/SessionDetail/SessionDetailView.swift index c0b3d29..dba3c60 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailView.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailView.swift @@ -272,6 +272,9 @@ struct SessionDetailView: View { onNotice: { model.notice = $0 }, onSend: { model.send() }, onStop: { model.cancel() }, + midTurnChannel: model.midTurnChannel, + isSendingMidTurn: model.isSendingMidTurn, + onSendMidTurn: { model.sendMidTurn() }, onDismissNotice: { model.notice = nil }, insertModel: model.insertModel ) diff --git a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift index 70d4dfd..69e2cc0 100644 --- a/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift +++ b/CodegiOS/Features/SessionDetail/SessionDetailViewModel.swift @@ -89,6 +89,12 @@ final class SessionDetailViewModel { /// Revision notes waiting to be sent as a follow-up prompt after a /// "request changes" decision (see ``answerPlanApproval(decision:feedback:)``). private var pendingPlanFollowUp: String? + /// The composer's draft is waiting for the running turn to end so it can go + /// as an ordinary prompt — set when a mid-turn send couldn't ride the live + /// channel (see ``queueDraftForTurnEnd(notice:)``). There is no message queue + /// here, so the draft stays in the composer and this only decides when it + /// leaves. + private var queuedSendAfterTurn = false private(set) var summary: ConversationSummary? private(set) var sessionStats: SessionStats? @@ -126,6 +132,48 @@ final class SessionDetailViewModel { && attachments.reduce(0) { $0 + $1.byteCount } < AttachmentPrep.maxTotalBytes } + // MARK: - Mid-turn delivery + + /// How a message typed while the agent is still replying would reach it. The + /// backend decides this per connection at launch and reports both halves in + /// the attach snapshot; it is never re-derived from the agent type here. + enum MidTurnChannel: Equatable { + /// Pushed into the running turn (`_session/steering`) — the agent takes + /// it as a user message, and it appears in the transcript where it landed. + case native + /// Left as a note the agent collects on its next `check_user_feedback` + /// call. It reaches the agent as a tool result, never as a message. + case pull + } + + /// The server's global "Live feedback" toggle (Settings › Conversation Tools, + /// "Send notes and corrections to an agent while it's working"). Read once per + /// screen and false until it answers, so a session never offers the action + /// while the setting that describes it is off. + private var liveFeedbackEnabled = false + /// This connection's delivery capability, read from the attach snapshot. + private var nativeSteeringAvailable = false + private var feedbackToolAvailable = false + /// Latches the one downgrade the server signals — a note that comes back + /// `pending` on a session we believed was native — so a stale snapshot that + /// resolves afterwards can't restore the instant-insert promise. + private var nativeSteeringDowngraded = false + + /// The channel a mid-turn message would ride on this session, or nil when it + /// has none and the composer must keep its Stop-only form. Nil also while the + /// capability is still unknown: an affordance that cannot deliver is worse + /// than one that appears a beat late. + var midTurnChannel: MidTurnChannel? { + guard liveFeedbackEnabled else { return nil } + if nativeSteeringAvailable { return .native } + if feedbackToolAvailable { return .pull } + return nil + } + + /// True while a mid-turn message is in flight, so a second tap can't send the + /// same draft twice. + private(set) var isSendingMidTurn = false + /// Coarse phase of an in-flight send, for the compose status line. enum SendState: Equatable { case idle @@ -165,7 +213,17 @@ final class SessionDetailViewModel { // MARK: - Streaming internals - private var connectionID: String? + /// The live ACP connection this screen is bound to. Mid-turn delivery is + /// decided per connection at launch, so a different one starts from "unknown" + /// rather than inheriting the previous agent process's answer. + private var connectionID: String? { + didSet { + guard connectionID != oldValue else { return } + nativeSteeringAvailable = false + feedbackToolAvailable = false + nativeSteeringDowngraded = false + } + } /// The conversation row THIS draft's first send created up front (via /// `create_conversation`). Held until the prompt is accepted; if the send is /// rolled back before then, this row is deleted so no empty conversation @@ -289,6 +347,7 @@ final class SessionDetailViewModel { private var didLoadDraftOptions = false func load() async { + loadLiveFeedbackSetting() switch mode { case .existing(let id): phase = .loading @@ -344,6 +403,17 @@ final class SessionDetailViewModel { } } + /// Read the server's global live-feedback toggle in the background — it gates + /// the mid-turn send, and the transcript must not wait on it. A failure leaves + /// the flag false, which only costs the affordance; the retry button re-runs + /// `load()`, so a blip is recoverable without leaving the screen. + private func loadLiveFeedbackSetting() { + Task { [weak self] in + guard let self, let enabled = try? await self.client.feedbackEnabled() else { return } + self.liveFeedbackEnabled = enabled + } + } + /// Populate the draft's folder/agent lists and pick sensible defaults /// (preselected folder → most-recent folder; its default agent → first /// installed). Failures leave the full `AgentType` fallback in place. @@ -877,6 +947,113 @@ final class SessionDetailViewModel { return String(firstLine.prefix(80)) } + // MARK: - Mid-turn send + + /// Send the composer's draft to the agent WITHOUT waiting for the running + /// turn to finish. Only reachable while ``midTurnChannel`` reports a channel, + /// so the affordance never promises a delivery the session can't make. + /// + /// Images ride neither channel: the pull tool carries plain text, and while + /// the native one can take a full draft, a server predating that support + /// ignores the field silently instead of rejecting it — which would drop the + /// image with no error anywhere. A draft holding any is therefore queued + /// whole for the end of the turn rather than sent stripped. + func sendMidTurn() { + guard !isSendingMidTurn, isInFlight, midTurnChannel != nil, + let conn = connectionID else { return } + guard attachments.isEmpty else { + queueDraftForTurnEnd(notice: "Images can't join a running turn. This will send when the reply finishes.") + return + } + let raw = draft + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return } + isSendingMidTurn = true + notice = nil + Task { [weak self] in + await self?.runMidTurnSend(conn: conn, text: text, draftSent: raw) + } + } + + private func runMidTurnSend(conn: String, text: String, draftSent raw: String) async { + defer { isSendingMidTurn = false } + do { + let note = try await client.submitFeedback(connectionId: conn, text: text) + // Clear the composer only now that the server holds the message, and + // only while it is still the one we sent — the user may have typed on + // during the round-trip. + if draft == raw { draft = "" } + // The draft reached the agent, so an earlier "wait for the turn to + // end" no longer applies — leaving it set would fire whatever the + // user types next the moment the reply lands. + queuedSendAfterTurn = false + applyMidTurnNote(note) + } catch let error as APIError where error.isNoActiveTurn { + // The turn ended inside the race window and nothing was recorded, so + // the draft goes as an ordinary prompt instead: immediately if the + // stream has already caught up, otherwise the moment it finalizes. + if isInFlight { + queueDraftForTurnEnd(notice: "The turn just ended. This will send as a new message.") + } else { + send() + } + } catch { + // Nothing was delivered and the draft is untouched, so it is still on + // screen to edit or retry. + notice = Self.describe(error) + } + } + + /// Reconcile the note the server actually stored. Its `status` is the only + /// honest report of what happened, and the two outcomes look nothing alike to + /// the user. + private func applyMidTurnNote(_ note: FeedbackNote) { + if note.status == "delivered" { + // Pushed into the running turn as a user message — show it where it + // landed. The `feedback_submitted` broadcast repeats this for every + // attached client, including us, and dedups on the note id. + if isTurnActive, let live = liveTurn { + live.appendUserMessage(id: note.id, text: note.text, sentAt: note.createdAt ?? Date()) + requestScrollToBottom() + } + return + } + // Pending — and anything unrecognized, which we can't claim was + // delivered. The agent has not read it, and a pulled note never becomes a + // user turn (here or on reload), so this banner is the only sign it + // arrived at all. + notice = "Sent as a note. The agent will read it at its next check." + if note.status == "pending", nativeSteeringAvailable { + // A pending note on a session we believed was native proves the + // server has already rerouted to the pull tool. Latch the downgrade + // so the composer stops promising an instant insert. + nativeSteeringAvailable = false + nativeSteeringDowngraded = true + } + } + + /// Hold the draft for the end of the running turn, then send it as an ordinary + /// prompt. There is no message queue on this client, so the composer itself is + /// the queue: the text and any staged images stay visible and editable, and + /// `finalize` releases them. + private func queueDraftForTurnEnd(notice message: String) { + queuedSendAfterTurn = true + notice = message + } + + /// Fold this connection's mid-turn delivery capability out of an attach + /// snapshot. Upgrade-only: the flags are fixed at launch, but a snapshot read + /// while the agent was still coming up reports false, and that false must + /// never retract a capability already seen. Clearing them is the + /// `connectionID` observer's job; the one genuine downgrade (native rerouting + /// to the pull tool) comes from a submit result, not from here. + private func applyMidTurnCapability(from snap: LiveSessionSnapshot) { + if snap.feedbackToolAvailable { feedbackToolAvailable = true } + if snap.nativeSteeringAvailable, !nativeSteeringDowngraded { + nativeSteeringAvailable = true + } + } + // MARK: - Stream lifecycle /// Resolved by the consumer loop the moment the socket reports `.ready`, so @@ -947,6 +1124,7 @@ final class SessionDetailViewModel { // Attach confirmed — a healthy socket. Reset the reconnect budget // and (for the initial connect) release the waiting send. streamReconnects = 0 + if isCurrent { applyMidTurnCapability(from: snap) } // A mid-turn RECONNECT can drop the socket exactly as a // `permission_request` / `question_request` arrives — losing that // live event. The fresh snapshot still carries the pending card, so @@ -1109,6 +1287,7 @@ final class SessionDetailViewModel { case .snapshot(let snap): // A snapshot means the socket is healthy — reset the reconnect budget. streamReconnects = 0 + applyMidTurnCapability(from: snap) if let rebuilt = buildLiveTurn(from: snap) { live = rebuilt liveTurn = rebuilt @@ -1388,7 +1567,16 @@ final class SessionDetailViewModel { Task { [weak self] in await self?.refreshAfterTurn(reconciling: live) } // The keep-planning turn just ended — deliver the revision notes as the // follow-up prompt Grok expects (it discards them on the reply itself). - if let planFollowUp { send(overrideText: planFollowUp) } + if let planFollowUp { + // The queued draft (if any) keeps its flag and rides the NEXT + // finalize, since this send occupies the turn it was waiting for. + send(overrideText: planFollowUp) + } else if queuedSendAfterTurn { + // A mid-turn draft that couldn't ride the live channel goes now, as + // the ordinary prompt it always was. + queuedSendAfterTurn = false + send() + } } /// Roll back an optimistic send that failed *before the server accepted the @@ -1727,6 +1915,10 @@ final class SessionDetailViewModel { guard let live = liveTurn else { return } let conn = connectionID isTurnActive = false + // Stopping the agent is an explicit "not now": a draft parked for the end + // of this turn stays in the composer instead of firing off the moment the + // user hit Stop. + queuedSendAfterTurn = false clearInteractivePrompts() live.flushAllText() live.isStreaming = false diff --git a/CodegiOS/Models/AcpEvent.swift b/CodegiOS/Models/AcpEvent.swift index b6b5b65..bb527e9 100644 --- a/CodegiOS/Models/AcpEvent.swift +++ b/CodegiOS/Models/AcpEvent.swift @@ -269,10 +269,19 @@ struct LiveSessionSnapshot: Sendable, Decodable { let pendingPermission: PendingPermissionSnapshot? let pendingQuestion: PendingQuestionSnapshot? let pendingPlanApproval: PendingPlanApprovalSnapshot? + /// Whether this agent has the cooperative `check_user_feedback` tool, and + /// whether notes instead ride the native `_session/steering` push — the two + /// halves of "can this session take a message while it is already replying". + /// The backend synthesizes both at launch (from the MCP injection and from + /// the `initialize` handshake) and always serializes them as plain bools, so + /// a payload without them is an older server and reads as no channel at all. + let feedbackToolAvailable: Bool + let nativeSteeringAvailable: Bool private enum CodingKeys: String, CodingKey { case connectionId, conversationId, folderId, status, externalId, eventSeq case liveMessage, activeToolCalls, pendingPermission, pendingQuestion, pendingPlanApproval + case feedbackToolAvailable, nativeSteeringAvailable } init(from decoder: Decoder) throws { @@ -288,6 +297,8 @@ struct LiveSessionSnapshot: Sendable, Decodable { pendingPermission = (try? c.decodeIfPresent(PendingPermissionSnapshot.self, forKey: .pendingPermission)) ?? nil pendingQuestion = (try? c.decodeIfPresent(PendingQuestionSnapshot.self, forKey: .pendingQuestion)) ?? nil pendingPlanApproval = (try? c.decodeIfPresent(PendingPlanApprovalSnapshot.self, forKey: .pendingPlanApproval)) ?? nil + feedbackToolAvailable = try c.decodeIfPresent(Bool.self, forKey: .feedbackToolAvailable) ?? false + nativeSteeringAvailable = try c.decodeIfPresent(Bool.self, forKey: .nativeSteeringAvailable) ?? false } } diff --git a/CodegiOS/Networking/APIError.swift b/CodegiOS/Networking/APIError.swift index fe16ee0..53fe8f4 100644 --- a/CodegiOS/Networking/APIError.swift +++ b/CodegiOS/Networking/APIError.swift @@ -41,6 +41,19 @@ enum APIError: LocalizedError, Sendable { return false } + /// A mid-turn message was refused because the turn had already ended. The + /// server maps this to a 400 under the generic `invalid_input` code it shares + /// with the other feedback rejections (empty text, feature unavailable), so + /// the message is the only discriminator — the same marker the web client + /// matches. Expected and recoverable: the caller sends the draft as an + /// ordinary prompt instead of surfacing an error. + var isNoActiveTurn: Bool { + if case .server(_, _, let message) = self { + return message.lowercased().contains("no active turn") + } + return false + } + /// A remote git operation (push/pull/fetch) failed to authenticate — the /// server returns `authentication_failed`, or git's own message leaks through. /// Mirrors the web `isAuthError` so the credential-retry flow can decide to diff --git a/CodegiOS/Networking/CodegClient.swift b/CodegiOS/Networking/CodegClient.swift index c00880e..25e0905 100644 --- a/CodegiOS/Networking/CodegClient.swift +++ b/CodegiOS/Networking/CodegClient.swift @@ -259,6 +259,24 @@ struct CodegClient: Sendable { )) } + /// Send a message into the turn already running on `connectionId`, rather + /// than waiting for it to end. Returns the note the server stored, whose + /// `status` reports how it was delivered: `delivered` = pushed into the + /// running turn over the native steering channel (the agent sees it as a user + /// message), `pending` = left for the agent's next `check_user_feedback` + /// call. The same note is broadcast to every attached client as + /// `feedback_submitted`, so the sender's own transcript splice dedups on its id. + /// + /// Rejected when the session has no delivery channel at all, and when the + /// turn ended first — the latter is the expected turn-end race, recognized by + /// ``APIError/isNoActiveTurn`` and answered by sending an ordinary prompt. + func submitFeedback(connectionId: String, text: String) async throws -> FeedbackNote { + try await postJSON("submit_session_feedback", SubmitFeedbackBody( + connectionId: connectionId, + text: text + )) + } + /// Find a live connection already bound to a conversation, if any. The /// server requires `agentType` and uses `sessionId` (the conversation's /// `external_id`) to match a connection before the first prompt binds one. diff --git a/CodegiOS/Networking/WireRequests.swift b/CodegiOS/Networking/WireRequests.swift index 0895dc8..c2e9ef8 100644 --- a/CodegiOS/Networking/WireRequests.swift +++ b/CodegiOS/Networking/WireRequests.swift @@ -119,6 +119,19 @@ struct PlanApprovalAnswer: Encodable, Sendable { var feedback: String? } +/// Body for `submit_session_feedback` — a message delivered to the turn already +/// running on `connectionId`, instead of waiting for it to finish. +/// +/// Text only. The server also accepts a `blocks` draft, but only the native +/// steering channel can carry one (the pull tool delivers plain text), and a +/// server predating that field ignores it silently rather than rejecting it — +/// so an attachment sent this way could vanish with no error. Drafts carrying +/// images go through `acp_prompt` as an ordinary turn instead. +struct SubmitFeedbackBody: Encodable, Sendable { + let connectionId: String + let text: String +} + /// Body for `acp_describe_agent_options`. Takes the agent type + an optional /// working dir — NOT a connection id (the server spawns a throwaway probe agent /// to enumerate options). camelCase keys, encoded as-is.