Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
34 changes: 34 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,40 @@ search, and switching groups preserves the query. Provider and continuation rest

**Last updated:** 2026-09-07

### DL034 — Client-side prompt queue for sequential follow-up turns

While a turn is running, the composer can queue follow-up prompts (up to 10 per
thread) that each fire as their own fresh turn once the thread goes idle. This is
distinct from steering, which is preserved: sending mid-turn (Enter / the
running-send button) still injects text into the running turn, while the new
"Queue" button holds a prompt to run after completion. The queue drains only into
a genuinely idle thread — it pauses on a running turn, a pending approval, a
pending user-input request, a connecting/unavailable environment — so an
auto-fired follow-up never buries blocked-on-you work. A failed dispatch restores
the prompt to the head of the queue and pauses auto-dispatch on it to avoid a hot
retry loop.

The queue is client-side and persisted per device (localStorage), keyed by the
thread's scoped ref, so it survives reload but does not sync across devices or run
while the app is closed. It drains for the thread currently open in the chat view.
Queued prompts are text-only in this revision (attachments and composer contexts
stay in the composer). The pure queueing rules and the idle-dispatch decision live
in `client-runtime` so a future mobile surface can reuse them; mobile is a
deliberate follow-up because it already ships a separate durable thread-outbox with
its own delivery semantics.

**Implementation evidence:** `packages/client-runtime/src/state/promptQueue.ts`
(+ test), `apps/web/src/promptQueueStore.ts` (+ test),
`apps/web/src/components/chat/PromptQueueList.tsx`,
`apps/web/src/components/chat/ComposerPrimaryActions.tsx`,
`apps/web/src/components/chat/ChatComposer.tsx`, and
`apps/web/src/components/ChatView.tsx`.

**Recorded validation:** focused queue-core and web-store unit tests; repo-wide
`vp check` and `vp run typecheck`.

**Last updated:** 2026-09-03

## Merge History

This is an append-only historical decision record. It provides context for integrations but never, by itself, establishes an ongoing fork divergence; use the current Divergence Log for that determination.
Expand Down
153 changes: 153 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ import {
useComposerDraftStore,
type DraftId,
} from "../composerDraftStore";
import { MAX_QUEUED_PROMPTS, usePromptQueue, usePromptQueueStore } from "../promptQueueStore";
import { shouldDispatchNextQueuedPrompt } from "@t3tools/client-runtime/state/prompt-queue";
import { PromptQueueList } from "./chat/PromptQueueList";
import {
appendTerminalContextsToPrompt,
formatTerminalContextLabel,
Expand Down Expand Up @@ -2736,6 +2739,49 @@ function ChatViewContent(props: ChatViewProps) {
!compactionSettled;
const isWorking =
phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting;

// ── Prompt queue (client-side, per device) ──────────────────────────────
// Lets the user line up follow-up prompts while a turn runs; each fires as a
// fresh turn once the thread goes idle. Distinct from steering, which injects
// into the running turn. The queue drains only while the thread is open.
const promptQueueThreadKey = isServerThread ? activeThreadKey : null;
const activePromptQueue = usePromptQueue(promptQueueThreadKey);
const enqueuePromptToQueue = usePromptQueueStore((store) => store.enqueue);
const dequeueQueuedPromptHead = usePromptQueueStore((store) => store.dequeueHead);
const restoreQueuedPromptHead = usePromptQueueStore((store) => store.restoreHead);
const removeQueuedPromptFromQueue = usePromptQueueStore((store) => store.removePrompt);
const queueDispatchInFlightRef = useRef(false);
const queueDispatchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Head id whose dispatch failed: auto-dispatch pauses on it to avoid a hot
// retry loop, and resumes once the queue advances or a manual turn recovers
// the thread.
const failedQueueHeadIdRef = useRef<string | null>(null);
const clearQueueDispatchInFlight = useCallback(() => {
queueDispatchInFlightRef.current = false;
if (queueDispatchTimeoutRef.current !== null) {
clearTimeout(queueDispatchTimeoutRef.current);
queueDispatchTimeoutRef.current = null;
}
}, []);
useEffect(() => clearQueueDispatchInFlight, [clearQueueDispatchInFlight]);

const handleQueuePrompt = useCallback(
(text: string): { accepted: boolean; reason?: "empty" | "full" } => {
if (!promptQueueThreadKey) {
return { accepted: false, reason: "empty" };
}
return enqueuePromptToQueue(promptQueueThreadKey, text);
},
[enqueuePromptToQueue, promptQueueThreadKey],
);
const handleRemoveQueuedPrompt = useCallback(
(id: string) => {
if (!promptQueueThreadKey) return;
removeQueuedPromptFromQueue(promptQueueThreadKey, id);
},
[promptQueueThreadKey, removeQueuedPromptFromQueue],
);

const activeWorkStartedAt = deriveActiveWorkStartedAt(
activeLatestTurn,
activeThread?.session ?? null,
Expand Down Expand Up @@ -3281,6 +3327,105 @@ function ChatViewContent(props: ChatViewProps) {
},
[activeServerThread, draftId, routeThreadKey, routeThreadRef],
);

// Drains the client-side prompt queue: once the thread is genuinely idle,
// pop the head and start it as a fresh turn. Defined here (after
// setThreadError) so a failed dispatch can surface an error.
useEffect(() => {
if (!isServerThread || !activeThread || !promptQueueThreadKey) return;
// A dispatch is in flight: hold until the started turn is observed running
// (isWorking), which bridges the projection lag between the command
// resolving and the session flipping to running. Never dispatch again in
// this state, so two queued prompts can't start concurrent turns.
if (queueDispatchInFlightRef.current) {
if (isWorking) clearQueueDispatchInFlight();
return;
}
const head = activePromptQueue[0];
if (!head) {
failedQueueHeadIdRef.current = null;
return;
}
if (failedQueueHeadIdRef.current !== null) {
if (failedQueueHeadIdRef.current !== head.id || isWorking) {
failedQueueHeadIdRef.current = null;
} else {
return;
}
}
const shouldDispatch = shouldDispatchNextQueuedPrompt({
isWorking,
hasPendingApproval: pendingApprovals.length > 0,
hasPendingUserInput: pendingUserInputs.length > 0,
isConnecting,
isEnvironmentAvailable: !activeEnvironmentUnavailable,
queueLength: activePromptQueue.length,
});
if (!shouldDispatch) return;

const dequeued = dequeueQueuedPromptHead(promptQueueThreadKey);
if (!dequeued) return;
queueDispatchInFlightRef.current = true;
// Safety net: if the started turn is never observed running (e.g. a silent
// start failure), release the in-flight hold so the queue is not wedged.
queueDispatchTimeoutRef.current = setTimeout(clearQueueDispatchInFlight, 15_000);
const targetThreadId = activeThread.id;
const targetEnvironmentId = activeThread.environmentId;
const targetQueueKey = promptQueueThreadKey;
const createdAt = new Date().toISOString();
void (async () => {
try {
const result = await startThreadTurn({
environmentId: targetEnvironmentId,
input: {
threadId: targetThreadId,
message: {
messageId: newMessageId(),
role: "user",
text: dequeued.text,
attachments: [],
},
runtimeMode,
interactionMode,
createdAt,
},
});
if (result._tag === "Failure") {
restoreQueuedPromptHead(targetQueueKey, dequeued);
failedQueueHeadIdRef.current = dequeued.id;
clearQueueDispatchInFlight();
if (!isAtomCommandInterrupted(result)) {
setThreadError(
targetThreadId,
"A queued prompt could not start. It's back at the top of the queue.",
);
}
}
} catch {
restoreQueuedPromptHead(targetQueueKey, dequeued);
failedQueueHeadIdRef.current = dequeued.id;
clearQueueDispatchInFlight();
}
})();
}, [
activeEnvironmentUnavailable,
activePromptQueue,
activeThread,
clearQueueDispatchInFlight,
dequeueQueuedPromptHead,
interactionMode,
isConnecting,
isServerThread,
isWorking,
pendingApprovals.length,
pendingUserInputs.length,
promptQueueThreadKey,
restoreQueuedPromptHead,
runtimeMode,
setThreadError,
startThreadTurn,
]);

const retryableTurnStartMessage = useMemo(() => {
if (
serverThread?.session?.status !== "error" ||
Expand Down Expand Up @@ -8299,6 +8444,12 @@ function ChatViewContent(props: ChatViewProps) {
>
<ComposerSurface.Shell contextStrip={showComposerContextStrip}>
<ComposerSurface.Host>
{isServerThread ? (
<PromptQueueList
queue={activePromptQueue}
onRemove={handleRemoveQueuedPrompt}
/>
) : null}
<div ref={attachDraftHeroComposerAnchorRef} className="relative z-10">
<ChatComposer
composerRef={composerRef}
Expand Down Expand Up @@ -8379,6 +8530,8 @@ function ChatViewContent(props: ChatViewProps) {
onPageScrollKeyUp={onComposerPageScrollKeyUp}
onPageScrollRelease={onComposerPageScrollRelease}
onSend={onSend}
onQueuePrompt={isServerThread ? handleQueuePrompt : undefined}
isPromptQueueFull={activePromptQueue.length >= MAX_QUEUED_PROMPTS}
onInterrupt={onInterrupt}
onImplementPlanInNewThread={onImplementPlanInNewThread}
onRespondToApproval={onRespondToApproval}
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommand
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
import { MAX_QUEUED_PROMPTS } from "../../promptQueueStore";
import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
Expand Down Expand Up @@ -1058,6 +1059,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
hasSendableContent: boolean;
preserveComposerFocusOnPointerDown?: boolean;
showSendWhileRunning?: boolean;
onQueuePrompt?: (() => void) | undefined;
queueDisabledReason?: string | null | undefined;
onPreviousPendingQuestion: () => void;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
Expand Down Expand Up @@ -1090,6 +1093,8 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions(
hasSendableContent={props.hasSendableContent}
preserveComposerFocusOnPointerDown={props.preserveComposerFocusOnPointerDown ?? false}
showSendWhileRunning={props.showSendWhileRunning ?? false}
onQueuePrompt={props.onQueuePrompt}
queueDisabledReason={props.queueDisabledReason ?? null}
onPreviousPendingQuestion={props.onPreviousPendingQuestion}
onInterrupt={props.onInterrupt}
onImplementPlanInNewThread={props.onImplementPlanInNewThread}
Expand Down Expand Up @@ -1264,6 +1269,14 @@ export interface ChatComposerProps {

// Callbacks
onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void;
/**
* Queues the given prompt text to run as a fresh turn once the active turn
* finishes. Returns whether it was accepted (rejected when blank or the
* per-thread queue is full). Absent for surfaces that cannot queue (e.g.
* pre-thread drafts). See {@link ChatComposerProps.isPromptQueueFull}.
*/
onQueuePrompt?: ((text: string) => { accepted: boolean; reason?: "empty" | "full" }) | undefined;
isPromptQueueFull?: boolean | undefined;
onInterrupt: () => void;
onImplementPlanInNewThread: () => void;
onRespondToApproval: (
Expand Down Expand Up @@ -1366,6 +1379,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onPageScrollKeyUp,
onPageScrollRelease,
onSend,
onQueuePrompt,
isPromptQueueFull = false,
onInterrupt,
onImplementPlanInNewThread,
onRespondToApproval,
Expand Down Expand Up @@ -2927,6 +2942,36 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
});
submitComposer(undefined, intent ?? "foreground");
}, [isMobileViewport, routeKind, submitComposer]);

/**
* Queues the current composer text to run as its own turn after the active
* one finishes, then clears the prompt. Triggered by the Queue button and by
* Enter while a turn is running. Steering (injecting into the running turn)
* stays on the running send button. Queued prompts are text-only; attachments
* and contexts stay in the composer.
*/
const queueComposerPrompt = useCallback(() => {
if (!onQueuePrompt) return;
const text = promptRef.current;
if (text.trim().length === 0) return;
const outcome = onQueuePrompt(text);
if (!outcome.accepted) {
if (outcome.reason === "full") {
toastManager.add({
type: "info",
title: "Prompt queue is full",
description: `A thread can hold at most ${MAX_QUEUED_PROMPTS} queued prompts. Remove one before adding another.`,
});
}
return;
}
promptRef.current = "";
setComposerDraftPrompt(composerDraftTarget, "");
setComposerCursor(0);
setComposerTrigger(null);
setComposerHighlightedItemId(null);
}, [composerDraftTarget, onQueuePrompt, promptRef, setComposerDraftPrompt]);

const compactThreadContext = useCallback(() => {
if (
compactDisabled ||
Expand Down Expand Up @@ -3040,6 +3085,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
})
: null;
if (submissionIntent) {
// While a turn is running, Enter queues the prompt to run next (matching
// the Queue button) instead of steering into the running turn — but only
// in the plain composing state, never over a pending approval / input
// request or the plan follow-up prompt, where Enter has its own meaning.
if (
key === "Enter" &&
phase === "running" &&
onQueuePrompt &&
!activePendingProgress &&
!showPlanFollowUpPrompt &&
!isComposerApprovalState &&
pendingUserInputs.length === 0 &&
activePendingApproval === null &&
promptRef.current.trim().length > 0
) {
queueComposerPrompt();
return true;
}
submitComposer(undefined, submissionIntent);
return true;
}
Expand Down Expand Up @@ -5574,6 +5637,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
hasSendableContent={composerSendState.hasSendableContent}
preserveComposerFocusOnPointerDown={isMobileViewport || isComposerResting}
showSendWhileRunning={isMobileViewport}
onQueuePrompt={onQueuePrompt ? queueComposerPrompt : undefined}
queueDisabledReason={
isPromptQueueFull ? `Queue is full (max ${MAX_QUEUED_PROMPTS})` : null
}
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryAction}
onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
Expand Down
Loading
Loading