@jupyter-ai/persona-manager 0.1.1 → 0.1.3

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,12 +81,37 @@ 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
- export declare function ControlItem(props: {
114
+ export declare function ControlMenu(props: {
60
115
  control: Control;
61
116
  onSelect: (value: string | null) => void;
62
117
  }): JSX.Element;
@@ -65,7 +120,7 @@ export declare function ControlItem(props: {
65
120
  * menu (no nested dropdowns). Each control renders as a group label followed by
66
121
  * its Default row and choices. Exported for tests.
67
122
  */
68
- export declare function OverflowMenu(props: {
123
+ export declare function OverflowControlsMenu(props: {
69
124
  controls: Control[];
70
125
  anchor: HTMLElement | null;
71
126
  onClose: () => void;
@@ -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
@@ -219,49 +276,126 @@ function defaultChoiceLabel(control) {
219
276
  * The uppercase group label used in control menus: it titles a control's own
220
277
  * dropdown and labels each control's section in the overflow menu. Rendered
221
278
  * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
222
- * it and the menu stays keyboard-navigable.
279
+ * it and the menu stays keyboard-navigable. Sticky: when the menu scrolls, the
280
+ * label pins to the top, so the group the visible rows belong to stays
281
+ * readable; in the overflow menu the next section's label paints over it on
282
+ * arrival.
223
283
  */
224
- function ControlSubheader(props) {
225
- return (React.createElement(ListSubheader, { id: props.id, disableSticky: true, className: `${MENU_CLASS}-subheader` }, props.label));
284
+ function ControlMenuSubheader(props) {
285
+ return (React.createElement(ListSubheader, { id: props.id, className: `${MENU_CLASS}-subheader` }, props.label));
226
286
  }
227
287
  // MUI's MenuList skips initial focus for children whose type carries this
228
288
  // static; ListSubheader's own copy is hidden behind the wrapper.
229
- ControlSubheader.muiSkipListHighlight = true;
289
+ ControlMenuSubheader.muiSkipListHighlight = true;
230
290
  /**
231
- * A dropdown for a control, titled with the control's label. The first choice
232
- * row is "Default" (selection = null); the rest are the persona's advertised
233
- * 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.
234
302
  */
235
- export function ControlItem(props) {
303
+ export function ControlMenu(props) {
236
304
  const { control, onSelect } = props;
237
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);
238
311
  // The heading names the menu for assistive tech: the subheader itself is a
239
312
  // roleless, never-focused list row, so without this wiring the popup has no
240
313
  // accessible name at all.
241
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
+ };
242
377
  return (React.createElement(React.Fragment, null,
243
- 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 },
244
379
  React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
245
- React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
246
- React.createElement(ControlSubheader, { id: headingId, label: control.label }),
247
- React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
248
- setAnchor(null);
249
- onSelect(null);
250
- } }),
251
- control.options.map(option => (React.createElement(ChoiceMenuItem, { key: option.id, primary: option.name, description: option.description, selected: control.selection === option.id, onSelect: () => {
252
- setAnchor(null);
253
- onSelect(option.id);
254
- } }))))));
380
+ React.createElement(Popover, { anchorEl: anchor, open: !!anchor, onClose: close, ...menuAnchorProps },
381
+ React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
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"))))));
255
389
  }
256
390
  /**
257
391
  * The overflow popover: controls that did not fit inline, shown as a single flat
258
392
  * menu (no nested dropdowns). Each control renders as a group label followed by
259
393
  * its Default row and choices. Exported for tests.
260
394
  */
261
- export function OverflowMenu(props) {
395
+ export function OverflowControlsMenu(props) {
262
396
  const { controls, anchor, onClose, onChange } = props;
263
397
  return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, MenuListProps: { 'aria-label': 'More controls' }, ...menuAnchorProps }, controls.flatMap(control => [
264
- React.createElement(ControlSubheader, { key: `${control.id}-label`, label: control.label }),
398
+ React.createElement(ControlMenuSubheader, { key: `${control.id}-label`, label: control.label }),
265
399
  React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
266
400
  onClose();
267
401
  onChange(control, null);
@@ -340,12 +474,12 @@ function ControlsRow(props) {
340
474
  const visible = controls.slice(0, visibleCount);
341
475
  const overflow = controls.slice(visibleCount);
342
476
  return (React.createElement("div", { className: `${SELECTOR_CLASS}-controls`, ref: rowRef },
343
- React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) })))),
344
- visible.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
477
+ React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(ControlMenu, { key: control.id, control: control, onSelect: v => onChange(control, v) })))),
478
+ visible.map(control => (React.createElement(ControlMenu, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
345
479
  overflow.length ? (React.createElement(React.Fragment, null,
346
480
  React.createElement("button", { type: "button", ref: overflowBtnRef, className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-overflow-btn`, onClick: event => setOverflowAnchor(event.currentTarget), title: "More controls", "aria-label": "More controls" },
347
481
  React.createElement(MoreHorizIcon, { fontSize: "small" })),
348
- React.createElement(OverflowMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
482
+ React.createElement(OverflowControlsMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
349
483
  }
350
484
  // All formatters pin the `en` locale so numbers agree with each other and
351
485
  // with the surrounding English labels.
@@ -564,7 +698,7 @@ export function PersonaControls(props) {
564
698
  return;
565
699
  }
566
700
  const list = manager.personas;
567
- setPersonas(list);
701
+ setPersonas(prev => reconcilePersonas(prev, list));
568
702
  setSelectedId(current => {
569
703
  const next = reconcileSelection(list, current, userPicked.current);
570
704
  return next === undefined ? current : next;
@@ -593,14 +727,19 @@ export function PersonaControls(props) {
593
727
  };
594
728
  // Track the selected persona's view in state, re-reading on every awareness
595
729
  // change (a persona updating usage, model, or commands) so the toolbar
596
- // 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.
597
736
  useEffect(() => {
598
737
  if (!awareness || !manager || !selectedId) {
599
738
  setPersonaState(null);
600
739
  return;
601
740
  }
602
- const read = () => setPersonaState(readSelectedPersona());
603
- read();
741
+ setPersonaState(readSelectedPersona());
742
+ const read = () => setPersonaState(prev => reconcilePersonaState(prev, readSelectedPersona()));
604
743
  awareness.on('change', read);
605
744
  return () => {
606
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.1",
3
+ "version": "0.1.3",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -67,6 +67,9 @@
67
67
  "devDependencies": {
68
68
  "@jupyterlab/builder": "^4.0.0",
69
69
  "@jupyterlab/testutils": "^4.0.0",
70
+ "@testing-library/dom": "^10.4.0",
71
+ "@testing-library/react": "^16.3.0",
72
+ "@testing-library/user-event": "^14.6.0",
70
73
  "@types/jest": "^29.2.0",
71
74
  "@types/json-schema": "^7.0.11",
72
75
  "@types/react": "^18.0.26",
@@ -5,18 +5,25 @@
5
5
  * type-ahead, and Enter drive the choice rows.
6
6
  */
7
7
  import React from 'react';
8
- import { createRoot, Root } from 'react-dom/client';
9
- import { Control, ControlItem, OverflowMenu } from '../persona-controls';
10
-
11
- // React 18.3 ships `React.act`, which the installed @types/react (18.0) does
12
- // not declare yet.
13
- const { act } = React as unknown as { act: (callback: () => void) => void };
14
-
15
- (
16
- globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
17
- ).IS_REACT_ACT_ENVIRONMENT = true;
8
+ import { render, screen, within } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import {
11
+ Control,
12
+ ControlMenu,
13
+ OverflowControlsMenu
14
+ } from '../persona-controls';
18
15
 
19
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
+ }
20
27
 
21
28
  function modelControl(selection: string | null): Control {
22
29
  return {
@@ -32,105 +39,112 @@ function modelControl(selection: string | null): Control {
32
39
  };
33
40
  }
34
41
 
35
- describe('control menus', () => {
36
- let container: HTMLDivElement;
37
- let root: Root;
38
-
39
- beforeEach(() => {
40
- container = document.createElement('div');
41
- document.body.appendChild(container);
42
- root = createRoot(container);
43
- });
44
-
45
- afterEach(() => {
46
- act(() => root.unmount());
47
- container.remove();
48
- });
49
-
50
- function menu(): HTMLElement {
51
- return document.querySelector('ul[role="menu"]') as HTMLElement;
52
- }
42
+ function heading(): HTMLElement {
43
+ return document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
44
+ }
53
45
 
54
- function focused(): HTMLElement {
55
- return document.activeElement as HTMLElement;
56
- }
46
+ function focused(): HTMLElement {
47
+ return document.activeElement as HTMLElement;
48
+ }
57
49
 
58
- function press(key: string): void {
59
- act(() => {
60
- focused().dispatchEvent(
61
- new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
62
- );
63
- });
64
- }
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
+ }
65
57
 
58
+ describe('control menus', () => {
66
59
  describe('control dropdown', () => {
67
- function openMenu(
60
+ async function openMenu(
68
61
  control: Control,
69
62
  onSelect: (value: string | null) => void = () => undefined
70
- ): void {
71
- act(() => {
72
- root.render(<ControlItem control={control} onSelect={onSelect} />);
73
- });
74
- act(() => {
75
- (container.querySelector('button') as HTMLElement).dispatchEvent(
76
- new MouseEvent('click', { bubbles: true })
77
- );
78
- });
63
+ ): Promise<ReturnType<typeof userEvent.setup>> {
64
+ const user = userEvent.setup();
65
+ render(<ControlMenu control={control} onSelect={onSelect} />);
66
+ await user.click(screen.getByRole('button'));
67
+ return user;
79
68
  }
80
69
 
81
- it('opens titled by a heading that names the menu', () => {
82
- openMenu(modelControl('beta'));
83
- const heading = document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
84
- expect(heading.textContent).toBe('Model');
85
- expect(heading.hasAttribute('tabindex')).toBe(false);
86
- expect(heading.id).toBeTruthy();
87
- expect(menu().getAttribute('aria-labelledby')).toBe(heading.id);
70
+ it('opens titled by a heading that names the menu', async () => {
71
+ await openMenu(modelControl('beta'));
72
+ expect(heading().textContent).toBe('Model');
73
+ expect(heading().hasAttribute('tabindex')).toBe(false);
74
+ expect(heading().id).toBeTruthy();
75
+ // Sticky, so the title stays visible while a long option list scrolls.
76
+ expect(heading().classList.contains('MuiListSubheader-sticky')).toBe(
77
+ true
78
+ );
79
+ });
80
+
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 () => {
87
+ const user = await openMenu(modelControl('beta'));
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.
91
+ await user.keyboard('{ArrowDown}');
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)');
88
100
  });
89
101
 
90
- it('focuses the selected row and moves focus with the keyboard', () => {
91
- openMenu(modelControl('beta'));
92
- // Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
93
- expect(focused().textContent).toBe('Beta');
94
- expect(focused().getAttribute('role')).toBe('menuitem');
95
- // Wrapping from the last row skips the heading to the first choice row.
96
- press('ArrowDown');
97
- expect(focused().textContent).toBe('Default (Alpha)');
98
- press('ArrowDown');
99
- expect(focused().textContent).toBe('Alpha');
100
- // Type-ahead: "b" jumps to Beta.
101
- press('b');
102
- expect(focused().textContent).toBe('Beta');
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');
106
+ await user.keyboard('{ArrowDown}');
107
+ expect(highlighted()?.textContent).toContain('Default (Alpha)');
108
+ await user.keyboard('{ArrowUp}');
109
+ expect(highlighted()?.textContent).toContain('Beta');
103
110
  });
104
111
 
105
- it('leaves focus in place when type-ahead matches only the heading', () => {
106
- openMenu(modelControl('beta'));
107
- // First key after open, so it cannot buffer onto a previous press: "m"
108
- // matches the heading ("Model") and no choice row, and the heading
109
- // never takes focus.
110
- press('m');
111
- expect(focused().textContent).toBe('Beta');
112
+ it('filters rows by the search text, always keeping the Default row', async () => {
113
+ const user = await openMenu(modelControl('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();
112
120
  });
113
121
 
114
- it('falls back to the first choice row when the selection is stale', () => {
115
- // A selection id the persona no longer advertises leaves no row
116
- // selected; initial focus then skips the heading to the first row.
117
- openMenu(modelControl('stale'));
118
- expect(focused().textContent).toBe('Default (Alpha)');
119
- expect(focused().getAttribute('role')).toBe('menuitem');
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)');
120
128
  });
121
129
 
122
- it('activates the focused row with Enter', () => {
130
+ it('activates the highlighted row with Enter', async () => {
123
131
  const onSelect = jest.fn();
124
- openMenu(modelControl(null), onSelect);
125
- press('ArrowDown');
126
- press('ArrowDown');
127
- press('Enter');
132
+ const user = await openMenu(modelControl(null), onSelect);
133
+ await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
128
134
  expect(onSelect).toHaveBeenCalledWith('beta');
129
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
+ });
130
144
  });
131
145
 
132
146
  describe('overflow menu', () => {
133
- it('is labeled and arrows skip the section headings', () => {
147
+ it('is labeled and arrows skip the section headings', async () => {
134
148
  const first: Control = {
135
149
  id: 'a',
136
150
  kind: 'setting',
@@ -143,32 +157,39 @@ describe('control menus', () => {
143
157
  id: 'b',
144
158
  kind: 'setting',
145
159
  label: 'Bbb',
146
- current: null,
160
+ current: 'b1',
147
161
  selection: 'stale',
148
162
  options: [{ id: 'b1', name: 'B one', description: null }]
149
163
  };
164
+ const user = userEvent.setup();
150
165
  const anchor = document.createElement('button');
151
166
  document.body.appendChild(anchor);
152
- act(() => {
153
- root.render(
154
- <OverflowMenu
155
- controls={[first, second]}
156
- anchor={anchor}
157
- onClose={() => undefined}
158
- onChange={() => undefined}
159
- />
160
- );
161
- });
162
- expect(menu().getAttribute('aria-label')).toBe('More controls');
167
+ render(
168
+ <OverflowControlsMenu
169
+ controls={[first, second]}
170
+ anchor={anchor}
171
+ onClose={() => undefined}
172
+ onChange={() => undefined}
173
+ />
174
+ );
175
+ expect(screen.getByRole('menu').getAttribute('aria-label')).toBe(
176
+ 'More controls'
177
+ );
163
178
  const headings = Array.from(
164
179
  document.querySelectorAll(SUBHEADER_SELECTOR)
165
- ).map(h => h.textContent);
166
- expect(headings).toEqual(['Aaa', 'Bbb']);
180
+ );
181
+ expect(headings.map(h => h.textContent)).toEqual(['Aaa', 'Bbb']);
182
+ // Sticky, so the section a scrolled row belongs to stays visible.
183
+ for (const h of headings) {
184
+ expect(h.classList.contains('MuiListSubheader-sticky')).toBe(true);
185
+ }
167
186
  // "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
168
187
  // to the next section's first choice row.
169
- expect(focused().textContent).toBe('A one');
170
- press('ArrowDown');
171
- expect(focused().textContent).toBe('Default');
188
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'A one' }));
189
+ await user.keyboard('{ArrowDown}');
190
+ expect(focused()).toBe(
191
+ screen.getByRole('menuitem', { name: 'Default (B one)' })
192
+ );
172
193
  anchor.remove();
173
194
  });
174
195
  });
@@ -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
@@ -333,15 +410,17 @@ function defaultChoiceLabel(control: Control): string {
333
410
  * The uppercase group label used in control menus: it titles a control's own
334
411
  * dropdown and labels each control's section in the overflow menu. Rendered
335
412
  * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
336
- * it and the menu stays keyboard-navigable.
413
+ * it and the menu stays keyboard-navigable. Sticky: when the menu scrolls, the
414
+ * label pins to the top, so the group the visible rows belong to stays
415
+ * readable; in the overflow menu the next section's label paints over it on
416
+ * arrival.
337
417
  */
338
- function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
418
+ function ControlMenuSubheader(props: {
419
+ label: string;
420
+ id?: string;
421
+ }): JSX.Element {
339
422
  return (
340
- <ListSubheader
341
- id={props.id}
342
- disableSticky
343
- className={`${MENU_CLASS}-subheader`}
344
- >
423
+ <ListSubheader id={props.id} className={`${MENU_CLASS}-subheader`}>
345
424
  {props.label}
346
425
  </ListSubheader>
347
426
  );
@@ -349,23 +428,107 @@ function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
349
428
 
350
429
  // MUI's MenuList skips initial focus for children whose type carries this
351
430
  // static; ListSubheader's own copy is hidden behind the wrapper.
352
- ControlSubheader.muiSkipListHighlight = true;
431
+ ControlMenuSubheader.muiSkipListHighlight = true;
353
432
 
354
433
  /**
355
- * A dropdown for a control, titled with the control's label. The first choice
356
- * row is "Default" (selection = null); the rest are the persona's advertised
357
- * 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.
358
445
  */
359
- export function ControlItem(props: {
446
+ export function ControlMenu(props: {
360
447
  control: Control;
361
448
  onSelect: (value: string | null) => void;
362
449
  }): JSX.Element {
363
450
  const { control, onSelect } = props;
364
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);
365
457
  // The heading names the menu for assistive tech: the subheader itself is a
366
458
  // roleless, never-focused list row, so without this wiring the popup has no
367
459
  // accessible name at all.
368
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
+
369
532
  return (
370
533
  <>
371
534
  <Button
@@ -374,43 +537,63 @@ export function ControlItem(props: {
374
537
  variant="text"
375
538
  disableRipple
376
539
  endIcon={<ArrowDropDownIcon className={`${SELECTOR_CLASS}-arrow`} />}
377
- onClick={event => setAnchor(event.currentTarget)}
540
+ onClick={openMenu}
378
541
  title={control.label}
379
542
  >
380
543
  <span className={`${SELECTOR_CLASS}-control-value`}>
381
544
  {currentControlLabel(control)}
382
545
  </span>
383
546
  </Button>
384
- <Menu
547
+ <Popover
385
548
  anchorEl={anchor}
386
549
  open={!!anchor}
387
- onClose={() => setAnchor(null)}
388
- MenuListProps={{ 'aria-labelledby': headingId }}
550
+ onClose={close}
389
551
  {...menuAnchorProps}
390
552
  >
391
- <ControlSubheader id={headingId} label={control.label} />
392
- <ChoiceMenuItem
393
- primary={defaultChoiceLabel(control)}
394
- description={null}
395
- selected={control.selection === null}
396
- onSelect={() => {
397
- setAnchor(null);
398
- onSelect(null);
399
- }}
400
- />
401
- {control.options.map(option => (
402
- <ChoiceMenuItem
403
- key={option.id}
404
- primary={option.name}
405
- description={option.description}
406
- selected={control.selection === option.id}
407
- onSelect={() => {
408
- setAnchor(null);
409
- onSelect(option.id);
410
- }}
553
+ <ControlMenuSubheader id={headingId} label={control.label} />
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`}
411
565
  />
412
- ))}
413
- </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>
414
597
  </>
415
598
  );
416
599
  }
@@ -420,7 +603,7 @@ export function ControlItem(props: {
420
603
  * menu (no nested dropdowns). Each control renders as a group label followed by
421
604
  * its Default row and choices. Exported for tests.
422
605
  */
423
- export function OverflowMenu(props: {
606
+ export function OverflowControlsMenu(props: {
424
607
  controls: Control[];
425
608
  anchor: HTMLElement | null;
426
609
  onClose: () => void;
@@ -436,7 +619,10 @@ export function OverflowMenu(props: {
436
619
  {...menuAnchorProps}
437
620
  >
438
621
  {controls.flatMap(control => [
439
- <ControlSubheader key={`${control.id}-label`} label={control.label} />,
622
+ <ControlMenuSubheader
623
+ key={`${control.id}-label`}
624
+ label={control.label}
625
+ />,
440
626
  <ChoiceMenuItem
441
627
  key={`${control.id}-default`}
442
628
  primary={defaultChoiceLabel(control)}
@@ -550,7 +736,7 @@ function ControlsRow(props: {
550
736
  aria-hidden="true"
551
737
  >
552
738
  {controls.map(control => (
553
- <ControlItem
739
+ <ControlMenu
554
740
  key={control.id}
555
741
  control={control}
556
742
  onSelect={v => onChange(control, v)}
@@ -559,7 +745,7 @@ function ControlsRow(props: {
559
745
  </div>
560
746
 
561
747
  {visible.map(control => (
562
- <ControlItem
748
+ <ControlMenu
563
749
  key={control.id}
564
750
  control={control}
565
751
  onSelect={v => onChange(control, v)}
@@ -578,7 +764,7 @@ function ControlsRow(props: {
578
764
  >
579
765
  <MoreHorizIcon fontSize="small" />
580
766
  </button>
581
- <OverflowMenu
767
+ <OverflowControlsMenu
582
768
  controls={overflow}
583
769
  anchor={overflowAnchor}
584
770
  onClose={() => setOverflowAnchor(null)}
@@ -956,7 +1142,7 @@ export function PersonaControls(
956
1142
  return;
957
1143
  }
958
1144
  const list = manager.personas;
959
- setPersonas(list);
1145
+ setPersonas(prev => reconcilePersonas(prev, list));
960
1146
  setSelectedId(current => {
961
1147
  const next = reconcileSelection(list, current, userPicked.current);
962
1148
  return next === undefined ? current : next;
@@ -988,14 +1174,22 @@ export function PersonaControls(
988
1174
 
989
1175
  // Track the selected persona's view in state, re-reading on every awareness
990
1176
  // change (a persona updating usage, model, or commands) so the toolbar
991
- // 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.
992
1183
  useEffect(() => {
993
1184
  if (!awareness || !manager || !selectedId) {
994
1185
  setPersonaState(null);
995
1186
  return;
996
1187
  }
997
- const read = () => setPersonaState(readSelectedPersona());
998
- read();
1188
+ setPersonaState(readSelectedPersona());
1189
+ const read = () =>
1190
+ setPersonaState(prev =>
1191
+ reconcilePersonaState(prev, readSelectedPersona())
1192
+ );
999
1193
  awareness.on('change', read);
1000
1194
  return () => {
1001
1195
  awareness.off('change', read);
package/style/base.css CHANGED
@@ -229,6 +229,15 @@
229
229
  box-shadow: var(--jp-elevation-z6);
230
230
  }
231
231
 
232
+ /* Keep focus-scrolled rows clear of the stuck group label: when arrow keys
233
+ scroll a row into view at the top, offset it by the label's height so the
234
+ label does not cover it. Only menus containing a group label need the
235
+ offset; the persona picker and usage popover share this paper class and
236
+ keep edge-aligned scrolling. */
237
+ .jp-jai-controlMenu-paper:has(.jp-jai-controlMenu-subheader) {
238
+ scroll-padding-top: 32px;
239
+ }
240
+
232
241
  /* stylelint-disable selector-class-pattern -- theming MUI's menu item classes */
233
242
  .jp-jai-controlMenu-paper .MuiMenuItem-root {
234
243
  padding: 6px 12px;
@@ -281,7 +290,11 @@
281
290
  /* Control menu group label: an uppercase heading above a control's choices.
282
291
  It titles each control's own dropdown; in the overflow popover a hairline
283
292
  divider and top spacing separate the controls' sections. Scoped under the
284
- paper to win over MUI's ListSubheader defaults. */
293
+ paper to win over MUI's ListSubheader defaults. The labels are sticky:
294
+ sticky is MUI's ListSubheader default, and its sticky class supplies the
295
+ positioning. The opaque background is load-bearing, scrolled rows pass
296
+ beneath it. One line only, so stacked sticky labels in the overflow menu
297
+ cover each other exactly. */
285
298
  .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
286
299
  margin-top: 8px;
287
300
  padding: 8px 12px 2px;
@@ -294,6 +307,9 @@
294
307
  text-transform: uppercase;
295
308
  color: var(--jp-ui-font-color1);
296
309
  background-color: var(--jp-layout-color1);
310
+ white-space: nowrap;
311
+ overflow: hidden;
312
+ text-overflow: ellipsis;
297
313
  }
298
314
 
299
315
  /* The first (or only) label sits flush at the top, no divider or extra gap
@@ -303,6 +319,39 @@
303
319
  border-top: none;
304
320
  }
305
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
+
306
355
  /* Usage chip: a small ring gauge and percent of the active persona's context
307
356
  fill, next to the persona it describes. Borderless, matching the overflow
308
357
  button. The ring fill and percent take the chip's color, so the warn/error