From 0ba1ee466c4e57f8cc3e12598e7c93cec5a5b7e8 Mon Sep 17 00:00:00 2001 From: Tutu Date: Thu, 3 Sep 2026 13:21:32 +0700 Subject: [PATCH 1/2] feat(web): queue follow-up prompts to run after the current turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While an agent turn is running, the composer had only two options for a follow-up: steer it into the running turn, or wait. This adds a client-side prompt queue so a user can line up follow-up prompts (up to 10 per thread) that each fire as their own fresh turn once the thread goes idle. Steering is preserved and unchanged — a separate "Queue" button holds a prompt instead of injecting it into the running turn. The queue drains only into a genuinely idle thread (it pauses on a running turn, a pending approval or user-input request, and 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 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; it drains for the thread open in the chat view. Queued prompts are text-only in this revision. The pure queueing rules and the idle-dispatch decision live in client-runtime so a future mobile surface can reuse them. Co-Authored-By: Claude Opus 4.8 --- FORK.md | 34 ++++ apps/web/src/components/ChatView.tsx | 153 ++++++++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 48 ++++++ .../chat/ComposerPrimaryActions.tsx | 25 +++ .../src/components/chat/PromptQueueList.tsx | 58 +++++++ apps/web/src/promptQueueStore.test.ts | 68 ++++++++ apps/web/src/promptQueueStore.ts | 119 ++++++++++++++ packages/client-runtime/package.json | 4 + .../src/state/promptQueue.test.ts | 140 ++++++++++++++++ .../client-runtime/src/state/promptQueue.ts | 118 ++++++++++++++ 10 files changed, 767 insertions(+) create mode 100644 apps/web/src/components/chat/PromptQueueList.tsx create mode 100644 apps/web/src/promptQueueStore.test.ts create mode 100644 apps/web/src/promptQueueStore.ts create mode 100644 packages/client-runtime/src/state/promptQueue.test.ts create mode 100644 packages/client-runtime/src/state/promptQueue.ts diff --git a/FORK.md b/FORK.md index f203923e4717b..60f40901711d2 100644 --- a/FORK.md +++ b/FORK.md @@ -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. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 147a664687ccb..4060c44cf031f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -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, @@ -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 | 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(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, @@ -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" || @@ -8299,6 +8444,12 @@ function ChatViewContent(props: ChatViewProps) { > + {isServerThread ? ( + + ) : null}
= MAX_QUEUED_PROMPTS} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 26bd7691a147a..0400ffcc88867 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -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"; @@ -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; @@ -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} @@ -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: ( @@ -1366,6 +1379,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyUp, onPageScrollRelease, onSend, + onQueuePrompt, + isPromptQueueFull = false, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -2927,6 +2942,35 @@ 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. Distinct from steering (Enter / the + * running send button), which injects text into the running turn. 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 || @@ -5574,6 +5618,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} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 46323aa2b09aa..abf0f1081157f 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -32,6 +32,12 @@ interface ComposerPrimaryActionsProps { /** Enter-to-send is disabled on mobile viewports, where stop would otherwise * be the only primary action and a running turn could not be steered. */ showSendWhileRunning?: boolean; + /** Queues the current prompt to run as a fresh turn after the running one + * finishes. Shown only while a turn is running and the composer has text; a + * queue-full reason disables it. Distinct from steering (which sends into the + * running turn). */ + onQueuePrompt?: (() => void) | undefined; + queueDisabledReason?: string | null | undefined; onPreviousPendingQuestion: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -73,6 +79,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ hasSendableContent, preserveComposerFocusOnPointerDown = false, showSendWhileRunning = false, + onQueuePrompt, + queueDisabledReason = null, onPreviousPendingQuestion, onInterrupt, onImplementPlanInNewThread, @@ -271,12 +279,29 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); + const queueButton = + onQueuePrompt && promptHasText ? ( + + ) : null; + if (!isRunning) { return sendButton; } return ( <> + {queueButton} {renderStopGenerationButton(false)} {showSendWhileRunning && hasSendableContent ? sendButton : null} diff --git a/apps/web/src/components/chat/PromptQueueList.tsx b/apps/web/src/components/chat/PromptQueueList.tsx new file mode 100644 index 0000000000000..1983ad7e306a7 --- /dev/null +++ b/apps/web/src/components/chat/PromptQueueList.tsx @@ -0,0 +1,58 @@ +import { memo } from "react"; +import { XIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { MAX_QUEUED_PROMPTS, type QueuedPrompt } from "../../promptQueueStore"; + +interface PromptQueueListProps { + queue: ReadonlyArray; + onRemove: (id: string) => void; + className?: string; +} + +/** + * Compact list of prompts waiting to run after the current turn. Each entry + * fires as its own fresh turn once the thread goes idle; removing one drops it + * before it runs. Renders nothing when the queue is empty. + */ +export const PromptQueueList = memo(function PromptQueueList({ + queue, + onRemove, + className, +}: PromptQueueListProps) { + if (queue.length === 0) { + return null; + } + return ( +
+
+ Queued to run next + + {queue.length}/{MAX_QUEUED_PROMPTS} + +
+
    + {queue.map((entry, index) => ( +
  • + {index + 1} + {entry.text} + +
  • + ))} +
+
+ ); +}); diff --git a/apps/web/src/promptQueueStore.test.ts b/apps/web/src/promptQueueStore.test.ts new file mode 100644 index 0000000000000..3d6c1a0ef5dde --- /dev/null +++ b/apps/web/src/promptQueueStore.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import { MAX_QUEUED_PROMPTS, usePromptQueueStore } from "./promptQueueStore"; + +const THREAD = "env-1::thread-1"; +const OTHER = "env-1::thread-2"; + +function reset(): void { + usePromptQueueStore.setState({ queuesByThreadKey: {} }); +} + +describe("promptQueueStore", () => { + beforeEach(reset); + + it("enqueues and reads back in order, scoped per thread", () => { + const store = usePromptQueueStore.getState(); + expect(store.enqueue(THREAD, "first")).toEqual({ accepted: true }); + expect(store.enqueue(THREAD, "second")).toEqual({ accepted: true }); + expect(store.enqueue(OTHER, "other")).toEqual({ accepted: true }); + + expect( + usePromptQueueStore + .getState() + .getQueue(THREAD) + .map((p) => p.text), + ).toEqual(["first", "second"]); + expect( + usePromptQueueStore + .getState() + .getQueue(OTHER) + .map((p) => p.text), + ).toEqual(["other"]); + }); + + it("rejects blank prompts and enforces the cap", () => { + const store = usePromptQueueStore.getState(); + expect(store.enqueue(THREAD, " ")).toEqual({ accepted: false, reason: "empty" }); + + for (let index = 0; index < MAX_QUEUED_PROMPTS; index += 1) { + expect(store.enqueue(THREAD, `p${index}`).accepted).toBe(true); + } + expect(store.enqueue(THREAD, "overflow")).toEqual({ accepted: false, reason: "full" }); + expect(usePromptQueueStore.getState().getQueue(THREAD)).toHaveLength(MAX_QUEUED_PROMPTS); + }); + + it("dequeues the head and drops the key once empty", () => { + const store = usePromptQueueStore.getState(); + store.enqueue(THREAD, "only"); + const head = usePromptQueueStore.getState().dequeueHead(THREAD); + expect(head?.text).toBe("only"); + expect(usePromptQueueStore.getState().dequeueHead(THREAD)).toBeNull(); + expect(usePromptQueueStore.getState().queuesByThreadKey[THREAD]).toBeUndefined(); + }); + + it("removes a specific queued prompt by id", () => { + const store = usePromptQueueStore.getState(); + store.enqueue(THREAD, "keep"); + store.enqueue(THREAD, "drop"); + const dropId = usePromptQueueStore.getState().getQueue(THREAD)[1]!.id; + usePromptQueueStore.getState().removePrompt(THREAD, dropId); + expect( + usePromptQueueStore + .getState() + .getQueue(THREAD) + .map((p) => p.text), + ).toEqual(["keep"]); + }); +}); diff --git a/apps/web/src/promptQueueStore.ts b/apps/web/src/promptQueueStore.ts new file mode 100644 index 0000000000000..c6bbb2e1f8f77 --- /dev/null +++ b/apps/web/src/promptQueueStore.ts @@ -0,0 +1,119 @@ +import { + dequeueHead as dequeueHeadCore, + enqueuePrompt as enqueuePromptCore, + prependPrompt as prependPromptCore, + removeQueuedPrompt as removeQueuedPromptCore, + type PromptQueue, + type QueuedPrompt, +} from "@t3tools/client-runtime/state/prompt-queue"; +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; + +import { createMemoryStorage } from "./lib/storage"; +import { randomUUID } from "./lib/utils"; + +export { MAX_QUEUED_PROMPTS, type QueuedPrompt } from "@t3tools/client-runtime/state/prompt-queue"; + +export const PROMPT_QUEUE_STORAGE_KEY = "t3code:prompt-queue:v1"; +const PROMPT_QUEUE_STORAGE_VERSION = 1; + +/** Stable empty queue so a thread with nothing queued never churns subscribers. */ +const EMPTY_QUEUE: PromptQueue = Object.freeze([]); + +export type EnqueueOutcome = + | { readonly accepted: true } + | { readonly accepted: false; readonly reason: "empty" | "full" }; + +interface PromptQueueStoreState { + /** Queued prompts keyed by the composer target key (a `ScopedThreadRef`). */ + queuesByThreadKey: Record; + getQueue: (threadKey: string) => PromptQueue; + /** Appends a prompt to the thread's queue, honoring the per-thread cap. */ + enqueue: (threadKey: string, text: string) => EnqueueOutcome; + removePrompt: (threadKey: string, id: string) => void; + /** Pops and returns the next prompt to run, or null when the queue is empty. */ + dequeueHead: (threadKey: string) => QueuedPrompt | null; + /** Puts a prompt back at the head after a failed dispatch. */ + restoreHead: (threadKey: string, prompt: QueuedPrompt) => void; + clearQueue: (threadKey: string) => void; +} + +function withQueue( + state: PromptQueueStoreState, + threadKey: string, + next: PromptQueue, +): Pick { + if (next.length === 0) { + if (state.queuesByThreadKey[threadKey] === undefined) { + return { queuesByThreadKey: state.queuesByThreadKey }; + } + const { [threadKey]: _removed, ...rest } = state.queuesByThreadKey; + return { queuesByThreadKey: rest }; + } + return { queuesByThreadKey: { ...state.queuesByThreadKey, [threadKey]: next } }; +} + +export const usePromptQueueStore = create()( + persist( + (set, get) => ({ + queuesByThreadKey: {}, + getQueue: (threadKey) => get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE, + enqueue: (threadKey, text) => { + const current = get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; + const prompt: QueuedPrompt = { + id: randomUUID(), + text, + createdAt: new Date().toISOString(), + }; + const result = enqueuePromptCore(current, prompt); + if (!result.accepted) { + return { accepted: false, reason: result.reason }; + } + set((state) => withQueue(state, threadKey, result.queue)); + return { accepted: true }; + }, + removePrompt: (threadKey, id) => { + const current = get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; + const next = removeQueuedPromptCore(current, id); + if (next === current) { + return; + } + set((state) => withQueue(state, threadKey, next)); + }, + dequeueHead: (threadKey) => { + const current = get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; + const { head, queue } = dequeueHeadCore(current); + if (head === null) { + return null; + } + set((state) => withQueue(state, threadKey, queue)); + return head; + }, + restoreHead: (threadKey, prompt) => { + const current = get().queuesByThreadKey[threadKey] ?? EMPTY_QUEUE; + set((state) => withQueue(state, threadKey, prependPromptCore(current, prompt))); + }, + clearQueue: (threadKey) => { + if (get().queuesByThreadKey[threadKey] === undefined) { + return; + } + set((state) => withQueue(state, threadKey, EMPTY_QUEUE)); + }, + }), + { + name: PROMPT_QUEUE_STORAGE_KEY, + version: PROMPT_QUEUE_STORAGE_VERSION, + storage: createJSONStorage(() => + typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), + ), + partialize: (state) => ({ queuesByThreadKey: state.queuesByThreadKey }), + }, + ), +); + +/** Reactive read of one thread's queued prompts. */ +export function usePromptQueue(threadKey: string | null | undefined): PromptQueue { + return usePromptQueueStore((state) => + threadKey ? (state.queuesByThreadKey[threadKey] ?? EMPTY_QUEUE) : EMPTY_QUEUE, + ); +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f6ad9a5fdf1f3..b9fae929438af 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -135,6 +135,10 @@ "types": "./src/state/projectGrouping.ts", "default": "./src/state/projectGrouping.ts" }, + "./state/prompt-queue": { + "types": "./src/state/promptQueue.ts", + "default": "./src/state/promptQueue.ts" + }, "./state/relay": { "types": "./src/state/relayDiscovery.ts", "default": "./src/state/relayDiscovery.ts" diff --git a/packages/client-runtime/src/state/promptQueue.test.ts b/packages/client-runtime/src/state/promptQueue.test.ts new file mode 100644 index 0000000000000..1543cc24cd670 --- /dev/null +++ b/packages/client-runtime/src/state/promptQueue.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MAX_QUEUED_PROMPTS, + dequeueHead, + enqueuePrompt, + isPromptQueueFull, + prependPrompt, + removeQueuedPrompt, + shouldDispatchNextQueuedPrompt, + type PromptQueue, + type PromptQueueDispatchSignals, +} from "./promptQueue.ts"; + +function prompt( + id: string, + text = `prompt ${id}`, +): { id: string; text: string; createdAt: string } { + return { id, text, createdAt: `2026-01-01T00:00:0${id}.000Z` }; +} + +function fill(count: number): PromptQueue { + return Array.from({ length: count }, (_, index) => prompt(String(index))); +} + +const IDLE_SIGNALS: PromptQueueDispatchSignals = { + isWorking: false, + hasPendingApproval: false, + hasPendingUserInput: false, + isConnecting: false, + isEnvironmentAvailable: true, + queueLength: 1, +}; + +describe("enqueuePrompt", () => { + it("appends to the tail preserving order", () => { + const result = enqueuePrompt([prompt("0")], prompt("1")); + expect(result.accepted).toBe(true); + expect(result.queue.map((entry) => entry.id)).toEqual(["0", "1"]); + }); + + it("rejects blank prompts", () => { + const result = enqueuePrompt([], prompt("0", " \n\t ")); + expect(result).toMatchObject({ accepted: false, reason: "empty" }); + expect(result.queue).toEqual([]); + }); + + it("rejects once the cap is reached", () => { + const full = fill(MAX_QUEUED_PROMPTS); + expect(isPromptQueueFull(full)).toBe(true); + const result = enqueuePrompt(full, prompt("extra")); + expect(result).toMatchObject({ accepted: false, reason: "full" }); + expect(result.queue).toBe(full); + }); + + it("accepts up to exactly the cap", () => { + let queue: PromptQueue = []; + for (let index = 0; index < MAX_QUEUED_PROMPTS; index += 1) { + const result = enqueuePrompt(queue, prompt(String(index))); + expect(result.accepted).toBe(true); + queue = result.queue; + } + expect(queue).toHaveLength(MAX_QUEUED_PROMPTS); + }); +}); + +describe("removeQueuedPrompt", () => { + it("removes the matching entry", () => { + const queue = [prompt("0"), prompt("1"), prompt("2")]; + expect(removeQueuedPrompt(queue, "1").map((entry) => entry.id)).toEqual(["0", "2"]); + }); + + it("returns the same reference when nothing matched", () => { + const queue = [prompt("0")]; + expect(removeQueuedPrompt(queue, "missing")).toBe(queue); + }); +}); + +describe("prependPrompt", () => { + it("restores a prompt to the head, keeping order", () => { + const restored = prependPrompt([prompt("1"), prompt("2")], prompt("0")); + expect(restored.map((entry) => entry.id)).toEqual(["0", "1", "2"]); + }); + + it("dequeue then prepend round-trips the queue", () => { + const original: PromptQueue = [prompt("0"), prompt("1")]; + const { head, queue } = dequeueHead(original); + expect(prependPrompt(queue, head!).map((entry) => entry.id)).toEqual(["0", "1"]); + }); +}); + +describe("dequeueHead", () => { + it("pops the head and returns the rest", () => { + const { head, queue } = dequeueHead([prompt("0"), prompt("1")]); + expect(head?.id).toBe("0"); + expect(queue.map((entry) => entry.id)).toEqual(["1"]); + }); + + it("returns null head on an empty queue", () => { + const { head, queue } = dequeueHead([]); + expect(head).toBeNull(); + expect(queue).toEqual([]); + }); +}); + +describe("shouldDispatchNextQueuedPrompt", () => { + it("dispatches into a genuinely idle thread", () => { + expect(shouldDispatchNextQueuedPrompt(IDLE_SIGNALS)).toBe(true); + }); + + it("does not dispatch an empty queue", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, queueLength: 0 })).toBe(false); + }); + + it("waits while the thread is working", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, isWorking: true })).toBe(false); + }); + + it("pauses on a pending approval", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, hasPendingApproval: true })).toBe( + false, + ); + }); + + it("pauses on a pending user-input request", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, hasPendingUserInput: true })).toBe( + false, + ); + }); + + it("waits while connecting", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, isConnecting: true })).toBe(false); + }); + + it("waits while the environment is unavailable", () => { + expect(shouldDispatchNextQueuedPrompt({ ...IDLE_SIGNALS, isEnvironmentAvailable: false })).toBe( + false, + ); + }); +}); diff --git a/packages/client-runtime/src/state/promptQueue.ts b/packages/client-runtime/src/state/promptQueue.ts new file mode 100644 index 0000000000000..29ecf633201e5 --- /dev/null +++ b/packages/client-runtime/src/state/promptQueue.ts @@ -0,0 +1,118 @@ +/** + * Client-side prompt queue: lets a user line up follow-up prompts while an + * agent turn is still running, so each fires as its own fresh turn once the + * previous one finishes. This is deliberately distinct from steering (which + * injects text INTO the running turn) — a queued prompt always waits for the + * thread to go idle and then starts a new turn. + * + * This module is the pure, framework-free core: the persisted store (web) and + * any future mobile adoption wrap these reducers so the queueing rules and the + * idle-dispatch decision stay identical across surfaces. + */ + +/** Hard cap on queued prompts per thread. A queue is a convenience, not a + * batch runner — beyond a handful the user has lost the plot, and an unbounded + * queue would let a runaway loop stack work indefinitely. */ +export const MAX_QUEUED_PROMPTS = 10; + +export interface QueuedPrompt { + /** Stable id used as the React key and the removal handle. */ + readonly id: string; + /** The prompt text exactly as it will be sent to start the next turn. */ + readonly text: string; + /** ISO timestamp the prompt was queued, for ordering and display. */ + readonly createdAt: string; +} + +export type PromptQueue = ReadonlyArray; + +export type EnqueuePromptResult = + | { readonly accepted: true; readonly queue: PromptQueue } + | { readonly accepted: false; readonly reason: "empty" | "full"; readonly queue: PromptQueue }; + +export function isPromptQueueFull(queue: PromptQueue): boolean { + return queue.length >= MAX_QUEUED_PROMPTS; +} + +/** + * Appends a prompt to the tail of the queue. Rejects blank prompts (nothing to + * run) and rejects once the cap is reached rather than silently dropping the + * oldest — the user should see the queue is full and decide what to cut. + */ +export function enqueuePrompt(queue: PromptQueue, prompt: QueuedPrompt): EnqueuePromptResult { + if (prompt.text.trim().length === 0) { + return { accepted: false, reason: "empty", queue }; + } + if (isPromptQueueFull(queue)) { + return { accepted: false, reason: "full", queue }; + } + return { accepted: true, queue: [...queue, prompt] }; +} + +/** + * Puts a prompt back at the head of the queue. Used to restore a prompt whose + * dispatch failed so it keeps its place at the front rather than being lost or + * shuffled to the tail. Intentionally skips the cap check: it is returning an + * entry the queue already accounted for. + */ +export function prependPrompt(queue: PromptQueue, prompt: QueuedPrompt): PromptQueue { + return [prompt, ...queue]; +} + +export function removeQueuedPrompt(queue: PromptQueue, id: string): PromptQueue { + const next = queue.filter((entry) => entry.id !== id); + // Preserve reference identity when nothing matched so store subscribers do + // not re-render on a no-op removal. + return next.length === queue.length ? queue : next; +} + +/** + * Pops the head of the queue — the next prompt to run. Returns the remaining + * queue and the removed head (or null on an empty queue). Callers dispatch the + * head as a fresh turn only after they have confirmed the thread is idle (see + * {@link shouldDispatchNextQueuedPrompt}). + */ +export function dequeueHead(queue: PromptQueue): { + readonly queue: PromptQueue; + readonly head: QueuedPrompt | null; +} { + if (queue.length === 0) { + return { queue, head: null }; + } + const [head, ...rest] = queue; + return { queue: rest, head: head ?? null }; +} + +/** Signals the idle-dispatch decision needs about the thread's current state. */ +export interface PromptQueueDispatchSignals { + /** The thread has a live/starting turn or a send is mid-flight. */ + readonly isWorking: boolean; + /** The agent is blocked waiting for an approval decision. */ + readonly hasPendingApproval: boolean; + /** The agent is blocked waiting for a user-input answer. */ + readonly hasPendingUserInput: boolean; + /** The environment connection is not ready to accept a turn. */ + readonly isConnecting: boolean; + /** The environment is reachable at all. */ + readonly isEnvironmentAvailable: boolean; + /** Number of prompts currently queued. */ + readonly queueLength: number; +} + +/** + * Whether the next queued prompt should fire now. The queue only advances into + * a genuinely idle thread: no running turn, nothing blocked on the user, and a + * live environment to send to. Blocked-on-you work (an approval or input + * request) deliberately pauses the queue — the user must clear it first, so we + * never bury a raised hand under an auto-fired follow-up. + */ +export function shouldDispatchNextQueuedPrompt(signals: PromptQueueDispatchSignals): boolean { + return ( + signals.queueLength > 0 && + !signals.isWorking && + !signals.hasPendingApproval && + !signals.hasPendingUserInput && + !signals.isConnecting && + signals.isEnvironmentAvailable + ); +} From c96b75d3c827d5696112388e9e703daa5b311af8 Mon Sep 17 00:00:00 2001 From: Tutu Date: Mon, 7 Sep 2026 09:40:47 +0700 Subject: [PATCH 2/2] feat(web): queue follow-up on Enter while a turn is running While a turn runs, Enter now holds the prompt to run as its own turn after completion (matching the Queue button) instead of steering into the running turn. Restricted to the plain composing state -- never over a pending approval, input request, or plan follow-up prompt. Steering stays on the running-send button. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/chat/ChatComposer.tsx | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 0400ffcc88867..6d5260e193de4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2945,9 +2945,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Queues the current composer text to run as its own turn after the active - * one finishes, then clears the prompt. Distinct from steering (Enter / the - * running send button), which injects text into the running turn. Queued - * prompts are text-only; attachments and contexts stay in the composer. + * 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; @@ -3084,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; }