diff --git a/src/chat-prefix-suggestions.ts b/src/chat-prefix-suggestions.ts new file mode 100644 index 00000000..983af790 --- /dev/null +++ b/src/chat-prefix-suggestions.ts @@ -0,0 +1,30 @@ +// Copyright (c) Mehmet Bektas + +/** + * The slash command and participant suggestions matching what has been typed. + * + * An empty prompt offers everything; anything else is a substring match, so + * typing past the last match legitimately leaves nothing to suggest. + */ +export function prefixesMatching(prefixes: string[], prompt: string): string[] { + const userInput = prompt.trimStart(); + if (userInput === '') { + return [...prefixes]; + } + return prefixes.filter(prefix => prefix.includes(userInput)); +} + +/** + * Whether the suggestion popover should act on a keystroke. + * + * The popover is opened by typing `@` or `/` and stays open while the prompt + * is edited, so it can end up with nothing to offer. It renders nothing in + * that state, and it must not claim Enter or Tab either: a popover that + * accepts a suggestion it does not have leaves the message unsendable. + */ +export function isPrefixPopoverUsable( + showPopover: boolean, + suggestions: string[] +): boolean { + return showPopover && suggestions.length > 0; +} diff --git a/src/chat-sidebar.tsx b/src/chat-sidebar.tsx index 9ec29dea..8d2357a4 100644 --- a/src/chat-sidebar.tsx +++ b/src/chat-sidebar.tsx @@ -99,6 +99,10 @@ import { TOUR_ANCHOR } from './tour/tour-anchors'; import { TOUR_START_EVENT, TOUR_STOP_EVENT } from './tour/tour-events'; import { hasCompletedTour } from './tour/tour-state'; import { IClaudeSessionInfo } from './api'; +import { + isPrefixPopoverUsable, + prefixesMatching +} from './chat-prefix-suggestions'; import { NOTEBOOK_GENERATION_PROGRESS_EVENT, type INotebookGenerationProgressDetail @@ -2879,6 +2883,8 @@ function SidebarComponent(props: any) { } }; + const popoverUsable = isPrefixPopoverUsable(showPopover, prefixSuggestions); + const applyPrefixSuggestion = async (prefix: string) => { let mcpArguments = ''; if (prefix.startsWith('/mcp:')) { @@ -3334,14 +3340,7 @@ function SidebarComponent(props: any) { ); const filterPrefixSuggestions = (prmpt: string) => { - const userInput = prmpt.trimStart(); - if (userInput === '') { - setPrefixSuggestions(originalPrefixes); - } else { - setPrefixSuggestions( - originalPrefixes.filter(prefix => prefix.includes(userInput)) - ); - } + setPrefixSuggestions(prefixesMatching(originalPrefixes, prmpt)); }; const resetPrefixSuggestions = () => { @@ -3408,15 +3407,16 @@ function SidebarComponent(props: any) { } event.stopPropagation(); event.preventDefault(); - if (showPopover) { + if (popoverUsable) { applyPrefixSuggestion(prefixSuggestions[selectedPrefixSuggestionIndex]); return; } + setShowPopover(false); setSelectedPrefixSuggestionIndex(0); handleSubmitStopChatButtonClick(); } else if (event.key === 'Tab') { - if (showPopover) { + if (popoverUsable) { event.stopPropagation(); event.preventDefault(); applyPrefixSuggestion(prefixSuggestions[selectedPrefixSuggestionIndex]); @@ -3433,7 +3433,7 @@ function SidebarComponent(props: any) { event.stopPropagation(); event.preventDefault(); - if (showPopover) { + if (popoverUsable) { setSelectedPrefixSuggestionIndex( (selectedPrefixSuggestionIndex - 1 + prefixSuggestions.length) % prefixSuggestions.length @@ -3464,7 +3464,7 @@ function SidebarComponent(props: any) { event.stopPropagation(); event.preventDefault(); - if (showPopover) { + if (popoverUsable) { setSelectedPrefixSuggestionIndex( (selectedPrefixSuggestionIndex + 1 + prefixSuggestions.length) % prefixSuggestions.length @@ -4535,7 +4535,7 @@ function SidebarComponent(props: any) { - {showPopover && prefixSuggestions.length > 0 && ( + {popoverUsable && (
{prefixSuggestions.map((prefix, index) => (
+ +// A prompt beginning with @ or / opens the suggestion popover, and the +// popover used to keep claiming Enter after the prompt was typed past every +// match, so such a message could not be sent from the keyboard at all. +import { + isPrefixPopoverUsable, + prefixesMatching +} from '../../src/chat-prefix-suggestions'; + +const PREFIXES = ['@mcp', '/clear', '/newNotebook', '/newPythonFile']; + +describe('prefixesMatching', () => { + it('offers everything for an empty prompt', () => { + expect(prefixesMatching(PREFIXES, '')).toEqual(PREFIXES); + expect(prefixesMatching(PREFIXES, ' ')).toEqual(PREFIXES); + }); + + it('narrows to substring matches', () => { + expect(prefixesMatching(PREFIXES, '/new')).toEqual([ + '/newNotebook', + '/newPythonFile' + ]); + expect(prefixesMatching(PREFIXES, '@')).toEqual(['@mcp']); + }); + + it('offers nothing once the prompt is typed past every match', () => { + expect(prefixesMatching(PREFIXES, '@Ada please review this')).toEqual([]); + expect(prefixesMatching(PREFIXES, '/usr/bin/python is missing')).toEqual( + [] + ); + }); + + it('does not mutate the list it was given', () => { + const original = [...PREFIXES]; + prefixesMatching(PREFIXES, '').push('/mutated'); + expect(PREFIXES).toEqual(original); + }); +}); + +describe('isPrefixPopoverUsable', () => { + it('is false with nothing to suggest, even while open', () => { + // The case behind the bug: the popover renders nothing here, so it must + // not claim Enter or Tab either. + expect(isPrefixPopoverUsable(true, [])).toBe(false); + }); + + it('is true while it has something to offer', () => { + expect(isPrefixPopoverUsable(true, ['/clear'])).toBe(true); + }); + + it('is false when closed', () => { + expect(isPrefixPopoverUsable(false, ['/clear'])).toBe(false); + }); +});