From 9022b01bc36ddfa5f998c3a0fdf024f0d39dfeff Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 7 Jun 2026 09:25:28 +0100 Subject: [PATCH] Improve Assistant queued follow-up keyboard handling --- .../v6/internal/subsystems/ai-runtime.md | 12 +++- .../AI/Chat/__tests__/AIChat.test.tsx | 57 +++++++++++++++++++ .../src/components/AI/Chat/index.tsx | 32 ++++++++++- 3 files changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4a94d3692..5f1bbb7a8 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 33d38ba65..e727799c5 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -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([ diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 1fad3f2c5..0a9b4cdaf 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -1607,6 +1607,25 @@ export const AIChat: Component = (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 = (props) => {