diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 05294ee13..ad2ca2909 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -214,6 +214,16 @@ runtime cost control, and shared AI transport surfaces. such as New session, session history, collapse/close, autonomous-warning recovery, and the control-mode selector instead of depending on title-only icon controls or ambiguous short labels. + Slash autocomplete close semantics are owned by that same prompt command + surface. The referenced OpenCode source at fetched `origin/dev` commit + `4519a1da329c1a4fc384054e7203ba7d06928205` clears a transient slash + command query when `hide()` closes prompt autocomplete in + `packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx` + (lines 668-678), so Pulse's browser slash command popup must close Escape + and outside-click locally, clear only slash-only command drafts, return focus + to the composer, and leave ordinary prompts or already-submitted local + command actions untouched. Closing the popup must not leave `/mo`, `/new`, or + another executable command token behind for the next Enter press. Failed-turn retry is part of that same local chat-runtime boundary: a retryable in-memory assistant error may replay the original user turn's structured mentions, finding id, approval override, handoff resources, diff --git a/frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx b/frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx index 482615b3b..f3228699e 100644 --- a/frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx +++ b/frontend-modern/src/components/AI/Chat/SlashCommandAutocomplete.tsx @@ -72,22 +72,27 @@ export function SlashCommandAutocomplete(props: SlashCommandAutocompleteProps) { switch (event.key) { case 'ArrowDown': event.preventDefault(); + event.stopPropagation(); setSelectedIndex((index) => Math.min(index + 1, Math.max(0, options.length - 1))); break; case 'ArrowUp': event.preventDefault(); + event.stopPropagation(); setSelectedIndex((index) => Math.max(index - 1, 0)); break; case 'Enter': event.preventDefault(); + event.stopPropagation(); selectCommand(options[selectedIndex()]); break; case 'Tab': event.preventDefault(); + event.stopPropagation(); selectCommand(options[selectedIndex()]); break; case 'Escape': event.preventDefault(); + event.stopPropagation(); props.onClose(); break; } 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 33a9b71d6..1982712ef 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -1702,13 +1702,14 @@ describe('AIChat', () => { expect(mockChat.sendMessage).not.toHaveBeenCalled(); }); - it('closes slash command suggestions with Escape without submitting', async () => { - renderChat(); + it('clears a transient slash command draft with Escape without submitting', async () => { + const onClose = vi.fn(); + renderChat(onClose); const textarea = screen.getByPlaceholderText( 'Ask about your infrastructure...', ) as HTMLTextAreaElement; - fireEvent.input(textarea, { target: { value: '/' } }); + fireEvent.input(textarea, { target: { value: '/mo' } }); expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument(); fireEvent.keyDown(textarea, { key: 'Escape' }); @@ -1718,7 +1719,30 @@ describe('AIChat', () => { screen.queryByRole('listbox', { name: 'Assistant commands' }), ).not.toBeInTheDocument(); }); - expect(textarea.value).toBe('/'); + expect(textarea.value).toBe(''); + expect(screen.getByTestId('model-selector')).toHaveAttribute('data-open-request', '0'); + expect(onClose).not.toHaveBeenCalled(); + expect(mockChat.sendMessage).not.toHaveBeenCalled(); + }); + + it('clears a transient slash command draft when autocomplete closes from outside click', async () => { + renderChat(); + const textarea = screen.getByPlaceholderText( + 'Ask about your infrastructure...', + ) as HTMLTextAreaElement; + + fireEvent.input(textarea, { target: { value: '/new' } }); + expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument(); + + fireEvent.click(document.body); + + await waitFor(() => { + expect( + screen.queryByRole('listbox', { name: 'Assistant commands' }), + ).not.toBeInTheDocument(); + }); + expect(textarea.value).toBe(''); + expect(mockChat.newSession).not.toHaveBeenCalled(); expect(mockChat.sendMessage).not.toHaveBeenCalled(); }); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index 052ce42b7..641216c4f 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -850,6 +850,34 @@ export const AIChat: Component = (props) => { setSavedPromptDraft(null); }; + const isTransientSlashCommandDraft = () => { + const text = input(); + const cursor = textareaRef?.selectionStart ?? text.length; + const textBeforeCursor = text.slice(0, cursor); + const textAfterCursor = text.slice(cursor); + return ( + textBeforeCursor.startsWith('/') && + !/\s/.test(textBeforeCursor) && + !textAfterCursor.trim() + ); + }; + + const closeSlashCommandAutocomplete = (options?: { clearTransientDraft?: boolean }) => { + const shouldClearDraft = Boolean(options?.clearTransientDraft && isTransientSlashCommandDraft()); + setSlashCommandActive(false); + setSlashCommandQuery(''); + if (!shouldClearDraft) return; + + setInput(''); + setAccumulatedMentions([]); + resetPromptHistoryNavigation(); + queueMicrotask(() => { + resizeTextarea(); + textareaRef?.focus(); + textareaRef?.setSelectionRange(0, 0); + }); + }; + const stashComposerDraftForRemount = () => { const text = input(); const mentions = accumulatedMentions(); @@ -2271,7 +2299,7 @@ export const AIChat: Component = (props) => { setMentionActive(false); } if (!target.closest('[data-slash-command-autocomplete]') && !target.closest('textarea')) { - setSlashCommandActive(false); + closeSlashCommandAutocomplete({ clearTransientDraft: true }); } }; document.addEventListener('click', handleClickOutside); @@ -2888,7 +2916,13 @@ export const AIChat: Component = (props) => { } } if (slashCommandActive()) { - if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(e.key)) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + closeSlashCommandAutocomplete({ clearTransientDraft: true }); + return; + } + if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab'].includes(e.key)) { // These are handled by SlashCommandAutocomplete. return; } @@ -4182,7 +4216,7 @@ export const AIChat: Component = (props) => { query={slashCommandQuery()} position={{ top: 58, left: 0 }} onSelect={handleSlashCommandSelect} - onClose={() => setSlashCommandActive(false)} + onClose={() => closeSlashCommandAutocomplete({ clearTransientDraft: true })} visible={slashCommandActive()} />