jupyterlab_ai_code_assistants_extension 1.2.105 → 1.2.115

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/README.md CHANGED
@@ -76,6 +76,23 @@ pip install jupyterlab_ai_code_assistants_extension
76
76
  > [!WARNING]
77
77
  > `package.json` pins `webpack: 5.106.0` and `chalk: 4.1.2` in both `resolutions` and `overrides`. Do not remove these. webpack `>= 5.106.1` changed its module-federation share identifier format and crashes the unmaintained `license-webpack-plugin` (`split('=')[1].trim()`) that `@jupyterlab/builder` injects into every production build; the duplicate `chalk@2.4.2` pulled by `duplicate-package-checker-webpack-plugin` crashes on Node 24+ in the build-isolation install. Without the pins, `make publish` and CI fail on `python -m build`.
78
78
 
79
+ ## Agent skill
80
+
81
+ The wheel ships a session-operations skill for coding agents at `share/jupyter/agents/skills/jupyterlab-ai-code-assistants-extension/SKILL.md`. It covers finding, moving, splitting, renaming and promoting sessions, copying a session into another project under a fresh id, the task-list environment variables, and running Claude Code against Kimi K3 or DeepSeek.
82
+
83
+ After `pip install`, with the Python that runs the lab:
84
+
85
+ ```bash
86
+ mkdir -p ~/.agents/skills
87
+ ln -s "$(python -c 'import sys; print(sys.prefix)')/share/jupyter/agents/skills/jupyterlab-ai-code-assistants-extension" ~/.agents/skills/jupyterlab-ai-code-assistants-extension
88
+ ```
89
+
90
+ From a clone:
91
+
92
+ ```bash
93
+ ln -s "$PWD/.agents/skills/jupyterlab-ai-code-assistants-extension" ~/.claude/skills/jupyterlab-ai-code-assistants-extension
94
+ ```
95
+
79
96
  ## Migrating from the standalone extensions
80
97
 
81
98
  This package supersedes `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension`. On first run it carries their settings and favourites over automatically, and it never replaces a setting you have already saved in this extension; uninstall the standalones once you are ready:
@@ -45,10 +45,14 @@ 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. */
48
+ /** Raise the click-time busy overlay. Each action gets a token, because an
49
+ * open and a branch can overlap; the overlay clears when the last one ends,
50
+ * and the label names the newest action still running. */
50
51
  private _beginBusy;
51
52
  private _endBusy;
53
+ /** Name the newest still-running action in the visible label and the live
54
+ * region, so neither keeps naming an action that ended. */
55
+ private _paintBusyMessage;
52
56
  /** Show / hide the filter input. Hiding also clears the active filter, so
53
57
  * the user never returns to an "invisible" filter narrowing the rows. */
54
58
  private _toggleFilterBar;
@@ -90,9 +94,9 @@ export declare class AssistantSessionsPanel extends Widget {
90
94
  private _logError;
91
95
  private _fetch;
92
96
  private _toggleFavourite;
93
- /** The warning a permanent deletion earns and a trashed one does not - a
94
- * trashed conversation is recoverable, and the popup one click away says
95
- * so; the same sentence on both would be false half the time. */
97
+ /** The warning a deletion earns: with the trash setting on, a refused move
98
+ * may remove the conversations permanently or leave them in place, so
99
+ * neither the verb nor the note may promise one outcome. */
96
100
  private get _irreversibleNote();
97
101
  private get _disposalVerb();
98
102
  private _removeProject;
@@ -307,8 +311,10 @@ export declare class AssistantSessionsPanel extends Widget {
307
311
  private _bodyEl;
308
312
  private _loadingEl;
309
313
  private _busyEl;
314
+ private _busyLabelEl;
310
315
  private _busyTextEl;
311
- private _busyCount;
316
+ private _busySeq;
317
+ private _busyMessages;
312
318
  private _errorEl;
313
319
  private _refreshBtn;
314
320
  private _filterBtn;
package/lib/core/panel.js CHANGED
@@ -113,8 +113,10 @@ export class AssistantSessionsPanel extends Widget {
113
113
  this._removingPaths = new Set();
114
114
  this._loadingEl = null;
115
115
  this._busyEl = null;
116
+ this._busyLabelEl = null;
116
117
  this._busyTextEl = null;
117
- this._busyCount = 0;
118
+ this._busySeq = 0;
119
+ this._busyMessages = new Map();
118
120
  this._errorEl = null;
119
121
  this._refreshBtn = null;
120
122
  this._filterBtn = null;
@@ -414,6 +416,14 @@ export class AssistantSessionsPanel extends Widget {
414
416
  'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
415
417
  busySpinner.setAttribute('aria-hidden', 'true');
416
418
  busy.appendChild(busySpinner);
419
+ // The action, named in sight: under a reduced-motion preference the ring
420
+ // is held still, so the label carries the busy state with no animation.
421
+ // Hidden from assistive technology because the live region below
422
+ // announces the same words once.
423
+ const busyLabel = document.createElement('div');
424
+ busyLabel.className = 'jp-AiAssistants-busyLabel';
425
+ busyLabel.setAttribute('aria-hidden', 'true');
426
+ busy.appendChild(busyLabel);
417
427
  // The spinner is decoration; a screen-reader user hears the action
418
428
  // instead. The region sits on the root, always rendered and outside the
419
429
  // overlay and the aria-busy body: a live region created inside a
@@ -444,29 +454,40 @@ export class AssistantSessionsPanel extends Widget {
444
454
  this._bodyEl = body;
445
455
  this._loadingEl = loading;
446
456
  this._busyEl = busy;
457
+ this._busyLabelEl = busyLabel;
447
458
  this._busyTextEl = busyText;
448
459
  this._errorEl = error;
449
460
  }
450
- /** Raise the click-time busy overlay. Counted, because an open and a branch
451
- * can overlap; the overlay clears when the last one ends. */
461
+ /** Raise the click-time busy overlay. Each action gets a token, because an
462
+ * open and a branch can overlap; the overlay clears when the last one ends,
463
+ * and the label names the newest action still running. */
452
464
  _beginBusy(message) {
453
- this._busyCount += 1;
465
+ const token = ++this._busySeq;
466
+ this._busyMessages.set(token, message);
454
467
  if (this._busyEl) {
455
468
  this._busyEl.hidden = false;
456
- if (this._busyTextEl) {
457
- this._busyTextEl.textContent = message;
458
- }
469
+ this._paintBusyMessage();
459
470
  // The body is what the action changes; the root stays outside the
460
471
  // busy subtree so the live region above is not withheld.
461
472
  if (this._bodyEl) {
462
473
  this._bodyEl.setAttribute('aria-busy', 'true');
463
474
  }
464
475
  }
476
+ return token;
465
477
  }
466
- _endBusy() {
467
- this._busyCount = Math.max(0, this._busyCount - 1);
468
- if (this._busyCount === 0 && this._busyEl) {
478
+ _endBusy(token) {
479
+ this._busyMessages.delete(token);
480
+ if (this._busyMessages.size > 0) {
481
+ // Another action is still running; neither the visible label nor the
482
+ // live region may keep naming the one that just ended.
483
+ this._paintBusyMessage();
484
+ return;
485
+ }
486
+ if (this._busyEl) {
469
487
  this._busyEl.hidden = true;
488
+ if (this._busyLabelEl) {
489
+ this._busyLabelEl.textContent = '';
490
+ }
470
491
  if (this._busyTextEl) {
471
492
  this._busyTextEl.textContent = '';
472
493
  }
@@ -475,6 +496,20 @@ export class AssistantSessionsPanel extends Widget {
475
496
  }
476
497
  }
477
498
  }
499
+ /** Name the newest still-running action in the visible label and the live
500
+ * region, so neither keeps naming an action that ended. */
501
+ _paintBusyMessage() {
502
+ let message = '';
503
+ for (const active of this._busyMessages.values()) {
504
+ message = active;
505
+ }
506
+ if (this._busyLabelEl) {
507
+ this._busyLabelEl.textContent = message;
508
+ }
509
+ if (this._busyTextEl) {
510
+ this._busyTextEl.textContent = message;
511
+ }
512
+ }
478
513
  /** Show / hide the filter input. Hiding also clears the active filter, so
479
514
  * the user never returns to an "invisible" filter narrowing the rows. */
480
515
  _toggleFilterBar() {
@@ -674,14 +709,18 @@ export class AssistantSessionsPanel extends Widget {
674
709
  this._showActionError('Could not toggle favorite - try again.', err);
675
710
  }
676
711
  }
677
- /** The warning a permanent deletion earns and a trashed one does not - a
678
- * trashed conversation is recoverable, and the popup one click away says
679
- * so; the same sentence on both would be false half the time. */
712
+ /** The warning a deletion earns: with the trash setting on, a refused move
713
+ * may remove the conversations permanently or leave them in place, so
714
+ * neither the verb nor the note may promise one outcome. */
680
715
  get _irreversibleNote() {
681
- return this._deleteToTrash ? '' : ' This cannot be undone.';
716
+ return this._deleteToTrash
717
+ ? ' A permanent removal cannot be undone.'
718
+ : ' This cannot be undone.';
682
719
  }
683
720
  get _disposalVerb() {
684
- return this._deleteToTrash ? 'moved to trash' : 'deleted permanently';
721
+ return this._deleteToTrash
722
+ ? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
723
+ : 'deleted permanently';
685
724
  }
686
725
  async _removeProject(session) {
687
726
  var _a;
@@ -816,7 +855,7 @@ export class AssistantSessionsPanel extends Widget {
816
855
  async _openSession(session, force, sessionId) {
817
856
  // The click's own feedback, raised before the terminal walk and the
818
857
  // launch - both can take seconds (DEF-PANE-318).
819
- this._beginBusy(this._trans.__('Opening conversation'));
858
+ const busy = this._beginBusy(this._trans.__('Opening conversation'));
820
859
  try {
821
860
  await this._terminals.openSession(session, this._launchMode(force), sessionId);
822
861
  // Not awaited: the open has succeeded, and the sync is not part of it.
@@ -831,7 +870,7 @@ export class AssistantSessionsPanel extends Widget {
831
870
  }
832
871
  }
833
872
  finally {
834
- this._endBusy();
873
+ this._endBusy(busy);
835
874
  // Reuse or fresh launch, either way the picture changed. Pull fresh
836
875
  // state; a poll tick retries if this one fails.
837
876
  void this._fetch().catch(() => undefined);
@@ -1063,12 +1102,12 @@ export class AssistantSessionsPanel extends Widget {
1063
1102
  // Raised on the click: the branch POST mints the fork server-side (for
1064
1103
  // Claude and Codex by running their own CLI) and can take seconds
1065
1104
  // (DEF-PANE-318).
1066
- this._beginBusy(this._trans.__('Branching conversation'));
1105
+ const busy = this._beginBusy(this._trans.__('Branching conversation'));
1067
1106
  try {
1068
1107
  await this._branchByServerCopy(session, undefined, force);
1069
1108
  }
1070
1109
  finally {
1071
- this._endBusy();
1110
+ this._endBusy(busy);
1072
1111
  }
1073
1112
  }
1074
1113
  /** The server mints the new id and answers with it before the terminal
package/lib/core/popup.js CHANGED
@@ -44,9 +44,9 @@ export function showManageSessionsPopup(options) {
44
44
  let items = [...options.branches];
45
45
  const selected = new Set();
46
46
  let deleting = false; // guards the async delete against double invocation
47
- // Permanent deletion arms first and executes on the second click; a trashed
48
- // deletion is recoverable and goes straight through.
49
- const armsFirst = !options.deleteToTrash;
47
+ // Every deletion arms first and executes on the second click: the trash
48
+ // setting does not promise the system will accept the move, and a fallback
49
+ // removal is permanent.
50
50
  let armed = false;
51
51
  let armTimer = 0;
52
52
  const body = document.createElement('div');
@@ -87,7 +87,7 @@ export function showManageSessionsPopup(options) {
87
87
  deleteBtn.type = 'button';
88
88
  deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
89
89
  deleteBtn.title = options.deleteToTrash
90
- ? 'Deleted conversations move to the trash (recoverable)'
90
+ ? 'Deleted conversations move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
91
91
  : 'Deleted conversations are removed permanently';
92
92
  footer.appendChild(deleteBtn);
93
93
  // Plain visual counter (selection, or the last action), beside the button
@@ -336,12 +336,12 @@ export function showManageSessionsPopup(options) {
336
336
  return;
337
337
  }
338
338
  // No confirmation DIALOG here: a second Lumino dialog stacked on this popup
339
- // renders detached. A permanent deletion still confirms, in the footer
340
- // itself - the first click arms the button, the second executes, and any
341
- // selection change or four seconds of hesitation disarms it. A trashed
342
- // deletion is recoverable, so it keeps going through on one click, with
343
- // the live "N deleted" status and a toast from the caller on failure.
344
- if (armsFirst && !armed) {
339
+ // renders detached. The deletion confirms in the footer itself - the first
340
+ // click arms the button, the second executes, and any selection change or
341
+ // four seconds of hesitation disarms it. Every deletion arms, because the
342
+ // trash setting does not promise the system will accept the move, and a
343
+ // refused move may remove the conversation permanently or leave it.
344
+ if (!armed) {
345
345
  armed = true;
346
346
  window.clearTimeout(armTimer);
347
347
  armTimer = window.setTimeout(() => {
@@ -384,9 +384,9 @@ export function showManageSessionsPopup(options) {
384
384
  (_a = options.onSync) === null || _a === void 0 ? void 0 : _a.call(options, items);
385
385
  render();
386
386
  updateControls();
387
- const moved = options.deleteToTrash
388
- ? `${deleted} moved to trash`
389
- : `${deleted} deleted`;
387
+ // One wording for both outcomes: the client cannot know whether the
388
+ // trash accepted the move, so it does not claim it did.
389
+ const moved = `${deleted} deleted`;
390
390
  selCount.textContent = moved;
391
391
  selCount.classList.add('jp-mod-deleted');
392
392
  // Clear then re-set on the next tick so an identical count (two single
package/lib/index.js CHANGED
@@ -174,9 +174,9 @@ const plugin = {
174
174
  descriptor: module.descriptor,
175
175
  hooks: module.hooks,
176
176
  rootDir: (_a = status === null || status === void 0 ? void 0 : status.root_dir) !== null && _a !== void 0 ? _a : '',
177
- // Unknown reads as "not trash": the popup then arms its Delete
178
- // button before it executes, which is the only confirmation a
179
- // permanent bulk delete has (DEF-135).
177
+ // Unknown reads as "not trash", so the dialogs and the popup
178
+ // present the permanent-delete wording rather than promising a
179
+ // recoverable move the server never confirmed (DEF-135).
180
180
  deleteToTrash: status ? status.delete_to_trash !== false : false,
181
181
  terminalTracker,
182
182
  fileBrowser,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.105",
3
+ "version": "1.2.115",
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",
@@ -213,13 +213,16 @@ describe('DEF-51 - the destructive dialogs open with Cancel under the keyboard',
213
213
  expect(request).not.toHaveBeenCalled();
214
214
  });
215
215
 
216
- it('says a deletion cannot be undone only when it is permanent', async () => {
217
- // Default: trash is on, and a trashed conversation is recoverable.
216
+ it('says a deletion may be permanent when the trash might refuse', async () => {
217
+ // Default: trash is on, but the server falls back to a permanent removal
218
+ // when no trash accepts the move.
218
219
  let done = (panel as any)._removeProject(session()) as Promise<void>;
219
220
  await openButtons();
220
221
  let body = document.querySelector('.jp-Dialog-body')!.textContent ?? '';
221
- expect(body).toContain('moved to trash');
222
- expect(body).not.toContain('cannot be undone');
222
+ expect(body).toContain(
223
+ 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
224
+ );
225
+ expect(body).toContain('A permanent removal cannot be undone');
223
226
  pressEnter();
224
227
  await done;
225
228
 
@@ -562,6 +565,31 @@ describe('click-time busy state (DEF-PANE-318)', () => {
562
565
  target.dispose();
563
566
  });
564
567
 
568
+ it('names the action still running when an overlapping action ends first', () => {
569
+ const target = makePanel(DESCRIPTOR);
570
+ const label = (): HTMLElement =>
571
+ shellNodeOf(target).querySelector('.jp-AiAssistants-busyLabel')!;
572
+ const live = (): HTMLElement =>
573
+ target.node.querySelector('.jp-AiAssistantsPanel-srOnly')!;
574
+
575
+ const opening = (target as any)._beginBusy('Opening conversation');
576
+ const branching = (target as any)._beginBusy('Branching conversation');
577
+ expect(label().textContent).toBe('Branching conversation');
578
+ expect(live().textContent).toBe('Branching conversation');
579
+
580
+ // The later-started action ends first: the label and the live region must
581
+ // return to the one that is still running, not keep naming the finished
582
+ // action.
583
+ (target as any)._endBusy(branching);
584
+ expect(label().textContent).toBe('Opening conversation');
585
+ expect(live().textContent).toBe('Opening conversation');
586
+
587
+ (target as any)._endBusy(opening);
588
+ expect(label().textContent).toBe('');
589
+ expect(live().textContent).toBe('');
590
+ target.dispose();
591
+ });
592
+
565
593
  it('announces the branch action while the request is pending', async () => {
566
594
  const target = makePanel(DESCRIPTOR);
567
595
  const parent = session({ name: 'analysis', name_source: 'session' });
@@ -158,9 +158,22 @@ describe('Manage Sessions popup Delete button (DEF-PANE-307)', () => {
158
158
  expect(deleteButton().textContent).toBe('Delete (1)');
159
159
  });
160
160
 
161
- it('deletes without closing the dialog', async () => {
161
+ it('names the trash outcome without promising recoverability', () => {
162
+ expect(deleteButton().title).toContain(
163
+ 'a refused move may remove them permanently or leave them in place'
164
+ );
165
+ });
166
+
167
+ it('arms, then deletes without closing the dialog', async () => {
162
168
  checkbox(rows()[1]).click();
163
- deleteButton().click();
169
+ const del = deleteButton();
170
+ // The trash setting does not promise the system will accept the move, so
171
+ // the first click arms rather than deletes.
172
+ del.click();
173
+ expect(onDelete).not.toHaveBeenCalled();
174
+ expect(del.textContent).toBe('Confirm delete 1');
175
+
176
+ del.click();
164
177
  await Promise.resolve();
165
178
  expect(onDelete).toHaveBeenCalledWith([FIRST]);
166
179
  expect(document.querySelector('.jp-Dialog')).not.toBeNull();
package/src/core/panel.ts CHANGED
@@ -488,6 +488,14 @@ export class AssistantSessionsPanel extends Widget {
488
488
  'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
489
489
  busySpinner.setAttribute('aria-hidden', 'true');
490
490
  busy.appendChild(busySpinner);
491
+ // The action, named in sight: under a reduced-motion preference the ring
492
+ // is held still, so the label carries the busy state with no animation.
493
+ // Hidden from assistive technology because the live region below
494
+ // announces the same words once.
495
+ const busyLabel = document.createElement('div');
496
+ busyLabel.className = 'jp-AiAssistants-busyLabel';
497
+ busyLabel.setAttribute('aria-hidden', 'true');
498
+ busy.appendChild(busyLabel);
491
499
  // The spinner is decoration; a screen-reader user hears the action
492
500
  // instead. The region sits on the root, always rendered and outside the
493
501
  // overlay and the aria-busy body: a live region created inside a
@@ -521,31 +529,42 @@ export class AssistantSessionsPanel extends Widget {
521
529
  this._bodyEl = body;
522
530
  this._loadingEl = loading;
523
531
  this._busyEl = busy;
532
+ this._busyLabelEl = busyLabel;
524
533
  this._busyTextEl = busyText;
525
534
  this._errorEl = error;
526
535
  }
527
536
 
528
- /** Raise the click-time busy overlay. Counted, because an open and a branch
529
- * can overlap; the overlay clears when the last one ends. */
530
- private _beginBusy(message: string): void {
531
- this._busyCount += 1;
537
+ /** Raise the click-time busy overlay. Each action gets a token, because an
538
+ * open and a branch can overlap; the overlay clears when the last one ends,
539
+ * and the label names the newest action still running. */
540
+ private _beginBusy(message: string): number {
541
+ const token = ++this._busySeq;
542
+ this._busyMessages.set(token, message);
532
543
  if (this._busyEl) {
533
544
  this._busyEl.hidden = false;
534
- if (this._busyTextEl) {
535
- this._busyTextEl.textContent = message;
536
- }
545
+ this._paintBusyMessage();
537
546
  // The body is what the action changes; the root stays outside the
538
547
  // busy subtree so the live region above is not withheld.
539
548
  if (this._bodyEl) {
540
549
  this._bodyEl.setAttribute('aria-busy', 'true');
541
550
  }
542
551
  }
552
+ return token;
543
553
  }
544
554
 
545
- private _endBusy(): void {
546
- this._busyCount = Math.max(0, this._busyCount - 1);
547
- if (this._busyCount === 0 && this._busyEl) {
555
+ private _endBusy(token: number): void {
556
+ this._busyMessages.delete(token);
557
+ if (this._busyMessages.size > 0) {
558
+ // Another action is still running; neither the visible label nor the
559
+ // live region may keep naming the one that just ended.
560
+ this._paintBusyMessage();
561
+ return;
562
+ }
563
+ if (this._busyEl) {
548
564
  this._busyEl.hidden = true;
565
+ if (this._busyLabelEl) {
566
+ this._busyLabelEl.textContent = '';
567
+ }
549
568
  if (this._busyTextEl) {
550
569
  this._busyTextEl.textContent = '';
551
570
  }
@@ -555,6 +574,21 @@ export class AssistantSessionsPanel extends Widget {
555
574
  }
556
575
  }
557
576
 
577
+ /** Name the newest still-running action in the visible label and the live
578
+ * region, so neither keeps naming an action that ended. */
579
+ private _paintBusyMessage(): void {
580
+ let message = '';
581
+ for (const active of this._busyMessages.values()) {
582
+ message = active;
583
+ }
584
+ if (this._busyLabelEl) {
585
+ this._busyLabelEl.textContent = message;
586
+ }
587
+ if (this._busyTextEl) {
588
+ this._busyTextEl.textContent = message;
589
+ }
590
+ }
591
+
558
592
  /** Show / hide the filter input. Hiding also clears the active filter, so
559
593
  * the user never returns to an "invisible" filter narrowing the rows. */
560
594
  private _toggleFilterBar(): void {
@@ -781,15 +815,19 @@ export class AssistantSessionsPanel extends Widget {
781
815
  }
782
816
  }
783
817
 
784
- /** The warning a permanent deletion earns and a trashed one does not - a
785
- * trashed conversation is recoverable, and the popup one click away says
786
- * so; the same sentence on both would be false half the time. */
818
+ /** The warning a deletion earns: with the trash setting on, a refused move
819
+ * may remove the conversations permanently or leave them in place, so
820
+ * neither the verb nor the note may promise one outcome. */
787
821
  private get _irreversibleNote(): string {
788
- return this._deleteToTrash ? '' : ' This cannot be undone.';
822
+ return this._deleteToTrash
823
+ ? ' A permanent removal cannot be undone.'
824
+ : ' This cannot be undone.';
789
825
  }
790
826
 
791
827
  private get _disposalVerb(): string {
792
- return this._deleteToTrash ? 'moved to trash' : 'deleted permanently';
828
+ return this._deleteToTrash
829
+ ? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
830
+ : 'deleted permanently';
793
831
  }
794
832
 
795
833
  private async _removeProject(session: ISession): Promise<void> {
@@ -954,7 +992,7 @@ export class AssistantSessionsPanel extends Widget {
954
992
  ): Promise<void> {
955
993
  // The click's own feedback, raised before the terminal walk and the
956
994
  // launch - both can take seconds (DEF-PANE-318).
957
- this._beginBusy(this._trans.__('Opening conversation'));
995
+ const busy = this._beginBusy(this._trans.__('Opening conversation'));
958
996
  try {
959
997
  await this._terminals.openSession(
960
998
  session,
@@ -975,7 +1013,7 @@ export class AssistantSessionsPanel extends Widget {
975
1013
  this._notifyLaunchError('Could not open that conversation', err);
976
1014
  }
977
1015
  } finally {
978
- this._endBusy();
1016
+ this._endBusy(busy);
979
1017
  // Reuse or fresh launch, either way the picture changed. Pull fresh
980
1018
  // state; a poll tick retries if this one fails.
981
1019
  void this._fetch().catch(() => undefined);
@@ -1239,11 +1277,11 @@ export class AssistantSessionsPanel extends Widget {
1239
1277
  // Raised on the click: the branch POST mints the fork server-side (for
1240
1278
  // Claude and Codex by running their own CLI) and can take seconds
1241
1279
  // (DEF-PANE-318).
1242
- this._beginBusy(this._trans.__('Branching conversation'));
1280
+ const busy = this._beginBusy(this._trans.__('Branching conversation'));
1243
1281
  try {
1244
1282
  await this._branchByServerCopy(session, undefined, force);
1245
1283
  } finally {
1246
- this._endBusy();
1284
+ this._endBusy(busy);
1247
1285
  }
1248
1286
  }
1249
1287
 
@@ -2858,8 +2896,10 @@ export class AssistantSessionsPanel extends Widget {
2858
2896
  private _bodyEl!: HTMLDivElement;
2859
2897
  private _loadingEl: HTMLElement | null = null;
2860
2898
  private _busyEl: HTMLElement | null = null;
2899
+ private _busyLabelEl: HTMLElement | null = null;
2861
2900
  private _busyTextEl: HTMLElement | null = null;
2862
- private _busyCount = 0;
2901
+ private _busySeq = 0;
2902
+ private _busyMessages = new Map<number, string>();
2863
2903
  private _errorEl: HTMLElement | null = null;
2864
2904
  private _refreshBtn: HTMLButtonElement | null = null;
2865
2905
  private _filterBtn: HTMLButtonElement | null = null;
package/src/core/popup.ts CHANGED
@@ -106,9 +106,9 @@ export function showManageSessionsPopup(
106
106
  let items = [...options.branches];
107
107
  const selected = new Set<string>();
108
108
  let deleting = false; // guards the async delete against double invocation
109
- // Permanent deletion arms first and executes on the second click; a trashed
110
- // deletion is recoverable and goes straight through.
111
- const armsFirst = !options.deleteToTrash;
109
+ // Every deletion arms first and executes on the second click: the trash
110
+ // setting does not promise the system will accept the move, and a fallback
111
+ // removal is permanent.
112
112
  let armed = false;
113
113
  let armTimer = 0;
114
114
 
@@ -154,7 +154,7 @@ export function showManageSessionsPopup(
154
154
  deleteBtn.type = 'button';
155
155
  deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
156
156
  deleteBtn.title = options.deleteToTrash
157
- ? 'Deleted conversations move to the trash (recoverable)'
157
+ ? 'Deleted conversations move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
158
158
  : 'Deleted conversations are removed permanently';
159
159
  footer.appendChild(deleteBtn);
160
160
  // Plain visual counter (selection, or the last action), beside the button
@@ -421,12 +421,12 @@ export function showManageSessionsPopup(
421
421
  return;
422
422
  }
423
423
  // No confirmation DIALOG here: a second Lumino dialog stacked on this popup
424
- // renders detached. A permanent deletion still confirms, in the footer
425
- // itself - the first click arms the button, the second executes, and any
426
- // selection change or four seconds of hesitation disarms it. A trashed
427
- // deletion is recoverable, so it keeps going through on one click, with
428
- // the live "N deleted" status and a toast from the caller on failure.
429
- if (armsFirst && !armed) {
424
+ // renders detached. The deletion confirms in the footer itself - the first
425
+ // click arms the button, the second executes, and any selection change or
426
+ // four seconds of hesitation disarms it. Every deletion arms, because the
427
+ // trash setting does not promise the system will accept the move, and a
428
+ // refused move may remove the conversation permanently or leave it.
429
+ if (!armed) {
430
430
  armed = true;
431
431
  window.clearTimeout(armTimer);
432
432
  armTimer = window.setTimeout(() => {
@@ -467,9 +467,9 @@ export function showManageSessionsPopup(
467
467
  options.onSync?.(items);
468
468
  render();
469
469
  updateControls();
470
- const moved = options.deleteToTrash
471
- ? `${deleted} moved to trash`
472
- : `${deleted} deleted`;
470
+ // One wording for both outcomes: the client cannot know whether the
471
+ // trash accepted the move, so it does not claim it did.
472
+ const moved = `${deleted} deleted`;
473
473
  selCount.textContent = moved;
474
474
  selCount.classList.add('jp-mod-deleted');
475
475
  // Clear then re-set on the next tick so an identical count (two single
package/src/index.ts CHANGED
@@ -250,9 +250,9 @@ const plugin: JupyterFrontEndPlugin<void> = {
250
250
  descriptor: module.descriptor,
251
251
  hooks: module.hooks,
252
252
  rootDir: status?.root_dir ?? '',
253
- // Unknown reads as "not trash": the popup then arms its Delete
254
- // button before it executes, which is the only confirmation a
255
- // permanent bulk delete has (DEF-135).
253
+ // Unknown reads as "not trash", so the dialogs and the popup
254
+ // present the permanent-delete wording rather than promising a
255
+ // recoverable move the server never confirmed (DEF-135).
256
256
  deleteToTrash: status ? status.delete_to_trash !== false : false,
257
257
  terminalTracker,
258
258
  fileBrowser,
package/style/base.css CHANGED
@@ -562,7 +562,9 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
562
562
 
563
563
  /* The click-time busy spinner is the only thing on screen while a click is
564
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. */
565
+ 1.74:1 on the light panel, below the 3:1 minimum for a non-text cue. Held
566
+ still it would read as idle, so the overlay names the action in a visible
567
+ label beside it; the label, not an animation, carries the busy state. */
566
568
  .jp-AiAssistants-busySpinner {
567
569
  opacity: 1;
568
570
  }
@@ -601,6 +603,7 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
601
603
  display: flex;
602
604
  align-items: center;
603
605
  justify-content: center;
606
+ gap: 12px;
604
607
  pointer-events: none;
605
608
  }
606
609
 
@@ -610,6 +613,15 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
610
613
  border-width: 3px;
611
614
  }
612
615
 
616
+ .jp-AiAssistants-busyLabel {
617
+ color: var(--jp-ui-font-color1);
618
+ background: var(--jp-layout-color1);
619
+ border: 1px solid var(--jp-border-color2);
620
+ border-radius: 4px;
621
+ padding: 4px 10px;
622
+ font-size: var(--jp-ui-font-size1);
623
+ }
624
+
613
625
  .jp-AiAssistantsPanel-launchOverlay {
614
626
  display: flex;
615
627
  flex-direction: column;