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/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/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 1a23eb3..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 @@ -1275,6 +1454,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) @@ -1372,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 @@ -1675,16 +1879,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() } @@ -1712,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/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..bb527e9 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", @@ -229,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 { @@ -248,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.