From 4e2ae3109e24a65151db53b96fa5498829d88ef7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 6 Jun 2026 04:36:35 +0100 Subject: [PATCH] Add Assistant keyboard interrupt guard --- .../v6/internal/subsystems/ai-runtime.md | 12 ++++ frontend-modern/src/App.tsx | 9 ++- .../src/__tests__/App.architecture.test.ts | 2 + .../AI/Chat/__tests__/AIChat.test.tsx | 16 +++++ .../src/components/AI/Chat/index.tsx | 59 ++++++++++++++++--- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index df8662c52..530bcc059 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -247,6 +247,18 @@ runtime cost control, and shared AI transport surfaces. those rows through the existing chat-runtime queue without aborting the active model stream. The referenced OpenCode source at fetched `origin/dev` commit + `09d9cf01f93798939c1284fbe974b6e1f4d2759d` registers the + `session.interrupt` command while a turn is non-idle in + `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and its + direct-run footer implements the same two-press interrupt guard in + `packages/opencode/src/cli/cmd/run/footer.ts` while rendering the armed + state in `packages/opencode/src/cli/cmd/run/footer.view.tsx`. Pulse's + Assistant drawer adapts that ergonomics model by letting Escape from the + focused composer arm the visible Stop control first and letting the next + Escape confirm the same governed `chat.stop()` path as the Stop button, + including aborting the active stream, clearing queued follow-ups, preserving + partial text, and returning focus to the composer. + The referenced OpenCode source at fetched `origin/dev` commit `fa2b63f850fc0a23bec2bdff9e660450d3fe7913` keeps prompt/footer status visible only while the session is non-idle in `packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx`, and maps diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 9df4ab367..d825e52bd 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -394,9 +394,14 @@ function App() { // Setup escape handling for the assistant drawer. onMount(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Escape to close + // Escape closes the drawer only after mounted drawer controls have had + // a chance to claim the key for local flows such as interrupt confirm. if (e.key === 'Escape' && aiChatStore.isOpen) { - aiChatStore.close(); + window.setTimeout(() => { + if (!e.defaultPrevented && aiChatStore.isOpen) { + aiChatStore.close(); + } + }, 0); } }; diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index d6c01fe1c..4d60d98ff 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -173,6 +173,8 @@ describe('App architecture', () => { 'if (dialogStackHasBlockingDialog() && aiChatStore.isOpenSignal()) {', ); expect(appSource).toContain("if (e.key === 'Escape' && aiChatStore.isOpen) {"); + expect(appSource).toContain('window.setTimeout(() => {'); + expect(appSource).toContain('if (!e.defaultPrevented && aiChatStore.isOpen) {'); expect(appSource).toContain(' aiChatStore.close()} />'); expect(appSource).toContain('showOrgSwitcher={runtime.showOrgSwitcher}'); expect(appSource).not.toContain('TrialBanner'); 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 935f41929..3a32d4332 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -1476,6 +1476,22 @@ describe('AIChat', () => { expect(mockChat.stop).toHaveBeenCalledTimes(1); }); + it('arms keyboard interruption on first Escape and stops on second Escape', async () => { + mockChat.isLoading.mockReturnValue(true); + renderChat(); + const textarea = screen.getByPlaceholderText('Ask about your infrastructure...'); + + fireEvent.keyDown(textarea, { key: 'Escape' }); + + expect(mockChat.stop).not.toHaveBeenCalled(); + expect(screen.getByTitle('Stop response armed')).toBeInTheDocument(); + + fireEvent.keyDown(textarea, { key: 'Escape' }); + + expect(mockChat.stop).toHaveBeenCalledTimes(1); + await waitFor(() => expect(document.activeElement).toBe(textarea)); + }); + it('returns focus to the composer after stopping a response', async () => { mockChat.isLoading.mockReturnValue(true); renderChat(); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 750d4a6c4..9cda8bc05 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -457,6 +457,7 @@ export const AIChat: Component = (props) => { const [editingQueuedFollowUp, setEditingQueuedFollowUp] = createSignal( null, ); + const [interruptArmed, setInterruptArmed] = createSignal(false); const [promptHistory, setPromptHistory] = createSignal([]); const [promptHistoryIndex, setPromptHistoryIndex] = createSignal(-1); const [savedPromptDraft, setSavedPromptDraft] = createSignal(null); @@ -497,6 +498,7 @@ export const AIChat: Component = (props) => { const [mentionResources, setMentionResources] = createSignal([]); const [accumulatedMentions, setAccumulatedMentions] = createSignal([]); let textareaRef: HTMLTextAreaElement | undefined; + let interruptArmTimeout: ReturnType | undefined; const focusComposer = () => { queueMicrotask(() => { @@ -504,6 +506,23 @@ export const AIChat: Component = (props) => { }); }; + const clearInterruptArm = () => { + if (interruptArmTimeout) { + clearTimeout(interruptArmTimeout); + interruptArmTimeout = undefined; + } + setInterruptArmed(false); + }; + + const armKeyboardInterrupt = () => { + clearInterruptArm(); + setInterruptArmed(true); + interruptArmTimeout = setTimeout(() => { + interruptArmTimeout = undefined; + setInterruptArmed(false); + }, 5000); + }; + const resizeTextarea = () => { if (!textareaRef) return; textareaRef.style.height = 'auto'; @@ -756,6 +775,12 @@ export const AIChat: Component = (props) => { onConversationChanged: refreshSessions, }); + const stopActiveResponse = () => { + clearInterruptArm(); + chat.stop(); + focusComposer(); + }; + const queuedFollowUpPreview = (prompt: string) => { const firstLine = prompt .split(/\r?\n/) @@ -1183,6 +1208,12 @@ export const AIChat: Component = (props) => { void initializeWhenOpen(); }); + createEffect(() => { + if (!chat.isLoading() && interruptArmed()) { + clearInterruptArm(); + } + }); + createEffect(() => { const open = isOpen(); const model = selectedChatModel().trim(); @@ -1259,6 +1290,7 @@ export const AIChat: Component = (props) => { onCleanup(() => { document.removeEventListener('click', handleClickOutside); aiChatStore.registerInput?.(null); + clearInterruptArm(); }); }); @@ -1714,6 +1746,18 @@ export const AIChat: Component = (props) => { return; } + if (e.key === 'Escape' && chat.isLoading()) { + e.preventDefault(); + e.stopPropagation(); + if (interruptArmed()) { + stopActiveResponse(); + } else { + armKeyboardInterrupt(); + focusComposer(); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(); @@ -2516,13 +2560,14 @@ export const AIChat: Component = (props) => {