From 1687ad723e09fa650b583afd3553ffbfbf2ebeba Mon Sep 17 00:00:00 2001 From: PJ Doland Date: Fri, 18 Sep 2026 00:46:35 -0400 Subject: [PATCH] fix(chat): let Enter send a message that starts with an unmatched @ or / Typing `@` or `/` opens the suggestion popover, and every later keystroke re-filters the list. The filter can empty it, and nothing closed the popover when it did, so the Enter handler still tried to accept the highlighted suggestion from an empty list: `applyPrefixSuggestion(undefined)`, which throws on its first line. The message was never sent and nothing appeared on screen, so any prompt opening with a name or a path (`@Ada please review this`, `/usr/bin/python is missing`) could not be sent from the keyboard at all. Clicking Send worked, which is what made it look so arbitrary. The popover already rendered only when it had suggestions; the key handlers disagreed. Both now read one condition, so a popover with nothing to offer claims no keystroke. That covers Enter, Tab, and the arrow keys, whose wraparound arithmetic divided by the same empty length and selected NaN. The condition and the filter move into their own module: the sidebar is 4600 lines and has no tests, and these two rules are what the regression is about. Closes #463 --- src/chat-prefix-suggestions.ts | 30 +++++++++++++ src/chat-sidebar.tsx | 26 +++++------ tests/ts/chat-prefix-suggestions.test.ts | 55 ++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 13 deletions(-) create mode 100644 src/chat-prefix-suggestions.ts create mode 100644 tests/ts/chat-prefix-suggestions.test.ts 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); + }); +});