@jupyter-ai/persona-manager 0.1.2 → 0.2.0-a0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,36 @@ export type Control = {
27
27
  * The user's current selection seeds each control's `selection`.
28
28
  */
29
29
  export declare function buildControls(persona: PersonaAwareness | null, settings: PersonaSettings): Control[];
30
+ /**
31
+ * Decide which persona list the toolbar should display: a freshly read empty
32
+ * list is treated as a transient blip (e.g. a Yjs awareness sync hiccup
33
+ * during a persona reload) rather than "no personas", so the toolbar keeps
34
+ * showing the previous list instead of unmounting - `PersonaControls` hides
35
+ * itself entirely when `personas.length === 0`, so accepting every empty
36
+ * read verbatim flashes the whole toolbar (persona name, model picker,
37
+ * everything) to nothing and back on each blip. A genuinely personas-less
38
+ * chat never reads a non-empty list in the first place, so this doesn't mask
39
+ * that case - it only guards against reverting an already-populated list.
40
+ */
41
+ export declare function reconcilePersonas(previous: PersonaOption[], next: PersonaOption[]): PersonaOption[];
42
+ /**
43
+ * Decide which selected-persona state the toolbar should track: a fresh
44
+ * null read is treated as a transient blip (the same class of awareness
45
+ * sync hiccup `reconcilePersonas` guards against, e.g. during a persona
46
+ * reload) rather than "nothing selected", so the toolbar keeps the last
47
+ * known state instead of dropping it. This one is not just cosmetic:
48
+ * `buildControls` returns `[]` for a null persona state, so `controls`
49
+ * reads empty and `PersonaControls` conditionally unmounts `ControlsRow`
50
+ * entirely - destroying any open `ControlMenu`'s own state (its search
51
+ * query, its open popover) mid-interaction, not merely blanking a label.
52
+ *
53
+ * Only for the *recurring* awareness-driven read, not the initial one: the
54
+ * initial read (on mount, or when `selectedId` itself changes to a
55
+ * different persona) must apply unconditionally, including a genuine null
56
+ * result, or switching personas could show the previous persona's stale
57
+ * state under the new selection.
58
+ */
59
+ export declare function reconcilePersonaState(previous: PersonaAwareness | null, next: PersonaAwareness | null): PersonaAwareness | null;
30
60
  /**
31
61
  * Decide how to reconcile the current selection with a freshly read persona
32
62
  * list: the new selection to apply, or `undefined` to keep the current one.
@@ -51,10 +81,35 @@ export declare function showLoadingPlaceholder(hasAwareness: boolean, managerRes
51
81
  * control's kind. A null value resets that control to the persona's default.
52
82
  */
53
83
  export declare function applyControlChange(settings: PersonaSettings, control: Control, value: string | null): PersonaSettings;
84
+ /** One selectable row in a control's dropdown: an option, or the leading
85
+ * "Default" row (`id: null`). */
86
+ type Choice = {
87
+ id: string | null;
88
+ primary: string;
89
+ description: string | null;
90
+ };
91
+ /**
92
+ * Filter a control's choices by a search query: a case-insensitive substring
93
+ * match against each choice's name. An empty (or whitespace-only) query
94
+ * matches everything, so a freshly opened menu shows the full list. The
95
+ * leading "Default" choice (`id: null`) is excluded from filtering entirely -
96
+ * always kept, and always first - since it's a fixed, load-bearing option
97
+ * (what "no explicit selection" resolves to), not just another item to
98
+ * search among.
99
+ */
100
+ export declare function filterChoices(choices: Choice[], query: string): Choice[];
54
101
  /**
55
- * A dropdown for a control, titled with the control's label. The first choice
56
- * row is "Default" (selection = null); the rest are the persona's advertised
57
- * options (selection = that option's id). Exported for tests.
102
+ * A searchable dropdown for a control, titled with the control's label. The
103
+ * first choice row is always "Default" (selection = null, never filtered
104
+ * out); the rest are the persona's advertised options (selection = that
105
+ * option's id), filtered by the search box as the user types. Exported for
106
+ * tests.
107
+ *
108
+ * Built on `Popover` rather than `Menu`/`MenuList`: `MenuList` owns its own
109
+ * keyboard handling (arrow keys, type-ahead-by-letter) which would fight a
110
+ * text input for keystrokes, so with a search box in the picture this
111
+ * component manages its own keyboard navigation (`focusedIndex`) instead of
112
+ * relying on `MenuList`'s.
58
113
  */
59
114
  export declare function ControlMenu(props: {
60
115
  control: Control;
@@ -117,3 +172,4 @@ export declare function PersonaControls(props: InputToolbarRegistry.IToolbarItem
117
172
  */
118
173
  controlRegistry?: IPersonaControlRegistry;
119
174
  }): JSX.Element | null;
175
+ export {};
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
- import { Button, ListItemText, ListSubheader, Menu, MenuItem, Popover, Skeleton } from '@mui/material';
2
+ import { Button, ListItemText, ListSubheader, Menu, MenuItem, Popover, Skeleton, TextField } from '@mui/material';
3
3
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
4
4
  import CheckIcon from '@mui/icons-material/Check';
5
5
  import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
@@ -89,6 +89,40 @@ export function buildControls(persona, settings) {
89
89
  }
90
90
  return controls;
91
91
  }
92
+ /**
93
+ * Decide which persona list the toolbar should display: a freshly read empty
94
+ * list is treated as a transient blip (e.g. a Yjs awareness sync hiccup
95
+ * during a persona reload) rather than "no personas", so the toolbar keeps
96
+ * showing the previous list instead of unmounting - `PersonaControls` hides
97
+ * itself entirely when `personas.length === 0`, so accepting every empty
98
+ * read verbatim flashes the whole toolbar (persona name, model picker,
99
+ * everything) to nothing and back on each blip. A genuinely personas-less
100
+ * chat never reads a non-empty list in the first place, so this doesn't mask
101
+ * that case - it only guards against reverting an already-populated list.
102
+ */
103
+ export function reconcilePersonas(previous, next) {
104
+ return next.length ? next : previous;
105
+ }
106
+ /**
107
+ * Decide which selected-persona state the toolbar should track: a fresh
108
+ * null read is treated as a transient blip (the same class of awareness
109
+ * sync hiccup `reconcilePersonas` guards against, e.g. during a persona
110
+ * reload) rather than "nothing selected", so the toolbar keeps the last
111
+ * known state instead of dropping it. This one is not just cosmetic:
112
+ * `buildControls` returns `[]` for a null persona state, so `controls`
113
+ * reads empty and `PersonaControls` conditionally unmounts `ControlsRow`
114
+ * entirely - destroying any open `ControlMenu`'s own state (its search
115
+ * query, its open popover) mid-interaction, not merely blanking a label.
116
+ *
117
+ * Only for the *recurring* awareness-driven read, not the initial one: the
118
+ * initial read (on mount, or when `selectedId` itself changes to a
119
+ * different persona) must apply unconditionally, including a genuine null
120
+ * result, or switching personas could show the previous persona's stale
121
+ * state under the new selection.
122
+ */
123
+ export function reconcilePersonaState(previous, next) {
124
+ return next !== null && next !== void 0 ? next : previous;
125
+ }
92
126
  /**
93
127
  * Decide how to reconcile the current selection with a freshly read persona
94
128
  * list: the new selection to apply, or `undefined` to keep the current one.
@@ -192,6 +226,11 @@ function ChoiceMenuItem(props) {
192
226
  // MenuList clones the row it picks for initial focus with extra props
193
227
  // (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
194
228
  // focused and the menu's arrow-key and type-ahead handling never engages.
229
+ // Shared by OverflowControlsMenu (still a real Menu/MenuList, still
230
+ // depends on this) and ControlMenu (no longer rendered inside a
231
+ // MenuList - see the comment there - so this spread is a no-op for it,
232
+ // and it leaves `role` at MenuItem's own default rather than overriding
233
+ // it, to avoid disturbing OverflowControlsMenu's ARIA contract).
195
234
  const { primary, description: rawDescription, selected, onSelect, ...menuItemProps } = props;
196
235
  const description = rawDescription &&
197
236
  rawDescription.trim().toLowerCase() !== primary.trim().toLowerCase()
@@ -204,6 +243,24 @@ function ChoiceMenuItem(props) {
204
243
  } }),
205
244
  selected ? (React.createElement(CheckIcon, { className: `${MENU_CLASS}-check`, fontSize: "small" })) : null));
206
245
  }
246
+ /**
247
+ * Filter a control's choices by a search query: a case-insensitive substring
248
+ * match against each choice's name. An empty (or whitespace-only) query
249
+ * matches everything, so a freshly opened menu shows the full list. The
250
+ * leading "Default" choice (`id: null`) is excluded from filtering entirely -
251
+ * always kept, and always first - since it's a fixed, load-bearing option
252
+ * (what "no explicit selection" resolves to), not just another item to
253
+ * search among.
254
+ */
255
+ export function filterChoices(choices, query) {
256
+ const defaultChoice = choices.filter(c => c.id === null);
257
+ const rest = choices.filter(c => c.id !== null);
258
+ const q = query.trim().toLowerCase();
259
+ const filteredRest = q
260
+ ? rest.filter(c => c.primary.toLowerCase().includes(q))
261
+ : rest;
262
+ return [...defaultChoice, ...filteredRest];
263
+ }
207
264
  /**
208
265
  * The "Default" row shown at the top of every control. Selecting it sets the
209
266
  * user's value to null, i.e. "use the persona's current value". Its label shows
@@ -231,30 +288,104 @@ function ControlMenuSubheader(props) {
231
288
  // static; ListSubheader's own copy is hidden behind the wrapper.
232
289
  ControlMenuSubheader.muiSkipListHighlight = true;
233
290
  /**
234
- * A dropdown for a control, titled with the control's label. The first choice
235
- * row is "Default" (selection = null); the rest are the persona's advertised
236
- * options (selection = that option's id). Exported for tests.
291
+ * A searchable dropdown for a control, titled with the control's label. The
292
+ * first choice row is always "Default" (selection = null, never filtered
293
+ * out); the rest are the persona's advertised options (selection = that
294
+ * option's id), filtered by the search box as the user types. Exported for
295
+ * tests.
296
+ *
297
+ * Built on `Popover` rather than `Menu`/`MenuList`: `MenuList` owns its own
298
+ * keyboard handling (arrow keys, type-ahead-by-letter) which would fight a
299
+ * text input for keystrokes, so with a search box in the picture this
300
+ * component manages its own keyboard navigation (`focusedIndex`) instead of
301
+ * relying on `MenuList`'s.
237
302
  */
238
303
  export function ControlMenu(props) {
239
304
  const { control, onSelect } = props;
240
305
  const [anchor, setAnchor] = useState(null);
306
+ const [query, setQuery] = useState('');
307
+ // Index into the *filtered* choice list (Default row included at 0), for
308
+ // arrow-key navigation. Reset whenever the query changes, since the
309
+ // previously-focused row may no longer be in the filtered list.
310
+ const [focusedIndex, setFocusedIndex] = useState(0);
241
311
  // The heading names the menu for assistive tech: the subheader itself is a
242
312
  // roleless, never-focused list row, so without this wiring the popup has no
243
313
  // accessible name at all.
244
314
  const headingId = useId();
315
+ const choices = [
316
+ { id: null, primary: defaultChoiceLabel(control), description: null },
317
+ ...control.options.map(o => ({
318
+ id: o.id,
319
+ primary: o.name,
320
+ description: o.description
321
+ }))
322
+ ];
323
+ const filtered = filterChoices(choices, query);
324
+ const close = () => {
325
+ setAnchor(null);
326
+ setQuery('');
327
+ setFocusedIndex(0);
328
+ };
329
+ const select = (id) => {
330
+ close();
331
+ onSelect(id);
332
+ };
333
+ const handleQueryChange = (event) => {
334
+ setQuery(event.target.value);
335
+ setFocusedIndex(0);
336
+ };
337
+ const handleKeyDown = (event) => {
338
+ if (!filtered.length) {
339
+ if (event.key === 'Escape') {
340
+ close();
341
+ }
342
+ return;
343
+ }
344
+ switch (event.key) {
345
+ case 'ArrowDown':
346
+ event.preventDefault();
347
+ setFocusedIndex(i => (i + 1) % filtered.length);
348
+ break;
349
+ case 'ArrowUp':
350
+ event.preventDefault();
351
+ setFocusedIndex(i => (i - 1 + filtered.length) % filtered.length);
352
+ break;
353
+ case 'Enter': {
354
+ event.preventDefault();
355
+ const choice = filtered[focusedIndex];
356
+ if (choice) {
357
+ select(choice.id);
358
+ }
359
+ break;
360
+ }
361
+ case 'Escape':
362
+ event.preventDefault();
363
+ close();
364
+ break;
365
+ }
366
+ };
367
+ // Open focused on the currently selected row (falling back to Default,
368
+ // index 0, when the selection is stale/absent) - computed fresh on each
369
+ // open rather than via a lazy useState initializer, since this component
370
+ // instance persists across opens/closes (only `anchor` toggles), so a
371
+ // one-time initializer would never re-run for a later open.
372
+ const openMenu = (event) => {
373
+ setAnchor(event.currentTarget);
374
+ const idx = choices.findIndex(c => c.id === control.selection);
375
+ setFocusedIndex(idx >= 0 ? idx : 0);
376
+ };
245
377
  return (React.createElement(React.Fragment, null,
246
- React.createElement(Button, { className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-control-btn`, size: "small", variant: "text", disableRipple: true, endIcon: React.createElement(ArrowDropDownIcon, { className: `${SELECTOR_CLASS}-arrow` }), onClick: event => setAnchor(event.currentTarget), title: control.label },
378
+ React.createElement(Button, { className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-control-btn`, size: "small", variant: "text", disableRipple: true, endIcon: React.createElement(ArrowDropDownIcon, { className: `${SELECTOR_CLASS}-arrow` }), onClick: openMenu, title: control.label },
247
379
  React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
248
- React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
380
+ React.createElement(Popover, { anchorEl: anchor, open: !!anchor, onClose: close, ...menuAnchorProps },
249
381
  React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
250
- React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
251
- setAnchor(null);
252
- onSelect(null);
253
- } }),
254
- control.options.map(option => (React.createElement(ChoiceMenuItem, { key: option.id, primary: option.name, description: option.description, selected: control.selection === option.id, onSelect: () => {
255
- setAnchor(null);
256
- onSelect(option.id);
257
- } }))))));
382
+ control.options.length > 0 ? (React.createElement(TextField, { autoFocus: true, fullWidth: true, size: "small", variant: "standard", placeholder: `Search ${control.label.toLowerCase()}`, value: query, onChange: handleQueryChange, onKeyDown: handleKeyDown, className: `${MENU_CLASS}-search-input` })) : null,
383
+ React.createElement("div", { role: "group", "aria-labelledby": headingId, className: `${MENU_CLASS}-search-list` }, filtered.length ? (filtered.map((choice, index) => {
384
+ var _a;
385
+ return (React.createElement(ChoiceMenuItem, { key: (_a = choice.id) !== null && _a !== void 0 ? _a : '__default__', primary: choice.primary, description: choice.description, selected: control.selection === choice.id, onSelect: () => select(choice.id), className: index === focusedIndex
386
+ ? `${MENU_CLASS}-kbd-focused`
387
+ : undefined }));
388
+ })) : (React.createElement(MenuItem, { disabled: true }, "No matches"))))));
258
389
  }
259
390
  /**
260
391
  * The overflow popover: controls that did not fit inline, shown as a single flat
@@ -567,7 +698,7 @@ export function PersonaControls(props) {
567
698
  return;
568
699
  }
569
700
  const list = manager.personas;
570
- setPersonas(list);
701
+ setPersonas(prev => reconcilePersonas(prev, list));
571
702
  setSelectedId(current => {
572
703
  const next = reconcileSelection(list, current, userPicked.current);
573
704
  return next === undefined ? current : next;
@@ -596,14 +727,19 @@ export function PersonaControls(props) {
596
727
  };
597
728
  // Track the selected persona's view in state, re-reading on every awareness
598
729
  // change (a persona updating usage, model, or commands) so the toolbar
599
- // reflects the latest published state.
730
+ // reflects the latest published state. The first read (right below)
731
+ // applies unconditionally, since it runs whenever `selectedId` itself
732
+ // changes and must reflect the newly selected persona, even a genuinely
733
+ // absent one; every later read goes through reconcilePersonaState so a
734
+ // transient blip doesn't blank this out - see its comment for why that
735
+ // matters more than it looks like it should.
600
736
  useEffect(() => {
601
737
  if (!awareness || !manager || !selectedId) {
602
738
  setPersonaState(null);
603
739
  return;
604
740
  }
605
- const read = () => setPersonaState(readSelectedPersona());
606
- read();
741
+ setPersonaState(readSelectedPersona());
742
+ const read = () => setPersonaState(prev => reconcilePersonaState(prev, readSelectedPersona()));
607
743
  awareness.on('change', read);
608
744
  return () => {
609
745
  awareness.off('change', read);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/persona-manager",
3
- "version": "0.1.2",
3
+ "version": "0.2.0-a0",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -5,7 +5,7 @@
5
5
  * type-ahead, and Enter drive the choice rows.
6
6
  */
7
7
  import React from 'react';
8
- import { render, screen } from '@testing-library/react';
8
+ import { render, screen, within } from '@testing-library/react';
9
9
  import userEvent from '@testing-library/user-event';
10
10
  import {
11
11
  Control,
@@ -14,6 +14,16 @@ import {
14
14
  } from '../persona-controls';
15
15
 
16
16
  const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
17
+ const KBD_FOCUSED_SELECTOR = 'li.jp-jai-controlMenu-kbd-focused';
18
+ const RESULTS_SELECTOR = 'div.jp-jai-controlMenu-search-list';
19
+
20
+ // The trigger button shows the current effective value as its own label
21
+ // (e.g. "Beta" when that's selected), which can collide with a row of the
22
+ // same name in the open dropdown - scope result-row queries to the results
23
+ // list, not the whole document, to avoid matching the button too.
24
+ function results(): HTMLElement {
25
+ return document.querySelector(RESULTS_SELECTOR) as HTMLElement;
26
+ }
17
27
 
18
28
  function modelControl(selection: string | null): Control {
19
29
  return {
@@ -37,6 +47,14 @@ function focused(): HTMLElement {
37
47
  return document.activeElement as HTMLElement;
38
48
  }
39
49
 
50
+ // ControlMenu shows keyboard navigation as a visual highlight on the
51
+ // currently-considered row rather than moving real DOM focus off the search
52
+ // input (see the comment on ControlMenu in persona-controls.tsx) - so
53
+ // keyboard-driven tests read this instead of `focused()`.
54
+ function highlighted(): HTMLElement | null {
55
+ return document.querySelector(KBD_FOCUSED_SELECTOR);
56
+ }
57
+
40
58
  describe('control menus', () => {
41
59
  describe('control dropdown', () => {
42
60
  async function openMenu(
@@ -54,55 +72,75 @@ describe('control menus', () => {
54
72
  expect(heading().textContent).toBe('Model');
55
73
  expect(heading().hasAttribute('tabindex')).toBe(false);
56
74
  expect(heading().id).toBeTruthy();
57
- expect(screen.getByRole('menu').getAttribute('aria-labelledby')).toBe(
58
- heading().id
59
- );
60
75
  // Sticky, so the title stays visible while a long option list scrolls.
61
76
  expect(heading().classList.contains('MuiListSubheader-sticky')).toBe(
62
77
  true
63
78
  );
64
79
  });
65
80
 
66
- it('focuses the selected row and moves focus with the keyboard', async () => {
81
+ it('opens with the search box focused, so typing filters immediately', async () => {
82
+ await openMenu(modelControl('beta'));
83
+ expect(focused()).toBe(screen.getByPlaceholderText('Search model'));
84
+ });
85
+
86
+ it('highlights the selected row on open, without moving real focus off the search box', async () => {
67
87
  const user = await openMenu(modelControl('beta'));
68
- // Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
69
- expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
70
- // Wrapping from the last row skips the heading to the first choice row.
88
+ expect(highlighted()?.textContent).toContain('Beta');
89
+ // Real DOM focus never leaves the search box - arrow keys move the
90
+ // highlight, not focus, so the user can keep typing at any point.
71
91
  await user.keyboard('{ArrowDown}');
72
- expect(focused()).toBe(
73
- screen.getByRole('menuitem', { name: 'Default (Alpha)' })
74
- );
92
+ expect(focused()).toBe(screen.getByPlaceholderText('Search model'));
93
+ });
94
+
95
+ it('falls back to the Default row when the selection is stale', async () => {
96
+ // A selection id the persona no longer advertises matches no row;
97
+ // initial highlight then falls back to the Default row.
98
+ await openMenu(modelControl('stale'));
99
+ expect(highlighted()?.textContent).toContain('Default (Alpha)');
100
+ });
101
+
102
+ it('moves the highlight with the arrow keys, wrapping at both ends', async () => {
103
+ // Rows: "Default (Alpha)", "Alpha", "Beta"; starts on "Beta" (selected).
104
+ const user = await openMenu(modelControl('beta'));
105
+ expect(highlighted()?.textContent).toContain('Beta');
75
106
  await user.keyboard('{ArrowDown}');
76
- expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Alpha' }));
77
- // Type-ahead: "b" jumps to Beta.
78
- await user.keyboard('b');
79
- expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
107
+ expect(highlighted()?.textContent).toContain('Default (Alpha)');
108
+ await user.keyboard('{ArrowUp}');
109
+ expect(highlighted()?.textContent).toContain('Beta');
80
110
  });
81
111
 
82
- it('leaves focus in place when type-ahead matches only the heading', async () => {
112
+ it('filters rows by the search text, always keeping the Default row', async () => {
83
113
  const user = await openMenu(modelControl('beta'));
84
- // First key after open, so it cannot buffer onto a previous press: "m"
85
- // matches the heading ("Model") and no choice row, and the heading
86
- // never takes focus.
87
- await user.keyboard('m');
88
- expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
114
+ await user.keyboard('bet');
115
+ // getByText throws if not found, so a successful call is itself the
116
+ // presence assertion.
117
+ within(results()).getByText('Beta');
118
+ within(results()).getByText('Default (Alpha)');
119
+ expect(within(results()).queryByText('Alpha')).toBeNull();
89
120
  });
90
121
 
91
- it('falls back to the first choice row when the selection is stale', async () => {
92
- // A selection id the persona no longer advertises leaves no row
93
- // selected; initial focus then skips the heading to the first row.
94
- await openMenu(modelControl('stale'));
95
- expect(focused()).toBe(
96
- screen.getByRole('menuitem', { name: 'Default (Alpha)' })
97
- );
122
+ it('resets the highlight to the first (filtered) row as the search text changes', async () => {
123
+ const user = await openMenu(modelControl('beta'));
124
+ await user.keyboard('bet');
125
+ // Only "Default (Alpha)" and "Beta" match; highlight resets to the
126
+ // first row rather than staying on an index that may no longer exist.
127
+ expect(highlighted()?.textContent).toContain('Default (Alpha)');
98
128
  });
99
129
 
100
- it('activates the focused row with Enter', async () => {
130
+ it('activates the highlighted row with Enter', async () => {
101
131
  const onSelect = jest.fn();
102
132
  const user = await openMenu(modelControl(null), onSelect);
103
133
  await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
104
134
  expect(onSelect).toHaveBeenCalledWith('beta');
105
135
  });
136
+
137
+ it('closes on Escape without selecting anything', async () => {
138
+ const onSelect = jest.fn();
139
+ const user = await openMenu(modelControl('beta'), onSelect);
140
+ await user.keyboard('{Escape}');
141
+ expect(screen.queryByPlaceholderText('Search model')).toBeNull();
142
+ expect(onSelect).not.toHaveBeenCalled();
143
+ });
106
144
  });
107
145
 
108
146
  describe('overflow menu', () => {
@@ -8,6 +8,9 @@ import { PersonaOption } from '../awareness';
8
8
 
9
9
  import {
10
10
  buildControls,
11
+ filterChoices,
12
+ reconcilePersonas,
13
+ reconcilePersonaState,
11
14
  reconcileSelection,
12
15
  showLoadingPlaceholder
13
16
  } from '../persona-controls';
@@ -112,6 +115,40 @@ describe('buildControls', () => {
112
115
  });
113
116
  });
114
117
 
118
+ describe('reconcilePersonas', () => {
119
+ it('accepts a fresh non-empty list', () => {
120
+ const previous = [personaOption('a')];
121
+ const next = [personaOption('a'), personaOption('b')];
122
+ expect(reconcilePersonas(previous, next)).toBe(next);
123
+ });
124
+
125
+ it('keeps the previous list on a transient empty read', () => {
126
+ const previous = [personaOption('a'), personaOption('b')];
127
+ expect(reconcilePersonas(previous, [])).toBe(previous);
128
+ });
129
+
130
+ it('stays empty when nothing has ever loaded', () => {
131
+ expect(reconcilePersonas([], [])).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe('reconcilePersonaState', () => {
136
+ it('accepts a fresh non-null read', () => {
137
+ const previous = personaAwareness({});
138
+ const next = personaAwareness({});
139
+ expect(reconcilePersonaState(previous, next)).toBe(next);
140
+ });
141
+
142
+ it('keeps the previous state on a transient null read', () => {
143
+ const previous = personaAwareness({});
144
+ expect(reconcilePersonaState(previous, null)).toBe(previous);
145
+ });
146
+
147
+ it('stays null when nothing has ever loaded', () => {
148
+ expect(reconcilePersonaState(null, null)).toBeNull();
149
+ });
150
+ });
151
+
115
152
  describe('reconcileSelection', () => {
116
153
  it('keeps a valid selection', () => {
117
154
  expect(
@@ -177,3 +214,40 @@ describe('showLoadingPlaceholder', () => {
177
214
  expect(showLoadingPlaceholder(false, false, false, false)).toBe(false);
178
215
  });
179
216
  });
217
+
218
+ describe('filterChoices', () => {
219
+ const opus = { id: 'opus-48', primary: 'Opus 4.8', description: null };
220
+ const fable = { id: 'fable-5', primary: 'Fable 5', description: null };
221
+ const defaultChoice = {
222
+ id: null,
223
+ primary: 'Default (Opus 4.8)',
224
+ description: null
225
+ };
226
+ const choices = [defaultChoice, opus, fable];
227
+
228
+ it('returns everything for an empty query', () => {
229
+ expect(filterChoices(choices, '')).toEqual(choices);
230
+ });
231
+
232
+ it('returns everything for a whitespace-only query', () => {
233
+ expect(filterChoices(choices, ' ')).toEqual(choices);
234
+ });
235
+
236
+ it('matches case-insensitively as a substring', () => {
237
+ expect(filterChoices(choices, 'fable')).toEqual([defaultChoice, fable]);
238
+ expect(filterChoices(choices, 'FABLE')).toEqual([defaultChoice, fable]);
239
+ expect(filterChoices(choices, 'abl')).toEqual([defaultChoice, fable]);
240
+ });
241
+
242
+ it('always keeps the Default row first, even when it would not match', () => {
243
+ expect(filterChoices(choices, 'fable')).toEqual([defaultChoice, fable]);
244
+ });
245
+
246
+ it('drops non-Default choices with no match', () => {
247
+ expect(filterChoices(choices, 'nonexistent')).toEqual([defaultChoice]);
248
+ });
249
+
250
+ it('handles a Default-only list (no options advertised)', () => {
251
+ expect(filterChoices([defaultChoice], 'anything')).toEqual([defaultChoice]);
252
+ });
253
+ });
@@ -13,7 +13,8 @@ import {
13
13
  Menu,
14
14
  MenuItem,
15
15
  Popover,
16
- Skeleton
16
+ Skeleton,
17
+ TextField
17
18
  } from '@mui/material';
18
19
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
19
20
  import CheckIcon from '@mui/icons-material/Check';
@@ -154,6 +155,48 @@ export function buildControls(
154
155
  return controls;
155
156
  }
156
157
 
158
+ /**
159
+ * Decide which persona list the toolbar should display: a freshly read empty
160
+ * list is treated as a transient blip (e.g. a Yjs awareness sync hiccup
161
+ * during a persona reload) rather than "no personas", so the toolbar keeps
162
+ * showing the previous list instead of unmounting - `PersonaControls` hides
163
+ * itself entirely when `personas.length === 0`, so accepting every empty
164
+ * read verbatim flashes the whole toolbar (persona name, model picker,
165
+ * everything) to nothing and back on each blip. A genuinely personas-less
166
+ * chat never reads a non-empty list in the first place, so this doesn't mask
167
+ * that case - it only guards against reverting an already-populated list.
168
+ */
169
+ export function reconcilePersonas(
170
+ previous: PersonaOption[],
171
+ next: PersonaOption[]
172
+ ): PersonaOption[] {
173
+ return next.length ? next : previous;
174
+ }
175
+
176
+ /**
177
+ * Decide which selected-persona state the toolbar should track: a fresh
178
+ * null read is treated as a transient blip (the same class of awareness
179
+ * sync hiccup `reconcilePersonas` guards against, e.g. during a persona
180
+ * reload) rather than "nothing selected", so the toolbar keeps the last
181
+ * known state instead of dropping it. This one is not just cosmetic:
182
+ * `buildControls` returns `[]` for a null persona state, so `controls`
183
+ * reads empty and `PersonaControls` conditionally unmounts `ControlsRow`
184
+ * entirely - destroying any open `ControlMenu`'s own state (its search
185
+ * query, its open popover) mid-interaction, not merely blanking a label.
186
+ *
187
+ * Only for the *recurring* awareness-driven read, not the initial one: the
188
+ * initial read (on mount, or when `selectedId` itself changes to a
189
+ * different persona) must apply unconditionally, including a genuine null
190
+ * result, or switching personas could show the previous persona's stale
191
+ * state under the new selection.
192
+ */
193
+ export function reconcilePersonaState(
194
+ previous: PersonaAwareness | null,
195
+ next: PersonaAwareness | null
196
+ ): PersonaAwareness | null {
197
+ return next ?? previous;
198
+ }
199
+
157
200
  /**
158
201
  * Decide how to reconcile the current selection with a freshly read persona
159
202
  * list: the new selection to apply, or `undefined` to keep the current one.
@@ -280,10 +323,17 @@ function ChoiceMenuItem(props: {
280
323
  description: string | null;
281
324
  selected: boolean;
282
325
  onSelect: () => void;
326
+ /** Extra class name, used by ControlMenu to show keyboard focus. */
327
+ className?: string;
283
328
  }): JSX.Element {
284
329
  // MenuList clones the row it picks for initial focus with extra props
285
330
  // (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
286
331
  // focused and the menu's arrow-key and type-ahead handling never engages.
332
+ // Shared by OverflowControlsMenu (still a real Menu/MenuList, still
333
+ // depends on this) and ControlMenu (no longer rendered inside a
334
+ // MenuList - see the comment there - so this spread is a no-op for it,
335
+ // and it leaves `role` at MenuItem's own default rather than overriding
336
+ // it, to avoid disturbing OverflowControlsMenu's ARIA contract).
287
337
  const {
288
338
  primary,
289
339
  description: rawDescription,
@@ -318,6 +368,33 @@ function ChoiceMenuItem(props: {
318
368
  );
319
369
  }
320
370
 
371
+ /** One selectable row in a control's dropdown: an option, or the leading
372
+ * "Default" row (`id: null`). */
373
+ type Choice = {
374
+ id: string | null;
375
+ primary: string;
376
+ description: string | null;
377
+ };
378
+
379
+ /**
380
+ * Filter a control's choices by a search query: a case-insensitive substring
381
+ * match against each choice's name. An empty (or whitespace-only) query
382
+ * matches everything, so a freshly opened menu shows the full list. The
383
+ * leading "Default" choice (`id: null`) is excluded from filtering entirely -
384
+ * always kept, and always first - since it's a fixed, load-bearing option
385
+ * (what "no explicit selection" resolves to), not just another item to
386
+ * search among.
387
+ */
388
+ export function filterChoices(choices: Choice[], query: string): Choice[] {
389
+ const defaultChoice = choices.filter(c => c.id === null);
390
+ const rest = choices.filter(c => c.id !== null);
391
+ const q = query.trim().toLowerCase();
392
+ const filteredRest = q
393
+ ? rest.filter(c => c.primary.toLowerCase().includes(q))
394
+ : rest;
395
+ return [...defaultChoice, ...filteredRest];
396
+ }
397
+
321
398
  /**
322
399
  * The "Default" row shown at the top of every control. Selecting it sets the
323
400
  * user's value to null, i.e. "use the persona's current value". Its label shows
@@ -354,9 +431,17 @@ function ControlMenuSubheader(props: {
354
431
  ControlMenuSubheader.muiSkipListHighlight = true;
355
432
 
356
433
  /**
357
- * A dropdown for a control, titled with the control's label. The first choice
358
- * row is "Default" (selection = null); the rest are the persona's advertised
359
- * options (selection = that option's id). Exported for tests.
434
+ * A searchable dropdown for a control, titled with the control's label. The
435
+ * first choice row is always "Default" (selection = null, never filtered
436
+ * out); the rest are the persona's advertised options (selection = that
437
+ * option's id), filtered by the search box as the user types. Exported for
438
+ * tests.
439
+ *
440
+ * Built on `Popover` rather than `Menu`/`MenuList`: `MenuList` owns its own
441
+ * keyboard handling (arrow keys, type-ahead-by-letter) which would fight a
442
+ * text input for keystrokes, so with a search box in the picture this
443
+ * component manages its own keyboard navigation (`focusedIndex`) instead of
444
+ * relying on `MenuList`'s.
360
445
  */
361
446
  export function ControlMenu(props: {
362
447
  control: Control;
@@ -364,10 +449,86 @@ export function ControlMenu(props: {
364
449
  }): JSX.Element {
365
450
  const { control, onSelect } = props;
366
451
  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
452
+ const [query, setQuery] = useState('');
453
+ // Index into the *filtered* choice list (Default row included at 0), for
454
+ // arrow-key navigation. Reset whenever the query changes, since the
455
+ // previously-focused row may no longer be in the filtered list.
456
+ const [focusedIndex, setFocusedIndex] = useState(0);
367
457
  // The heading names the menu for assistive tech: the subheader itself is a
368
458
  // roleless, never-focused list row, so without this wiring the popup has no
369
459
  // accessible name at all.
370
460
  const headingId = useId();
461
+
462
+ const choices: Choice[] = [
463
+ { id: null, primary: defaultChoiceLabel(control), description: null },
464
+ ...control.options.map(o => ({
465
+ id: o.id,
466
+ primary: o.name,
467
+ description: o.description
468
+ }))
469
+ ];
470
+ const filtered = filterChoices(choices, query);
471
+
472
+ const close = (): void => {
473
+ setAnchor(null);
474
+ setQuery('');
475
+ setFocusedIndex(0);
476
+ };
477
+
478
+ const select = (id: string | null): void => {
479
+ close();
480
+ onSelect(id);
481
+ };
482
+
483
+ const handleQueryChange = (
484
+ event: React.ChangeEvent<HTMLInputElement>
485
+ ): void => {
486
+ setQuery(event.target.value);
487
+ setFocusedIndex(0);
488
+ };
489
+
490
+ const handleKeyDown = (event: React.KeyboardEvent): void => {
491
+ if (!filtered.length) {
492
+ if (event.key === 'Escape') {
493
+ close();
494
+ }
495
+ return;
496
+ }
497
+ switch (event.key) {
498
+ case 'ArrowDown':
499
+ event.preventDefault();
500
+ setFocusedIndex(i => (i + 1) % filtered.length);
501
+ break;
502
+ case 'ArrowUp':
503
+ event.preventDefault();
504
+ setFocusedIndex(i => (i - 1 + filtered.length) % filtered.length);
505
+ break;
506
+ case 'Enter': {
507
+ event.preventDefault();
508
+ const choice = filtered[focusedIndex];
509
+ if (choice) {
510
+ select(choice.id);
511
+ }
512
+ break;
513
+ }
514
+ case 'Escape':
515
+ event.preventDefault();
516
+ close();
517
+ break;
518
+ }
519
+ };
520
+
521
+ // Open focused on the currently selected row (falling back to Default,
522
+ // index 0, when the selection is stale/absent) - computed fresh on each
523
+ // open rather than via a lazy useState initializer, since this component
524
+ // instance persists across opens/closes (only `anchor` toggles), so a
525
+ // one-time initializer would never re-run for a later open.
526
+ const openMenu = (event: React.MouseEvent<HTMLElement>): void => {
527
+ setAnchor(event.currentTarget);
528
+ const idx = choices.findIndex(c => c.id === control.selection);
529
+ setFocusedIndex(idx >= 0 ? idx : 0);
530
+ };
531
+
371
532
  return (
372
533
  <>
373
534
  <Button
@@ -376,43 +537,63 @@ export function ControlMenu(props: {
376
537
  variant="text"
377
538
  disableRipple
378
539
  endIcon={<ArrowDropDownIcon className={`${SELECTOR_CLASS}-arrow`} />}
379
- onClick={event => setAnchor(event.currentTarget)}
540
+ onClick={openMenu}
380
541
  title={control.label}
381
542
  >
382
543
  <span className={`${SELECTOR_CLASS}-control-value`}>
383
544
  {currentControlLabel(control)}
384
545
  </span>
385
546
  </Button>
386
- <Menu
547
+ <Popover
387
548
  anchorEl={anchor}
388
549
  open={!!anchor}
389
- onClose={() => setAnchor(null)}
390
- MenuListProps={{ 'aria-labelledby': headingId }}
550
+ onClose={close}
391
551
  {...menuAnchorProps}
392
552
  >
393
553
  <ControlMenuSubheader id={headingId} label={control.label} />
394
- <ChoiceMenuItem
395
- primary={defaultChoiceLabel(control)}
396
- description={null}
397
- selected={control.selection === null}
398
- onSelect={() => {
399
- setAnchor(null);
400
- onSelect(null);
401
- }}
402
- />
403
- {control.options.map(option => (
404
- <ChoiceMenuItem
405
- key={option.id}
406
- primary={option.name}
407
- description={option.description}
408
- selected={control.selection === option.id}
409
- onSelect={() => {
410
- setAnchor(null);
411
- onSelect(option.id);
412
- }}
554
+ {control.options.length > 0 ? (
555
+ <TextField
556
+ autoFocus
557
+ fullWidth
558
+ size="small"
559
+ variant="standard"
560
+ placeholder={`Search ${control.label.toLowerCase()}`}
561
+ value={query}
562
+ onChange={handleQueryChange}
563
+ onKeyDown={handleKeyDown}
564
+ className={`${MENU_CLASS}-search-input`}
413
565
  />
414
- ))}
415
- </Menu>
566
+ ) : null}
567
+ {/* role="group", not "listbox": MenuItem's own default role
568
+ (menuitem) would mismatch a listbox's expected "option"
569
+ children, and changing MenuItem's role risks disturbing
570
+ OverflowControlsMenu, which shares ChoiceMenuItem and still
571
+ relies on the real Menu/MenuList's own ARIA menu semantics. */}
572
+ <div
573
+ role="group"
574
+ aria-labelledby={headingId}
575
+ className={`${MENU_CLASS}-search-list`}
576
+ >
577
+ {filtered.length ? (
578
+ filtered.map((choice, index) => (
579
+ <ChoiceMenuItem
580
+ key={choice.id ?? '__default__'}
581
+ primary={choice.primary}
582
+ description={choice.description}
583
+ selected={control.selection === choice.id}
584
+ onSelect={() => select(choice.id)}
585
+ className={
586
+ index === focusedIndex
587
+ ? `${MENU_CLASS}-kbd-focused`
588
+ : undefined
589
+ }
590
+ />
591
+ ))
592
+ ) : (
593
+ <MenuItem disabled>No matches</MenuItem>
594
+ )}
595
+ </div>
596
+ </Popover>
416
597
  </>
417
598
  );
418
599
  }
@@ -961,7 +1142,7 @@ export function PersonaControls(
961
1142
  return;
962
1143
  }
963
1144
  const list = manager.personas;
964
- setPersonas(list);
1145
+ setPersonas(prev => reconcilePersonas(prev, list));
965
1146
  setSelectedId(current => {
966
1147
  const next = reconcileSelection(list, current, userPicked.current);
967
1148
  return next === undefined ? current : next;
@@ -993,14 +1174,22 @@ export function PersonaControls(
993
1174
 
994
1175
  // Track the selected persona's view in state, re-reading on every awareness
995
1176
  // change (a persona updating usage, model, or commands) so the toolbar
996
- // reflects the latest published state.
1177
+ // reflects the latest published state. The first read (right below)
1178
+ // applies unconditionally, since it runs whenever `selectedId` itself
1179
+ // changes and must reflect the newly selected persona, even a genuinely
1180
+ // absent one; every later read goes through reconcilePersonaState so a
1181
+ // transient blip doesn't blank this out - see its comment for why that
1182
+ // matters more than it looks like it should.
997
1183
  useEffect(() => {
998
1184
  if (!awareness || !manager || !selectedId) {
999
1185
  setPersonaState(null);
1000
1186
  return;
1001
1187
  }
1002
- const read = () => setPersonaState(readSelectedPersona());
1003
- read();
1188
+ setPersonaState(readSelectedPersona());
1189
+ const read = () =>
1190
+ setPersonaState(prev =>
1191
+ reconcilePersonaState(prev, readSelectedPersona())
1192
+ );
1004
1193
  awareness.on('change', read);
1005
1194
  return () => {
1006
1195
  awareness.off('change', read);
package/style/base.css CHANGED
@@ -319,6 +319,39 @@
319
319
  border-top: none;
320
320
  }
321
321
 
322
+ /* Search box in a control's dropdown (ControlMenu - the searchable model/
323
+ settings picker). Sits between the subheader and the choice list; the
324
+ choice list scrolls independently (see below) so the box stays visible
325
+ while scrolling a long, filtered result set. */
326
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-search-input {
327
+ padding: 4px 12px 8px;
328
+ }
329
+
330
+ /* stylelint-disable-next-line selector-class-pattern -- theming MUI's input classes */
331
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-search-input .MuiInput-root {
332
+ font-family: var(--jp-ui-font-family);
333
+ font-size: var(--jp-ui-font-size1);
334
+ }
335
+
336
+ /* The choice list scrolls on its own, independent of the paper's own
337
+ max-height bound (see .jp-jai-controlMenu-paper above) - without this the
338
+ search box could scroll out of view along with the results, the opposite
339
+ of the point of keeping it pinned at the top. */
340
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-search-list {
341
+ overflow-y: auto;
342
+ max-height: calc(60vh - 80px);
343
+ }
344
+
345
+ /* Keyboard-focused row when navigating the search results with arrow keys.
346
+ ControlMenu no longer renders choices inside a MenuList - see the comment
347
+ on ControlMenu in persona-controls.tsx - so this is a plain state-driven
348
+ highlight rather than real DOM/MUI focus styling, matching the existing
349
+ hover/selected treatment for visual consistency. */
350
+ /* stylelint-disable-next-line selector-class-pattern -- theming MUI's menu item classes */
351
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-kbd-focused.MuiMenuItem-root {
352
+ background-color: var(--jp-layout-color2);
353
+ }
354
+
322
355
  /* Usage chip: a small ring gauge and percent of the active persona's context
323
356
  fill, next to the persona it describes. Borderless, matching the overflow
324
357
  button. The ring fill and percent take the chip's color, so the warn/error