From 8a92fc7c1982c8bb3327e6c226b5a194cd36d07b Mon Sep 17 00:00:00 2001 From: jtenniswood Date: Wed, 2 Sep 2026 11:37:32 +0100 Subject: [PATCH] feat(studio): message queue and mid-run steering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue machinery first: messages typed while a run drives park as queuedMessages, drain one per run with flush-on-idle, and render as the queued strip in the chat view. Steer lands over it: PendingSteer with the pure splitPendingSteersOnWatermark, orphan requeue, steerMessage/steerQueued/ cancelPendingSteers, and the steer-echo assistant re-anchor. The composer's resolveComposerAction routes Enter to send/queue/steer, and Personalize gains the Message-queuing preference (Shift+Enter does the opposite). Steer is gated on features.has('http_steer') — the daemon endpoints from earlier in this series — with the explicit queue fallback (too_late, 409 stale_run_control, or an absent capability all queue; text is never dropped). Against the e2e fixture (no steer capability) the gate stays on the queue path, so specs are unchanged; the watermark-split and client-action suites own the steer contract. Co-Authored-By: Claude Fable 5 --- .../workspace/_components/chat-input.test.tsx | 56 ++- .../app/workspace/_components/chat-input.tsx | 130 ++++++- .../workspace/chat/_components/chat-view.tsx | 142 ++++++- .../chat/_components/chat-workspace.tsx | 22 ++ .../workspace/settings/appearance/page.tsx | 20 + .../agent/hooks/use-agent-chat.test.ts | 61 ++- .../features/agent/hooks/use-agent-chat.ts | 353 +++++++++++++++++- studio/src/lib/profile-preferences.ts | 29 ++ 8 files changed, 770 insertions(+), 43 deletions(-) diff --git a/studio/src/app/workspace/_components/chat-input.test.tsx b/studio/src/app/workspace/_components/chat-input.test.tsx index 80a0397b88..d7b95aef14 100644 --- a/studio/src/app/workspace/_components/chat-input.test.tsx +++ b/studio/src/app/workspace/_components/chat-input.test.tsx @@ -1,23 +1,49 @@ import { render, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AttachmentPill, ChatInput } from "./chat-input"; +import { AttachmentPill, resolveComposerAction } from "./chat-input"; -// jsdom cannot host a live ProseMirror view (TipTap mounts asynchronously and -// owns its own capture-phase handlers), so the editor is stubbed to its -// pre-mount (null) state and this file covers the composer chrome around it. -vi.mock("@tiptap/react", () => ({ - useEditor: () => null, - EditorContent: () =>
, -})); +/** + * The Enter matrix is tested through `resolveComposerAction`, the pure + * decision function the composer's keydown handler and send button both call. + * Driving the TipTap editor's ProseMirror view with synthetic keydowns in + * jsdom is impractical (the editor mounts asynchronously and owns its own + * capture-phase handlers), so the decision table is extracted and tested + * exhaustively instead; "newline" means the key is NOT intercepted — the + * editor's own hardBreak inserts the newline and onSend is never called. + */ +describe("resolveComposerAction", () => { + const resolve = ( + shift: boolean, + isStreaming: boolean, + behavior: "queue" | "steer", + ) => resolveComposerAction({ shift, isStreaming, behavior }); -describe("ChatInput", () => { - it("renders the composer chrome with the send button disabled while empty", () => { - const { getByLabelText, getByTestId } = render(); - expect(getByTestId("composer-editor")).toBeTruthy(); - expect((getByLabelText("Send message") as HTMLButtonElement).disabled).toBe( - true, - ); + it("sends on idle Enter, whatever the preference", () => { + expect(resolve(false, false, "queue")).toBe("send"); + expect(resolve(false, false, "steer")).toBe("send"); + }); + + it("keeps idle Shift+Enter as a newline (the key is not intercepted, so onSend is never called)", () => { + expect(resolve(true, false, "queue")).toBe("newline"); + expect(resolve(true, false, "steer")).toBe("newline"); + }); + + it("queues on streaming Enter with the default preference", () => { + expect(resolve(false, true, "queue")).toBe("queue"); }); + + it("steers on streaming Shift+Enter with the default preference", () => { + expect(resolve(true, true, "queue")).toBe("steer"); + }); + + it("inverts both keys when the preference is steer", () => { + expect(resolve(false, true, "steer")).toBe("steer"); + expect(resolve(true, true, "steer")).toBe("queue"); + }); + + // Attachments no longer force the queue path: a steer carries staged image + // parts (ADR 0251). Availability is the handler's business — performAction + // degrades steer→queue when onSteer is absent, keeping the files attached. }); describe("AttachmentPill", () => { diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx index c4840b6b38..e438b6ad7e 100644 --- a/studio/src/app/workspace/_components/chat-input.tsx +++ b/studio/src/app/workspace/_components/chat-input.tsx @@ -26,6 +26,10 @@ import { getSlashCommands, } from "@/features/agent/composer-capabilities"; import { fileKindMeta } from "@/lib/file-meta"; +import { + type EnterSendBehavior, + useEnterSendBehavior, +} from "@/lib/profile-preferences"; import { cn } from "@/lib/utils"; import { type ComposerMenuItem, @@ -39,6 +43,12 @@ interface ChatInputProps { rows?: number; compact?: boolean; onSend?: (content: string, files?: File[]) => void; + onQueue?: (content: string) => void; + /** Injects the text — plus any staged image attachments (ADR 0251) — into + the in-flight run at the next step (mid-run steering). Only meaningful + while `isStreaming`; absent when the daemon lacks the steer capability + (mid-run sends then queue and files stay attached). */ + onSteer?: (content: string, files?: File[]) => void; /** Preview an attached file in the canvas panel. */ onPreviewAttachment?: (file: File) => void; disabled?: boolean; @@ -320,6 +330,32 @@ export function AttachmentPill({ ); } +/** What Enter resolves to in the composer. `newline` means "do not intercept + * the key" — the editor's own hardBreak handles it. */ +export type ComposerEnterAction = "send" | "queue" | "steer" | "newline"; + +/** + * The composer's Enter decision table, extracted pure so the whole matrix is + * testable without driving the TipTap editor: + * + * - Idle: Enter sends; Shift+Enter inserts a newline (fall through to the + * editor's hardBreak). + * - Streaming: Enter performs the preferred action (Settings → Personalize) and + * Shift+Enter the opposite. Attachments no longer force the queue path — + * steers carry image parts (ADR 0251), so a mid-run send with files steers + * when steering is available (and degrades to queue when it is not, via + * performAction's missing-handler fallback, keeping the files attached). + */ +export function resolveComposerAction(input: { + shift: boolean; + isStreaming: boolean; + behavior: EnterSendBehavior; +}): ComposerEnterAction { + if (!input.isStreaming) return input.shift ? "newline" : "send"; + if (!input.shift) return input.behavior; + return input.behavior === "queue" ? "steer" : "queue"; +} + /** Open autocomplete menu state, mirrored from TipTap's suggestion lifecycle. */ interface ComposerMenu { kind: "agent" | "command"; @@ -369,7 +405,10 @@ export function ChatInput({ placeholder: placeholderProp, compact = false, onSend, + onQueue, + onSteer, disabled = false, + isStreaming = false, appendText, onAppendConsumed, initialText, @@ -378,8 +417,8 @@ export function ChatInput({ }: ChatInputProps) { const placeholder = placeholderProp ?? DEFAULT_PLACEHOLDER; // Plain-text mirror of the editor, kept in sync via onUpdate. Used only for - // "is there something to send" checks; the editor document is the source of - // truth for the message itself. + // "is there something to send" checks and the streaming border state; + // the editor document is the source of truth for the message itself. const [text, setText] = useState(""); const [attachedFiles, setAttachedFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); @@ -525,19 +564,65 @@ export function ChatInput({ ), ); - const handleSend = useCallback(() => { - const trimmed = editor ? composerText(editor) : ""; - if (!trimmed || disabled) return; - const files = attachedFiles.length > 0 ? attachedFiles : undefined; - onSend?.(trimmed, files); - editor?.commands.clearContent(); - setText(""); - setAttachedFiles([]); - }, [editor, disabled, onSend, attachedFiles]); + // The preferred Enter action while a reply is streaming (Settings → Personalize). + // Hydrates on mount, so the first frame is always the "queue" default. + const { behavior: enterBehavior } = useEnterSendBehavior(); + + /** Executes one resolved Enter action against the current editor content. + * Missing handlers degrade toward the pre-steer behavior: steer without + * onSteer queues, queue without onQueue sends. */ + const performAction = useCallback( + (action: ComposerEnterAction) => { + if (action === "newline") return; + const trimmed = editor ? composerText(editor) : ""; + if (!trimmed || disabled) return; + const files = attachedFiles.length > 0 ? attachedFiles : undefined; + const resolved = action === "steer" && !onSteer ? "queue" : action; + if (resolved === "steer" && onSteer) { + // A steer carries the staged attachments as image parts (ADR 0251); + // they leave the composer with the text. + onSteer(trimmed, files); + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + return; + } + if (resolved === "queue" && onQueue) { + onQueue(trimmed); + editor?.commands.clearContent(); + setText(""); + // Attached files deliberately stay attached: a queued text cannot + // carry them, so they ride the next real send. + return; + } + onSend?.(trimmed, files); + editor?.commands.clearContent(); + setText(""); + setAttachedFiles([]); + }, + [editor, disabled, onQueue, onSteer, onSend, attachedFiles], + ); + + /** Resolve + perform for one Enter press (or a send-button click, which is + * the plain-Enter path). */ + const actOnEnter = useCallback( + (shift: boolean) => { + performAction( + resolveComposerAction({ + shift, + isStreaming, + behavior: enterBehavior, + }), + ); + }, + [performAction, isStreaming, enterBehavior], + ); + + const handleSend = useCallback(() => actOnEnter(false), [actOnEnter]); // Menu nav + Enter-to-send are wired with a native capture-phase keydown // listener on the editor DOM, re-subscribed each render with fresh closures - // over `menu`/`handleSend`. Capture phase runs before ProseMirror's own + // over `menu`/`actOnEnter`. Capture phase runs before ProseMirror's own // (bubble-phase) handler, and stopPropagation keeps the base keymap and the // suggestion plugins from also acting. This sidesteps both TipTap re-syncing // its editorProps and the React Compiler not preserving render-phase refs. @@ -552,15 +637,26 @@ export function ChatInput({ } return; } - if (event.key === "Enter" && !event.shiftKey) { + if (event.key !== "Enter") return; + if (event.shiftKey) { + // Shift+Enter acts (as the opposite of the Enter preference) only + // while a reply is streaming AND there is text to act on; otherwise + // it keeps its newline behavior — fall through to the editor's + // hardBreak without preventDefault. + const trimmed = editor ? composerText(editor) : ""; + if (!isStreaming || !trimmed) return; event.preventDefault(); event.stopPropagation(); - handleSend(); + actOnEnter(true); + return; } + event.preventDefault(); + event.stopPropagation(); + actOnEnter(false); }; dom.addEventListener("keydown", onKeyDown, true); return () => dom.removeEventListener("keydown", onKeyDown, true); - }, [editor, menu, handleSend]); + }, [editor, menu, isStreaming, actOnEnter]); const hasText = text.trim().length > 0; @@ -651,7 +747,9 @@ export function ChatInput({ ? "border-brand bg-brand/5 dark:bg-brand/10 ring-2 ring-brand/20" : isWindowDrag ? "border-brand/50 ring-1 ring-brand/10" - : "border-zinc-300 dark:border-zinc-700", + : isStreaming && hasText + ? "border-warning shadow-warning/10" + : "border-zinc-300 dark:border-zinc-700", )} > {voice.isListening && ( diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx index edf1566e85..0c5e5dd164 100644 --- a/studio/src/app/workspace/chat/_components/chat-view.tsx +++ b/studio/src/app/workspace/chat/_components/chat-view.tsx @@ -7,6 +7,7 @@ import { Ellipsis, FileText, FoldVertical, + ListEnd, Loader2, MessageCircle, PanelLeftClose, @@ -40,11 +41,14 @@ import type { ClarificationRequest, ToolCallInfo, } from "@/features/agent"; +import type { QueuedMessage } from "@/features/agent/hooks/use-agent-chat"; import { formatTokens } from "@/lib/formatters"; import { type SessionListSide, + useEnterSendBehavior, useShowToolCalls, } from "@/lib/profile-preferences"; +import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; import { cn } from "@/lib/utils"; import { ChatInput } from "../../_components/chat-input"; import { ApprovalPanel } from "./approval-panel"; @@ -136,6 +140,76 @@ function UsageMenuRow({ ); } +/** + * Messages held while a run is active, shown above the composer. Steered + * messages the daemon accepted but has not yet applied render first, with a + * pulsing "steering…" marker and a single retract control for the whole + * bundle (the daemon retracts bundles, not single messages). Queued rows + * offer Steer (inject into the in-flight run), Edit (back into the composer), + * and Delete; they drain in order as runs complete. + */ +function QueuedMessageStrip({ + queued, + onSteer, + onEdit, + onDelete, +}: { + queued: QueuedMessage[]; + onSteer: (id: string) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +}) { + if (queued.length === 0) return null; + return ( + // ONE opaque group (the strip floats over the transcript): pending steers + // first, then the queue, as divided rows — never a stack of panels. +
+
+ {queued.map((message) => ( +
+ + + {message.text} + + + + + + + onSteer(message.id)}> + Steer + + onEdit(message.id)}> + Edit + + onDelete(message.id)} + > + Delete + + + +
+ ))} +
+
+ ); +} + function AttachmentPanel({ attachment, onClose, @@ -296,6 +370,13 @@ export function ChatView({ onSidePanelOpenChange, initialDraft, onInitialDraftConsumed, + queuedMessages = [], + onQueueMessage, + onSteerQueued, + onDeleteQueued, + onTakeQueued, + onSteerMessage, + onCancelRun, onCompact, contextInfo, }: { @@ -326,6 +407,21 @@ export function ChatView({ chip that seeds the message without sending it). */ initialDraft?: string | null; onInitialDraftConsumed?: () => void; + /** Messages held while a run is active (see QueuedMessageStrip). */ + queuedMessages?: QueuedMessage[]; + onQueueMessage?: (text: string) => void; + onSteerQueued?: (id: string) => void; + onDeleteQueued?: (id: string) => void; + /** Removes a queued message and returns its text (the Edit action). */ + onTakeQueued?: (id: string) => string | null; + /** Steers the daemon accepted but has not yet applied to the run. */ + /** Injects composer text (plus staged image attachments, ADR 0251) into + the in-flight run at the next step. Absent when the daemon lacks the + steer capability — mid-run sends then queue. */ + onSteerMessage?: (text: string, files?: File[]) => void; + /** Retracts the whole pending steer bundle. */ + /** Cancels the in-flight run (Esc with no panel open). */ + onCancelRun?: () => void; /** Manually compacts the conversation (B1.2); present only when the daemon's manual_compaction capability is on. Disabled while streaming. */ onCompact?: () => void; @@ -347,6 +443,14 @@ export function ChatView({ // time" structural rather than something to coordinate by hand. const [panel, setPanel] = useState(null); const [appendText, setAppendText] = useState(null); + // The Enter preference (Settings → Chat) decides the streaming placeholder. + const { behavior: enterBehavior } = useEnterSendBehavior(); + // Editing a queued message pulls it out of the queue into the composer. + const [editSeed, setEditSeed] = useState(null); + const handleEditQueued = (id: string) => { + const text = onTakeQueued?.(id); + if (text) setEditSeed(text); + }; // When maximized, the panel fills the pane and the conversation column is // hidden. Always reset when the panel is closed. const [panelMaximized, setPanelMaximized] = useState(false); @@ -419,6 +523,19 @@ export function ChatView({ if (el) el.scrollTop = el.scrollHeight; }, [messages]); + // Esc, layered (close.esc): an open Radix dialog/menu — and the composer's + // autocomplete — consume their own Escape before the dispatcher sees it + // (`defaultPrevented`), so by the time this fires nothing transient is + // open. Close the side panel if one is up; otherwise interrupt a streaming + // run (the Claude Code convention: Esc cancels). + useShortcut("close.esc", () => { + if (panel !== null) { + closeSidePanel(); + return; + } + if (isStreaming) onCancelRun?.(); + }); + // Let the parent collapse the chat list while the side panel is open so // both panels fit side by side. const sidePanelOpen = panel !== null; @@ -616,6 +733,12 @@ export function ChatView({ outputTokens={usage?.outputTokens ?? 0} /> )} + onSteerQueued?.(id)} + onEdit={handleEditQueued} + onDelete={(id) => onDeleteQueued?.(id)} + /> {error && (
@@ -644,14 +767,27 @@ export function ChatView({ ) : ( { + if (editSeed !== null) setEditSeed(null); + else onInitialDraftConsumed?.(); + }} + placeholder={ + isStreaming + ? // "Steer" is only an honest promise while the daemon + // actually supports it (C1.2) — absent, sends queue. + enterBehavior === "steer" && onSteerMessage + ? "Steer the agent..." + : "Queue a message..." + : "Send a message..." + } /> )}
diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx index abfdd677d8..2375a1bcc1 100644 --- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx +++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx @@ -384,10 +384,23 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { pendingClarification, respondToClarification, usage, + queuedMessages, + queueMessage, + deleteQueued, + takeQueued, + steerQueued, + steerMessage, + steerSupported, + cancelChat, } = useAgentChat(hookSessionId, { onSessionCreated: handleSessionCreated, }); + /** Esc with nothing else open interrupts the in-flight run (close.esc). */ + const handleCancelRun = useCallback(() => { + void cancelChat(); + }, [cancelChat]); + // The daemon's operator-enabled capabilities (A3 caches /v1/compatibility). const { connected, serverCapabilities } = useRuntimeStatus(); @@ -586,6 +599,15 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) { error={turnError} onRetry={retryLast} onSend={sendMessage} + queuedMessages={queuedMessages} + onQueueMessage={queueMessage} + onSteerQueued={steerQueued} + onDeleteQueued={deleteQueued} + onTakeQueued={takeQueued} + // Steer is capability-gated (C1.2): absent, mid-run sends queue and + // the composer's steer action degrades to queue. + onSteerMessage={steerSupported ? steerMessage : undefined} + onCancelRun={handleCancelRun} onCompact={compactSupported ? handleCompact : undefined} contextInfo={ resolvedModel && resolvedModel.contextWindow > 0 diff --git a/studio/src/app/workspace/settings/appearance/page.tsx b/studio/src/app/workspace/settings/appearance/page.tsx index 7efe7f02a1..1a429dd85a 100644 --- a/studio/src/app/workspace/settings/appearance/page.tsx +++ b/studio/src/app/workspace/settings/appearance/page.tsx @@ -18,8 +18,10 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { + type EnterSendBehavior, UI_SCALE_MAX, UI_SCALE_MIN, + useEnterSendBehavior, useSessionListSide, useUiScale, } from "@/lib/profile-preferences"; @@ -37,10 +39,16 @@ const SIDE_OPTIONS = [ { value: "right", label: "Right", icon: PanelRight }, ] as const; +const ENTER_BEHAVIOR_OPTIONS = [ + { value: "queue", label: "Queue message", icon: ListEnd }, + { value: "steer", label: "Steer the agent", icon: CornerDownRight }, +] as const; + export default function AppearanceSettingsPage() { const { theme: activeTheme, setTheme } = useTheme(); const { side, setSide } = useSessionListSide(); const { scale, setScale } = useUiScale(); + const { behavior, setBehavior } = useEnterSendBehavior(); // Browser notifications: permission mirrored into state so the row reflects // granted / denied / not-yet-asked; "unsupported" hides the row's actions. @@ -148,6 +156,18 @@ export default function AppearanceSettingsPage() { /> + + setBehavior(next as EnterSendBehavior)} + /> + + {notifyPermission !== "unsupported" && ( { + it("drops every pending steer up to and including the watermark, keeping the tail", () => { + expect(splitPendingSteersOnWatermark(pending, "steer-2")).toEqual([ + { id: "steer-3", text: "third" }, + ]); + }); + + it("clears the whole list when the watermark is the last pending steer", () => { + expect(splitPendingSteersOnWatermark(pending, "steer-3")).toEqual([]); + }); + + it("clears the whole list on an empty watermark — the daemon's FIFO is authoritative", () => { + expect(splitPendingSteersOnWatermark(pending, "")).toEqual([]); + }); + + it("clears the whole list on an unmatched watermark rather than text-matching", () => { + expect(splitPendingSteersOnWatermark(pending, "steer-unknown")).toEqual([]); + }); + + it("leaves an empty list empty", () => { + expect(splitPendingSteersOnWatermark([], "steer-1")).toEqual([]); + }); +}); // ── delegation cards (D1) ──────────────────────────────────────────────────── @@ -77,3 +111,28 @@ describe("applyDelegationUpdate", () => { ).toBe(before); }); }); + +// ── steer echo attachments (ADR 0251 / C2.2) ───────────────────────────────── + +describe("attachmentsFromSteerParts", () => { + it("renders inline bytes as data: URLs and passes url parts through", () => { + expect( + attachmentsFromSteerParts([ + { kind: "image", mimeType: "image/png", data: "aGk=" }, + { kind: "audio", mimeType: "audio/wav", url: "mecatl://a" }, + ]), + ).toEqual([ + { + name: "image-1.png", + type: "image/png", + url: "data:image/png;base64,aGk=", + }, + { name: "audio-2.wav", type: "audio/wav", url: "mecatl://a" }, + ]); + }); + + it("returns undefined for an empty bundle", () => { + expect(attachmentsFromSteerParts(undefined)).toBeUndefined(); + expect(attachmentsFromSteerParts([])).toBeUndefined(); + }); +}); diff --git a/studio/src/features/agent/hooks/use-agent-chat.ts b/studio/src/features/agent/hooks/use-agent-chat.ts index 25b3e0cd9d..d79fec6d5e 100644 --- a/studio/src/features/agent/hooks/use-agent-chat.ts +++ b/studio/src/features/agent/hooks/use-agent-chat.ts @@ -8,12 +8,14 @@ import { import { fileFromToolCall } from "@/lib/file-meta"; import { cancelHarnessRun, + cancelHarnessSteer, createHarnessSession, fetchSessionTranscriptMessages, HarnessApiError, type PromptPart, respondToHarnessApproval, retryHarnessRun, + steerHarnessRun, streamHarnessPrompt, } from "@/lib/harness/client"; import { @@ -30,6 +32,7 @@ import type { ClarificationRequest, DelegationInfo, RetryDisposition, + SteerEchoPart, StreamEvent, ToolCallInfo, } from "../types"; @@ -41,6 +44,40 @@ type ChatStatus = | "waiting_clarification" | "error"; +/** A message held while a run is active, drained as a prompt when idle. + * `files` are staged attachments the text must not lose on the way. */ +export interface QueuedMessage { + id: string; + text: string; + files?: File[]; +} + +/** A steer the daemon accepted but has not yet drained into the run. */ +export interface PendingSteer { + id: string; + text: string; + /** Staged attachments the steer carried (requeued with the text if the + * run ends before the drain). */ + files?: File[]; +} + +/** + * Splits the ordered pending-steer list on the drain echo's watermark: every + * steer up to AND including the matching id was merged into the drained + * bundle and drops. An empty or unmatched id clears the whole list — the + * daemon's correlation FIFO is authoritative, so an echo we cannot correlate + * means the local list is stale. Never text-match. + */ +export function splitPendingSteersOnWatermark( + pending: readonly PendingSteer[], + messageId: string, +): PendingSteer[] { + if (!messageId) return []; + const index = pending.findIndex((steer) => steer.id === messageId); + if (index === -1) return []; + return pending.slice(index + 1); +} + /** Formats every vision provider accepts; anything else gets re-encoded. */ const WIRE_IMAGE_TYPES = new Set([ "image/png", @@ -179,6 +216,29 @@ function messagesFromTranscript(transcript: SessionTranscript): AgentMessage[] { return messages; } +/** + * Renders the steer drain echo's committed media bundle (ADR 0251) as + * message-attachment chips: inline bytes become data: URLs the existing + * thumbnail path already displays; url-sourced parts pass the URL through. + */ +export function attachmentsFromSteerParts( + parts: readonly SteerEchoPart[] | undefined, +): Attachment[] | undefined { + if (!parts?.length) return undefined; + const attachments: Attachment[] = []; + for (const [index, part] of parts.entries()) { + const extension = part.mimeType.split("/")[1] || "bin"; + attachments.push({ + name: `${part.kind}-${index + 1}.${extension}`, + type: part.mimeType, + url: part.data + ? `data:${part.mimeType};base64,${part.data}` + : part.url || undefined, + }); + } + return attachments; +} + /** * Applies one live child-activity event (`delegation_progress` / * `delegation_end`, D1) onto the delegation card it belongs to, searching the @@ -251,13 +311,17 @@ export function useAgentChat( createModel?: () => { modelId: string; providerId: string } | null; }, ) { - const { connected } = useRuntimeStatus(); + const { connected, features, serverCapabilities } = useRuntimeStatus(); const [messages, setMessages] = useState([]); const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); const [pendingApproval, setPendingApproval] = useState(null); const [pendingClarification] = useState(null); + /** Steers the daemon accepted but has not yet drained into the run, in send + * order. The stream's `steer` echo splits this list on its watermark id. */ + const [pendingSteers, setPendingSteers] = useState([]); + const steerSerialRef = useRef(0); const [usage, setUsage] = useState({ inputTokens: 0, outputTokens: 0, @@ -353,10 +417,24 @@ export function useAgentChat( return () => controller.abort(); }, [sessionId, connected, rehydrate]); + const queueMessage = useCallback((text: string, files?: File[]) => { + const trimmed = text.trim(); + if (!trimmed && !files?.length) return; + setQueuedMessages((prev) => [ + ...prev, + { + id: `queued-${Date.now()}-${prev.length}`, + text: trimmed, + files: files?.length ? files : undefined, + }, + ]); + }, []); + /** * Builds the per-run stream-event handler shared by the prompt stream and * the failed-step retry relay (ADR 0239) — both drive the SAME translated - * event switch. + * event switch. `ids.assistant` is deliberately mutable: the steer drain + * echo re-anchors accumulation onto a fresh assistant bubble. */ const makeStreamHandler = useCallback( (daemonId: string, ids: { assistant: string }) => { @@ -432,6 +510,61 @@ export function useAgentChat( current === "waiting_approval" ? "streaming" : current, ); break; + case "steer": { + // The daemon drained the pending steer bundle into the run. + // The accepted steers are already optimistic user bubbles; + // MOVE them to the drain boundary and open a fresh assistant + // bubble there, so the reply to the injection streams below + // it in reading order. No duplicate echo message is added — + // the optimistic bubbles carry the same text the echo merges. + const nextAssistantId = `assistant-${Date.now() + 1}`; + ids.assistant = nextAssistantId; + setPendingSteers((prev) => { + const remaining = splitPendingSteersOnWatermark( + prev, + event.messageId, + ); + const remainingIds = new Set(remaining.map((p) => p.id)); + const drained = prev.filter((p) => !remainingIds.has(p.id)); + const drainedBubbleIds = new Set( + drained.map((p) => `steer-user-${p.id}`), + ); + setMessages((msgs) => { + const moved = msgs.filter((m) => drainedBubbleIds.has(m.id)); + const rest = msgs.filter((m) => !drainedBubbleIds.has(m.id)); + // A steer accepted by the daemon but missing locally + // (e.g. after a reload) still surfaces via the echo — with + // its committed media bundle (ADR 0251). + const echoAttachments = attachmentsFromSteerParts(event.parts); + const bubbles = + moved.length > 0 + ? moved + : event.text || echoAttachments + ? [ + { + id: `steer-echo-${Date.now()}`, + role: "user" as const, + content: event.text, + timestamp: Date.now(), + attachments: echoAttachments, + }, + ] + : []; + return [ + ...rest, + ...bubbles, + { + id: nextAssistantId, + role: "assistant", + content: "", + timestamp: Date.now() + 1, + }, + ]; + }); + return remaining; + }); + break; + } case "notice": patch((message) => ({ ...message, @@ -516,7 +649,13 @@ export function useAgentChat( const sendMessage = useCallback( async (content: string, files?: File[]) => { if (!connected) return; - if (status === "streaming" || status === "waiting_approval") return; + if (status === "streaming" || status === "waiting_approval") { + // Defense in depth behind the composer's own routing: text sent while + // a run is live is HELD, never fired into the funnel (which would + // refuse with "already has an active run") and never dropped. + queueMessage(content, files); + return; + } // Only images cross the wire — the daemon's prompt parts are // image/audio only (documents are a daemon capability gap). @@ -581,8 +720,8 @@ export function useAgentChat( const controller = new AbortController(); abortRef.current = controller; - // Failure patches outside the stream handler target the assistant - // bubble. + // Failure patches outside the stream handler target the CURRENT + // assistant bubble (the steer echo may have re-anchored it). const patch = (apply: (message: AgentMessage) => AgentMessage) => setMessages((prev) => prev.map((message) => @@ -661,7 +800,7 @@ export function useAgentChat( abortRef.current = null; } }, - [status, connected, makeStreamHandler], + [status, connected, queueMessage, makeStreamHandler], ); /** Re-sends the last prompt after a failure — the legacy Retry path, kept @@ -773,6 +912,26 @@ export function useAgentChat( if (ineligible) await resendLast(); }, [status, connected, resendLast, makeStreamHandler]); + /** A message typed while a run was active, held client-side: the daemon is + * strictly one-run-at-a-time (a mid-run prompt answers 412), so the queue + * lives here and drains one message per completed run. */ + const [queuedMessages, setQueuedMessages] = useState([]); + const flushingRef = useRef(false); + + const deleteQueued = useCallback((id: string) => { + setQueuedMessages((prev) => prev.filter((m) => m.id !== id)); + }, []); + + /** Removes the message from the queue and returns its text (for editing). */ + const takeQueued = useCallback( + (id: string) => { + const hit = queuedMessages.find((m) => m.id === id); + if (hit) setQueuedMessages((prev) => prev.filter((m) => m.id !== id)); + return hit?.text ?? null; + }, + [queuedMessages], + ); + const cancelChat = useCallback(async () => { abortRef.current?.abort(); if (daemonIdRef.current) { @@ -781,6 +940,171 @@ export function useAgentChat( setStatus("idle"); }, []); + // Steer is capability-gated (C1.2): the live `capabilities.steer` off + // /v1/compatibility, or the `http_steer` feature-registry row a rebuilt + // daemon serves. Absent both, mid-run sends queue instead. + const steerSupported = + serverCapabilities.steer === true || features.has("http_steer"); + + /** + * Injects a message — text plus staged image attachments (ADR 0251) — into + * the in-flight run at the next turn boundary. accepted/appended park it on + * the pending list until the drain echo; too_late or a failed request fall + * back to the queue so nothing is lost — the queue drains it as a normal + * prompt. + */ + const steerMessage = useCallback( + async (text: string, files?: File[]) => { + const trimmed = text.trim(); + if (!trimmed && !files?.length) return; + const daemonId = daemonIdRef.current; + if (!daemonId || !steerSupported) { + queueMessage(trimmed, files); + return; + } + const images = (files ?? []).filter((file) => + file.type.startsWith("image/"), + ); + let parts: PromptPart[]; + try { + parts = await Promise.all(images.map(imageToPart)); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + return; + } + // Same bytes as the wire parts, so the optimistic bubble's chips show + // thumbnails (mirrors sendMessage's attachment handling). + const attachments: Attachment[] | undefined = + images.length > 0 + ? images.map((file, index) => { + const part = parts[index]; + return { + name: file.name, + type: file.type, + url: part + ? `data:${part.mime_type};base64,${part.data}` + : undefined, + }; + }) + : undefined; + steerSerialRef.current += 1; + const id = `steer-${Date.now()}-${steerSerialRef.current}`; + try { + const { outcome } = await steerHarnessRun(daemonId, trimmed, id, { + parts, + }); + if (outcome === "accepted" || outcome === "appended") { + // The injected text IS a chat message — show it in the transcript + // right away, attachments included. pendingSteers stays internal + // bookkeeping (watermark reconciliation at the drain echo). + setMessages((prev) => [ + ...prev, + { + id: `steer-user-${id}`, + role: "user", + content: trimmed, + timestamp: Date.now(), + attachments, + }, + ]); + setPendingSteers((prev) => [ + ...prev, + { id, text: trimmed, files: images.length ? images : undefined }, + ]); + return; + } + queueMessage(trimmed, files); + } catch (caught) { + queueMessage(trimmed, files); + setError(caught instanceof Error ? caught.message : String(caught)); + } + }, + [queueMessage, steerSupported], + ); + + /** "Steer": pull a queued message and inject it into the in-flight run at + * the next turn boundary; on an idle chat it just sends. */ + const steerQueued = useCallback( + (id: string) => { + const hit = queuedMessages.find((m) => m.id === id); + if (!hit) return; + setQueuedMessages((prev) => prev.filter((m) => m.id !== id)); + if (status === "streaming" || status === "waiting_approval") { + void steerMessage(hit.text, hit.files); + return; + } + void sendMessage(hit.text, hit.files); + }, + [status, queuedMessages, steerMessage, sendMessage], + ); + + /** + * Retracts the pending steer bundle. The daemon models one bundle per run — + * only the whole thing can be retracted, not a single message. On + * `none_pending` the bundle already drained (the echo reconciles the list), + * so the pending list clears on either outcome. + */ + const cancelPendingSteers = useCallback(async () => { + const daemonId = daemonIdRef.current; + if (!daemonId) { + setPendingSteers([]); + return; + } + try { + await cancelHarnessSteer(daemonId); + setPendingSteers([]); + } catch (caught) { + // Unknown daemon state: keep the list rather than pretend it retracted. + setError(caught instanceof Error ? caught.message : String(caught)); + } + }, []); + + // On run end, steers that never drained were dropped with the run (the + // daemon's steer buffer is run-scoped, best-effort): move them to the FRONT + // of the queue, in order, so the drain below sends them as normal prompts — + // never lose text. Runs on error too: the texts wait visibly in the strip. + useEffect(() => { + if (status !== "idle" && status !== "error") return; + if (pendingSteers.length === 0) return; + const orphaned = pendingSteers; + setPendingSteers([]); + // Their optimistic bubbles come out of the transcript too — the daemon + // dropped these with the run, so the queue (visible) owns the text now. + const bubbleIds = new Set(orphaned.map((p) => `steer-user-${p.id}`)); + setMessages((prev) => prev.filter((m) => !bubbleIds.has(m.id))); + setQueuedMessages((prev) => [ + ...orphaned.map((steer) => ({ + id: `queued-${steer.id}`, + text: steer.text, + files: steer.files, + })), + ...prev, + ]); + }, [status, pendingSteers]); + + // Drain the queue one message per completed run. Only a clean idle flushes: + // an error waits for the user (retry/edit), a parked approval waits for the + // verdict, and orphaned pending steers get requeued (above) before anything + // sends. flushingRef bridges the async gap before sendMessage flips the + // status, so a re-render can't double-send. + useEffect(() => { + if ( + status !== "idle" || + !connected || + queuedMessages.length === 0 || + pendingSteers.length > 0 || + flushingRef.current + ) { + return; + } + flushingRef.current = true; + const next = queuedMessages[0]; + setQueuedMessages((prev) => prev.filter((m) => m.id !== next.id)); + void sendMessage(next.text, next.files).finally(() => { + flushingRef.current = false; + }); + }, [status, connected, queuedMessages, pendingSteers, sendMessage]); + const respondToApproval = useCallback( async (choice: ApprovalChoice) => { const daemonId = daemonIdRef.current; @@ -789,7 +1113,10 @@ export function useAgentChat( // The verdict resumes the SAME run — the prompt stream stays open and // keeps delivering (the daemon acks the approve; only the run's end // closes the stream). Mirror the retract handler: back to streaming, - // never idle. The stream's own end handler owns the eventual idle. + // never idle — a premature idle here let the steer-requeue and queue + // drain effects fire against the still-live run (a pending steer got + // re-sent as a prompt that 412s). The stream's own end handler owns + // the eventual idle. setStatus((current) => current === "waiting_approval" ? "streaming" : current, ); @@ -830,8 +1157,18 @@ export function useAgentChat( return { messages, // A parked approval is still an in-flight run daemon-side; the composer - // treats both as "run active". + // treats both as "run active" (queue/steer, never a raw prompt). isStreaming: status === "streaming" || status === "waiting_approval", + queuedMessages, + queueMessage, + deleteQueued, + takeQueued, + steerQueued, + pendingSteers, + steerMessage, + /** Mid-run steering is available (capability/feature gate, C1.2). */ + steerSupported, + cancelPendingSteers, status, error, harnessLive: connected, diff --git a/studio/src/lib/profile-preferences.ts b/studio/src/lib/profile-preferences.ts index db12c54f29..f7c3ed5801 100644 --- a/studio/src/lib/profile-preferences.ts +++ b/studio/src/lib/profile-preferences.ts @@ -186,6 +186,35 @@ export function useShowToolCalls() { return { showToolCalls, setShowToolCalls }; } +export type EnterSendBehavior = "queue" | "steer"; + +const ENTER_SEND_BEHAVIOR_KEY = "mecatl-studio.enter-send-behavior"; + +/** + * What Enter does while the agent is replying: queue the message for the next + * run (the factory default) or steer it into the in-flight run at the next + * step. Shift+Enter does the opposite. Hydrates on mount, so the first frame + * always reads "queue" — the composer tolerates that. + */ +export function useEnterSendBehavior() { + const [behavior, setBehaviorState] = useState("queue"); + useEffect(() => { + if (readLocalStorage(ENTER_SEND_BEHAVIOR_KEY) === "steer") { + setBehaviorState("steer"); + } + }, []); + + const setBehavior = useCallback((next: EnterSendBehavior) => { + setBehaviorState(next); + writeLocalStorage( + ENTER_SEND_BEHAVIOR_KEY, + next === "steer" ? "steer" : null, + ); + }, []); + + return { behavior, setBehavior }; +} + /** * The user's display name — browser-local, cosmetic. It labels your chat * messages in Studio; the AGENT learns your name in conversation (its memory