@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
|
|
56
|
-
* row is "Default" (selection = null
|
|
57
|
-
*
|
|
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 {};
|
package/lib/persona-controls.js
CHANGED
|
@@ -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
|
|
235
|
-
* row is "Default" (selection = null
|
|
236
|
-
*
|
|
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:
|
|
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(
|
|
380
|
+
React.createElement(Popover, { anchorEl: anchor, open: !!anchor, onClose: close, ...menuAnchorProps },
|
|
249
381
|
React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
|
|
250
|
-
React.createElement(
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
@@ -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('
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
//
|
|
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
|
-
|
|
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(
|
|
77
|
-
|
|
78
|
-
|
|
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('
|
|
112
|
+
it('filters rows by the search text, always keeping the Default row', async () => {
|
|
83
113
|
const user = await openMenu(modelControl('beta'));
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
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('
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
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
|
+
});
|
package/src/persona-controls.tsx
CHANGED
|
@@ -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
|
|
358
|
-
* row is "Default" (selection = null
|
|
359
|
-
*
|
|
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={
|
|
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
|
-
<
|
|
547
|
+
<Popover
|
|
387
548
|
anchorEl={anchor}
|
|
388
549
|
open={!!anchor}
|
|
389
|
-
onClose={
|
|
390
|
-
MenuListProps={{ 'aria-labelledby': headingId }}
|
|
550
|
+
onClose={close}
|
|
391
551
|
{...menuAnchorProps}
|
|
392
552
|
>
|
|
393
553
|
<ControlMenuSubheader id={headingId} label={control.label} />
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|