jupyterlab_ai_code_assistants_extension 1.2.83 → 1.2.99

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.
@@ -45,6 +45,10 @@ export declare class AssistantSessionsPanel extends Widget {
45
45
  protected onBeforeHide(_msg: Message): void;
46
46
  protected onCloseRequest(msg: Message): void;
47
47
  private _buildShell;
48
+ /** Raise the click-time busy overlay. Counted, because an open and a branch
49
+ * can overlap; the overlay clears when the last one ends. */
50
+ private _beginBusy;
51
+ private _endBusy;
48
52
  /** Show / hide the filter input. Hiding also clears the active filter, so
49
53
  * the user never returns to an "invisible" filter narrowing the rows. */
50
54
  private _toggleFilterBar;
@@ -302,6 +306,9 @@ export declare class AssistantSessionsPanel extends Widget {
302
306
  private readonly _removingPaths;
303
307
  private _bodyEl;
304
308
  private _loadingEl;
309
+ private _busyEl;
310
+ private _busyTextEl;
311
+ private _busyCount;
305
312
  private _errorEl;
306
313
  private _refreshBtn;
307
314
  private _filterBtn;
package/lib/core/panel.js CHANGED
@@ -112,6 +112,9 @@ export class AssistantSessionsPanel extends Widget {
112
112
  this._deleteToTrash = true;
113
113
  this._removingPaths = new Set();
114
114
  this._loadingEl = null;
115
+ this._busyEl = null;
116
+ this._busyTextEl = null;
117
+ this._busyCount = 0;
115
118
  this._errorEl = null;
116
119
  this._refreshBtn = null;
117
120
  this._filterBtn = null;
@@ -389,6 +392,28 @@ export class AssistantSessionsPanel extends Widget {
389
392
  loadingSpinner.className =
390
393
  'jp-aiAssistants-spinner jp-AiAssistantsPanel-loadingSpinner';
391
394
  loading.appendChild(loadingSpinner);
395
+ // Click-time busy overlay: raised synchronously when a row is opened or
396
+ // branched, before the terminal probe or the branch request, so the click
397
+ // never reads as ignored (DEF-PANE-318). Unlike the refresh veil it takes
398
+ // no pointer events and no focus: the context menu that launched a branch
399
+ // stays open until its command runs, and a focus change would close it.
400
+ const busy = document.createElement('div');
401
+ busy.className = 'jp-AiAssistantsPanel-busyOverlay';
402
+ busy.hidden = true;
403
+ const busySpinner = document.createElement('div');
404
+ busySpinner.className =
405
+ 'jp-aiAssistants-spinner jp-AiAssistantsPanel-busySpinner';
406
+ busySpinner.setAttribute('aria-hidden', 'true');
407
+ busy.appendChild(busySpinner);
408
+ // The spinner is decoration; a screen-reader user hears the action
409
+ // instead. The region sits on the root, always rendered and outside the
410
+ // overlay and the aria-busy body: a live region created inside a
411
+ // display:none container, or one whose update lands in an aria-busy
412
+ // subtree, can be withheld by assistive technology.
413
+ const busyText = document.createElement('div');
414
+ busyText.className = 'jp-AiAssistantsPanel-srOnly';
415
+ busyText.setAttribute('role', 'status');
416
+ busyText.setAttribute('aria-live', 'polite');
392
417
  // Inline error strip - a failed poll reports here and keeps retrying,
393
418
  // rather than tearing the panel down or raising a modal that would also
394
419
  // interrupt the other providers' panels.
@@ -403,10 +428,42 @@ export class AssistantSessionsPanel extends Widget {
403
428
  root.appendChild(error);
404
429
  root.appendChild(body);
405
430
  root.appendChild(loading);
431
+ root.appendChild(busy);
432
+ root.appendChild(busyText);
406
433
  this._bodyEl = body;
407
434
  this._loadingEl = loading;
435
+ this._busyEl = busy;
436
+ this._busyTextEl = busyText;
408
437
  this._errorEl = error;
409
438
  }
439
+ /** Raise the click-time busy overlay. Counted, because an open and a branch
440
+ * can overlap; the overlay clears when the last one ends. */
441
+ _beginBusy(message) {
442
+ this._busyCount += 1;
443
+ if (this._busyEl) {
444
+ this._busyEl.hidden = false;
445
+ if (this._busyTextEl) {
446
+ this._busyTextEl.textContent = message;
447
+ }
448
+ // The body is what the action changes; the root stays outside the
449
+ // busy subtree so the live region above is not withheld.
450
+ if (this._bodyEl) {
451
+ this._bodyEl.setAttribute('aria-busy', 'true');
452
+ }
453
+ }
454
+ }
455
+ _endBusy() {
456
+ this._busyCount = Math.max(0, this._busyCount - 1);
457
+ if (this._busyCount === 0 && this._busyEl) {
458
+ this._busyEl.hidden = true;
459
+ if (this._busyTextEl) {
460
+ this._busyTextEl.textContent = '';
461
+ }
462
+ if (this._bodyEl) {
463
+ this._bodyEl.removeAttribute('aria-busy');
464
+ }
465
+ }
466
+ }
410
467
  /** Show / hide the filter input. Hiding also clears the active filter, so
411
468
  * the user never returns to an "invisible" filter narrowing the rows. */
412
469
  _toggleFilterBar() {
@@ -746,6 +803,9 @@ export class AssistantSessionsPanel extends Widget {
746
803
  return resolveLaunchMode(this._descriptor.launchModes, this._modes, force);
747
804
  }
748
805
  async _openSession(session, force, sessionId) {
806
+ // The click's own feedback, raised before the terminal walk and the
807
+ // launch - both can take seconds (DEF-PANE-318).
808
+ this._beginBusy(this._trans.__('Opening conversation'));
749
809
  try {
750
810
  await this._terminals.openSession(session, this._launchMode(force), sessionId);
751
811
  // Not awaited: the open has succeeded, and the sync is not part of it.
@@ -760,6 +820,7 @@ export class AssistantSessionsPanel extends Widget {
760
820
  }
761
821
  }
762
822
  finally {
823
+ this._endBusy();
763
824
  // Reuse or fresh launch, either way the picture changed. Pull fresh
764
825
  // state; a poll tick retries if this one fails.
765
826
  void this._fetch().catch(() => undefined);
@@ -985,7 +1046,16 @@ export class AssistantSessionsPanel extends Widget {
985
1046
  if (!session) {
986
1047
  return;
987
1048
  }
988
- await this._branchByServerCopy(session, undefined, force);
1049
+ // Raised on the click: the branch POST mints the fork server-side (for
1050
+ // Claude and Codex by running their own CLI) and can take seconds
1051
+ // (DEF-PANE-318).
1052
+ this._beginBusy(this._trans.__('Branching conversation'));
1053
+ try {
1054
+ await this._branchByServerCopy(session, undefined, force);
1055
+ }
1056
+ finally {
1057
+ this._endBusy();
1058
+ }
989
1059
  }
990
1060
  /** The server mints the new id and answers with it before the terminal
991
1061
  * opens - `server-copy` copies the store entry, `server-native` drives the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.83",
3
+ "version": "1.2.99",
4
4
  "description": "A full launcher and manager for every AI code assistant in JupyterLab - Claude Code, Codex, Kimi, Gemini and DeepSeek. Start, resume, fork, switch, and clean up CLI sessions from a side panel per assistant - one click lands you in the right terminal with the assistant already running, no duplicate tabs, no UUID hunting.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -455,6 +455,146 @@ describe('ACC-SESS-203 - automatic branch names', () => {
455
455
  );
456
456
  });
457
457
 
458
+ describe('click-time busy state (DEF-PANE-318)', () => {
459
+ it('raises the overlay and the body busy state on the click, before the open settles', async () => {
460
+ const target = makePanel(DESCRIPTOR);
461
+ const parent = session();
462
+ let release!: () => void;
463
+ const pending = new Promise<void>(resolve => {
464
+ release = resolve;
465
+ });
466
+ (target as any)._terminals.openSession = jest.fn(() => pending);
467
+ (target as any)._fetch = jest.fn().mockResolvedValue(undefined);
468
+ const overlay = (): HTMLElement =>
469
+ target.node.querySelector('.jp-AiAssistantsPanel-busyOverlay')!;
470
+ const body = (): HTMLElement =>
471
+ target.node.querySelector('.jp-AiAssistantsPanel-body')!;
472
+
473
+ const live = target.node.querySelector(
474
+ '.jp-AiAssistantsPanel-srOnly'
475
+ ) as HTMLElement;
476
+ // Rendered at build, not created on demand: the region must be in the
477
+ // accessibility tree before the click, so its first update is announced.
478
+ expect(live).not.toBeNull();
479
+ expect(live.textContent).toBe('');
480
+ expect(live.getAttribute('role')).toBe('status');
481
+ expect(live.getAttribute('aria-live')).toBe('polite');
482
+
483
+ expect(overlay().hidden).toBe(true);
484
+ // Root placement is load-bearing: _render wipes the body on every fetch,
485
+ // including the 30 s poll, which would delete an overlay kept there.
486
+ expect(overlay().parentElement).toBe(target.node);
487
+ // The overlay takes no focus, so the context menu that launched an action
488
+ // cannot be closed by an element inside it taking the focus.
489
+ expect(overlay().hasAttribute('tabindex')).toBe(false);
490
+ expect(
491
+ overlay().querySelectorAll(
492
+ 'a[href], button, input, select, textarea, [tabindex]'
493
+ )
494
+ ).toHaveLength(0);
495
+
496
+ const call = (target as any)._openSession(parent);
497
+ // The click's own task, before the open settles.
498
+ expect(overlay().hidden).toBe(false);
499
+ expect(body().getAttribute('aria-busy')).toBe('true');
500
+ // Outside both the hidden overlay and the busy body, so assistive
501
+ // technology is not told to withhold the announcement.
502
+ expect(live.parentElement).toBe(target.node);
503
+ expect(body().contains(live)).toBe(false);
504
+ expect(overlay().contains(live)).toBe(false);
505
+ expect(live.textContent).toBe('Opening conversation');
506
+
507
+ release();
508
+ await call;
509
+ expect(overlay().hidden).toBe(true);
510
+ expect(body().getAttribute('aria-busy')).toBeNull();
511
+ // Cleared, so an identical action can announce again.
512
+ expect(live.textContent).toBe('');
513
+
514
+ target.dispose();
515
+ });
516
+
517
+ it('keeps the overlay up while a second action is still running', async () => {
518
+ const target = makePanel(DESCRIPTOR);
519
+ let releaseFirst!: () => void;
520
+ let releaseSecond!: () => void;
521
+ const first = new Promise<void>(resolve => {
522
+ releaseFirst = resolve;
523
+ });
524
+ const second = new Promise<void>(resolve => {
525
+ releaseSecond = resolve;
526
+ });
527
+ const requests = [first, second];
528
+ (target as any)._terminals.openSession = jest.fn(() => requests.shift());
529
+ (target as any)._fetch = jest.fn().mockResolvedValue(undefined);
530
+ const overlay = (): HTMLElement =>
531
+ target.node.querySelector('.jp-AiAssistantsPanel-busyOverlay')!;
532
+ const body = (): HTMLElement =>
533
+ target.node.querySelector('.jp-AiAssistantsPanel-body')!;
534
+
535
+ const callOne = (target as any)._openSession(
536
+ session({ session_id: 'one' })
537
+ );
538
+ const callTwo = (target as any)._openSession(
539
+ session({ session_id: 'two' })
540
+ );
541
+ expect(overlay().hidden).toBe(false);
542
+
543
+ releaseFirst();
544
+ await callOne;
545
+ expect(overlay().hidden).toBe(false);
546
+ expect(body().getAttribute('aria-busy')).toBe('true');
547
+
548
+ releaseSecond();
549
+ await callTwo;
550
+ expect(overlay().hidden).toBe(true);
551
+ expect(body().getAttribute('aria-busy')).toBeNull();
552
+
553
+ target.dispose();
554
+ });
555
+
556
+ it('announces the branch action while the request is pending', async () => {
557
+ const target = makePanel(DESCRIPTOR);
558
+ const parent = session({ name: 'analysis', name_source: 'session' });
559
+ const launch = jest.fn().mockResolvedValue({});
560
+ (target as any)._terminals.launch = launch;
561
+ (target as any)._activeSession = parent;
562
+ (target as any)._fetch = jest.fn().mockResolvedValue(undefined);
563
+ let release!: (value: unknown) => void;
564
+ const pending = new Promise(resolve => {
565
+ release = resolve;
566
+ });
567
+ request.mockImplementation((_provider, path) =>
568
+ path === 'branch' ? pending : Promise.resolve({})
569
+ );
570
+ const body = (): HTMLElement =>
571
+ target.node.querySelector('.jp-AiAssistantsPanel-body')!;
572
+ const live = target.node.querySelector(
573
+ '.jp-AiAssistantsPanel-srOnly'
574
+ ) as HTMLElement;
575
+ let call: Promise<void> | undefined;
576
+ try {
577
+ call = (target as any)._branchSession();
578
+ expect(body().getAttribute('aria-busy')).toBe('true');
579
+ expect(live.textContent).toBe('Branching conversation');
580
+
581
+ release({ session_id: 'child' });
582
+ await call;
583
+ expect(body().getAttribute('aria-busy')).toBeNull();
584
+ expect(live.textContent).toBe('');
585
+ } finally {
586
+ // A failed assertion must not leave the mock unresolved for the next
587
+ // test; resolving twice is harmless.
588
+ release({ session_id: 'child' });
589
+ if (call) {
590
+ await call.catch(() => undefined);
591
+ }
592
+ target.dispose();
593
+ request.mockImplementation(() => Promise.resolve({}));
594
+ }
595
+ });
596
+ });
597
+
458
598
  describe('a branch whose terminal launch was refused after the mint', () => {
459
599
  it('says the branch was created and still lists it when the launch is refused', async () => {
460
600
  // The DeepSeek preflight refuses the follow-up launch with a 409 whose
@@ -493,6 +633,15 @@ describe('a branch whose terminal launch was refused after the mint', () => {
493
633
  try {
494
634
  await (target as any)._branchSession();
495
635
 
636
+ // The refused launch still clears the click-time busy state.
637
+ expect(
638
+ (
639
+ target.node.querySelector(
640
+ '.jp-AiAssistantsPanel-busyOverlay'
641
+ ) as HTMLElement
642
+ ).hidden
643
+ ).toBe(true);
644
+
496
645
  // What the user is told: the branch was created, its terminal did not
497
646
  // open, and the refusal's own words - neither sentence that blames the
498
647
  // branch itself.
@@ -1820,6 +1969,14 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
1820
1969
 
1821
1970
  expect(opened).toHaveBeenCalledTimes(1);
1822
1971
  expect(moves(panel)).toEqual([]);
1972
+ // The failed open clears the click-time busy state.
1973
+ expect(
1974
+ (
1975
+ panel.node.querySelector(
1976
+ '.jp-AiAssistantsPanel-busyOverlay'
1977
+ ) as HTMLElement
1978
+ ).hidden
1979
+ ).toBe(true);
1823
1980
  // One notification, and it is the one the failed open raises itself.
1824
1981
  expect(notified()).toHaveLength(1);
1825
1982
  expect(notified()[0]).toContain('Could not open that conversation');
package/src/core/panel.ts CHANGED
@@ -467,6 +467,29 @@ export class AssistantSessionsPanel extends Widget {
467
467
  'jp-aiAssistants-spinner jp-AiAssistantsPanel-loadingSpinner';
468
468
  loading.appendChild(loadingSpinner);
469
469
 
470
+ // Click-time busy overlay: raised synchronously when a row is opened or
471
+ // branched, before the terminal probe or the branch request, so the click
472
+ // never reads as ignored (DEF-PANE-318). Unlike the refresh veil it takes
473
+ // no pointer events and no focus: the context menu that launched a branch
474
+ // stays open until its command runs, and a focus change would close it.
475
+ const busy = document.createElement('div');
476
+ busy.className = 'jp-AiAssistantsPanel-busyOverlay';
477
+ busy.hidden = true;
478
+ const busySpinner = document.createElement('div');
479
+ busySpinner.className =
480
+ 'jp-aiAssistants-spinner jp-AiAssistantsPanel-busySpinner';
481
+ busySpinner.setAttribute('aria-hidden', 'true');
482
+ busy.appendChild(busySpinner);
483
+ // The spinner is decoration; a screen-reader user hears the action
484
+ // instead. The region sits on the root, always rendered and outside the
485
+ // overlay and the aria-busy body: a live region created inside a
486
+ // display:none container, or one whose update lands in an aria-busy
487
+ // subtree, can be withheld by assistive technology.
488
+ const busyText = document.createElement('div');
489
+ busyText.className = 'jp-AiAssistantsPanel-srOnly';
490
+ busyText.setAttribute('role', 'status');
491
+ busyText.setAttribute('aria-live', 'polite');
492
+
470
493
  // Inline error strip - a failed poll reports here and keeps retrying,
471
494
  // rather than tearing the panel down or raising a modal that would also
472
495
  // interrupt the other providers' panels.
@@ -482,12 +505,46 @@ export class AssistantSessionsPanel extends Widget {
482
505
  root.appendChild(error);
483
506
  root.appendChild(body);
484
507
  root.appendChild(loading);
508
+ root.appendChild(busy);
509
+ root.appendChild(busyText);
485
510
 
486
511
  this._bodyEl = body;
487
512
  this._loadingEl = loading;
513
+ this._busyEl = busy;
514
+ this._busyTextEl = busyText;
488
515
  this._errorEl = error;
489
516
  }
490
517
 
518
+ /** Raise the click-time busy overlay. Counted, because an open and a branch
519
+ * can overlap; the overlay clears when the last one ends. */
520
+ private _beginBusy(message: string): void {
521
+ this._busyCount += 1;
522
+ if (this._busyEl) {
523
+ this._busyEl.hidden = false;
524
+ if (this._busyTextEl) {
525
+ this._busyTextEl.textContent = message;
526
+ }
527
+ // The body is what the action changes; the root stays outside the
528
+ // busy subtree so the live region above is not withheld.
529
+ if (this._bodyEl) {
530
+ this._bodyEl.setAttribute('aria-busy', 'true');
531
+ }
532
+ }
533
+ }
534
+
535
+ private _endBusy(): void {
536
+ this._busyCount = Math.max(0, this._busyCount - 1);
537
+ if (this._busyCount === 0 && this._busyEl) {
538
+ this._busyEl.hidden = true;
539
+ if (this._busyTextEl) {
540
+ this._busyTextEl.textContent = '';
541
+ }
542
+ if (this._bodyEl) {
543
+ this._bodyEl.removeAttribute('aria-busy');
544
+ }
545
+ }
546
+ }
547
+
491
548
  /** Show / hide the filter input. Hiding also clears the active filter, so
492
549
  * the user never returns to an "invisible" filter narrowing the rows. */
493
550
  private _toggleFilterBar(): void {
@@ -885,6 +942,9 @@ export class AssistantSessionsPanel extends Widget {
885
942
  force?: string,
886
943
  sessionId?: string
887
944
  ): Promise<void> {
945
+ // The click's own feedback, raised before the terminal walk and the
946
+ // launch - both can take seconds (DEF-PANE-318).
947
+ this._beginBusy(this._trans.__('Opening conversation'));
888
948
  try {
889
949
  await this._terminals.openSession(
890
950
  session,
@@ -905,6 +965,7 @@ export class AssistantSessionsPanel extends Widget {
905
965
  this._notifyLaunchError('Could not open that conversation', err);
906
966
  }
907
967
  } finally {
968
+ this._endBusy();
908
969
  // Reuse or fresh launch, either way the picture changed. Pull fresh
909
970
  // state; a poll tick retries if this one fails.
910
971
  void this._fetch().catch(() => undefined);
@@ -1159,7 +1220,15 @@ export class AssistantSessionsPanel extends Widget {
1159
1220
  if (!session) {
1160
1221
  return;
1161
1222
  }
1162
- await this._branchByServerCopy(session, undefined, force);
1223
+ // Raised on the click: the branch POST mints the fork server-side (for
1224
+ // Claude and Codex by running their own CLI) and can take seconds
1225
+ // (DEF-PANE-318).
1226
+ this._beginBusy(this._trans.__('Branching conversation'));
1227
+ try {
1228
+ await this._branchByServerCopy(session, undefined, force);
1229
+ } finally {
1230
+ this._endBusy();
1231
+ }
1163
1232
  }
1164
1233
 
1165
1234
  /** The server mints the new id and answers with it before the terminal
@@ -2785,6 +2854,9 @@ export class AssistantSessionsPanel extends Widget {
2785
2854
  private readonly _removingPaths: Set<string> = new Set();
2786
2855
  private _bodyEl!: HTMLDivElement;
2787
2856
  private _loadingEl: HTMLElement | null = null;
2857
+ private _busyEl: HTMLElement | null = null;
2858
+ private _busyTextEl: HTMLElement | null = null;
2859
+ private _busyCount = 0;
2788
2860
  private _errorEl: HTMLElement | null = null;
2789
2861
  private _refreshBtn: HTMLButtonElement | null = null;
2790
2862
  private _filterBtn: HTMLButtonElement | null = null;
package/style/base.css CHANGED
@@ -559,6 +559,13 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
559
559
  animation: none;
560
560
  opacity: 0.5;
561
561
  }
562
+
563
+ /* The click-time busy spinner is the only thing on screen while a click is
564
+ being handled, so it keeps full opacity: dimmed, its brand arc measures
565
+ 1.74:1 on the light panel, below the 3:1 minimum for a non-text cue. */
566
+ .jp-AiAssistantsPanel-busySpinner {
567
+ opacity: 1;
568
+ }
562
569
  }
563
570
 
564
571
  /* Full-panel veil shown only during an explicit refresh (refresh button or
@@ -581,6 +588,26 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
581
588
  border-width: 3px;
582
589
  }
583
590
 
591
+ /* Click-time busy overlay (DEF-PANE-318): raised on the click, before the
592
+ terminal probe or the branch request. No pointer events and no focus - the
593
+ context menu that launched a branch stays open until its command runs, and
594
+ a focus change would close it. */
595
+ .jp-AiAssistantsPanel-busyOverlay:not([hidden]) {
596
+ position: absolute;
597
+ inset: 0;
598
+ z-index: 9;
599
+ display: flex;
600
+ align-items: center;
601
+ justify-content: center;
602
+ pointer-events: none;
603
+ }
604
+
605
+ .jp-AiAssistantsPanel-busySpinner {
606
+ width: 24px;
607
+ height: 24px;
608
+ border-width: 3px;
609
+ }
610
+
584
611
  .jp-AiAssistantsPanel-launchOverlay {
585
612
  display: flex;
586
613
  flex-direction: column;
@@ -740,7 +767,7 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
740
767
  border-radius: var(--aica-radius-lg);
741
768
  }
742
769
 
743
- /* Visually hidden polite live region (delete announcements). */
770
+ /* Visually hidden polite live region (polite announcements). */
744
771
  .jp-AiAssistantsPanel-srOnly {
745
772
  position: absolute;
746
773
  width: 1px;