From e1ae4756d9afe7fc5ba34e939fe5ba75dfb6e94e Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:52:27 +0100 Subject: [PATCH] Studio: rework prompt queue management UI/UX (#6467) * feat: add prompt queue management UI * fix: clean up prompt queue controls * fix: address prompt queue review feedback * fix: scope prompt queue controls to active thread * fix: tighten prompt queue row behavior --- .../src/components/assistant-ui/thread.tsx | 409 ++++++++++++++++-- 1 file changed, 371 insertions(+), 38 deletions(-) diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index 8789a1bb71..57b9f6c965 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -135,6 +135,7 @@ import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, + CornerDownRightIcon, GitBranchIcon, GlobeIcon, HeadphonesIcon, @@ -178,12 +179,33 @@ type PromptQueueUIEntry = { total: number; }; +type PromptQueueUIItemStatus = "queued" | "next" | "waiting" | "running"; + +type PromptQueueUIItem = { + id: string; + prompt: string; + position: number; + total: number; + status: PromptQueueUIItemStatus; + threadIds: string[]; + canEdit: boolean; + canRemove: boolean; +}; + interface PromptQueueUIState { byThreadId: Record; + current: number; + total: number; + items: PromptQueueUIItem[]; + isRunning: boolean; } const usePromptQueueUI = create(() => ({ byThreadId: {}, + current: 0, + total: 0, + items: [], + isRunning: false, })); type PromptQueueTarget = { @@ -195,8 +217,10 @@ type PromptQueueTarget = { }; type PromptQueueItem = { + id: string; prompt: string; target: PromptQueueTarget; + dispatched: boolean; }; const PROMPT_QUEUE_INDEXING_RETRY_MS = 500; @@ -214,6 +238,10 @@ function compactIds(ids: Array) { return Array.from(new Set(ids.filter((id): id is string => Boolean(id)))); } +function createPromptQueueItemId() { + return `prompt-queue-${crypto.randomUUID()}`; +} + function stopPromptQueueSubscription({ resetRunningState = true, }: { @@ -228,7 +256,7 @@ function stopPromptQueueSubscription({ } } -function resetPromptQueue(showToast = false) { +function resetPromptQueue() { promptQueueGeneration += 1; promptQueueIsRunning = false; promptQueueItems = []; @@ -240,16 +268,10 @@ function resetPromptQueue(showToast = false) { } stopPromptQueueSubscription(); syncPromptQueueUI(); - if (showToast) { - toast.success("Prompt queue complete"); - } -} - -function queueToastDescription(prompt: string) { - return prompt.length > 80 ? `${prompt.slice(0, 80)}...` : prompt; } function appendQueuedPrompt(item: PromptQueueItem) { + item.dispatched = true; syncPromptQueueUI(); item.target.append(item.prompt); } @@ -335,8 +357,10 @@ async function dispatchQueuedPrompt( function createQueuedPrompt(prompt: string, target: PromptQueueTarget) { return { + id: createPromptQueueItemId(), prompt, target, + dispatched: false, }; } @@ -368,13 +392,62 @@ function findPromptQueueEntry( return null; } +function canEditPromptQueueItem(item: PromptQueueItem) { + return !item.dispatched; +} + +function canRemovePromptQueueItem(item: PromptQueueItem) { + return !item.dispatched; +} + +function promptQueueItemMatchesThreadIds( + item: PromptQueueUIItem, + threadIds: string[], +) { + return item.threadIds.some((threadId) => threadIds.includes(threadId)); +} + function syncPromptQueueUI() { if (!promptQueueIsRunning || promptQueueItems.length === 0) { - usePromptQueueUI.setState({ byThreadId: {} }); + usePromptQueueUI.setState({ + byThreadId: {}, + current: 0, + total: 0, + items: [], + isRunning: false, + }); return; } const activeItemIndex = Math.max(promptQueueIndex, 0); + const total = promptQueueItems.length; + const current = promptQueueIndex >= 0 ? Math.min(activeItemIndex + 1, total) : 0; + const items = promptQueueItems + .map((item, index): PromptQueueUIItem | null => { + if (index < activeItemIndex || item.dispatched) { + return null; + } + const threadIds = getPromptQueueTargetIds(item.target); + const isActive = promptQueueIndex >= 0 && index === activeItemIndex; + const status: PromptQueueUIItemStatus = item.dispatched + ? "running" + : isActive + ? promptQueueWaitingForTargetIdle + ? "waiting" + : "next" + : "queued"; + return { + id: item.id, + prompt: item.prompt, + position: index + 1, + total, + status, + threadIds, + canEdit: canEditPromptQueueItem(item), + canRemove: canRemovePromptQueueItem(item), + }; + }) + .filter((item): item is PromptQueueUIItem => Boolean(item)); const groups: Array<{ ids: Set; current: number; @@ -423,7 +496,80 @@ function syncPromptQueueUI() { }); } - usePromptQueueUI.setState({ byThreadId }); + usePromptQueueUI.setState({ + byThreadId, + current, + total, + items, + isRunning: true, + }); +} + +function editPromptQueueItem(itemId: string, prompt: string) { + const nextPrompt = prompt.trim(); + if (!nextPrompt) { + return false; + } + const itemIndex = promptQueueItems.findIndex( + (candidate) => candidate.id === itemId, + ); + if (itemIndex < 0) { + return false; + } + const item = promptQueueItems[itemIndex]; + if (!canEditPromptQueueItem(item)) { + return false; + } + item.prompt = nextPrompt; + syncPromptQueueUI(); + return true; +} + +function clearPromptQueueRetryTimer() { + if (!promptQueueRetryTimer) { + return; + } + clearTimeout(promptQueueRetryTimer); + promptQueueRetryTimer = null; +} + +function removePromptQueueItem(itemId: string) { + const itemIndex = promptQueueItems.findIndex((item) => item.id === itemId); + if (itemIndex < 0) { + return false; + } + const item = promptQueueItems[itemIndex]; + if (!canRemovePromptQueueItem(item)) { + return false; + } + + const wasActive = + promptQueueIndex >= 0 && itemIndex === Math.max(promptQueueIndex, 0); + promptQueueItems.splice(itemIndex, 1); + if (promptQueueItems.length === 0) { + resetPromptQueue(); + return true; + } + + if (itemIndex < promptQueueIndex) { + promptQueueIndex -= 1; + } + if (wasActive && promptQueueIndex >= promptQueueItems.length) { + resetPromptQueue(); + return true; + } + + syncPromptQueueUI(); + if (wasActive) { + clearPromptQueueRetryTimer(); + promptQueueWaitingForTargetIdle = false; + promptQueuePrevStoreRunning = false; + const next = promptQueueItems[promptQueueIndex]; + if (next) { + scheduleQueuedPromptDispatch(next, 50); + } + } + return true; } function isPromptQueueTargetRunning( @@ -456,15 +602,12 @@ function isActivePromptQueueTargetRunning( function advancePromptQueue() { const nextIndex = promptQueueIndex + 1; if (nextIndex >= promptQueueItems.length) { - resetPromptQueue(true); + resetPromptQueue(); return; } promptQueueIndex = nextIndex; syncPromptQueueUI(); const next = promptQueueItems[nextIndex]; - toast(`Prompt ${nextIndex + 1} / ${promptQueueItems.length}`, { - description: queueToastDescription(next.prompt), - }); promptQueueWaitingForTargetIdle = false; promptQueuePrevStoreRunning = false; scheduleQueuedPromptDispatch(next, 100); @@ -529,9 +672,6 @@ function startPromptQueue( ...filtered.map((prompt) => createQueuedPrompt(prompt, target)), ); syncPromptQueueUI(); - toast.success("Added to prompt queue", { - description: `${filtered.length} prompt${filtered.length === 1 ? "" : "s"} queued.`, - }); return; } @@ -547,12 +687,6 @@ function startPromptQueue( promptQueueIsRunning = true; promptQueuePrevStoreRunning = shouldWaitForCurrentRun; syncPromptQueueUI(); - toast( - shouldWaitForCurrentRun ? "Prompt queued" : `Prompt 1 / ${filtered.length}`, - { - description: queueToastDescription(filtered[0]), - }, - ); startPromptQueueSubscription(); if (!shouldWaitForCurrentRun) { const first = promptQueueItems[0]; @@ -563,10 +697,15 @@ function startPromptQueue( } function stopPromptQueueRun() { - const activeTarget = promptQueueItems[Math.max(promptQueueIndex, 0)]?.target; + const activeItem = promptQueueItems[Math.max(promptQueueIndex, 0)]; + const activeTarget = activeItem?.target; + const shouldCancelActiveRun = Boolean(activeItem?.dispatched); resetPromptQueue(); + if (!shouldCancelActiveRun) { + return; + } try { - activeTarget?.cancel(); + activeTarget.cancel(); } catch { // The active run may have already ended. } @@ -1018,6 +1157,26 @@ const ThreadComposerDock: FC<{ onHeightChange?: (height: number | null) => void; }> = ({ disabled, threadId, onHeightChange }) => { const { overlay } = useGeneratedImageOverlay(); + const activeThreadId = useChatRuntimeStore((s) => s.activeThreadId); + const threadListItemId = useAuiState( + ({ threadListItem }) => threadListItem.id, + ); + const threadListItemRemoteId = useAuiState( + ({ threadListItem }) => threadListItem.remoteId, + ); + const promptQueueThreadIds = compactIds([ + threadListItemId, + threadListItemRemoteId, + threadId, + activeThreadId, + ]); + const queueVisible = usePromptQueueUI( + (s) => + Boolean(findPromptQueueEntry(s, promptQueueThreadIds)) && + s.items.some((item) => + promptQueueItemMatchesThreadIds(item, promptQueueThreadIds), + ), + ); // Report dock height so the viewport reserves matching scroll space when // attachments or multiline input grow the composer. @@ -1046,7 +1205,12 @@ const ThreadComposerDock: FC<{ {/* Fade the top edge so scrolling text is not cut off by a hard line. */}
@@ -1743,14 +1907,15 @@ const Composer: FC<{ aria-disabled={disabled} onSubmit={handleSubmit} > + {isTauri ? ( // Phase 1 native model owns Tauri local-path drops. Restore browser // attachment drops in Tauri once Phase 1d adds token bridging. -
+
{composerContent}
) : ( - + {composerContent} {/* Gemini-style drop affordance, shown while a file is dragged over the composer. Absolute + pointer-events-none so the outline adds @@ -2991,6 +3156,184 @@ const ComposerToolsMenu: FC<{ side?: "top" | "bottom" }> = ({ ); }; +function promptQueueStatusLabel(status: PromptQueueUIItemStatus) { + switch (status) { + case "running": + return "Running now"; + case "waiting": + return "Waiting"; + case "next": + return "Next"; + case "queued": + return "Queued"; + default: { + const exhaustiveStatus: never = status; + throw new Error(`Unhandled prompt queue status: ${exhaustiveStatus}`); + } + } +} + +const PromptQueueStack: FC<{ queueThreadIds: string[] }> = ({ + queueThreadIds, +}) => { + const queueEntry = usePromptQueueUI((s) => + findPromptQueueEntry(s, queueThreadIds), + ); + const items = usePromptQueueUI((s) => s.items); + const [editingItemId, setEditingItemId] = useState(null); + const [draftPrompt, setDraftPrompt] = useState(""); + const editInputRef = useRef(null); + const visibleItems = items.filter((item) => + promptQueueItemMatchesThreadIds(item, queueThreadIds), + ); + const editingItem = visibleItems.find((item) => item.id === editingItemId); + const editingItemCanEdit = editingItem?.canEdit ?? false; + const activeEditingItemId = editingItem ? editingItemId : null; + + useEffect(() => { + if (!activeEditingItemId) { + return; + } + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, [activeEditingItemId]); + + useEffect(() => { + if (!editingItemId || editingItemCanEdit) { + return; + } + setEditingItemId(null); + setDraftPrompt(""); + }, [editingItemCanEdit, editingItemId]); + + if (!queueEntry || visibleItems.length === 0) { + return null; + } + + const { current, total } = queueEntry; + + const startEditing = (item: PromptQueueUIItem) => { + if (!item.canEdit) { + return; + } + setEditingItemId(item.id); + setDraftPrompt(item.prompt); + }; + const saveEditing = () => { + if (!activeEditingItemId) { + return; + } + if (editPromptQueueItem(activeEditingItemId, draftPrompt)) { + setEditingItemId(null); + setDraftPrompt(""); + } + }; + const cancelEditing = () => { + setEditingItemId(null); + setDraftPrompt(""); + }; + + return ( +
+
+ {visibleItems.map((item, visibleIndex) => { + const isEditing = item.id === activeEditingItemId; + const visiblePosition = visibleIndex + 1; + return ( +
+ {isEditing ? ( +
+