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:
parent
7ce8dc73ac
commit
e1ae4756d9
1 changed files with 371 additions and 38 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue