@jupyter-ai/persona-manager 0.1.0 → 0.1.2

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 ControlMenu(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 OverflowControlsMenu(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,37 @@ 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. Sticky: when the menu scrolls, the
223
+ * label pins to the top, so the group the visible rows belong to stays
224
+ * readable; in the overflow menu the next section's label paints over it on
225
+ * arrival.
218
226
  */
219
- function ControlItem(props) {
227
+ function ControlMenuSubheader(props) {
228
+ return (React.createElement(ListSubheader, { id: props.id, className: `${MENU_CLASS}-subheader` }, props.label));
229
+ }
230
+ // MUI's MenuList skips initial focus for children whose type carries this
231
+ // static; ListSubheader's own copy is hidden behind the wrapper.
232
+ ControlMenuSubheader.muiSkipListHighlight = true;
233
+ /**
234
+ * A dropdown for a control, titled with the control's label. The first choice
235
+ * row is "Default" (selection = null); the rest are the persona's advertised
236
+ * options (selection = that option's id). Exported for tests.
237
+ */
238
+ export function ControlMenu(props) {
220
239
  const { control, onSelect } = props;
221
240
  const [anchor, setAnchor] = useState(null);
241
+ // The heading names the menu for assistive tech: the subheader itself is a
242
+ // roleless, never-focused list row, so without this wiring the popup has no
243
+ // accessible name at all.
244
+ const headingId = useId();
222
245
  return (React.createElement(React.Fragment, null,
223
246
  React.createElement(Button, { className: `${SELECTOR_CLASS} ${SELECTOR_CLASS}-control-btn`, size: "small", variant: "text", disableRipple: true, endIcon: React.createElement(ArrowDropDownIcon, { className: `${SELECTOR_CLASS}-arrow` }), onClick: event => setAnchor(event.currentTarget), title: control.label },
224
247
  React.createElement("span", { className: `${SELECTOR_CLASS}-control-value` }, currentControlLabel(control))),
225
- React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), ...menuAnchorProps },
248
+ React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: () => setAnchor(null), MenuListProps: { 'aria-labelledby': headingId }, ...menuAnchorProps },
249
+ React.createElement(ControlMenuSubheader, { id: headingId, label: control.label }),
226
250
  React.createElement(ChoiceMenuItem, { primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
227
251
  setAnchor(null);
228
252
  onSelect(null);
@@ -234,15 +258,13 @@ function ControlItem(props) {
234
258
  }
235
259
  /**
236
260
  * 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.
261
+ * menu (no nested dropdowns). Each control renders as a group label followed by
262
+ * its Default row and choices. Exported for tests.
241
263
  */
242
- function OverflowMenu(props) {
264
+ export function OverflowControlsMenu(props) {
243
265
  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),
266
+ return (React.createElement(Menu, { anchorEl: anchor, open: !!anchor, onClose: onClose, MenuListProps: { 'aria-label': 'More controls' }, ...menuAnchorProps }, controls.flatMap(control => [
267
+ React.createElement(ControlMenuSubheader, { key: `${control.id}-label`, label: control.label }),
246
268
  React.createElement(ChoiceMenuItem, { key: `${control.id}-default`, primary: defaultChoiceLabel(control), description: null, selected: control.selection === null, onSelect: () => {
247
269
  onClose();
248
270
  onChange(control, null);
@@ -321,12 +343,12 @@ function ControlsRow(props) {
321
343
  const visible = controls.slice(0, visibleCount);
322
344
  const overflow = controls.slice(visibleCount);
323
345
  return (React.createElement("div", { className: `${SELECTOR_CLASS}-controls`, ref: rowRef },
324
- React.createElement("div", { className: `${SELECTOR_CLASS}-controls-measure`, ref: measureRef, "aria-hidden": "true" }, controls.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) })))),
325
- visible.map(control => (React.createElement(ControlItem, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
346
+ 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) })))),
347
+ visible.map(control => (React.createElement(ControlMenu, { key: control.id, control: control, onSelect: v => onChange(control, v) }))),
326
348
  overflow.length ? (React.createElement(React.Fragment, null,
327
349
  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" },
328
350
  React.createElement(MoreHorizIcon, { fontSize: "small" })),
329
- React.createElement(OverflowMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
351
+ React.createElement(OverflowControlsMenu, { controls: overflow, anchor: overflowAnchor, onClose: () => setOverflowAnchor(null), onChange: onChange }))) : null));
330
352
  }
331
353
  // All formatters pin the `en` locale so numbers agree with each other and
332
354
  // with the surrounding English labels.
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.2",
4
4
  "description": "The core manager & registry for AI personas in Jupyter AI",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -67,10 +67,14 @@
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",
73
76
  "@types/react-addons-linked-state-mixin": "^0.14.22",
77
+ "@types/react-dom": "^18.0.0",
74
78
  "@typescript-eslint/eslint-plugin": "^6.1.0",
75
79
  "@typescript-eslint/parser": "^6.1.0",
76
80
  "css-loader": "^6.7.1",
@@ -0,0 +1,158 @@
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 { render, screen } from '@testing-library/react';
9
+ import userEvent from '@testing-library/user-event';
10
+ import {
11
+ Control,
12
+ ControlMenu,
13
+ OverflowControlsMenu
14
+ } from '../persona-controls';
15
+
16
+ const SUBHEADER_SELECTOR = 'li.jp-jai-controlMenu-subheader';
17
+
18
+ function modelControl(selection: string | null): Control {
19
+ return {
20
+ id: '__model__',
21
+ kind: 'model',
22
+ label: 'Model',
23
+ current: 'alpha',
24
+ selection,
25
+ options: [
26
+ { id: 'alpha', name: 'Alpha', description: null },
27
+ { id: 'beta', name: 'Beta', description: null }
28
+ ]
29
+ };
30
+ }
31
+
32
+ function heading(): HTMLElement {
33
+ return document.querySelector(SUBHEADER_SELECTOR) as HTMLElement;
34
+ }
35
+
36
+ function focused(): HTMLElement {
37
+ return document.activeElement as HTMLElement;
38
+ }
39
+
40
+ describe('control menus', () => {
41
+ describe('control dropdown', () => {
42
+ async function openMenu(
43
+ control: Control,
44
+ onSelect: (value: string | null) => void = () => undefined
45
+ ): Promise<ReturnType<typeof userEvent.setup>> {
46
+ const user = userEvent.setup();
47
+ render(<ControlMenu control={control} onSelect={onSelect} />);
48
+ await user.click(screen.getByRole('button'));
49
+ return user;
50
+ }
51
+
52
+ it('opens titled by a heading that names the menu', async () => {
53
+ await openMenu(modelControl('beta'));
54
+ expect(heading().textContent).toBe('Model');
55
+ expect(heading().hasAttribute('tabindex')).toBe(false);
56
+ expect(heading().id).toBeTruthy();
57
+ expect(screen.getByRole('menu').getAttribute('aria-labelledby')).toBe(
58
+ heading().id
59
+ );
60
+ // Sticky, so the title stays visible while a long option list scrolls.
61
+ expect(heading().classList.contains('MuiListSubheader-sticky')).toBe(
62
+ true
63
+ );
64
+ });
65
+
66
+ it('focuses the selected row and moves focus with the keyboard', async () => {
67
+ const user = await openMenu(modelControl('beta'));
68
+ // Rows: heading, "Default (Alpha)", "Alpha", "Beta"; "Beta" is selected.
69
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
70
+ // Wrapping from the last row skips the heading to the first choice row.
71
+ await user.keyboard('{ArrowDown}');
72
+ expect(focused()).toBe(
73
+ screen.getByRole('menuitem', { name: 'Default (Alpha)' })
74
+ );
75
+ await user.keyboard('{ArrowDown}');
76
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Alpha' }));
77
+ // Type-ahead: "b" jumps to Beta.
78
+ await user.keyboard('b');
79
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
80
+ });
81
+
82
+ it('leaves focus in place when type-ahead matches only the heading', async () => {
83
+ const user = await openMenu(modelControl('beta'));
84
+ // First key after open, so it cannot buffer onto a previous press: "m"
85
+ // matches the heading ("Model") and no choice row, and the heading
86
+ // never takes focus.
87
+ await user.keyboard('m');
88
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'Beta' }));
89
+ });
90
+
91
+ it('falls back to the first choice row when the selection is stale', async () => {
92
+ // A selection id the persona no longer advertises leaves no row
93
+ // selected; initial focus then skips the heading to the first row.
94
+ await openMenu(modelControl('stale'));
95
+ expect(focused()).toBe(
96
+ screen.getByRole('menuitem', { name: 'Default (Alpha)' })
97
+ );
98
+ });
99
+
100
+ it('activates the focused row with Enter', async () => {
101
+ const onSelect = jest.fn();
102
+ const user = await openMenu(modelControl(null), onSelect);
103
+ await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
104
+ expect(onSelect).toHaveBeenCalledWith('beta');
105
+ });
106
+ });
107
+
108
+ describe('overflow menu', () => {
109
+ it('is labeled and arrows skip the section headings', async () => {
110
+ const first: Control = {
111
+ id: 'a',
112
+ kind: 'setting',
113
+ label: 'Aaa',
114
+ current: null,
115
+ selection: 'a1',
116
+ options: [{ id: 'a1', name: 'A one', description: null }]
117
+ };
118
+ const second: Control = {
119
+ id: 'b',
120
+ kind: 'setting',
121
+ label: 'Bbb',
122
+ current: 'b1',
123
+ selection: 'stale',
124
+ options: [{ id: 'b1', name: 'B one', description: null }]
125
+ };
126
+ const user = userEvent.setup();
127
+ const anchor = document.createElement('button');
128
+ document.body.appendChild(anchor);
129
+ render(
130
+ <OverflowControlsMenu
131
+ controls={[first, second]}
132
+ anchor={anchor}
133
+ onClose={() => undefined}
134
+ onChange={() => undefined}
135
+ />
136
+ );
137
+ expect(screen.getByRole('menu').getAttribute('aria-label')).toBe(
138
+ 'More controls'
139
+ );
140
+ const headings = Array.from(
141
+ document.querySelectorAll(SUBHEADER_SELECTOR)
142
+ );
143
+ expect(headings.map(h => h.textContent)).toEqual(['Aaa', 'Bbb']);
144
+ // Sticky, so the section a scrolled row belongs to stays visible.
145
+ for (const h of headings) {
146
+ expect(h.classList.contains('MuiListSubheader-sticky')).toBe(true);
147
+ }
148
+ // "A one" is the only selected row; ArrowDown crosses the "Bbb" heading
149
+ // to the next section's first choice row.
150
+ expect(focused()).toBe(screen.getByRole('menuitem', { name: 'A one' }));
151
+ await user.keyboard('{ArrowDown}');
152
+ expect(focused()).toBe(
153
+ screen.getByRole('menuitem', { name: 'Default (B one)' })
154
+ );
155
+ anchor.remove();
156
+ });
157
+ });
158
+ });
@@ -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,44 @@ 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. Sticky: when the menu scrolls, the
337
+ * label pins to the top, so the group the visible rows belong to stays
338
+ * readable; in the overflow menu the next section's label paints over it on
339
+ * arrival.
324
340
  */
325
- function ControlItem(props: {
341
+ function ControlMenuSubheader(props: {
342
+ label: string;
343
+ id?: string;
344
+ }): JSX.Element {
345
+ return (
346
+ <ListSubheader id={props.id} className={`${MENU_CLASS}-subheader`}>
347
+ {props.label}
348
+ </ListSubheader>
349
+ );
350
+ }
351
+
352
+ // MUI's MenuList skips initial focus for children whose type carries this
353
+ // static; ListSubheader's own copy is hidden behind the wrapper.
354
+ ControlMenuSubheader.muiSkipListHighlight = true;
355
+
356
+ /**
357
+ * A dropdown for a control, titled with the control's label. The first choice
358
+ * row is "Default" (selection = null); the rest are the persona's advertised
359
+ * options (selection = that option's id). Exported for tests.
360
+ */
361
+ export function ControlMenu(props: {
326
362
  control: Control;
327
363
  onSelect: (value: string | null) => void;
328
364
  }): JSX.Element {
329
365
  const { control, onSelect } = props;
330
366
  const [anchor, setAnchor] = useState<HTMLElement | null>(null);
367
+ // The heading names the menu for assistive tech: the subheader itself is a
368
+ // roleless, never-focused list row, so without this wiring the popup has no
369
+ // accessible name at all.
370
+ const headingId = useId();
331
371
  return (
332
372
  <>
333
373
  <Button
@@ -347,8 +387,10 @@ function ControlItem(props: {
347
387
  anchorEl={anchor}
348
388
  open={!!anchor}
349
389
  onClose={() => setAnchor(null)}
390
+ MenuListProps={{ 'aria-labelledby': headingId }}
350
391
  {...menuAnchorProps}
351
392
  >
393
+ <ControlMenuSubheader id={headingId} label={control.label} />
352
394
  <ChoiceMenuItem
353
395
  primary={defaultChoiceLabel(control)}
354
396
  description={null}
@@ -377,12 +419,10 @@ function ControlItem(props: {
377
419
 
378
420
  /**
379
421
  * 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.
422
+ * menu (no nested dropdowns). Each control renders as a group label followed by
423
+ * its Default row and choices. Exported for tests.
384
424
  */
385
- function OverflowMenu(props: {
425
+ export function OverflowControlsMenu(props: {
386
426
  controls: Control[];
387
427
  anchor: HTMLElement | null;
388
428
  onClose: () => void;
@@ -394,16 +434,14 @@ function OverflowMenu(props: {
394
434
  anchorEl={anchor}
395
435
  open={!!anchor}
396
436
  onClose={onClose}
437
+ MenuListProps={{ 'aria-label': 'More controls' }}
397
438
  {...menuAnchorProps}
398
439
  >
399
440
  {controls.flatMap(control => [
400
- <ListSubheader
441
+ <ControlMenuSubheader
401
442
  key={`${control.id}-label`}
402
- disableSticky
403
- className={`${SELECTOR_CLASS}-overflow-subheader`}
404
- >
405
- {control.label}
406
- </ListSubheader>,
443
+ label={control.label}
444
+ />,
407
445
  <ChoiceMenuItem
408
446
  key={`${control.id}-default`}
409
447
  primary={defaultChoiceLabel(control)}
@@ -517,7 +555,7 @@ function ControlsRow(props: {
517
555
  aria-hidden="true"
518
556
  >
519
557
  {controls.map(control => (
520
- <ControlItem
558
+ <ControlMenu
521
559
  key={control.id}
522
560
  control={control}
523
561
  onSelect={v => onChange(control, v)}
@@ -526,7 +564,7 @@ function ControlsRow(props: {
526
564
  </div>
527
565
 
528
566
  {visible.map(control => (
529
- <ControlItem
567
+ <ControlMenu
530
568
  key={control.id}
531
569
  control={control}
532
570
  onSelect={v => onChange(control, v)}
@@ -545,7 +583,7 @@ function ControlsRow(props: {
545
583
  >
546
584
  <MoreHorizIcon fontSize="small" />
547
585
  </button>
548
- <OverflowMenu
586
+ <OverflowControlsMenu
549
587
  controls={overflow}
550
588
  anchor={overflowAnchor}
551
589
  onClose={() => setOverflowAnchor(null)}
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;
@@ -278,10 +287,15 @@
278
287
  flex-shrink: 0;
279
288
  }
280
289
 
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 {
290
+ /* Control menu group label: an uppercase heading above a control's choices.
291
+ It titles each control's own dropdown; in the overflow popover a hairline
292
+ divider and top spacing separate the controls' sections. Scoped under the
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. */
298
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader {
285
299
  margin-top: 8px;
286
300
  padding: 8px 12px 2px;
287
301
  border-top: 1px solid var(--jp-border-color2);
@@ -293,11 +307,14 @@
293
307
  text-transform: uppercase;
294
308
  color: var(--jp-ui-font-color1);
295
309
  background-color: var(--jp-layout-color1);
310
+ white-space: nowrap;
311
+ overflow: hidden;
312
+ text-overflow: ellipsis;
296
313
  }
297
314
 
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 {
315
+ /* The first (or only) label sits flush at the top, no divider or extra gap
316
+ above it. */
317
+ .jp-jai-controlMenu-paper .jp-jai-controlMenu-subheader:first-child {
301
318
  margin-top: 0;
302
319
  border-top: none;
303
320
  }
@@ -361,8 +378,8 @@
361
378
  white-space: nowrap;
362
379
  }
363
380
 
364
- /* Usage popover card: labeled sections in the overflow-subheader style, then
365
- one row per quantity, label muted and value right-aligned. */
381
+ /* Usage popover card: labeled sections in the control menu's subheader style,
382
+ then one row per quantity, label muted and value right-aligned. */
366
383
  .jp-jai-usage-card {
367
384
  padding: 8px 12px;
368
385
  min-width: 220px;