mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 02:55:51 +00:00
Improve Assistant queued follow-up keyboard handling
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user