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..6d5260e193de4 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,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 || @@ -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; } @@ -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} 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 + ); +}