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
This commit is contained in:
Lee Jackson 2026-06-19 22:52:27 +01:00 committed by GitHub
commit e1ae4756d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<string, PromptQueueUIEntry>;
current: number;
total: number;
items: PromptQueueUIItem[];
isRunning: boolean;
}
const usePromptQueueUI = create<PromptQueueUIState>(() => ({
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<string | null | undefined>) {
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<string>;
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. */}
<div
aria-hidden={true}
className="absolute inset-x-0 bottom-0 top-[10px] bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-transparent"
className={cn(
"absolute inset-x-0 bottom-0 bg-gradient-to-t from-background from-[calc(100%_-_28px)] to-transparent",
queueVisible
? "h-32 backdrop-blur-[1px] [mask-image:linear-gradient(to_top,black_0%,black_58%,transparent_100%)]"
: "top-[10px]",
)}
/>
<div className="relative px-5 pb-2">
<div className="pointer-events-auto mx-auto w-full max-w-(--thread-max-width)">
@ -1743,14 +1907,15 @@ const Composer: FC<{
aria-disabled={disabled}
onSubmit={handleSubmit}
>
<PromptQueueStack queueThreadIds={promptQueueThreadIds} />
{isTauri ? (
// Phase 1 native model owns Tauri local-path drops. Restore browser
// attachment drops in Tauri once Phase 1d adds token bridging.
<div className="aui-composer-attachment-dropzone unsloth-composer-surface">
<div className="aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
{composerContent}
</div>
) : (
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative">
<ComposerPrimitive.AttachmentDropzone className="group/dropzone aui-composer-attachment-dropzone unsloth-composer-surface relative z-10">
{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<string | null>(null);
const [draftPrompt, setDraftPrompt] = useState("");
const editInputRef = useRef<HTMLTextAreaElement>(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 (
<div
className="relative z-0 mx-7 mb-[-8px] max-h-[28vh] overflow-y-auto rounded-t-[18px] rounded-b-none border border-border/45 bg-background/90 px-5 py-2 text-muted-foreground shadow-none backdrop-blur-md dark:bg-card/85"
aria-label={`Prompt queue, ${current} of ${total}`}
>
<div className="divide-y divide-border/25">
{visibleItems.map((item, visibleIndex) => {
const isEditing = item.id === activeEditingItemId;
const visiblePosition = visibleIndex + 1;
return (
<div
key={item.id}
className={cn("min-h-10", isEditing ? "h-auto" : "h-10")}
aria-label={`${promptQueueStatusLabel(item.status)} prompt ${visiblePosition} of ${visibleItems.length}: ${item.prompt}`}
>
{isEditing ? (
<div className="grid min-h-10 grid-cols-[minmax(0,1fr)_auto_auto] items-center gap-2.5 py-1">
<textarea
ref={editInputRef}
value={draftPrompt}
rows={1}
onChange={(event) =>
setDraftPrompt(event.currentTarget.value)
}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
saveEditing();
} else if (event.key === "Escape") {
event.preventDefault();
cancelEditing();
}
}}
className="max-h-20 min-h-8 min-w-0 resize-none rounded-md border border-border/45 bg-transparent px-2 py-1.5 text-sm leading-5 text-foreground outline-none transition-colors focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35"
aria-label={`Edit queued prompt ${visiblePosition}`}
/>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={cancelEditing}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="h-7 px-2 text-xs"
disabled={draftPrompt.trim().length === 0}
onClick={saveEditing}
>
Save
</Button>
</div>
) : (
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto_2rem] items-center gap-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<CornerDownRightIcon className="size-4 shrink-0 text-muted-foreground/50" />
<div className="truncate text-sm text-muted-foreground">
{item.prompt}
</div>
</div>
{item.canEdit ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-[5.25rem] justify-center gap-1 px-0 text-sm font-normal text-muted-foreground/80 hover:text-foreground"
onClick={() => startEditing(item)}
>
<HugeiconsIcon icon={Edit03Icon} strokeWidth={2} />
Edit
</Button>
) : null}
<TooltipIconButton
tooltip="Remove from queue"
side="bottom"
type="button"
variant="ghost"
size="icon"
className="col-start-3 size-7 justify-self-center text-muted-foreground/70 hover:text-destructive"
aria-label={`Remove queued prompt ${visiblePosition}`}
disabled={!item.canRemove}
onClick={() => removePromptQueueItem(item.id)}
>
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} />
</TooltipIconButton>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
const ComposerRightControls: FC<{
disabled?: boolean;
queueDisabled?: boolean;
@ -3014,8 +3357,6 @@ const ComposerRightControls: FC<{
findPromptQueueEntry(s, queueThreadIds),
);
const isQueueRunning = Boolean(queueEntry);
const queueCurrent = queueEntry?.current ?? 0;
const queueTotal = queueEntry?.total ?? 0;
return (
<div className="aui-composer-action-wrapper flex shrink-0 items-center gap-1.5">
<ReasoningToggle side={menuSide} />
@ -3043,14 +3384,6 @@ const ComposerRightControls: FC<{
</TooltipIconButton>
</ComposerPrimitive.StopDictation>
</ComposerPrimitive.If>
{isQueueRunning ? (
<span
className="ml-1 flex h-7 items-center rounded-full bg-primary/10 px-2 text-[11px] font-semibold text-primary"
aria-live="polite"
>
<span className="tabular-nums">Queue {queueCurrent}/{queueTotal}</span>
</span>
) : null}
<AuiIf condition={({ thread }) => !thread.isRunning && !isQueueRunning}>
<ComposerPrimitive.Send asChild={true}>
<TooltipIconButton