@jupyter-ai/persona-manager 0.1.0 → 0.1.1

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.
@@ -51,6 +51,26 @@ export declare function showLoadingPlaceholder(hasAwareness: boolean, managerRes
51
51
  * control's kind. A null value resets that control to the persona's default.
52
52
  */
53
53
  export declare function applyControlChange(settings: PersonaSettings, control: Control, value: string | null): PersonaSettings;
54
+ /**
55
+ * A dropdown for a control, titled with the control's label. The first choice
56
+ * row is "Default" (selection = null); the rest are the persona's advertised
57
+ * options (selection = that option's id). Exported for tests.
58
+ */
59
+ export declare function ControlItem(props: {
60
+ control: Control;
61
+ onSelect: (value: string | null) => void;
62
+ }): JSX.Element;
63
+ /**
64
+ * The overflow popover: controls that did not fit inline, shown as a single flat
65
+ * menu (no nested dropdowns). Each control renders as a group label followed by
66
+ * its Default row and choices. Exported for tests.
67
+ */
68
+ export declare function OverflowMenu(props: {
69
+ controls: Control[];
70
+ anchor: HTMLElement | null;
71
+ onClose: () => void;
72
+ onChange: (control: Control, value: string | null) => void;
73
+ }): JSX.Element;
54
74
  /**
55
75
  * Format a token count compactly: 950 stays as-is, 41500 becomes "41.5k",
56
76
  * 1240000 becomes "1.24M". `Intl.NumberFormat` picks the tier after rounding,
@@ -1,4 +1,4 @@
1
- import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
1
+ import React, { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
2
2
  import { Button, ListItemText, ListSubheader, Menu, MenuItem, Popover, Skeleton } from '@mui/material';
3
3
  import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
4
4
  import CheckIcon from '@mui/icons-material/Check';
@@ -189,12 +189,15 @@ function currentControlLabel(control) {
189
189
  * description, which is just noise). The full description is available on hover.
190
190
  */
191
191
  function ChoiceMenuItem(props) {
192
- const { primary, selected, onSelect } = props;
193
- const description = props.description &&
194
- props.description.trim().toLowerCase() !== primary.trim().toLowerCase()
195
- ? props.description
192
+ // MenuList clones the row it picks for initial focus with extra props
193
+ // (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
194
+ // focused and the menu's arrow-key and type-ahead handling never engages.
195
+ const { primary, description: rawDescription, selected, onSelect, ...menuItemProps } = props;
196
+ const description = rawDescription &&
197
+ rawDescription.trim().toLowerCase() !== primary.trim().toLowerCase()
198
+ ? rawDescription
196
199
  : null;
197
- return (React.createElement(MenuItem, { selected: selected, onClick: onSelect, title: description !== null && description !== void 0 ? description : undefined },
200
+ return (React.createElement(MenuItem, { ...menuItemProps, selected: selected, onClick: onSelect, title: description !== null && description !== void 0 ? description : undefined },
198
201
  React.createElement(ListItemText, { primary: primary, secondary: description, classes: {
199
202
  primary: `${MENU_CLASS}-name`,
200
203
  secondary: `${MENU_CLASS}-desc`
@@ -213,16 +216,34 @@ function defaultChoiceLabel(control) {
213
216
  return name ? `Default (${name})` : 'Default';
214
217
  }
215
218
  /**
216
- * A dropdown for a control. The first row is "Default" (selection = null); the
217
- * rest are the persona's advertised options (selection = that option's id).
219
+ * The uppercase group label used in control menus: it titles a control's own
220
+ * dropdown and labels each control's section in the overflow menu. Rendered
221
+ * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
222
+ * it and the menu stays keyboard-navigable.
218
223
  */
219
- function ControlItem(props) {
224
+ function ControlSubheader(props) {
225
+ return (React.createElement(ListSubheader, { id: props.id, disableSticky: true, className: `${MENU_CLASS}-subheader` }, props.label));
226
+ }
227
+ // MUI's MenuList skips initial focus for children whose type carries this
228
+ // static; ListSubheader's own copy is hidden behind the wrapper.
229
+ ControlSubheader.muiSkipListHighlight = true;
230
+ /**
231
+ * A dropdown for a control, titled with the control's label. The first choice
232
+ * row is "Default" (selection = null); the rest are the persona's advertised
233
+ * options (selection = that option's id). Exported for tests.
234
+ */
235
+ export function ControlItem(props) {
220
236
  const { control, onSelect } = props;
221
237
  const [anchor, setAnchor] = useState(null);
238
+ // The heading names the menu for assistive tech: the subheader itself is a
239
+ // roleless, never-focused list row, so without this wiring the popup has no
240
+ // accessible name at all.
241
+ const headingId = useId();
222
242
  return (React.createElement(React.Fragment, null,
223
243
  React.createElement(Button, { className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-control-btn`, size: "small", variant: "text", disableRipple: true, endIcon: React.createElement(ArrowDropDownIcon, { className: `${SELECTOR_CLASS}-arrow` }), onClick: event => setAnchor(event.currentTarget), title: control.label },
224
244
  React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
225
- React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), ...menuAnchorProps },
245
+ React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
246
+ React.createElement(ControlSubheader, { id: headingId, label: control.label }),
226
247
  React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
227
248
  setAnchor(null);
228
249
  onSelect(null);
@@ -234,15 +255,13 @@ function ControlItem(props) {
234
255
  }
235
256
  /**
236
257
  * The overflow popover: controls that did not fit inline, shown as a single flat
237
- * menu (no nested dropdowns). Each control renders as a `ListSubheader` group
238
- * label followed by its Default row and choices. Using MUI primitives keeps the
239
- * menu keyboard-navigable: `ListSubheader` has no tabindex so arrow-key focus
240
- * skips it.
258
+ * menu (no nested dropdowns). Each control renders as a group label followed by
259
+ * its Default row and choices. Exported for tests.
241
260
  */
242
- function OverflowMenu(props) {
261
+ export function OverflowMenu(props) {
243
262
  const { controls, anchor, onClose, onChange } = props;
244
- return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, ...menuAnchorProps }, controls.flatMap(control => [
245
- React.createElement(ListSubheader, { key: `${control.id}-label`, disableSticky: true, className: `${SELECTOR_CLASS}-overflow-subheader` }, control.label),
263
+ return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, MenuListProps: { 'aria-label': 'More controls' }, ...menuAnchorProps }, controls.flatMap(control => [
264
+ React.createElement(ControlSubheader, { key: `${control.id}-label`, label: control.label }),
246
265
  React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
247
266
  onClose();
248
267
  onChange(control, null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jupyter-ai/persona-manager",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -71,6 +71,7 @@
71
71
  "@types/json-schema": "^7.0.11",
72
72
  "@types/react": "^18.0.26",
73
73
  "@types/react-addons-linked-state-mixin": "^0.14.22",
74
+ "@types/react-dom": "^18.0.0",
74
75
  "@typescript-eslint/eslint-plugin": "^6.1.0",
75
76
  "@typescript-eslint/parser": "^6.1.0",
76
77
  "css-loader": "^6.7.1",
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Renders the control menus and pins their user-observable contract: each
3
+ * dropdown opens titled by its control's heading, the heading names the menu
4
+ * for assistive tech without joining keyboard traversal, and arrow keys,
5
+ * type-ahead, and Enter drive the choice rows.
6
+ */
7
+ import React from 'react';
8
+ import { createRoot, Root } from 'react-dom/client';
9
+ import { Control, ControlItem, OverflowMenu } from '../persona-controls';
10
+
11
+ // React 18.3 ships `React.act`, which the installed @types/react (18.0) does
12
+ // not declare yet.
13
+ const { act } = React as unknown as { act: (callback: () => void) => void };
14
+
15
+ (
16
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
17
+ ).IS_REACT_ACT_ENVIRONMENT = true;
18
+
19
+ const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
20
+
21
+ function modelControl(selection: string | null): Control {
22
+ return {
23
+ id: '__model__',
24
+ kind: 'model',
25
+ label: 'Model',
26
+ current: 'alpha',
27
+ selection,
28
+ options: [
29
+ { id: 'alpha', name: 'Alpha', description: null },
30
+ { id: 'beta', name: 'Beta', description: null }
31
+ ]
32
+ };
33
+ }
34
+
35
+ describe('control menus', () => {
36
+ let container: HTMLDivElement;
37
+ let root: Root;
38
+
39
+ beforeEach(() => {
40
+ container = document.createElement('div');
41
+ document.body.appendChild(container);
42
+ root = createRoot(container);
43
+ });
44
+
45
+ afterEach(() => {
46
+ act(() => root.unmount());
47
+ container.remove();
48
+ });
49
+
50
+ function menu(): HTMLElement {
51
+ return document.querySelector('ul[role="menu"]') as HTMLElement;
52
+ }
53
+
54
+ function focused(): HTMLElement {
55
+ return document.activeElement as HTMLElement;
56
+ }
57
+
58
+ function press(key: string): void {
59
+ act(() => {
60
+ focused().dispatchEvent(
61
+ new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
62
+ );
63
+ });
64
+ }
65
+
66
+ describe('control dropdown', () => {
67
+ function openMenu(
68
+ control: Control,
69
+ onSelect: (value: string | null) => void = () => undefined
70
+ ): void {
71
+ act(() => {
72
+ root.render(<ControlItem control={control} onSelect={onSelect} />);
73
+ });
74
+ act(() => {
75
+ (container.querySelector('button') as HTMLElement).dispatchEvent(
76
+ new MouseEvent('click', { bubbles: true })
77
+ );
78
+ });
79
+ }
80
+
81
+ it('opens titled by a heading that names the menu', () => {
82
+ openMenu(modelControl('beta'));
83
+ const heading = document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
84
+ expect(heading.textContent).toBe('Model');
85
+ expect(heading.hasAttribute('tabindex')).toBe(false);
86
+ expect(heading.id).toBeTruthy();
87
+ expect(menu().getAttribute('aria-labelledby')).toBe(heading.id);
88
+ });
89
+
90
+ it('focuses the selected row and moves focus with the keyboard', () => {
91
+ openMenu(modelControl('beta'));
92
+ // Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
93
+ expect(focused().textContent).toBe('Beta');
94
+ expect(focused().getAttribute('role')).toBe('menuitem');
95
+ // Wrapping from the last row skips the heading to the first choice row.
96
+ press('ArrowDown');
97
+ expect(focused().textContent).toBe('Default (Alpha)');
98
+ press('ArrowDown');
99
+ expect(focused().textContent).toBe('Alpha');
100
+ // Type-ahead: "b" jumps to Beta.
101
+ press('b');
102
+ expect(focused().textContent).toBe('Beta');
103
+ });
104
+
105
+ it('leaves focus in place when type-ahead matches only the heading', () => {
106
+ openMenu(modelControl('beta'));
107
+ // First key after open, so it cannot buffer onto a previous press: "m"
108
+ // matches the heading ("Model") and no choice row, and the heading
109
+ // never takes focus.
110
+ press('m');
111
+ expect(focused().textContent).toBe('Beta');
112
+ });
113
+
114
+ it('falls back to the first choice row when the selection is stale', () => {
115
+ // A selection id the persona no longer advertises leaves no row
116
+ // selected; initial focus then skips the heading to the first row.
117
+ openMenu(modelControl('stale'));
118
+ expect(focused().textContent).toBe('Default (Alpha)');
119
+ expect(focused().getAttribute('role')).toBe('menuitem');
120
+ });
121
+
122
+ it('activates the focused row with Enter', () => {
123
+ const onSelect = jest.fn();
124
+ openMenu(modelControl(null), onSelect);
125
+ press('ArrowDown');
126
+ press('ArrowDown');
127
+ press('Enter');
128
+ expect(onSelect).toHaveBeenCalledWith('beta');
129
+ });
130
+ });
131
+
132
+ describe('overflow menu', () => {
133
+ it('is labeled and arrows skip the section headings', () => {
134
+ const first: Control = {
135
+ id: 'a',
136
+ kind: 'setting',
137
+ label: 'Aaa',
138
+ current: null,
139
+ selection: 'a1',
140
+ options: [{ id: 'a1', name: 'A one', description: null }]
141
+ };
142
+ const second: Control = {
143
+ id: 'b',
144
+ kind: 'setting',
145
+ label: 'Bbb',
146
+ current: null,
147
+ selection: 'stale',
148
+ options: [{ id: 'b1', name: 'B one', description: null }]
149
+ };
150
+ const anchor = document.createElement('button');
151
+ document.body.appendChild(anchor);
152
+ act(() => {
153
+ root.render(
154
+ <OverflowMenu
155
+ controls={[first, second]}
156
+ anchor={anchor}
157
+ onClose={() => undefined}
158
+ onChange={() => undefined}
159
+ />
160
+ );
161
+ });
162
+ expect(menu().getAttribute('aria-label')).toBe('More controls');
163
+ const headings = Array.from(
164
+ document.querySelectorAll(SUBHEADER_SELECTOR)
165
+ ).map(h => h.textContent);
166
+ expect(headings).toEqual(['Aaa', 'Bbb']);
167
+ // "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
168
+ // to the next section's first choice row.
169
+ expect(focused().textContent).toBe('A one');
170
+ press('ArrowDown');
171
+ expect(focused().textContent).toBe('Default');
172
+ anchor.remove();
173
+ });
174
+ });
175
+ });
@@ -1,6 +1,7 @@
1
1
  import React, {
2
2
  useCallback,
3
3
  useEffect,
4
+ useId,
4
5
  useLayoutEffect,
5
6
  useRef,
6
7
  useState
@@ -280,14 +281,24 @@ function ChoiceMenuItem(props: {
280
281
  selected: boolean;
281
282
  onSelect: () => void;
282
283
  }): JSX.Element {
283
- const { primary, selected, onSelect } = props;
284
+ // MenuList clones the row it picks for initial focus with extra props
285
+ // (tabIndex, autoFocus); forward them to the MenuItem, or no row is ever
286
+ // focused and the menu's arrow-key and type-ahead handling never engages.
287
+ const {
288
+ primary,
289
+ description: rawDescription,
290
+ selected,
291
+ onSelect,
292
+ ...menuItemProps
293
+ } = props;
284
294
  const description =
285
- props.description &&
286
- props.description.trim().toLowerCase() !== primary.trim().toLowerCase()
287
- ? props.description
295
+ rawDescription &&
296
+ rawDescription.trim().toLowerCase() !== primary.trim().toLowerCase()
297
+ ? rawDescription
288
298
  : null;
289
299
  return (
290
300
  <MenuItem
301
+ {...menuItemProps}
291
302
  selected={selected}
292
303
  onClick={onSelect}
293
304
  title={description ?? undefined}
@@ -319,15 +330,42 @@ function defaultChoiceLabel(control: Control): string {
319
330
  }
320
331
 
321
332
  /**
322
- * A dropdown for a control. The first row is "Default" (selection = null); the
323
- * rest are the persona's advertised options (selection = that option's id).
333
+ * The uppercase group label used in control menus: it titles a control's own
334
+ * dropdown and labels each control's section in the overflow menu. Rendered
335
+ * with MUI's `ListSubheader`, which has no tabindex, so arrow-key focus skips
336
+ * it and the menu stays keyboard-navigable.
324
337
  */
325
- function ControlItem(props: {
338
+ function ControlSubheader(props: { label: string; id?: string }): JSX.Element {
339
+ return (
340
+ <ListSubheader
341
+ id={props.id}
342
+ disableSticky
343
+ className={`${MENU_CLASS}-subheader`}
344
+ >
345
+ {props.label}
346
+ </ListSubheader>
347
+ );
348
+ }
349
+
350
+ // MUI's MenuList skips initial focus for children whose type carries this
351
+ // static; ListSubheader's own copy is hidden behind the wrapper.
352
+ ControlSubheader.muiSkipListHighlight = true;
353
+
354
+ /**
355
+ * A dropdown for a control, titled with the control's label. The first choice
356
+ * row is "Default" (selection = null); the rest are the persona's advertised
357
+ * options (selection = that option's id). Exported for tests.
358
+ */
359
+ export function ControlItem(props: {
326
360
  control: Control;
327
361
  onSelect: (value: string | null) => void;
328
362
  }): JSX.Element {
329
363
  const { control, onSelect } = props;
330
364
  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
365
+ // The heading names the menu for assistive tech: the subheader itself is a
366
+ // roleless, never-focused list row, so without this wiring the popup has no
367
+ // accessible name at all.
368
+ const headingId = useId();
331
369
  return (
332
370
  <>
333
371
  <Button
@@ -347,8 +385,10 @@ function ControlItem(props: {
347
385
  anchorEl={anchor}
348
386
  open={!!anchor}
349
387
  onClose={() => setAnchor(null)}
388
+ MenuListProps={{ 'aria-labelledby': headingId }}
350
389
  {...menuAnchorProps}
351
390
  >
391
+ <ControlSubheader id={headingId} label={control.label} />
352
392
  <ChoiceMenuItem
353
393
  primary={defaultChoiceLabel(control)}
354
394
  description={null}
@@ -377,12 +417,10 @@ function ControlItem(props: {
377
417
 
378
418
  /**
379
419
  * The overflow popover: controls that did not fit inline, shown as a single flat
380
- * menu (no nested dropdowns). Each control renders as a `ListSubheader` group
381
- * label followed by its Default row and choices. Using MUI primitives keeps the
382
- * menu keyboard-navigable: `ListSubheader` has no tabindex so arrow-key focus
383
- * skips it.
420
+ * menu (no nested dropdowns). Each control renders as a group label followed by
421
+ * its Default row and choices. Exported for tests.
384
422
  */
385
- function OverflowMenu(props: {
423
+ export function OverflowMenu(props: {
386
424
  controls: Control[];
387
425
  anchor: HTMLElement | null;
388
426
  onClose: () => void;
@@ -394,16 +432,11 @@ function OverflowMenu(props: {
394
432
  anchorEl={anchor}
395
433
  open={!!anchor}
396
434
  onClose={onClose}
435
+ MenuListProps={{ 'aria-label': 'More controls' }}
397
436
  {...menuAnchorProps}
398
437
  >
399
438
  {controls.flatMap(control => [
400
- <ListSubheader
401
- key={`${control.id}-label`}
402
- disableSticky
403
- className={`${SELECTOR_CLASS}-overflow-subheader`}
404
- >
405
- {control.label}
406
- </ListSubheader>,
439
+ <ControlSubheader key={`${control.id}-label`} label={control.label} />,
407
440
  <ChoiceMenuItem
408
441
  key={`${control.id}-default`}
409
442
  primary={defaultChoiceLabel(control)}
package/style/base.css CHANGED
@@ -278,10 +278,11 @@
278
278
  flex-shrink: 0;
279
279
  }
280
280
 
281
- /* overflow popover: an uppercase group label above each overflowed control's
282
- choices, with a hairline divider and top spacing separating the sections.
283
- Scoped under the paper to win over MUI's ListSubheader defaults. */
284
- .jp-jai-controlMenu-paper .jp-jai-personaControls-overflow-subheader {
281
+ /* Control menu group label: an uppercase heading above a control's choices.
282
+ It titles each control's own dropdown; in the overflow popover a hairline
283
+ divider and top spacing separate the controls' sections. Scoped under the
284
+ paper to win over MUI's ListSubheader defaults. */
285
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
285
286
  margin-top: 8px;
286
287
  padding: 8px 12px 2px;
287
288
  border-top: 1px solid var(--jp-border-color2);
@@ -295,9 +296,9 @@
295
296
  background-color: var(--jp-layout-color1);
296
297
  }
297
298
 
298
- /* first section sits flush at the top, no divider or extra gap above it. */
299
- .jp-jai-controlMenu-paper
300
- .jp-jai-personaControls-overflow-subheader:first-child {
299
+ /* The first (or only) label sits flush at the top, no divider or extra gap
300
+ above it. */
301
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader:first-child {
301
302
  margin-top: 0;
302
303
  border-top: none;
303
304
  }
@@ -361,8 +362,8 @@
361
362
  white-space: nowrap;
362
363
  }
363
364
 
364
- /* Usage popover card: labeled sections in the overflow-subheader style, then
365
- one row per quantity, label muted and value right-aligned. */
365
+ /* Usage popover card: labeled sections in the control menu's subheader style,
366
+ then one row per quantity, label muted and value right-aligned. */
366
367
  .jp-jai-usage-card {
367
368
  padding: 8px 12px;
368
369
  min-width: 220px;