Improve Assistant queued follow-up keyboard handling

This commit is contained in:
rcourtman
2026-06-07 09:25:28 +01:00
parent fc24ac0aee
commit 9022b01bc3
3 changed files with 98 additions and 3 deletions
@@ -146,7 +146,17 @@ runtime cost control, and shared AI transport surfaces.
`packages/opencode/src/cli/cmd/run/demo.ts` (`startTool` and `doneTool`,
lines 451-525; demo command routing, lines 1039-1076); Pulse adapts that by
keeping `/fixture queue-hold` and `/fixture queued-follow-up` fully local
browser fixtures for queue interaction proof. OpenCode's `DialogModel` feeds
browser fixtures for queue interaction proof. The referenced OpenCode source
at fetched `origin/dev` commit
`e82542b8023a8374f29c23b70ec019c8f256354e` exposes queued prompts through
`RunQueuedPromptSelectBody` in
`packages/opencode/src/cli/cmd/run/footer.command.tsx` lines 588-650:
queued rows are searchable and keyboard-operable, with Enter/Ctrl+E editing
the selected prompt and Delete/Ctrl+D removing it. Pulse adapts that pattern
to the browser drawer by making each composer-adjacent queued follow-up row
focusable and directly operable with Enter to edit and Delete/Backspace to
remove, while retaining the visible icon buttons and avoiding terminal-only
shortcuts that collide with browser chrome. OpenCode's `DialogModel` feeds
current, recent, favorite, and provider model rows into
`DialogSelect`, while `DialogSelect` maintains a selected row and handles
up/down/page/home/end/return navigation. The fetched OpenCode
@@ -2519,6 +2519,63 @@ describe('AIChat', () => {
expect(mockChat.cancelQueuedFollowUp).toHaveBeenCalledWith('queued-1');
});
it('loads a focused queued follow-up row into the composer with Enter', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'keyboard edit queued prompt',
timestamp: new Date(),
},
]);
mockChat.takeQueuedFollowUp.mockReturnValue({
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'keyboard edit queued prompt',
timestamp: new Date(),
});
renderChat();
const textarea = screen.getByPlaceholderText(
'Ask about your infrastructure...',
) as HTMLTextAreaElement;
fireEvent.keyDown(
screen.getByRole('listitem', {
name: 'Queued follow-up: keyboard edit queued prompt. Press Enter to edit or Delete to remove.',
}),
{ key: 'Enter' },
);
expect(mockChat.takeQueuedFollowUp).toHaveBeenCalledWith('queued-1');
expect(textarea.value).toBe('keyboard edit queued prompt');
});
it('removes a focused queued follow-up row with Delete', async () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
{
id: 'queued-1',
messageId: 'msg-queued-1',
prompt: 'keyboard remove queued prompt',
timestamp: new Date(),
},
]);
renderChat();
const textarea = screen.getByPlaceholderText('Ask about your infrastructure...');
fireEvent.keyDown(
screen.getByRole('listitem', {
name: 'Queued follow-up: keyboard remove queued prompt. Press Enter to edit or Delete to remove.',
}),
{ key: 'Delete' },
);
expect(mockChat.cancelQueuedFollowUp).toHaveBeenCalledWith('queued-1');
await waitFor(() => expect(document.activeElement).toBe(textarea));
});
it('loads an individual queued follow-up into the composer for editing', () => {
mockChat.queuedFollowUpCount.mockReturnValue(1);
mockChat.queuedFollowUps.mockReturnValue([
@@ -1607,6 +1607,25 @@ export const AIChat: Component<AIChatProps> = (props) => {
});
};
const handleQueuedFollowUpRowKeyDown = (
event: KeyboardEvent & { currentTarget: HTMLDivElement },
id: string,
) => {
if (event.defaultPrevented || event.target !== event.currentTarget) return;
if (event.key === 'Enter') {
event.preventDefault();
editQueuedFollowUp(id);
return;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
event.preventDefault();
chat.cancelQueuedFollowUp(id);
focusComposer();
}
};
const restoreLastTurnDraft = (draft: RestoredPromptDraft) => {
resetPromptHistoryNavigation();
setEditingQueuedFollowUp(null);
@@ -4450,12 +4469,21 @@ export const AIChat: Component<AIChatProps> = (props) => {
<XIcon class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
<div class="mt-1 max-h-24 space-y-1 overflow-y-auto">
<div class="mt-1 max-h-24 space-y-1 overflow-y-auto" role="list">
<For each={chat.queuedFollowUps()}>
{(queued, index) => {
const preview = () => queuedFollowUpPreview(queued.prompt);
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 dark:bg-blue-900/30 dark:text-blue-100">
<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"
role="listitem"
tabIndex={0}
aria-label={`Queued follow-up: ${preview()}. Press Enter to edit or Delete to remove.`}
data-testid="assistant-queued-follow-up-row"
onKeyDown={(event) =>
handleQueuedFollowUpRowKeyDown(event, queued.id)
}
>
<span class="min-w-0 flex-1 truncate">{preview()}</span>
<Show when={chat.queuedFollowUpCount() > 1 && index() > 0}>
<button