Skip to content

Commit 202b821

Browse files
authored
fix: update command suggestions on slash command register/unregister (#12281)
When a slash command is registered or unregistered at runtime, the command suggestion dropdown now updates immediately. Previously, suggestions only refreshed on user input change, causing stale entries to remain visible if a command was dynamically removed. Adds an onChange listener to SlashCommandRegistry that notifies the useCommandMenuKeyboard hook to re-evaluate suggestions. Signed-off-by: Lin Wang <wonglam@amazon.com>
1 parent 5517e06 commit 202b821

4 files changed

Lines changed: 150 additions & 2 deletions

File tree

src/plugins/chat/public/hooks/use_command_menu_keyboard.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jest.mock('../services/slash_commands', () => {
1313
const mockRegistry = {
1414
getSuggestions: jest.fn(),
1515
get: jest.fn(),
16+
onChange: jest.fn(() => jest.fn()),
1617
};
1718
return {
1819
slashCommandRegistry: mockRegistry,
@@ -663,4 +664,67 @@ describe('useCommandMenuKeyboard', () => {
663664
expect(mockOnKeyDown).toHaveBeenCalledWith(event);
664665
});
665666
});
667+
668+
describe('onChange registry subscription', () => {
669+
it('should not subscribe to onChange when command menu is hidden', () => {
670+
(slashCommandRegistry.getSuggestions as jest.Mock).mockReturnValue([]);
671+
(slashCommandRegistry.onChange as jest.Mock).mockClear();
672+
673+
renderHook(() =>
674+
useCommandMenuKeyboard({
675+
input: 'hello',
676+
onInputChange: mockOnInputChange,
677+
onKeyDown: mockOnKeyDown,
678+
inputRef,
679+
})
680+
);
681+
682+
expect(slashCommandRegistry.onChange).not.toHaveBeenCalled();
683+
});
684+
685+
it('should subscribe to onChange when command menu is shown', () => {
686+
(slashCommandRegistry.getSuggestions as jest.Mock).mockReturnValue([mockCommands[0]]);
687+
(slashCommandRegistry.onChange as jest.Mock).mockClear();
688+
689+
renderHook(() =>
690+
useCommandMenuKeyboard({
691+
input: '/h',
692+
onInputChange: mockOnInputChange,
693+
onKeyDown: mockOnKeyDown,
694+
inputRef,
695+
})
696+
);
697+
698+
expect(slashCommandRegistry.onChange).toHaveBeenCalled();
699+
});
700+
701+
it('should update suggestions when onChange fires', () => {
702+
let onChangeCallback: (() => void) | undefined;
703+
(slashCommandRegistry.onChange as jest.Mock).mockImplementation((cb) => {
704+
onChangeCallback = cb;
705+
return jest.fn();
706+
});
707+
(slashCommandRegistry.getSuggestions as jest.Mock).mockReturnValue([mockCommands[0]]);
708+
709+
const { result } = renderHook(() =>
710+
useCommandMenuKeyboard({
711+
input: '/h',
712+
onInputChange: mockOnInputChange,
713+
onKeyDown: mockOnKeyDown,
714+
inputRef,
715+
})
716+
);
717+
718+
expect(result.current.commandSuggestions).toHaveLength(1);
719+
720+
// Simulate command unregistration
721+
(slashCommandRegistry.getSuggestions as jest.Mock).mockReturnValue([]);
722+
act(() => {
723+
onChangeCallback?.();
724+
});
725+
726+
expect(result.current.commandSuggestions).toHaveLength(0);
727+
expect(result.current.showCommandMenu).toBe(false);
728+
});
729+
});
666730
});

src/plugins/chat/public/hooks/use_command_menu_keyboard.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* SPDX-License-Identifier: Apache-2.0
44
*/
55

6-
import { useState, useEffect, RefObject } from 'react';
6+
import { useState, useEffect, useRef, RefObject } from 'react';
77
import { slashCommandRegistry, SlashCommand } from '../services/slash_commands';
88

99
interface UseCommandMenuKeyboardParams {
@@ -36,7 +36,8 @@ export const useCommandMenuKeyboard = ({
3636
const [commandSuggestions, setCommandSuggestions] = useState<SlashCommand[]>([]);
3737
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
3838
const [ghostText, setGhostText] = useState('');
39-
39+
const inputValueRef = useRef(input);
40+
inputValueRef.current = input;
4041
// Update command suggestions when input changes
4142
useEffect(() => {
4243
if (input.startsWith('/')) {
@@ -84,6 +85,16 @@ export const useCommandMenuKeyboard = ({
8485
}
8586
}, [input]);
8687

88+
// Clear suggestions when commands are registered/unregistered
89+
useEffect(() => {
90+
if (!showCommandMenu) return;
91+
return slashCommandRegistry.onChange(() => {
92+
const suggestions = slashCommandRegistry.getSuggestions(inputValueRef.current);
93+
setCommandSuggestions(suggestions);
94+
setShowCommandMenu(suggestions.length > 0);
95+
});
96+
}, [showCommandMenu]);
97+
8798
useEffect(() => {
8899
const textArea = inputRef.current;
89100
if (textArea) {

src/plugins/chat/public/services/slash_commands.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,4 +624,65 @@ describe('SlashCommandRegistry', () => {
624624
consoleWarnSpy.mockRestore();
625625
});
626626
});
627+
628+
describe('onChange', () => {
629+
it('should notify listener on register', () => {
630+
const listener = jest.fn();
631+
slashCommandRegistry.onChange(listener);
632+
633+
slashCommandRegistry.register({
634+
command: 'test',
635+
description: 'Test',
636+
handler: () => 'result',
637+
});
638+
639+
expect(listener).toHaveBeenCalledTimes(1);
640+
});
641+
642+
it('should notify listener on unregister', () => {
643+
slashCommandRegistry.register({
644+
command: 'test',
645+
description: 'Test',
646+
handler: () => 'result',
647+
});
648+
649+
const listener = jest.fn();
650+
slashCommandRegistry.onChange(listener);
651+
652+
slashCommandRegistry.unregister('test');
653+
654+
expect(listener).toHaveBeenCalledTimes(1);
655+
});
656+
657+
it('should not notify after unsubscribe', () => {
658+
const listener = jest.fn();
659+
const unsubscribe = slashCommandRegistry.onChange(listener);
660+
661+
unsubscribe();
662+
663+
slashCommandRegistry.register({
664+
command: 'test',
665+
description: 'Test',
666+
handler: () => 'result',
667+
});
668+
669+
expect(listener).not.toHaveBeenCalled();
670+
});
671+
672+
it('should notify multiple listeners', () => {
673+
const listener1 = jest.fn();
674+
const listener2 = jest.fn();
675+
slashCommandRegistry.onChange(listener1);
676+
slashCommandRegistry.onChange(listener2);
677+
678+
slashCommandRegistry.register({
679+
command: 'test',
680+
description: 'Test',
681+
handler: () => 'result',
682+
});
683+
684+
expect(listener1).toHaveBeenCalledTimes(1);
685+
expect(listener2).toHaveBeenCalledTimes(1);
686+
});
687+
});
627688
});

src/plugins/chat/public/services/slash_commands.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface SlashCommand {
1717

1818
class SlashCommandRegistry {
1919
private commands: Map<string, SlashCommand> = new Map();
20+
private listeners: Set<() => void> = new Set();
2021

2122
register(command: SlashCommand) {
2223
if (this.commands.has(command.command)) {
@@ -25,10 +26,21 @@ class SlashCommandRegistry {
2526
return;
2627
}
2728
this.commands.set(command.command, command);
29+
this.notifyListeners();
2830
}
2931

3032
unregister(commandName: string) {
3133
this.commands.delete(commandName);
34+
this.notifyListeners();
35+
}
36+
37+
onChange(listener: () => void): () => void {
38+
this.listeners.add(listener);
39+
return () => this.listeners.delete(listener);
40+
}
41+
42+
private notifyListeners() {
43+
this.listeners.forEach((fn) => fn());
3244
}
3345

3446
get(commandName: string): SlashCommand | undefined {

0 commit comments

Comments
 (0)