diff --git a/src/components/ai-elements/prompt-input.tsx b/src/components/ai-elements/prompt-input.tsx
index 09acdbd..bb0aade 100644
--- a/src/components/ai-elements/prompt-input.tsx
+++ b/src/components/ai-elements/prompt-input.tsx
@@ -15,6 +15,7 @@ import {
Trash2,
UserRound,
X,
+ Zap,
} from "lucide-react";
import type {
Attachment,
@@ -324,6 +325,19 @@ interface PromptInputProps {
* queue drains automatically on completion instead.
*/
onSendQueuedTurn?: (args: { itemId: string }) => void;
+ /**
+ * Push one queued turn into the response that is currently streaming, so a
+ * message that landed in the queue mid-turn does not have to wait for that
+ * turn to finish. Only offered while a turn is active and the host reports
+ * the turn as steerable via `canSteerQueuedTurn`.
+ */
+ onSteerQueuedTurn?: (args: { itemId: string }) => void;
+ /**
+ * Whether the active turn accepts mid-turn steering at all (setting on,
+ * provider supports it, turn live and not stalled). Attachment eligibility
+ * is decided per queue item, not here.
+ */
+ canSteerQueuedTurn?: boolean;
onClearQueuedNextTurn?: () => void;
onAbort?: () => void;
}
@@ -680,6 +694,8 @@ export function PromptInput(args: PromptInputProps) {
onUpdateQueuedTurn,
onRemoveQueuedTurn,
onSendQueuedTurn,
+ onSteerQueuedTurn,
+ canSteerQueuedTurn = false,
onClearQueuedNextTurn,
onAbort,
} = args;
@@ -855,6 +871,14 @@ export function PromptInput(args: PromptInputProps) {
submitMode === "send" &&
!interactionsDisabled &&
queuedTurns.length > 0;
+ // Steering a queued item into the live turn is the mirror image: offered
+ // only while a turn IS running, and again only for store-backed items.
+ const canSteerQueuedTurnNow =
+ Boolean(onSteerQueuedTurn) &&
+ canSteerQueuedTurn &&
+ isTurnActive &&
+ !interactionsDisabled &&
+ queuedTurns.length > 0;
const modifierLabel = useMemo(
() =>
typeof navigator !== "undefined" &&
@@ -2160,7 +2184,9 @@ export function PromptInput(args: PromptInputProps) {
{visibleQueuedTurns.length} queued follow-up
{visibleQueuedTurns.length === 1 ? "" : "s"}
{isTurnActive
- ? " · next sends automatically when the current response finishes"
+ ? canSteerQueuedTurnNow
+ ? " · next sends automatically when the current response finishes, or steer one into it now"
+ : " · next sends automatically when the current response finishes"
: canSendQueuedTurnNow
? " · send one now, or it sends after your next message finishes"
: ""}
@@ -2272,6 +2298,22 @@ export function PromptInput(args: PromptInputProps) {
) : null}
+ {canSteerQueuedTurnNow &&
+ item.attachedFilePaths.length === 0 &&
+ item.attachments.length === 0 ? (
+
+ onSteerQueuedTurn?.({ itemId: item.id })
+ }
+ >
+
+
+ ) : null}
{canSendQueuedTurnNow ? (
queuedItem.id === itemId);
+ if (!item) {
+ return;
+ }
+ const submissionTaskId = args.providerSelectionTarget;
+ if (pendingSteerTaskIdsRef.current.has(submissionTaskId)) {
+ return;
+ }
+ useAppStore.getState().requestTaskScrollToLatest({
+ taskId: args.activeTaskId,
+ });
+ setSteerSubmissionPending(submissionTaskId, true);
+ try {
+ const result = await sendUserMessage({
+ taskId: args.activeTaskId,
+ content: item.content,
+ turnOrigin: "conversation",
+ queuedTurnId: itemId,
+ submitIntent: "steer",
+ });
+ if (result.status === "steer-unavailable") {
+ toast.error("Couldn't steer this queued prompt", {
+ description: result.message,
+ });
+ } else if (result.status === "steer-delivery-unknown") {
+ toast.warning("Steer delivery is unconfirmed", {
+ description: result.message,
+ });
+ } else if (result.status === "blocked") {
+ toast.warning("Couldn't steer the queued prompt", {
+ description:
+ "The task is busy or waiting on another action. The prompt stays queued.",
+ });
+ }
+ } finally {
+ setSteerSubmissionPending(submissionTaskId, false);
+ }
+ }
+
const filePicker = window.api?.fs?.pickFiles;
const workspaceRootPath = args.workspaceCwd?.trim() || undefined;
const handleOpenFileSelector =
@@ -1599,6 +1664,8 @@ function ChatInputComposer(args: ChatInputComposerProps) {
onUpdateQueuedTurn={updateQueuedTurn}
onRemoveQueuedTurn={({ itemId }) => removeQueuedTurn(itemId)}
onSendQueuedTurn={({ itemId }) => void sendQueuedTurnNow(itemId)}
+ canSteerQueuedTurn={canSteerQueuedTurns}
+ onSteerQueuedTurn={({ itemId }) => void steerQueuedTurnNow(itemId)}
onSuggestionSelect={async (suggestion) => {
cancelPendingDraftSave();
useAppStore.getState().requestTaskScrollToLatest({
diff --git a/src/store/app.store.ts b/src/store/app.store.ts
index 508a3b6..a1fe05b 100644
--- a/src/store/app.store.ts
+++ b/src/store/app.store.ts
@@ -139,7 +139,8 @@ import {
adoptRestoredTurnsIntoStallNet,
createProviderTurnLivenessReporter,
} from "@/store/provider-turn-stall-rearm";
-import { submitSteerWithDeadline } from "@/store/steer-submit";
+import { createSteerQueueReservations } from "@/store/steer-queue-reservations";
+import { buildFailedSteerResult } from "@/store/steer-submit";
import {
applyPendingProviderEventsToStoreState,
createWorkspaceSessionStateFromAppState,
@@ -165,6 +166,7 @@ import {
resolveTurnModelForSend,
} from "@/store/prompt-draft-runtime";
import {
+ applySteeredPromptDraft,
buildPreservedQueuedDraft,
resolvePromptDraftAfterSend,
resolvePromptDraftSendState,
@@ -760,10 +762,13 @@ export const useAppStore = create()(
}),
});
+ const steerQueueReservations = createSteerQueueReservations();
+
const dispatchNextQueuedTaskTurn = createQueuedTaskTurnDispatcher({
getSession: (workspaceId) =>
getWorkspaceSessionForState({ state: get(), workspaceId }),
getActions: get,
+ blocksAutoDispatch: steerQueueReservations.blocksAutoDispatch,
});
const hasAsyncIterable = (
@@ -1949,11 +1954,20 @@ export const useAppStore = create()(
if (activeTurnId && activeTurnStalled) {
get().abortTaskTurn({ taskId: resolvedTaskId });
}
- if (queuedTurnToSend && activeTurnId && !activeTurnStalled) {
- // Manual dispatch of a queued item only makes sense while no live
- // turn is running — during an active turn the item is already in
- // line to auto-dispatch, and falling through here would re-queue
- // it as a duplicate.
+ // A queued item dispatched during a live turn is already in line to
+ // auto-dispatch, so sending it here would duplicate it — unless the
+ // caller explicitly asked to steer, which promotes it into the running
+ // turn instead of waiting (see the steer branch below). An item whose
+ // own steer is still in flight is off limits to every path, steer
+ // included: the provider may be about to accept it.
+ if (
+ queuedTurnToSend &&
+ (steerQueueReservations.blocksDispatch({
+ taskId: resolvedTaskId,
+ queuedTurnId: queuedTurnToSend.id,
+ }) ||
+ (activeTurnId && !activeTurnStalled && submitIntent !== "steer"))
+ ) {
return { status: "blocked" } satisfies SendUserMessageResult;
}
if (activeTurnId && !activeTurnStalled && submitIntent === "steer") {
@@ -1991,7 +2005,9 @@ export const useAppStore = create()(
}
const activeTurnProvider = steeringContext.providerId;
const clientMessageId = crypto.randomUUID();
- const steerResult = await submitSteerWithDeadline({
+ const steerResult = await steerQueueReservations.submitSteer({
+ taskId: resolvedTaskId,
+ queuedTurnId: queuedTurnToSend?.id,
send: steerTurn,
request: {
turnId: activeTurnId,
@@ -2001,24 +2017,11 @@ export const useAppStore = create()(
},
});
if (!steerResult.ok) {
- if (steerResult.delivery === "unknown") {
- return {
- status: "steer-delivery-unknown",
- taskId: resolvedTaskId,
- workspaceId: taskWorkspaceId,
- message:
- steerResult.message ||
- "Steer delivery could not be confirmed. Wait for the current response before retrying or queueing.",
- } satisfies SendUserMessageResult;
- }
- return {
- status: "steer-unavailable",
+ return buildFailedSteerResult({
+ result: steerResult,
taskId: resolvedTaskId,
workspaceId: taskWorkspaceId,
- message:
- steerResult.message ||
- "The active turn rejected the steer request — press Tab to queue instead.",
- } satisfies SendUserMessageResult;
+ });
}
set((nextState) => {
const isActiveWorkspace =
@@ -2055,21 +2058,15 @@ export const useAppStore = create()(
});
const promptDraftByTask =
cachedSession?.promptDraftByTask ?? nextState.promptDraftByTask;
- const currentDraft = promptDraftByTask[resolvedTaskId];
- const shouldClearSubmittedDraft =
- !preservePromptDraft && currentDraft?.text === promptDraft.text;
- const nextPromptDraftByTask = shouldClearSubmittedDraft
- ? {
- ...promptDraftByTask,
- [resolvedTaskId]: normalizePromptDraftForStorage({
- ...(currentDraft ?? sourcePromptDraft),
- text: "",
- attachedFilePaths: [],
- attachments: [],
- promptBatch: undefined,
- }),
- }
- : promptDraftByTask;
+ const nextPromptDraftByTask = applySteeredPromptDraft({
+ promptDraftByTask,
+ taskId: resolvedTaskId,
+ storedDraft: storedPromptDraftForTask,
+ sourceDraft: sourcePromptDraft,
+ sentDraft: promptDraft,
+ preservePromptDraft,
+ steeredQueuedTurn: queuedTurnToSend,
+ });
const activityByTask = turnStillActive
? startProviderTurnActivity({
activityByTask: nextState.providerTurnActivityByTask,
diff --git a/src/store/chat-state-helpers.ts b/src/store/chat-state-helpers.ts
index 9cd68dd..cb6df9d 100644
--- a/src/store/chat-state-helpers.ts
+++ b/src/store/chat-state-helpers.ts
@@ -22,6 +22,28 @@ export function buildRecentTimestamp() {
return new Date().toISOString();
}
+/**
+ * The provider actually serving `activeTurnId`.
+ *
+ * Never the composer's current selection: switching the model selector while a
+ * turn streams retargets the NEXT turn, not the running one, so steer
+ * eligibility has to follow the turn. The live activity snapshot is
+ * authoritative; history is the fallback for turns restored without one.
+ */
+export function resolveActiveTurnProviderId(args: {
+ activeTurnId: string;
+ activity?: Pick;
+ fallbackProviderId: ProviderId;
+ messages: ChatMessage[];
+}): ProviderId {
+ return args.activity?.turnId === args.activeTurnId
+ ? args.activity.providerId
+ : getRespondingProviderId({
+ fallbackProviderId: args.fallbackProviderId,
+ messages: args.messages,
+ });
+}
+
export function resolveMidTurnSteeringContext(args: {
activeTurnId: string;
activity?: Pick;
@@ -29,13 +51,7 @@ export function resolveMidTurnSteeringContext(args: {
messages: ChatMessage[];
hasAttachments: boolean;
}) {
- const providerId =
- args.activity?.turnId === args.activeTurnId
- ? args.activity.providerId
- : getRespondingProviderId({
- fallbackProviderId: args.fallbackProviderId,
- messages: args.messages,
- });
+ const providerId = resolveActiveTurnProviderId(args);
if (args.hasAttachments) {
return {
diff --git a/src/store/prompt-draft-send.ts b/src/store/prompt-draft-send.ts
index 40bfaa5..06ccbd7 100644
--- a/src/store/prompt-draft-send.ts
+++ b/src/store/prompt-draft-send.ts
@@ -123,3 +123,52 @@ export function resolvePromptDraftAfterSend(args: {
queuedTurns: args.queuedTurns,
});
}
+
+/**
+ * The prompt-draft map to store after a mid-turn steer succeeds.
+ *
+ * Two shapes, depending on where the steered payload came from. Steering the
+ * composer's own text clears it — but only while the composer still holds
+ * exactly what was sent, so a newer draft typed while the steer was in flight
+ * survives. Steering a staged queue item instead leaves the composer entirely
+ * alone and drops just that one item from the queue; every other queued item
+ * keeps waiting for its automatic dispatch.
+ */
+export function applySteeredPromptDraft(args: {
+ promptDraftByTask: Record;
+ taskId: string;
+ storedDraft?: PromptDraft;
+ sourceDraft: PromptDraft;
+ sentDraft: PromptDraft;
+ preservePromptDraft?: boolean;
+ steeredQueuedTurn?: PromptDraftQueuedTurn;
+}): Record {
+ const currentDraft = args.promptDraftByTask[args.taskId];
+ if (args.steeredQueuedTurn) {
+ const steeredId = args.steeredQueuedTurn.id;
+ const baseDraft = currentDraft ?? args.storedDraft ?? args.sourceDraft;
+ return {
+ ...args.promptDraftByTask,
+ [args.taskId]: normalizePromptDraftForStorage({
+ ...baseDraft,
+ queuedTurns: (baseDraft.queuedTurns ?? []).filter(
+ (item) => item.id !== steeredId,
+ ),
+ queuedNextTurn: undefined,
+ }),
+ };
+ }
+ if (args.preservePromptDraft || currentDraft?.text !== args.sentDraft.text) {
+ return args.promptDraftByTask;
+ }
+ return {
+ ...args.promptDraftByTask,
+ [args.taskId]: normalizePromptDraftForStorage({
+ ...(currentDraft ?? args.sourceDraft),
+ text: "",
+ attachedFilePaths: [],
+ attachments: [],
+ promptBatch: undefined,
+ }),
+ };
+}
diff --git a/src/store/queued-task-turn-dispatch.ts b/src/store/queued-task-turn-dispatch.ts
index 481e54a..e93ea4e 100644
--- a/src/store/queued-task-turn-dispatch.ts
+++ b/src/store/queued-task-turn-dispatch.ts
@@ -12,11 +12,27 @@ interface QueuedTaskTurnActions {
export function createQueuedTaskTurnDispatcher(args: {
getSession: (workspaceId: string) => WorkspaceSessionState | null;
getActions: () => QueuedTaskTurnActions;
+ /**
+ * Whether an item must sit out automatic dispatch — currently only true
+ * while a steer that promotes it into the running turn is in flight or
+ * unconfirmed. Sending it here would run the same prompt a second time, so
+ * such items are skipped and stay queued for the user to act on.
+ */
+ blocksAutoDispatch: (target: {
+ taskId: string;
+ queuedTurnId: string;
+ }) => boolean;
}) {
return (target: { workspaceId: string; taskId: string }) => {
const queuedPromptDraft =
args.getSession(target.workspaceId)?.promptDraftByTask[target.taskId];
- const [nextQueuedTurn] = queuedPromptDraft?.queuedTurns ?? [];
+ const nextQueuedTurn = (queuedPromptDraft?.queuedTurns ?? []).find(
+ (item) =>
+ !args.blocksAutoDispatch({
+ taskId: target.taskId,
+ queuedTurnId: item.id,
+ }),
+ );
if (!nextQueuedTurn) {
return;
}
diff --git a/src/store/steer-queue-reservations.ts b/src/store/steer-queue-reservations.ts
new file mode 100644
index 0000000..a0258cf
--- /dev/null
+++ b/src/store/steer-queue-reservations.ts
@@ -0,0 +1,100 @@
+import type {
+ ProviderSteerTurnRequest,
+ ProviderSteerTurnResponse,
+} from "@/lib/providers/provider.types";
+import { submitSteerWithDeadline } from "@/store/steer-submit";
+
+/**
+ * Why a queued item needs to sit out the queue for a while.
+ *
+ * - `in-flight`: a steer for it is waiting on the provider's acknowledgement.
+ * Nothing may dispatch it — the provider may be about to accept the steer,
+ * so any other send would run the same prompt twice.
+ * - `unconfirmed`: the acknowledgement never arrived (see
+ * `RENDERER_STEER_ACK_TIMEOUT_MS`). The provider may or may not have taken
+ * the text, so the item is held back from AUTOMATIC dispatch only. The user
+ * was told delivery is unconfirmed and can still send or re-steer it by hand.
+ */
+export type SteerQueueHoldReason = "in-flight" | "unconfirmed";
+
+export interface SteerQueueReservationTarget {
+ taskId: string;
+ queuedTurnId: string;
+}
+
+function buildHoldKey(target: SteerQueueReservationTarget) {
+ return `${target.taskId}::${target.queuedTurnId}`;
+}
+
+/**
+ * Keeps a queued prompt from being dispatched while its steer is in flight.
+ *
+ * Steering a staged queue item awaits the provider's acknowledgement, which
+ * can take up to `RENDERER_STEER_ACK_TIMEOUT_MS`. The running turn can easily
+ * finish inside that window, and turn completion drains the queue — so without
+ * a reservation the very item being steered gets started as a fresh turn while
+ * the provider is still accepting it into the old one, running the prompt
+ * twice. Reserving the item before the await closes that window.
+ *
+ * Holds are in-memory only and keyed by the queued item's UUID: a reload drops
+ * them along with the turn they were ambiguous about, and a stale key for an
+ * item that has since left the queue can never match a different item.
+ */
+export function createSteerQueueReservations() {
+ const holdsByKey = new Map();
+
+ function getHold(target: SteerQueueReservationTarget) {
+ return holdsByKey.get(buildHoldKey(target));
+ }
+
+ return {
+ /** True while no path at all may dispatch the item. */
+ blocksDispatch(target: SteerQueueReservationTarget) {
+ return getHold(target) === "in-flight";
+ },
+ /** True while turn-completion queue draining must skip the item. */
+ blocksAutoDispatch(target: SteerQueueReservationTarget) {
+ return getHold(target) !== undefined;
+ },
+ /**
+ * Submit a steer, reserving `queuedTurnId` (when the payload came from the
+ * queue) for exactly as long as delivery is undecided.
+ */
+ async submitSteer(args: {
+ taskId: string;
+ queuedTurnId?: string;
+ request: ProviderSteerTurnRequest;
+ send: (
+ request: ProviderSteerTurnRequest,
+ ) => Promise;
+ }): Promise {
+ const key = args.queuedTurnId
+ ? buildHoldKey({ taskId: args.taskId, queuedTurnId: args.queuedTurnId })
+ : null;
+ if (key) {
+ holdsByKey.set(key, "in-flight");
+ }
+ const result = await submitSteerWithDeadline({
+ request: args.request,
+ send: args.send,
+ });
+ if (key) {
+ // Accepted: the caller drops the item from the queue in the same tick.
+ // Rejected: the provider definitively did not take it, so it goes back
+ // to waiting for its normal turn. Unknown: keep it out of automatic
+ // dispatch, since a duplicate run is worse than a prompt the user has
+ // to send again deliberately.
+ if (result.ok || result.delivery !== "unknown") {
+ holdsByKey.delete(key);
+ } else {
+ holdsByKey.set(key, "unconfirmed");
+ }
+ }
+ return result;
+ },
+ };
+}
+
+export type SteerQueueReservations = ReturnType<
+ typeof createSteerQueueReservations
+>;
diff --git a/src/store/steer-submit.ts b/src/store/steer-submit.ts
index 50ea03f..980460c 100644
--- a/src/store/steer-submit.ts
+++ b/src/store/steer-submit.ts
@@ -6,6 +6,7 @@ import type {
ProviderSteerTurnRequest,
ProviderSteerTurnResponse,
} from "@/lib/providers/provider.types";
+import type { SendUserMessageResult } from "@/store/app-store.types";
export async function submitSteerWithDeadline(args: {
request: ProviderSteerTurnRequest;
@@ -36,3 +37,35 @@ export async function submitSteerWithDeadline(args: {
};
}
}
+
+/**
+ * The send result for a steer that did not land.
+ *
+ * Both shapes are returned BEFORE any state mutation, so whatever the user
+ * tried to steer — composer text or a staged queue item — stays exactly where
+ * it was and can be retried or left to dispatch normally.
+ */
+export function buildFailedSteerResult(args: {
+ result: ProviderSteerTurnResponse;
+ taskId: string;
+ workspaceId: string;
+}): SendUserMessageResult {
+ if (args.result.delivery === "unknown") {
+ return {
+ status: "steer-delivery-unknown",
+ taskId: args.taskId,
+ workspaceId: args.workspaceId,
+ message:
+ args.result.message ||
+ "Steer delivery could not be confirmed. Wait for the current response before retrying or queueing.",
+ };
+ }
+ return {
+ status: "steer-unavailable",
+ taskId: args.taskId,
+ workspaceId: args.workspaceId,
+ message:
+ args.result.message ||
+ "The active turn rejected the steer request — press Tab to queue instead.",
+ };
+}
diff --git a/tests/bridge-persistence-regression.test.ts b/tests/bridge-persistence-regression.test.ts
index c881156..b6ec8e5 100644
--- a/tests/bridge-persistence-regression.test.ts
+++ b/tests/bridge-persistence-regression.test.ts
@@ -5174,6 +5174,316 @@ describe("workspace store hydration ordering", () => {
);
});
+ test("a queued item can be steered into the live turn, leaving the rest of the queue and the composer intact", async () => {
+ const localStorage = createMemoryStorage();
+ const steerCalls: Array<{ turnId: string; text: string }> = [];
+ let nextSteerResult: ProviderSteerTurnResponse = {
+ ok: true,
+ delivery: "accepted",
+ };
+
+ (globalThis as { window: unknown }).window = {
+ localStorage,
+ setTimeout: globalThis.setTimeout.bind(globalThis),
+ clearTimeout: globalThis.clearTimeout.bind(globalThis),
+ api: {
+ provider: {
+ startPushTurn: async () => ({
+ ok: true,
+ streamId: "stream-1",
+ turnId: "turn-1",
+ }),
+ subscribeStreamEvents: () => () => {},
+ abortTurn: async () => ({ ok: true, message: "aborted" }),
+ cleanupTask: async () => ({ ok: true }),
+ steerTurn: async (steerArgs: { turnId: string; text: string }) => {
+ steerCalls.push({
+ turnId: steerArgs.turnId,
+ text: steerArgs.text,
+ });
+ return nextSteerResult;
+ },
+ },
+ fs: {
+ readFile: async () => ({
+ ok: false,
+ content: "",
+ revision: "",
+ stderr: "not found",
+ }),
+ },
+ },
+ } as unknown;
+
+ const { useAppStore } = await import("../src/store/app.store");
+ const initialState = useAppStore.getInitialState();
+ useAppStore.setState({
+ ...initialState,
+ hasHydratedWorkspaces: true,
+ workspaces: [
+ { id: "ws-main", name: "Main", updatedAt: "2026-04-09T00:00:00.000Z" },
+ ],
+ activeWorkspaceId: "ws-main",
+ activeTaskId: "task-main",
+ projectPath: "/tmp/stave-project",
+ workspacePathById: { "ws-main": "/tmp/stave-project" },
+ workspaceBranchById: { "ws-main": "main" },
+ workspaceDefaultById: { "ws-main": true },
+ draftProvider: "codex",
+ tasks: [
+ {
+ id: "task-main",
+ title: "Main Task",
+ provider: "codex",
+ updatedAt: "2026-04-09T00:00:00.000Z",
+ unread: false,
+ archivedAt: null,
+ },
+ ],
+ messagesByTask: { "task-main": [] },
+ activeTurnIdsByTask: {},
+ promptDraftByTask: {},
+ nativeSessionReadyByTask: {},
+ providerSessionByTask: {},
+ });
+
+ const started = await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "First prompt",
+ });
+ expect(started).toMatchObject({ status: "started" });
+ const activeTurnId = (started as { turnId: string }).turnId;
+
+ for (const content of ["First follow-up", "Second follow-up"]) {
+ const queued = await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content,
+ submitIntent: "queue",
+ });
+ expect(queued).toMatchObject({ status: "queued" });
+ }
+ // Something typed but not yet sent must survive the queue steer.
+ useAppStore.getState().updatePromptDraft({
+ taskId: "task-main",
+ patch: { text: "Still typing this one" },
+ });
+ const queuedTurnIds = (
+ useAppStore.getState().promptDraftByTask["task-main"]?.queuedTurns ?? []
+ ).map((item) => item.id);
+ expect(queuedTurnIds).toHaveLength(2);
+
+ // Without an explicit steer intent, dispatching a queued item during a
+ // live turn stays blocked — it is already in line to auto-dispatch.
+ const blocked = await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "First follow-up",
+ queuedTurnId: queuedTurnIds[0],
+ });
+ expect(blocked).toEqual({ status: "blocked" });
+ expect(steerCalls).toEqual([]);
+
+ const steered = await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "First follow-up",
+ queuedTurnId: queuedTurnIds[0],
+ submitIntent: "steer",
+ });
+ expect(steered).toEqual({
+ status: "steered",
+ taskId: "task-main",
+ workspaceId: "ws-main",
+ turnId: activeTurnId,
+ });
+ expect(steerCalls).toEqual([
+ { turnId: activeTurnId, text: "First follow-up" },
+ ]);
+
+ const steeredState = useAppStore.getState();
+ // Only the steered item leaves the queue; the composer keeps its text.
+ expect(
+ steeredState.promptDraftByTask["task-main"]?.queuedTurns?.map(
+ (item) => item.content,
+ ),
+ ).toEqual(["Second follow-up"]);
+ expect(steeredState.promptDraftByTask["task-main"]?.text).toBe(
+ "Still typing this one",
+ );
+ expect(steeredState.messagesByTask["task-main"]?.at(-2)).toMatchObject({
+ role: "user",
+ content: "First follow-up",
+ steeredIntoTurnId: activeTurnId,
+ });
+
+ // A rejected steer leaves the item queued so the user can retry or let it
+ // auto-dispatch when the turn ends.
+ nextSteerResult = {
+ ok: false,
+ delivery: "rejected",
+ message: "turn not steerable",
+ };
+ const rejected = await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Second follow-up",
+ queuedTurnId: queuedTurnIds[1],
+ submitIntent: "steer",
+ });
+ expect(rejected).toMatchObject({ status: "steer-unavailable" });
+ expect(
+ useAppStore
+ .getState()
+ .promptDraftByTask["task-main"]?.queuedTurns?.map(
+ (item) => item.content,
+ ),
+ ).toEqual(["Second follow-up"]);
+ expect(useAppStore.getState().promptDraftByTask["task-main"]?.text).toBe(
+ "Still typing this one",
+ );
+ });
+
+ test("a queued item with a steer in flight is off limits to every other dispatch path", async () => {
+ const localStorage = createMemoryStorage();
+ const steerCalls: Array<{ turnId: string; text: string }> = [];
+ let settlePendingSteer: (value: ProviderSteerTurnResponse) => void =
+ () => {};
+ let nextSteerResult: Promise = new Promise(
+ (resolve) => {
+ settlePendingSteer = resolve;
+ },
+ );
+
+ (globalThis as { window: unknown }).window = {
+ localStorage,
+ setTimeout: globalThis.setTimeout.bind(globalThis),
+ clearTimeout: globalThis.clearTimeout.bind(globalThis),
+ api: {
+ provider: {
+ startPushTurn: async () => ({
+ ok: true,
+ streamId: "stream-1",
+ turnId: "turn-1",
+ }),
+ subscribeStreamEvents: () => () => {},
+ abortTurn: async () => ({ ok: true, message: "aborted" }),
+ cleanupTask: async () => ({ ok: true }),
+ steerTurn: (steerArgs: { turnId: string; text: string }) => {
+ steerCalls.push({
+ turnId: steerArgs.turnId,
+ text: steerArgs.text,
+ });
+ return nextSteerResult;
+ },
+ },
+ fs: {
+ readFile: async () => ({
+ ok: false,
+ content: "",
+ revision: "",
+ stderr: "not found",
+ }),
+ },
+ },
+ } as unknown;
+
+ const { useAppStore } = await import("../src/store/app.store");
+ const initialState = useAppStore.getInitialState();
+ useAppStore.setState({
+ ...initialState,
+ hasHydratedWorkspaces: true,
+ workspaces: [
+ { id: "ws-main", name: "Main", updatedAt: "2026-04-09T00:00:00.000Z" },
+ ],
+ activeWorkspaceId: "ws-main",
+ activeTaskId: "task-main",
+ projectPath: "/tmp/stave-project",
+ workspacePathById: { "ws-main": "/tmp/stave-project" },
+ workspaceBranchById: { "ws-main": "main" },
+ workspaceDefaultById: { "ws-main": true },
+ draftProvider: "codex",
+ tasks: [
+ {
+ id: "task-main",
+ title: "Main Task",
+ provider: "codex",
+ updatedAt: "2026-04-09T00:00:00.000Z",
+ unread: false,
+ archivedAt: null,
+ },
+ ],
+ messagesByTask: { "task-main": [] },
+ activeTurnIdsByTask: {},
+ promptDraftByTask: {},
+ nativeSessionReadyByTask: {},
+ providerSessionByTask: {},
+ });
+
+ await useAppStore
+ .getState()
+ .sendUserMessage({ taskId: "task-main", content: "First prompt" });
+ await useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Follow-up",
+ submitIntent: "queue",
+ });
+ const [queuedTurnId] = (
+ useAppStore.getState().promptDraftByTask["task-main"]?.queuedTurns ?? []
+ ).map((item) => item.id);
+
+ // Steer #1 is parked on the provider acknowledgement, which can take up to
+ // RENDERER_STEER_ACK_TIMEOUT_MS. The item must not leave the queue by any
+ // other route while the provider may still accept it.
+ const inFlight = useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Follow-up",
+ queuedTurnId,
+ submitIntent: "steer",
+ });
+ await expect(
+ useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Follow-up",
+ queuedTurnId,
+ submitIntent: "steer",
+ }),
+ ).resolves.toEqual({ status: "blocked" });
+ await expect(
+ useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Follow-up",
+ queuedTurnId,
+ }),
+ ).resolves.toEqual({ status: "blocked" });
+ expect(steerCalls).toHaveLength(1);
+
+ // Unconfirmed delivery keeps the item queued. Automatic dispatch stays
+ // suppressed (a duplicate run is worse than a prompt sent again on
+ // purpose), but the user, who was warned, may still retry by hand.
+ settlePendingSteer({ ok: false, delivery: "unknown" });
+ await expect(inFlight).resolves.toMatchObject({
+ status: "steer-delivery-unknown",
+ });
+ expect(
+ useAppStore
+ .getState()
+ .promptDraftByTask["task-main"]?.queuedTurns?.map(
+ (item) => item.content,
+ ),
+ ).toEqual(["Follow-up"]);
+
+ nextSteerResult = Promise.resolve({ ok: true, delivery: "accepted" });
+ await expect(
+ useAppStore.getState().sendUserMessage({
+ taskId: "task-main",
+ content: "Follow-up",
+ queuedTurnId,
+ submitIntent: "steer",
+ }),
+ ).resolves.toMatchObject({ status: "steered" });
+ expect(steerCalls).toHaveLength(2);
+ expect(
+ useAppStore.getState().promptDraftByTask["task-main"]?.queuedTurns ?? [],
+ ).toEqual([]);
+ });
+
test("Fleet-style steer and queue target an inactive workspace without clearing its composer", async () => {
const localStorage = createMemoryStorage();
const steerCalls: Array<{ turnId: string; text: string }> = [];
diff --git a/tests/chat-state-helpers.test.ts b/tests/chat-state-helpers.test.ts
index 6117290..86576d6 100644
--- a/tests/chat-state-helpers.test.ts
+++ b/tests/chat-state-helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import {
buildPendingProviderTurnState,
buildSteeredUserMessageState,
+ resolveActiveTurnProviderId,
resolveMidTurnSteeringContext,
} from "@/store/chat-state-helpers";
import type { ChatMessage, Task } from "@/types/chat";
@@ -28,6 +29,42 @@ const sharedArgs = {
content: "hello",
};
+describe("resolveActiveTurnProviderId", () => {
+ test("follows the running turn, not a selector switched mid-turn", () => {
+ // The UI derives steer affordances from this too, so a provider switch
+ // that only retargets the NEXT turn must not change the answer here.
+ expect(
+ resolveActiveTurnProviderId({
+ activeTurnId: "turn-1",
+ activity: { turnId: "turn-1", providerId: "claude-code" },
+ fallbackProviderId: "codex",
+ messages: [],
+ }),
+ ).toBe("claude-code");
+ });
+
+ test("falls back to history when the activity snapshot is for another turn", () => {
+ expect(
+ resolveActiveTurnProviderId({
+ activeTurnId: "turn-2",
+ activity: { turnId: "turn-1", providerId: "claude-code" },
+ fallbackProviderId: "claude-code",
+ messages: [
+ {
+ id: "assistant-1",
+ role: "assistant",
+ model: "gpt-5.4",
+ providerId: "codex",
+ content: "Working",
+ isStreaming: true,
+ parts: [],
+ },
+ ],
+ }),
+ ).toBe("codex");
+ });
+});
+
describe("resolveMidTurnSteeringContext", () => {
test("keeps steering bound to the provider that owns the active turn", () => {
const result = resolveMidTurnSteeringContext({
diff --git a/tests/prompt-draft-send.test.ts b/tests/prompt-draft-send.test.ts
index ecf5710..c77a82c 100644
--- a/tests/prompt-draft-send.test.ts
+++ b/tests/prompt-draft-send.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
+ applySteeredPromptDraft,
buildPreservedQueuedDraft,
buildPromptDraftForSend,
resolvePromptDraftAfterSend,
@@ -133,4 +134,57 @@ describe("prompt draft send state", () => {
queuedTurns,
});
});
+ test("drops only the steered queue item and leaves the composer alone", () => {
+ const queuedTurn = {
+ id: "queued-1",
+ queuedAt: "2026-07-22T00:00:00.000Z",
+ sourceTurnId: "turn-1",
+ content: "Steer me into the live turn",
+ attachedFilePaths: [],
+ attachments: [],
+ };
+ const followUp = { ...queuedTurn, id: "queued-2", content: "Then this" };
+ const storedDraft = {
+ ...SOURCE_DRAFT,
+ queuedTurns: [queuedTurn, followUp],
+ };
+
+ const next = applySteeredPromptDraft({
+ promptDraftByTask: { "task-1": storedDraft },
+ taskId: "task-1",
+ storedDraft,
+ sourceDraft: SOURCE_DRAFT,
+ sentDraft: { ...SOURCE_DRAFT, text: queuedTurn.content },
+ steeredQueuedTurn: queuedTurn,
+ });
+
+ expect(next["task-1"]?.text).toBe("Keep this composer draft");
+ expect(next["task-1"]?.queuedTurns?.map((item) => item.id)).toEqual([
+ "queued-2",
+ ]);
+ });
+
+ test("clears the composer after steering its own text, but not a newer draft", () => {
+ const sentDraft = { ...SOURCE_DRAFT, text: "Steered text" };
+
+ expect(
+ applySteeredPromptDraft({
+ promptDraftByTask: { "task-1": sentDraft },
+ taskId: "task-1",
+ sourceDraft: SOURCE_DRAFT,
+ sentDraft,
+ })["task-1"],
+ ).toMatchObject({ text: "", attachedFilePaths: [], attachments: [] });
+
+ const newerDraft = { ...SOURCE_DRAFT, text: "Typed while in flight" };
+ const promptDraftByTask = { "task-1": newerDraft };
+ expect(
+ applySteeredPromptDraft({
+ promptDraftByTask,
+ taskId: "task-1",
+ sourceDraft: SOURCE_DRAFT,
+ sentDraft,
+ }),
+ ).toBe(promptDraftByTask);
+ });
});
diff --git a/tests/prompt-input-queue-mode.test.tsx b/tests/prompt-input-queue-mode.test.tsx
index ae9acc2..a2a6069 100644
--- a/tests/prompt-input-queue-mode.test.tsx
+++ b/tests/prompt-input-queue-mode.test.tsx
@@ -409,6 +409,114 @@ describe("PromptInput queue mode", () => {
);
});
+ test("offers a steer action on queued turns while a steerable turn is active", async () => {
+ setWindowContext();
+ const [{ PromptInput }, { TooltipProvider }] = await Promise.all([
+ import("@/components/ai-elements/prompt-input"),
+ import("@/components/ui"),
+ ]);
+ const html = renderToStaticMarkup(
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(PromptInput, {
+ value: "",
+ isTurnActive: true,
+ submitMode: "steer-or-queue" as const,
+ queuedTurns: [
+ {
+ id: "queue-1",
+ queuedAt: "2026-04-09T00:00:00.000Z",
+ sourceTurnId: "turn-1",
+ content: "Actually check the migration too",
+ attachedFilePaths: [],
+ attachments: [],
+ },
+ {
+ id: "queue-2",
+ queuedAt: "2026-04-09T00:01:00.000Z",
+ sourceTurnId: "turn-1",
+ content: "Then look at the screenshot",
+ attachedFilePaths: ["README.md"],
+ attachments: [],
+ },
+ ],
+ selectedModel: CLAUDE_MODEL_OPTION,
+ modelOptions: [CLAUDE_MODEL_OPTION],
+ attachedFilePaths: [],
+ attachments: [],
+ onValueChange: () => {},
+ onModelSelect: () => {},
+ onAttachFilesChange: () => {},
+ onSubmit: () => {},
+ onClearQueuedNextTurn: () => {},
+ canSteerQueuedTurn: true,
+ onSteerQueuedTurn: () => {},
+ onAbort: () => {},
+ }),
+ ),
+ );
+
+ expect(html).toContain(
+ 'aria-label="Steer queued prompt 1 into the current response"',
+ );
+ // Attachments can't ride along with a steer, so that item keeps waiting
+ // for the auto-dispatch instead of offering the button.
+ expect(html).not.toContain(
+ 'aria-label="Steer queued prompt 2 into the current response"',
+ );
+ expect(html).toContain("or steer one into it now");
+ // Steering is not the same as dispatching a fresh turn — the send-now
+ // action stays hidden while the turn runs.
+ expect(html).not.toContain('aria-label="Send queued prompt 1 now"');
+ });
+
+ test("hides the steer action on queued turns when the turn is not steerable", async () => {
+ setWindowContext();
+ const [{ PromptInput }, { TooltipProvider }] = await Promise.all([
+ import("@/components/ai-elements/prompt-input"),
+ import("@/components/ui"),
+ ]);
+ const html = renderToStaticMarkup(
+ createElement(
+ TooltipProvider,
+ null,
+ createElement(PromptInput, {
+ value: "",
+ isTurnActive: true,
+ submitMode: "queue-next" as const,
+ queuedTurns: [
+ {
+ id: "queue-1",
+ queuedAt: "2026-04-09T00:00:00.000Z",
+ sourceTurnId: "turn-1",
+ content: "Actually check the migration too",
+ attachedFilePaths: [],
+ attachments: [],
+ },
+ ],
+ selectedModel: MODEL_OPTION,
+ modelOptions: [MODEL_OPTION],
+ attachedFilePaths: [],
+ attachments: [],
+ onValueChange: () => {},
+ onModelSelect: () => {},
+ onAttachFilesChange: () => {},
+ onSubmit: () => {},
+ onClearQueuedNextTurn: () => {},
+ canSteerQueuedTurn: false,
+ onSteerQueuedTurn: () => {},
+ onAbort: () => {},
+ }),
+ ),
+ );
+
+ expect(html).not.toContain(
+ 'aria-label="Steer queued prompt 1 into the current response"',
+ );
+ expect(html).not.toContain("or steer one into it now");
+ });
+
test("shows Stop instead of Send when a turn is active and the draft is empty", async () => {
setWindowContext();
const [{ PromptInput }, { TooltipProvider }] = await Promise.all([
diff --git a/tests/queued-task-turn-dispatch.test.ts b/tests/queued-task-turn-dispatch.test.ts
index fe8da93..d53bf20 100644
--- a/tests/queued-task-turn-dispatch.test.ts
+++ b/tests/queued-task-turn-dispatch.test.ts
@@ -52,6 +52,7 @@ describe("queued task turn dispatcher", () => {
return Promise.resolve({ status: "started" });
},
}),
+ blocksAutoDispatch: () => false,
});
dispatch({ workspaceId: "ws-1", taskId: "task-1" });
@@ -86,13 +87,62 @@ describe("queued task turn dispatcher", () => {
attachments: [],
}),
getActions: () => actions,
+ blocksAutoDispatch: () => false,
})({ workspaceId: "ws-1", taskId: "task-1" });
createQueuedTaskTurnDispatcher({
getSession: () => null,
getActions: () => actions,
+ blocksAutoDispatch: () => false,
})({ workspaceId: "ws-1", taskId: "task-1" });
expect(sent).toEqual([]);
});
+
+ test("skips items reserved by an in-flight steer so an accepted steer never runs twice", () => {
+ const sent: Array<{ queuedTurnId: string }> = [];
+ const draft: PromptDraft = {
+ text: "",
+ attachedFilePaths: [],
+ attachments: [],
+ queuedTurns: [
+ {
+ id: "queued-steering",
+ queuedAt: "2026-08-01T00:00:00.000Z",
+ content: "Being steered into the running turn",
+ attachedFilePaths: [],
+ attachments: [],
+ },
+ {
+ id: "queued-waiting",
+ queuedAt: "2026-08-01T00:00:01.000Z",
+ content: "Still waiting its turn",
+ attachedFilePaths: [],
+ attachments: [],
+ },
+ ],
+ };
+ const actions = {
+ sendUserMessage: (args: { queuedTurnId: string }) => {
+ sent.push(args);
+ return Promise.resolve({ status: "started" });
+ },
+ };
+ const reserved = new Set(["queued-steering"]);
+ const dispatch = createQueuedTaskTurnDispatcher({
+ getSession: () => buildSessionWithDraft(draft),
+ getActions: () => actions,
+ blocksAutoDispatch: ({ queuedTurnId }) => reserved.has(queuedTurnId),
+ });
+
+ // The head is mid-steer: the turn that just settled must not start it as a
+ // fresh turn, so draining moves on to the next unreserved item.
+ dispatch({ workspaceId: "ws-1", taskId: "task-1" });
+ expect(sent.map((item) => item.queuedTurnId)).toEqual(["queued-waiting"]);
+
+ // Nothing dispatchable at all: the queue simply waits.
+ reserved.add("queued-waiting");
+ dispatch({ workspaceId: "ws-1", taskId: "task-1" });
+ expect(sent.map((item) => item.queuedTurnId)).toEqual(["queued-waiting"]);
+ });
});
diff --git a/tests/steer-queue-reservations.test.ts b/tests/steer-queue-reservations.test.ts
new file mode 100644
index 0000000..97d88d5
--- /dev/null
+++ b/tests/steer-queue-reservations.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, test } from "bun:test";
+import type { ProviderSteerTurnResponse } from "@/lib/providers/provider.types";
+import { createSteerQueueReservations } from "@/store/steer-queue-reservations";
+
+const REQUEST = {
+ turnId: "turn-1",
+ text: "Steered follow-up",
+ enabled: true,
+};
+
+const TARGET = { taskId: "task-1", queuedTurnId: "queued-1" };
+
+function createDeferredSteer() {
+ let resolveSteer: (value: ProviderSteerTurnResponse) => void = () => {};
+ const pending = new Promise((resolve) => {
+ resolveSteer = resolve;
+ });
+ return {
+ send: () => pending,
+ settle: (value: ProviderSteerTurnResponse) => resolveSteer(value),
+ };
+}
+
+describe("steer queue reservations", () => {
+ test("holds a queued item against every dispatch path until delivery resolves", async () => {
+ const reservations = createSteerQueueReservations();
+ const steer = createDeferredSteer();
+
+ const submission = reservations.submitSteer({
+ taskId: TARGET.taskId,
+ queuedTurnId: TARGET.queuedTurnId,
+ request: REQUEST,
+ send: steer.send,
+ });
+
+ // The reservation must exist BEFORE the acknowledgement lands: the running
+ // turn can finish inside the ack window, and turn completion drains the
+ // queue — the item being steered has to be invisible to that drain.
+ expect(reservations.blocksDispatch(TARGET)).toBe(true);
+ expect(reservations.blocksAutoDispatch(TARGET)).toBe(true);
+ // Other items are untouched.
+ expect(
+ reservations.blocksAutoDispatch({
+ taskId: TARGET.taskId,
+ queuedTurnId: "queued-2",
+ }),
+ ).toBe(false);
+ // So are same-id items in another task.
+ expect(
+ reservations.blocksAutoDispatch({
+ taskId: "task-2",
+ queuedTurnId: TARGET.queuedTurnId,
+ }),
+ ).toBe(false);
+
+ steer.settle({ ok: true, delivery: "accepted" });
+ await expect(submission).resolves.toMatchObject({ ok: true });
+
+ // Accepted: the caller drops the item from the queue in the same tick, so
+ // the hold is done.
+ expect(reservations.blocksDispatch(TARGET)).toBe(false);
+ expect(reservations.blocksAutoDispatch(TARGET)).toBe(false);
+ });
+
+ test("releases the hold when the provider rejects, so the item queues normally again", async () => {
+ const reservations = createSteerQueueReservations();
+
+ const result = await reservations.submitSteer({
+ taskId: TARGET.taskId,
+ queuedTurnId: TARGET.queuedTurnId,
+ request: REQUEST,
+ send: async () => ({
+ ok: false,
+ delivery: "rejected",
+ message: "turn not steerable",
+ }),
+ });
+
+ expect(result).toMatchObject({ ok: false, delivery: "rejected" });
+ expect(reservations.blocksDispatch(TARGET)).toBe(false);
+ expect(reservations.blocksAutoDispatch(TARGET)).toBe(false);
+ });
+
+ test("keeps an unconfirmed steer out of automatic dispatch while leaving manual sends open", async () => {
+ const reservations = createSteerQueueReservations();
+
+ const result = await reservations.submitSteer({
+ taskId: TARGET.taskId,
+ queuedTurnId: TARGET.queuedTurnId,
+ request: REQUEST,
+ send: async () => ({ ok: false, delivery: "unknown" }),
+ });
+
+ expect(result).toMatchObject({ ok: false, delivery: "unknown" });
+ // The provider may have taken the text, so turn completion must not run it
+ // a second time — but the user, who was told delivery is unconfirmed, can
+ // still send or re-steer it deliberately.
+ expect(reservations.blocksAutoDispatch(TARGET)).toBe(true);
+ expect(reservations.blocksDispatch(TARGET)).toBe(false);
+ });
+
+ test("steering the composer reserves nothing, since no queue item is at stake", async () => {
+ const reservations = createSteerQueueReservations();
+ const steer = createDeferredSteer();
+
+ const submission = reservations.submitSteer({
+ taskId: TARGET.taskId,
+ request: REQUEST,
+ send: steer.send,
+ });
+
+ expect(reservations.blocksAutoDispatch(TARGET)).toBe(false);
+ steer.settle({ ok: true, delivery: "accepted" });
+ await expect(submission).resolves.toMatchObject({ ok: true });
+ });
+});