Expose Assistant queued follow-ups in commands

This commit is contained in:
rcourtman 2026-06-08 06:01:29 +01:00
parent 43974e7760
commit 7745a20eea
7 changed files with 219 additions and 8 deletions

View file

@ -37,6 +37,8 @@ export const AssistantSlashCommandIcon = (props: { action: AssistantSlashCommand
return <PlusIcon class="h-4 w-4" aria-hidden="true" />;
case 'sessions':
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
case 'queue':
return <ClockIcon class="h-4 w-4" aria-hidden="true" />;
case 'compact':
return <Minimize2Icon class="h-4 w-4" aria-hidden="true" />;
case 'models':

View file

@ -2817,6 +2817,84 @@ describe('AIChat', () => {
expect(mockChat.clearQueuedFollowUps).toHaveBeenCalledTimes(1);
});
it('focuses the first queued follow-up from the queue slash command', async () => {
mockChat.queuedFollowUpCount.mockReturnValue(2);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'first queued prompt',
timestamp: new Date(),
},
{
id: 'queued-2',
messageId: 'msg-queued-2',
prompt: 'second queued prompt',
timestamp: new Date(),
},
]);
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: '/queue' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
const queuedRow = screen.getByRole('listitem', {
name: 'Queued follow-up: first queued prompt. Press Enter to edit or Delete to remove.',
});
await waitFor(() => expect(document.activeElement).toBe(queuedRow));
expect(queuedRow).toHaveAttribute(
'data-assistant-queue-command-target',
'true',
);
expect(textarea.value).toBe('');
});
it('runs the queue slash command from the composer send button', async () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-click',
messageId: 'msg-queued-click',
prompt: 'clicked queued prompt',
timestamp: new Date(),
},
]);
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: '/queue' } });
fireEvent.click(screen.getByRole('button', { name: 'Send message' }));
const queuedRow = screen.getByRole('listitem', {
name: 'Queued follow-up: clicked queued prompt. Press Enter to edit or Delete to remove.',
});
await waitFor(() =>
expect(queuedRow).toHaveAttribute('data-assistant-queue-command-target', 'true'),
);
expect(textarea.value).toBe('');
});
it('explains the queue slash command when no follow-ups are queued', async () => {
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.input(textarea, { target: { value: '/queue' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
expect(mockNotificationStore.info).toHaveBeenCalledWith('No queued follow-ups.', 2000);
await waitFor(() => expect(document.activeElement).toBe(textarea));
expect(textarea.value).toBe('');
});
it('shows paused queued follow-ups and lets the first one resume', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUpsPaused.mockReturnValue(true);

View file

@ -43,6 +43,19 @@ describe('AssistantCommandHelpDialog', () => {
expect(screen.getByRole('option', { name: /\/fixture/ })).toHaveTextContent('/fixture');
});
it('finds queued follow-up management from command help search', () => {
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={vi.fn()} />);
fireEvent.input(screen.getByLabelText('Search Assistant commands'), {
target: { value: 'queued' },
});
expect(screen.getByRole('option', { name: /\/queue/ })).toBeInTheDocument();
expect(screen.getByRole('option', { name: /\/queue/ })).toHaveTextContent(
'Manage queued follow-ups',
);
});
it('moves command selection with arrow keys before running', () => {
const onRunCommand = vi.fn();
render(() => <AssistantCommandHelpDialog onClose={vi.fn()} onRunCommand={onRunCommand} />);
@ -66,7 +79,7 @@ describe('AssistantCommandHelpDialog', () => {
expect(screen.getByText('Model')).toBeInTheDocument();
expect(screen.getByText('Transcript')).toBeInTheDocument();
expect(screen.getByText('Help')).toBeInTheDocument();
expect(screen.getAllByRole('option')).toHaveLength(13);
expect(screen.getAllByRole('option')).toHaveLength(14);
});
it('hides command group headers once help search is filtered', () => {

View file

@ -120,6 +120,9 @@ describe('SlashCommandAutocomplete', () => {
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
'Run /compact: Summarize older turns and keep this session moving',
);
expect(options.map((option) => option.getAttribute('aria-label'))).toContain(
'Run /queue: Manage queued follow-ups',
);
expect(container.querySelectorAll('[role="option"] svg')).toHaveLength(activeCommands.length);
expect(screen.getByText('Session')).toBeInTheDocument();
expect(screen.getByText('Model')).toBeInTheDocument();

View file

@ -16,6 +16,9 @@ describe('assistantSlashCommands', () => {
expect(parseAssistantSlashCommand('/sessions')).toBe('sessions');
expect(parseAssistantSlashCommand('/resume')).toBe('sessions');
expect(parseAssistantSlashCommand('/continue')).toBe('sessions');
expect(parseAssistantSlashCommand('/queue')).toBe('queue');
expect(parseAssistantSlashCommand('/queued')).toBe('queue');
expect(parseAssistantSlashCommand('/prompts')).toBe('queue');
expect(parseAssistantSlashCommand('/compact')).toBe('compact');
expect(parseAssistantSlashCommand('/summarize')).toBe('compact');
expect(parseAssistantSlashCommand('/models')).toBe('models');
@ -101,6 +104,7 @@ describe('assistantSlashCommands', () => {
expect(filterAssistantSlashCommands('').map((command) => command.name)).toEqual([
'new',
'sessions',
'queue',
'compact',
'fork',
'undo',
@ -116,6 +120,10 @@ describe('assistantSlashCommands', () => {
expect(filterAssistantSlashCommands('resume').map((command) => command.name)).toEqual([
'sessions',
]);
expect(filterAssistantSlashCommands('queued').map((command) => command.name)).toEqual([
'queue',
'fixture',
]);
expect(filterAssistantSlashCommands('summarize').map((command) => command.name)).toEqual([
'compact',
]);
@ -149,13 +157,13 @@ describe('assistantSlashCommands', () => {
'Developer',
]);
expect(groups.map((group) => group.items.map((item) => item.command.name))).toEqual([
['new', 'sessions', 'compact', 'fork', 'undo', 'redo'],
['new', 'sessions', 'queue', 'compact', 'fork', 'undo', 'redo'],
['models', 'providers', 'status'],
['copy', 'export'],
['help'],
['fixture'],
]);
expect(groups[1].items[0].index).toBe(6);
expect(groups[1].items[0].index).toBe(7);
});
it('keeps dev fixture commands out of production command surfaces', () => {
@ -215,6 +223,8 @@ describe('assistantSlashCommands', () => {
expect(getAssistantSlashCommandTokens(help)).toEqual(['help', 'commands']);
const sessions = filterAssistantSlashCommands('resume')[0];
expect(getAssistantSlashCommandTokens(sessions)).toEqual(['sessions', 'resume', 'continue']);
const queue = filterAssistantSlashCommands('queued')[0];
expect(getAssistantSlashCommandTokens(queue)).toEqual(['queue', 'queued', 'prompts']);
const compact = filterAssistantSlashCommands('summarize')[0];
expect(getAssistantSlashCommandTokens(compact)).toEqual(['compact', 'summarize']);
const models = filterAssistantSlashCommands('mo')[0];

View file

@ -13,6 +13,7 @@ export type AssistantSlashCommandAction =
| 'models'
| 'new'
| 'providers'
| 'queue'
| 'redo'
| 'status'
| 'undo'
@ -107,6 +108,14 @@ export const ASSISTANT_SLASH_COMMANDS: AssistantSlashCommand[] = [
category: 'Session',
description: 'Search or resume Assistant sessions (/sessions backup)',
},
{
name: 'queue',
aliases: ['queued', 'prompts'],
action: 'queue',
category: 'Session',
description: 'Manage queued follow-ups',
keywords: ['follow-up', 'follow-ups', 'queued', 'prompt', 'prompts'],
},
{
name: 'compact',
aliases: ['summarize'],

View file

@ -629,6 +629,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal<QueuedFollowUp | null>(
null,
);
const [queuedFollowUpCommandTargetId, setQueuedFollowUpCommandTargetId] = createSignal<
string | null
>(null);
const [restoredPromptDraft, setRestoredPromptDraft] = createSignal<RestoredPromptDraft | null>(
null,
);
@ -700,8 +703,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
let textareaRef: HTMLTextAreaElement | undefined;
let transcriptFallbackTextareaRef: HTMLTextAreaElement | undefined;
let interruptArmTimeout: ReturnType<typeof setTimeout> | undefined;
let queuedFollowUpCommandTargetTimeout: ReturnType<typeof setTimeout> | undefined;
let composerSubmitDispatchLocked = false;
let handledAssistantCommandRequestId = 0;
const queuedFollowUpRowRefs = new Map<string, HTMLDivElement>();
const focusComposer = () => {
queueMicrotask(() => {
@ -717,6 +722,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
setInterruptArmed(false);
};
const markQueuedFollowUpCommandTarget = (id: string | null) => {
if (queuedFollowUpCommandTargetTimeout) {
clearTimeout(queuedFollowUpCommandTargetTimeout);
queuedFollowUpCommandTargetTimeout = undefined;
}
setQueuedFollowUpCommandTargetId(id);
if (!id) return;
queuedFollowUpCommandTargetTimeout = setTimeout(() => {
queuedFollowUpCommandTargetTimeout = undefined;
setQueuedFollowUpCommandTargetId((current) => (current === id ? null : current));
}, 2500);
};
const armKeyboardInterrupt = () => {
clearInterruptArm();
setInterruptArmed(true);
@ -1557,6 +1575,45 @@ export const AIChat: Component<AIChatProps> = (props) => {
});
};
const focusQueuedFollowUps = () => {
const firstQueued = chat.queuedFollowUps()[0];
const findQueuedRow = () =>
(firstQueued ? queuedFollowUpRowRefs.get(firstQueued.id) : undefined) ||
document.querySelector<HTMLElement>('[data-testid="assistant-queued-follow-up-row"]');
const queuedRow = findQueuedRow();
const targetQueuedId =
firstQueued?.id ?? queuedRow?.dataset.assistantQueuedFollowUpId ?? null;
if (!firstQueued && !queuedRow) {
markQueuedFollowUpCommandTarget(null);
notificationStore.info('No queued follow-ups.', 2000);
focusComposer();
return;
}
setShowSessions(false);
setShowCommandHelp(false);
setSessionRefreshLoading(false);
resetSessionSearch();
markQueuedFollowUpCommandTarget(targetQueuedId);
const focusRow = () => {
const row = findQueuedRow();
if (!row) return;
row.scrollIntoView({ block: 'nearest' });
row.focus({ preventScroll: true });
if (document.activeElement !== row) {
row.querySelector<HTMLElement>('button:not(:disabled)')?.focus({ preventScroll: true });
}
};
queueMicrotask(focusRow);
window.requestAnimationFrame(() => {
focusRow();
});
window.setTimeout(focusRow, 0);
window.setTimeout(focusRow, 50);
window.setTimeout(focusRow, 150);
};
const handleQueuedFollowUpRowKeyDown = (
event: KeyboardEvent & { currentTarget: HTMLDivElement },
id: string,
@ -1729,6 +1786,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
disabled: !canRedoLastTurn(),
reason: 'No undone Assistant turn is available.',
},
queue: {
disabled: chat.queuedFollowUpCount() === 0,
reason: chat.queuedFollowUpCount() === 0 ? 'No queued follow-ups.' : undefined,
},
status: {
disabled: providerReadiness().status === 'checking',
reason: 'Route health check already running.',
@ -1745,6 +1806,19 @@ export const AIChat: Component<AIChatProps> = (props) => {
return state?.disabled ? state.reason || 'Assistant command is unavailable right now.' : '';
};
createEffect(() => {
const validQueuedIds = new Set(chat.queuedFollowUps().map((queued) => queued.id));
for (const queuedId of queuedFollowUpRowRefs.keys()) {
if (!validQueuedIds.has(queuedId)) {
queuedFollowUpRowRefs.delete(queuedId);
}
}
const targetQueuedId = queuedFollowUpCommandTargetId();
if (targetQueuedId && !validQueuedIds.has(targetQueuedId)) {
markQueuedFollowUpCommandTarget(null);
}
});
const buildCurrentTranscript = (generatedAt = new Date()) => {
const sessionId = chat.sessionId().trim();
const knownSession = sessionId ? findKnownSession(sessionId) : undefined;
@ -2604,6 +2678,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
(Boolean(node.closest('[data-mention-autocomplete]')) ||
Boolean(node.closest('[data-slash-command-autocomplete]'))),
);
const isInsideComposer = Boolean(target.closest('[data-assistant-composer]'));
// Only close if click is outside dropdown containers
if (!isInsideDropdown && !isInsideComposerPopup) {
setShowSessions(false);
@ -2612,10 +2687,10 @@ export const AIChat: Component<AIChatProps> = (props) => {
setShowControlMenu(false);
}
// Close mention autocomplete when clicking outside
if (!target.closest('[data-mention-autocomplete]') && !target.closest('textarea')) {
if (!target.closest('[data-mention-autocomplete]') && !isInsideComposer) {
setMentionActive(false);
}
if (!target.closest('[data-slash-command-autocomplete]') && !target.closest('textarea')) {
if (!target.closest('[data-slash-command-autocomplete]') && !isInsideComposer) {
closeSlashCommandAutocomplete({ clearTransientDraft: true });
}
};
@ -2625,6 +2700,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
document.removeEventListener('click', handleClickOutside);
aiChatStore.registerInput?.(null);
clearInterruptArm();
if (queuedFollowUpCommandTargetTimeout) {
clearTimeout(queuedFollowUpCommandTargetTimeout);
}
});
});
@ -2923,7 +3001,8 @@ export const AIChat: Component<AIChatProps> = (props) => {
return input();
};
const clearLocalComposerCommand = () => {
const clearLocalComposerCommand = (options: { refocusComposer?: boolean } = {}) => {
const shouldRefocusComposer = options.refocusComposer !== false;
stashedComposerDraft = null;
setEditingQueuedFollowUp(null);
setRestoredPromptDraft(null);
@ -2935,7 +3014,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
resetPromptHistoryNavigation();
queueMicrotask(() => {
resizeTextarea();
focusComposer();
if (shouldRefocusComposer) {
focusComposer();
}
});
};
@ -3018,7 +3099,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
return runAssistantFixtureSlashCommand(commandArgs);
}
clearLocalComposerCommand();
clearLocalComposerCommand({ refocusComposer: command !== 'queue' });
switch (command) {
case 'new':
@ -3028,6 +3109,9 @@ export const AIChat: Component<AIChatProps> = (props) => {
case 'sessions':
runSessionsSlashCommand(commandArgs);
break;
case 'queue':
focusQueuedFollowUps();
break;
case 'compact':
setShowCommandHelp(false);
void handleCompactSession();
@ -4641,10 +4725,21 @@ export const AIChat: Component<AIChatProps> = (props) => {
return (
<div
class="flex min-h-7 items-center gap-2 rounded-md bg-white/70 px-2 py-1 text-xs text-blue-900 outline-none transition-colors focus:bg-white focus:ring-2 focus:ring-blue-500/40 dark:bg-blue-900/30 dark:text-blue-100 dark:focus:bg-blue-900/50"
classList={{
'bg-blue-50 ring-2 ring-blue-500/60 dark:bg-blue-800/50':
queuedFollowUpCommandTargetId() === queued.id,
}}
ref={(element) => {
queuedFollowUpRowRefs.set(queued.id, element);
}}
role="listitem"
tabIndex={0}
aria-label={rowLabel()}
data-testid="assistant-queued-follow-up-row"
data-assistant-queued-follow-up-id={queued.id}
data-assistant-queue-command-target={
queuedFollowUpCommandTargetId() === queued.id ? 'true' : undefined
}
onKeyDown={(event) =>
handleQueuedFollowUpRowKeyDown(event, queued.id)
}
@ -4715,6 +4810,7 @@ export const AIChat: Component<AIChatProps> = (props) => {
</div>
</Show>
<form
data-assistant-composer
onSubmit={(e) => {
e.preventDefault();
handleSubmit();