Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/components/ai-elements/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
Trash2,
UserRound,
X,
Zap,
} from "lucide-react";
import type {
Attachment,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -680,6 +694,8 @@ export function PromptInput(args: PromptInputProps) {
onUpdateQueuedTurn,
onRemoveQueuedTurn,
onSendQueuedTurn,
onSteerQueuedTurn,
canSteerQueuedTurn = false,
onClearQueuedNextTurn,
onAbort,
} = args;
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -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"
: ""}
Expand Down Expand Up @@ -2272,6 +2298,22 @@ export function PromptInput(args: PromptInputProps) {
) : null}
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
{canSteerQueuedTurnNow &&
item.attachedFilePaths.length === 0 &&
item.attachments.length === 0 ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="text-muted-foreground hover:text-primary"
aria-label={`Steer queued prompt ${index + 1} into the current response`}
onClick={() =>
onSteerQueuedTurn?.({ itemId: item.id })
}
>
<Zap className="size-3.5" />
</Button>
) : null}
{canSendQueuedTurnNow ? (
<Button
type="button"
Expand Down
55 changes: 55 additions & 0 deletions src/components/session/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,16 @@ function ChatInputComposer(args: ChatInputComposerProps) {
providerSupportsMidTurnSteering({ providerId: args.activeProvider }) &&
(promptDraft.attachments?.length ?? 0) === 0 &&
(promptDraft.attachedFilePaths?.length ?? 0) === 0;
// Whether a staged queue item may be promoted into the turn that is already
// running, instead of waiting for that turn to finish. Deliberately
// independent of what is typed in the composer — only the queued item's own
// payload gets steered, so the composer's attachments are irrelevant here.
// Per-item attachments still block steering and are checked on each chip.
const canSteerQueuedTurns =
midTurnSteeringEnabled &&
providerSupportsMidTurnSteering({ providerId: args.activeProvider }) &&
args.isTurnActive &&
providerTurnDisplayState !== "stalled";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the running provider before offering queue steering

When the user changes the enabled model/provider selector while a turn is running, args.activeProvider becomes the newly selected task provider rather than the provider serving the active turn. Consequently, switching away from a steer-capable running provider hides these new actions, while switching toward one exposes actions that resolveMidTurnSteeringContext rejects after the click. Derive this capability from the matching providerTurnActivity.providerId (with the same history fallback used by the store) instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right in principle — fixed in 00204c9, though it is latent today: both descriptors set supportsMidTurnSteering: true, so no provider switch can currently make the UI and resolveMidTurnSteeringContext disagree. It would start biting silently the moment a provider turns the capability off, so worth closing now.

Extracted the store's own resolution into resolveActiveTurnProviderId (activity snapshot matching activeTurnId, else the getRespondingProviderId history fallback) and used it in both places, so the chip affordance and the dispatch gate cannot drift.

Left canSteerActiveTurn (composer Enter/Tab) on the selector: that is pre-existing behaviour and this PR deliberately does not touch the Enter/Tab affordances. Happy to follow up separately if you want them unified.

const managedTaskComposerAccess = resolveManagedTaskComposerAccess({
managedTaskOwner: args.managedTaskOwner,
isTurnActive: args.isTurnActive && providerTurnDisplayState !== "stalled",
Expand Down Expand Up @@ -918,6 +928,49 @@ function ChatInputComposer(args: ChatInputComposerProps) {
}
}

// Promote one staged queue item into the response that is already running
// instead of waiting for it to finish. On any failure the store returns
// before mutating, so the item simply stays queued.
async function steerQueuedTurnNow(itemId: string) {
const item = queuedTurns.find((queuedItem) => 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 =
Expand Down Expand Up @@ -1599,6 +1652,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({
Expand Down
40 changes: 20 additions & 20 deletions src/store/app.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import {
resolveTurnModelForSend,
} from "@/store/prompt-draft-runtime";
import {
applySteeredPromptDraft,
buildPreservedQueuedDraft,
resolvePromptDraftAfterSend,
resolvePromptDraftSendState,
Expand Down Expand Up @@ -1949,11 +1950,16 @@ export const useAppStore = create<AppState>()(
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).
if (
queuedTurnToSend &&
activeTurnId &&
!activeTurnStalled &&
submitIntent !== "steer"
) {
return { status: "blocked" } satisfies SendUserMessageResult;
}
if (activeTurnId && !activeTurnStalled && submitIntent === "steer") {
Expand Down Expand Up @@ -2055,21 +2061,15 @@ export const useAppStore = create<AppState>()(
});
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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reserve queued prompts before awaiting steer delivery

When the steer acknowledgment is delayed until after the active turn completes, the queued item remains visible to createQueuedTaskTurnDispatcher, which automatically starts it as a fresh turn; this later mutation only removes it after the steer reports success, so an accepted steer can execute the same prompt twice and append its transcript entry after the new turn has begun. The same duplication risk is unavoidable when delivery returns unknown, because the provider may have accepted the steer but the item is never removed before automatic dispatch. Mark the item in flight or otherwise exclude it from queue draining before awaiting delivery.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — fixed in 00204c9.

Confirmed the window is real and wide: submitSteerWithDeadline waits up to RENDERER_STEER_ACK_TIMEOUT_MS (32s), and turn completion calls dispatchNextQueuedTaskTurn, which took the queue head unconditionally.

Added createSteerQueueReservations (src/store/steer-queue-reservations.ts). The item is reserved before the await and the reservation is what resolves it:

  • in-flight — no path may dispatch it (the dispatcher skips it, and sendUserMessage returns blocked for a manual send-now or a second steer of the same item).
  • accepted — released; the caller drops it from the queue in the same tick as before.
  • rejected — released, so it goes back to waiting for its normal turn.
  • unknown — kept out of automatic dispatch only. A duplicate run is worse than a prompt the user re-sends deliberately, and they already got the "delivery unconfirmed" toast, so manual send/re-steer stays open.

Holds are in-memory and keyed by the item UUID, so a reload drops them with the turn they were ambiguous about and a stale key can never match a different item.

Covered by tests/steer-queue-reservations.test.ts, a dispatcher case in tests/queued-task-turn-dispatch.test.ts (reserved head skipped, next unreserved item dispatched), and a store-level case in tests/bridge-persistence-regression.test.ts.

promptDraftByTask,
taskId: resolvedTaskId,
storedDraft: storedPromptDraftForTask,
sourceDraft: sourcePromptDraft,
sentDraft: promptDraft,
preservePromptDraft,
steeredQueuedTurn: queuedTurnToSend,
});
const activityByTask = turnStillActive
? startProviderTurnActivity({
activityByTask: nextState.providerTurnActivityByTask,
Expand Down
49 changes: 49 additions & 0 deletions src/store/prompt-draft-send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PromptDraft>;
taskId: string;
storedDraft?: PromptDraft;
sourceDraft: PromptDraft;
sentDraft: PromptDraft;
preservePromptDraft?: boolean;
steeredQueuedTurn?: PromptDraftQueuedTurn;
}): Record<string, PromptDraft> {
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,
}),
};
}
Loading
Loading