@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.
- package/lib/persona-controls.d.ts +61 -5
- package/lib/persona-controls.js +168 -29
- package/package.json +4 -1
- package/src/__tests__/persona-controls-menus.spec.tsx +125 -104
- package/src/__tests__/persona-controls.spec.ts +74 -0
- package/src/persona-controls.tsx +242 -48
- package/style/base.css +50 -1
|
@@ -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
|
|
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
|
-
export declare function
|
|
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
|
|
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 {};
|
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
|
|
@@ -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
|
|
225
|
-
return (React.createElement(ListSubheader, { id: props.id,
|
|
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
|
-
|
|
289
|
+
ControlMenuSubheader.muiSkipListHighlight = true;
|
|
230
290
|
/**
|
|
231
|
-
* A dropdown for a control, titled with the control's label. The
|
|
232
|
-
* row is "Default" (selection = null
|
|
233
|
-
*
|
|
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
|
|
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:
|
|
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(
|
|
246
|
-
React.createElement(
|
|
247
|
-
React.createElement(
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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(
|
|
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(
|
|
344
|
-
visible.map(control => (React.createElement(
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
function focused(): HTMLElement {
|
|
47
|
+
return document.activeElement as HTMLElement;
|
|
48
|
+
}
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
):
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
84
|
-
expect(heading
|
|
85
|
-
expect(heading
|
|
86
|
-
|
|
87
|
-
expect(
|
|
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('
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
expect(
|
|
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('
|
|
106
|
-
openMenu(modelControl('beta'));
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
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('
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
expect(
|
|
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
|
|
130
|
+
it('activates the highlighted row with Enter', async () => {
|
|
123
131
|
const onSelect = jest.fn();
|
|
124
|
-
openMenu(modelControl(null), onSelect);
|
|
125
|
-
|
|
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:
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
)
|
|
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()
|
|
170
|
-
|
|
171
|
-
expect(focused()
|
|
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
|
+
});
|
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
|
|
@@ -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
|
|
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
|
-
|
|
431
|
+
ControlMenuSubheader.muiSkipListHighlight = true;
|
|
353
432
|
|
|
354
433
|
/**
|
|
355
|
-
* A dropdown for a control, titled with the control's label. The
|
|
356
|
-
* row is "Default" (selection = null
|
|
357
|
-
*
|
|
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
|
|
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={
|
|
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
|
-
<
|
|
547
|
+
<Popover
|
|
385
548
|
anchorEl={anchor}
|
|
386
549
|
open={!!anchor}
|
|
387
|
-
onClose={
|
|
388
|
-
MenuListProps={{ 'aria-labelledby': headingId }}
|
|
550
|
+
onClose={close}
|
|
389
551
|
{...menuAnchorProps}
|
|
390
552
|
>
|
|
391
|
-
<
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|