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.
+
+ );
+}
+
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