jupyterlab_ai_code_assistants_extension 1.2.105 → 1.2.118

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
@@ -37,7 +37,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
37
37
  - **Branch session** - fork the current conversation into a new named session via the right-click menu; each assistant forks its own way (Claude's native `--fork-session`, Codex's app-server `thread/fork`, server-side copies for Kimi, Gemini and DeepSeek) behind the same menu item
38
38
  - **Rename session** - name a conversation from the right-click menu, and the name is written where the assistant itself reads it back: Claude gets the record its own `/rename` writes, Kimi a custom title, Gemini the chat's summary, DeepSeek its title event. The panel shows the name the assistant actually kept. Codex's name is set through Codex's own app-server, so its CLI lists the same name the panel shows
39
39
  - **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo`, Gemini's YOLO; unsafe variants carry a shield glyph in the launch menus
40
- - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex, Gemini and DeepSeek have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
40
+ - **Coloured terminal tabs** - each session's colour tints its terminal tab via the companion `jupyterlab_colourful_tab_extension` (installed automatically). Claude's own `/color` supplies its default, Kimi derives a stable colour from the conversation id; Codex, Gemini and DeepSeek have no colour of their own until you set one, and a branched session inherits its parent's colour, which it keeps even if the parent's colour later changes or is reset. A conversation cleared with `/clear` keeps its colour too: the replacement inherits it, and a colour the replacement already has is kept. Tinting needs the companion release that reports the colours you pick and lets another extension own a tab - against an older one this extension tints no tabs at all and says so once, and the companion's own right-click colours keep working as they always did
41
41
  - **Your newest colour choice wins** - set a colour on a terminal tab and the extension remembers it for that conversation, so the tab shows it again when you reopen the conversation. In Claude Code, `/color` is the second way to choose, and whichever you used last wins: a colour picked on the tab after `/color` stays, and running `/color` after a pick takes over again, including `/color` with the same colour or a `/color` reset. `Reset Tab Colour (n)` in the session's right-click menu hands back every colour you set by hand on that project's conversations, and appears once there is one to hand back. Clearing the colour on the tab itself releases it too, for the conversation that terminal is running; `Reset Tab Colour` is the way back for a conversation with no tab open. A Kimi or DeepSeek conversation that has never been resumed cannot be tracked yet, so a colour set on its tab is not kept
42
42
  - **Favorites** - star projects you keep coming back to via the right-click menu; favourites from the standalone extensions are migrated on first run
43
43
  - **Remove and clean up** - drop a project's history or a project's extra parallel sessions from the right-click menu, confirmation dialog first; removed files honour JupyterLab's "move files to trash" setting
@@ -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:
@@ -32,7 +32,7 @@ export declare function effectiveColour(sessionId: string | null, source: Colour
32
32
  * with the representative's colour. */
33
33
  export declare function sessionForCwds(cwds: string[], sessions: ISession[]): ISession | null;
34
34
  /**
35
- * Client for one provider's user-set colour store.
35
+ * Client for one provider's stored colour store, hand-set or inherited.
36
36
  *
37
37
  * The store is server-side and per provider (`providers/<id>/colours`), so
38
38
  * colours survive a reload and one assistant's colours can never be read or
@@ -49,7 +49,7 @@ export declare class ColourStore {
49
49
  /** User-set colour for a conversation, from the in-memory cache. */
50
50
  get(sessionId: string | null | undefined): string | null;
51
51
  /** Whether this conversation's colour is the user's own choice rather than
52
- * one it inherited at fork time. Only a hand-set colour is offered for
52
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
53
53
  * release: dropping an inherited one would un-inherit the branch. */
54
54
  isOverride(sessionId: string | null | undefined): boolean;
55
55
  /** Whether a write against this conversation is still unanswered - queued
@@ -94,8 +94,8 @@ export declare class ColourStore {
94
94
  * The ceiling is per request, so K ops queued behind a dead server wait K
95
95
  * times it, not it.
96
96
  *
97
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
98
- * that is waiting on it. */
97
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
98
+ * on a chain that is waiting on it. */
99
99
  private _run;
100
100
  /** Open a write against one or more ids. Counted, not flagged: two writes to
101
101
  * one id can be queued at once, and a flag would let the first to settle
@@ -119,6 +119,22 @@ export declare class ColourStore {
119
119
  * it is checked for the value that was just written. A server that accepted
120
120
  * the request and then failed to persist it would otherwise be believed. */
121
121
  set(sessionId: string, colour: string | null): Promise<boolean>;
122
+ /** Record a colour inherited at `/clear`.
123
+ *
124
+ * The terminal process survives `/clear` and starts reporting a NEW
125
+ * conversation id, so the replacement has no colour of its own. It continues
126
+ * the conversation it replaced, not a new branch, and takes that
127
+ * conversation's effective colour over - whether the colour was picked on
128
+ * the tab or chosen by the assistant. `hand_set` false is what records the
129
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
130
+ * leaves it out of the release affordance exactly as a fork's inherited tint
131
+ * is left out.
132
+ *
133
+ * The cache is reconciled from the answer the same way `set` reconciles this
134
+ * one id: the colour when the answer carries it, and the id in `_overrides`
135
+ * only when the answer names it there. Answers false when the answer does
136
+ * not carry the colour. */
137
+ inherit(sessionId: string, colour: string): Promise<boolean>;
122
138
  /** Drop stored colours - conversations that no longer exist, or overrides
123
139
  * the user released. One request for the whole list, so a release is never
124
140
  * left half applied. Same contract as `set`: the answer is checked for the
@@ -1,8 +1,9 @@
1
1
  // Terminal tab tint per conversation.
2
2
  //
3
3
  // Effective colour, highest wins:
4
- // 1. user-set - the write-back store this module keeps, per provider.
5
- // Only entries the server still holds: it releases a pick
4
+ // 1. stored - the write-back store, hand-set or inherited, that this
5
+ // module keeps per provider. Only entries the server still
6
+ // holds: it releases a pick
6
7
  // that the assistant's own later colour command overtook,
7
8
  // because the newer of the two channels wins (DEF-COLO-306)
8
9
  // 2. native - the conversation's own colour, when the assistant has one
@@ -84,7 +85,7 @@ export function sessionForCwds(cwds, sessions) {
84
85
  return best;
85
86
  }
86
87
  /**
87
- * Client for one provider's user-set colour store.
88
+ * Client for one provider's stored colour store, hand-set or inherited.
88
89
  *
89
90
  * The store is server-side and per provider (`providers/<id>/colours`), so
90
91
  * colours survive a reload and one assistant's colours can never be read or
@@ -117,7 +118,7 @@ export class ColourStore {
117
118
  return (_a = this._colours.get(sessionId)) !== null && _a !== void 0 ? _a : null;
118
119
  }
119
120
  /** Whether this conversation's colour is the user's own choice rather than
120
- * one it inherited at fork time. Only a hand-set colour is offered for
121
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
121
122
  * release: dropping an inherited one would un-inherit the branch. */
122
123
  isOverride(sessionId) {
123
124
  return !!sessionId && this._overrides.has(sessionId);
@@ -188,8 +189,8 @@ export class ColourStore {
188
189
  * The ceiling is per request, so K ops queued behind a dead server wait K
189
190
  * times it, not it.
190
191
  *
191
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
192
- * that is waiting on it. */
192
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
193
+ * on a chain that is waiting on it. */
193
194
  _run(op) {
194
195
  const result = this._chain.then(op);
195
196
  const settled = () => undefined;
@@ -278,6 +279,59 @@ export class ColourStore {
278
279
  }
279
280
  });
280
281
  }
282
+ /** Record a colour inherited at `/clear`.
283
+ *
284
+ * The terminal process survives `/clear` and starts reporting a NEW
285
+ * conversation id, so the replacement has no colour of its own. It continues
286
+ * the conversation it replaced, not a new branch, and takes that
287
+ * conversation's effective colour over - whether the colour was picked on
288
+ * the tab or chosen by the assistant. `hand_set` false is what records the
289
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
290
+ * leaves it out of the release affordance exactly as a fork's inherited tint
291
+ * is left out.
292
+ *
293
+ * The cache is reconciled from the answer the same way `set` reconciles this
294
+ * one id: the colour when the answer carries it, and the id in `_overrides`
295
+ * only when the answer names it there. Answers false when the answer does
296
+ * not carry the colour. */
297
+ async inherit(sessionId, colour) {
298
+ if (!sessionId) {
299
+ return false;
300
+ }
301
+ const ids = [sessionId];
302
+ // Marked before the op is queued, not inside it - see `isPending`.
303
+ this._openWrite(ids);
304
+ return this._run(async () => {
305
+ var _a, _b;
306
+ try {
307
+ const data = await requestProvider(this._providerId, 'colours', this._serverSettings, {
308
+ method: 'POST',
309
+ body: JSON.stringify({
310
+ session_id: sessionId,
311
+ colour,
312
+ hand_set: false
313
+ })
314
+ });
315
+ if (((_a = data.colours) !== null && _a !== void 0 ? _a : {})[sessionId] !== colour) {
316
+ return false;
317
+ }
318
+ this._colours.set(sessionId, colour);
319
+ if (((_b = data.overrides) !== null && _b !== void 0 ? _b : []).includes(sessionId)) {
320
+ this._overrides.add(sessionId);
321
+ }
322
+ else {
323
+ this._overrides.delete(sessionId);
324
+ }
325
+ return true;
326
+ }
327
+ catch (_err) {
328
+ return false;
329
+ }
330
+ finally {
331
+ this._closeWrite(ids);
332
+ }
333
+ });
334
+ }
281
335
  /** Drop stored colours - conversations that no longer exist, or overrides
282
336
  * the user released. One request for the whole list, so a release is never
283
337
  * left half applied. Same contract as `set`: the answer is checked for the
@@ -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
@@ -212,6 +212,7 @@ export declare class TerminalManager {
212
212
  private readonly _serverSettings;
213
213
  private readonly _claims;
214
214
  private readonly _pendingChoices;
215
+ private readonly _lastPainted;
215
216
  private readonly _byProject;
216
217
  private readonly _pending;
217
218
  private _sessions;
@@ -98,6 +98,14 @@ export class TerminalManager {
98
98
  // pass can read the conversation and file it - and while an entry stands, its
99
99
  // terminal is not painted, because painting is what destroys the pick.
100
100
  this._pendingChoices = new Map();
101
+ // The conversation each terminal was last PAINTED from, and the effective
102
+ // colour it was painted. The probe follows the pidfile, so `/clear` answers
103
+ // the same widget with a new conversation id; a difference against this
104
+ // record is what tells an in-place replacement from a terminal that simply
105
+ // started later, and is the colour the replacement inherits. Pruned by
106
+ // `clearColours` and `dispose` so a tab that is no longer drawn leaves no
107
+ // entry behind.
108
+ this._lastPainted = new Map();
101
109
  // Most recent terminal per project, tagged with the conversation it runs so
102
110
  // reuse can tell a project's branches apart.
103
111
  this._byProject = new Map();
@@ -146,6 +154,9 @@ export class TerminalManager {
146
154
  // running. Kept past dispose it would be filed by nothing and would hold a
147
155
  // widget reference for the life of the page.
148
156
  this._pendingChoices.clear();
157
+ // Nothing is painted from here on, so the record would only hold widget
158
+ // ids for the life of the page.
159
+ this._lastPainted.clear();
149
160
  }
150
161
  /**
151
162
  * Open a conversation, reusing an open terminal only when that terminal is
@@ -378,7 +389,7 @@ export class TerminalManager {
378
389
  // terminal whose probe simply failed.
379
390
  const disowned = new Set();
380
391
  await this._eachAssistantTerminal(async (widget, info) => {
381
- var _a, _b, _c, _d, _e, _f, _g, _h;
392
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j;
382
393
  if (!this._colouredTabs ||
383
394
  this._disposed ||
384
395
  this._pass !== generation) {
@@ -446,7 +457,34 @@ export class TerminalManager {
446
457
  const native = (_f = info.colour) !== null && _f !== void 0 ? _f : (sessionId
447
458
  ? ((_h = (_g = this._sessions.find(s => s.session_id === sessionId)) === null || _g === void 0 ? void 0 : _g.colour) !== null && _h !== void 0 ? _h : null)
448
459
  : null);
449
- this._applyColour(widget, effectiveColour(sessionId, this._descriptor.colourSource, this._colours.get(sessionId), native));
460
+ // The probe follows the pidfile, so an in-place `/clear` answers the
461
+ // SAME terminal with a NEW conversation id. The replacement continues
462
+ // the conversation it replaced and takes over the effective colour
463
+ // that one was wearing - a pick made on the tab or a colour the
464
+ // assistant chose. A stored colour or an assistant-chosen colour on
465
+ // the new conversation is its own answer, though: only a bare new id
466
+ // is filled in, so the old colour never overwrites it.
467
+ let colour = effectiveColour(sessionId, this._descriptor.colourSource, this._colours.get(sessionId), native);
468
+ if (sessionId) {
469
+ const last = this._lastPainted.get(widget.id);
470
+ if (last &&
471
+ last.sessionId !== sessionId &&
472
+ last.colour !== null &&
473
+ this._colours.get(sessionId) === null &&
474
+ native === null) {
475
+ await this._colours.inherit(sessionId, last.colour);
476
+ if (!this._colouredTabs ||
477
+ this._disposed ||
478
+ this._pass !== generation) {
479
+ // Colouring went off, the panel went away or a newer pass took
480
+ // over while the write was queued. That pass paints the tabs.
481
+ return;
482
+ }
483
+ colour = (_j = this._colours.get(sessionId)) !== null && _j !== void 0 ? _j : last.colour;
484
+ }
485
+ this._lastPainted.set(widget.id, { sessionId, colour });
486
+ }
487
+ this._applyColour(widget, colour);
450
488
  }, widget => disowned.add(widget.id));
451
489
  if (this._pass === generation) {
452
490
  // Skipped by an overtaken pass: its view of what is recognised and what
@@ -487,6 +525,10 @@ export class TerminalManager {
487
525
  // drawing, which is the same thing `_captureColour` refuses to do while
488
526
  // colouring is off.
489
527
  this._pendingChoices.clear();
528
+ // Every terminal this manager still sees is about to be painted null, so
529
+ // no recorded paint describes what is on screen any more - and a `/clear`
530
+ // that happened while colouring was off must not be inherited from.
531
+ this._lastPainted.clear();
490
532
  const generation = ++this._pass;
491
533
  try {
492
534
  await this._eachAssistantTerminal(async (widget) => {
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.118",
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",
@@ -190,6 +190,32 @@ describe('ColourStore.set', () => {
190
190
  });
191
191
  });
192
192
 
193
+ describe('ColourStore.inherit', () => {
194
+ // The `/clear` path: the replacement conversation takes the effective colour
195
+ // of the one it replaced, but as an INHERITED entry. Only a hand-set colour
196
+ // is offered for release, and releasing this one would strip the branch.
197
+ const store = () => new ColourStore('claude', {} as any);
198
+
199
+ beforeEach(() => request.mockReset());
200
+
201
+ it('records the colour as inherited, not as the user own choice', async () => {
202
+ request.mockResolvedValue({
203
+ colours: { [SESSION_B]: 'sky' },
204
+ overrides: []
205
+ } as any);
206
+ const colours = store();
207
+ expect(await colours.inherit(SESSION_B, 'sky')).toBe(true);
208
+
209
+ expect(JSON.parse((request.mock.calls[0][3] as any).body)).toEqual({
210
+ session_id: SESSION_B,
211
+ colour: 'sky',
212
+ hand_set: false
213
+ });
214
+ expect(colours.get(SESSION_B)).toEqual('sky');
215
+ expect(colours.isOverride(SESSION_B)).toBe(false);
216
+ });
217
+ });
218
+
193
219
  describe('ColourStore origins', () => {
194
220
  // A stored colour is either the user's own choice or one a fork inherited.
195
221
  // Both beat the default; only the first is offered for release, or one click
@@ -525,7 +551,7 @@ describe('sessionForCwds', () => {
525
551
  * unrunnable invariant (see `core-neutrality.spec.ts`): by reading the source.
526
552
  */
527
553
  describe('ColourStore queue re-entrancy', () => {
528
- const QUEUED = ['load', 'set', 'forget'];
554
+ const QUEUED = ['load', 'set', 'forget', 'inherit'];
529
555
 
530
556
  /** The body of one method, by brace matching from its declaration. */
531
557
  const bodyOf = (source: string, name: string): string => {
@@ -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();
@@ -992,6 +992,102 @@ describe('the paint pass', () => {
992
992
  });
993
993
  });
994
994
 
995
+ describe('a conversation replaced in place by /clear', () => {
996
+ /** The POSTs that record an inherited colour rather than a pick on a tab. */
997
+ const inheritPosts = (): Record<string, unknown>[] =>
998
+ request.mock.calls
999
+ .filter(
1000
+ call =>
1001
+ call[1] === 'colours' &&
1002
+ (call[3] as RequestInit)?.method === 'POST' &&
1003
+ JSON.parse(String((call[3] as RequestInit).body)).hand_set === false
1004
+ )
1005
+ .map(call => JSON.parse(String((call[3] as RequestInit).body)));
1006
+
1007
+ it('hands the replaced conversation effective colour to the new id', async () => {
1008
+ // A pick on the tab of the conversation that `/clear` replaced. The probe
1009
+ // follows the pidfile, so the next pass answers the same terminal with the
1010
+ // new conversation id and the old colour must carry over.
1011
+ server.colours[SESSION] = 'sky';
1012
+ server.overrides = [SESSION];
1013
+ await store.load();
1014
+
1015
+ await manager.reconcileColours();
1016
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1017
+
1018
+ server.probes['1'] = {
1019
+ terminal_name: '1',
1020
+ running: true,
1021
+ session_id: OTHER_SESSION
1022
+ };
1023
+ await manager.reconcileColours();
1024
+
1025
+ expect(inheritPosts()).toEqual([
1026
+ { session_id: OTHER_SESSION, colour: 'sky', hand_set: false }
1027
+ ]);
1028
+ expect(store.get(OTHER_SESSION)).toEqual('sky');
1029
+ // Inherited, not hand-set: the release affordance must not offer it.
1030
+ expect(store.isOverride(OTHER_SESSION)).toBe(false);
1031
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1032
+ });
1033
+
1034
+ it('leaves a colour the new conversation already answers alone', async () => {
1035
+ // The new conversation already has a stored colour of its own. It is not
1036
+ // bare, so the replaced conversation's colour must not be written over it.
1037
+ server.colours[SESSION] = 'sky';
1038
+ server.overrides = [SESSION];
1039
+ await store.load();
1040
+ await manager.reconcileColours();
1041
+
1042
+ server.colours[OTHER_SESSION] = 'mint';
1043
+ server.overrides = [SESSION, OTHER_SESSION];
1044
+ await store.load();
1045
+ server.probes['1'] = {
1046
+ terminal_name: '1',
1047
+ running: true,
1048
+ session_id: OTHER_SESSION
1049
+ };
1050
+ await manager.reconcileColours();
1051
+
1052
+ expect(inheritPosts()).toEqual([]);
1053
+ expect(paints(terminals[0]).pop()).toEqual('mint');
1054
+ });
1055
+
1056
+ it('does not inherit over a colour the new conversation already answers with', async () => {
1057
+ // The replacement answers the probe with an assistant-chosen colour. That
1058
+ // is the new conversation's own answer even though the store holds nothing
1059
+ // for it, so `native === null` is what keeps the replaced conversation's
1060
+ // colour from overwriting it.
1061
+ //
1062
+ // The descriptor is switched to `native` so the probe's colour reaches the
1063
+ // ladder: under the default `derived` source a native colour is only a
1064
+ // guard input and would never be painted.
1065
+ manager.dispose();
1066
+ manager = makeManager({
1067
+ descriptor: { ...DESCRIPTOR, colourSource: 'native' }
1068
+ });
1069
+
1070
+ server.colours[SESSION] = 'sky';
1071
+ server.overrides = [SESSION];
1072
+ await store.load();
1073
+ await manager.reconcileColours();
1074
+ expect(paints(terminals[0]).pop()).toEqual('sky');
1075
+
1076
+ server.probes['1'] = {
1077
+ terminal_name: '1',
1078
+ running: true,
1079
+ session_id: OTHER_SESSION,
1080
+ colour: 'mint'
1081
+ };
1082
+ await manager.reconcileColours();
1083
+
1084
+ // Nothing was written for the replacement: its own colour outranks the
1085
+ // cleared conversation's, and the tab paints that colour.
1086
+ expect(inheritPosts()).toEqual([]);
1087
+ expect(paints(terminals[0]).pop()).toEqual('mint');
1088
+ });
1089
+ });
1090
+
995
1091
  /** Manager options against one companion, for the isolated-module case that
996
1092
  * cannot use `makeManager`. */
997
1093
  function optionsFor(colourfulTabs: unknown): TerminalManager.IOptions {
@@ -1,8 +1,9 @@
1
1
  // Terminal tab tint per conversation.
2
2
  //
3
3
  // Effective colour, highest wins:
4
- // 1. user-set - the write-back store this module keeps, per provider.
5
- // Only entries the server still holds: it releases a pick
4
+ // 1. stored - the write-back store, hand-set or inherited, that this
5
+ // module keeps per provider. Only entries the server still
6
+ // holds: it releases a pick
6
7
  // that the assistant's own later colour command overtook,
7
8
  // because the newer of the two channels wins (DEF-COLO-306)
8
9
  // 2. native - the conversation's own colour, when the assistant has one
@@ -101,7 +102,7 @@ export function sessionForCwds(
101
102
  }
102
103
 
103
104
  /**
104
- * Client for one provider's user-set colour store.
105
+ * Client for one provider's stored colour store, hand-set or inherited.
105
106
  *
106
107
  * The store is server-side and per provider (`providers/<id>/colours`), so
107
108
  * colours survive a reload and one assistant's colours can never be read or
@@ -128,7 +129,7 @@ export class ColourStore {
128
129
  }
129
130
 
130
131
  /** Whether this conversation's colour is the user's own choice rather than
131
- * one it inherited at fork time. Only a hand-set colour is offered for
132
+ * one it inherited at fork or /clear. Only a hand-set colour is offered for
132
133
  * release: dropping an inherited one would un-inherit the branch. */
133
134
  isOverride(sessionId: string | null | undefined): boolean {
134
135
  return !!sessionId && this._overrides.has(sessionId);
@@ -206,8 +207,8 @@ export class ColourStore {
206
207
  * The ceiling is per request, so K ops queued behind a dead server wait K
207
208
  * times it, not it.
208
209
  *
209
- * An op must never call `load`, `set` or `forget` - it would wait on a chain
210
- * that is waiting on it. */
210
+ * An op must never call `load`, `set`, `forget` or `inherit` - it would wait
211
+ * on a chain that is waiting on it. */
211
212
  private _run<T>(op: () => Promise<T>): Promise<T> {
212
213
  const result = this._chain.then(op);
213
214
  const settled = () => undefined;
@@ -298,6 +299,61 @@ export class ColourStore {
298
299
  });
299
300
  }
300
301
 
302
+ /** Record a colour inherited at `/clear`.
303
+ *
304
+ * The terminal process survives `/clear` and starts reporting a NEW
305
+ * conversation id, so the replacement has no colour of its own. It continues
306
+ * the conversation it replaced, not a new branch, and takes that
307
+ * conversation's effective colour over - whether the colour was picked on
308
+ * the tab or chosen by the assistant. `hand_set` false is what records the
309
+ * origin: an inherited entry, never a hand-set override, so `isOverride`
310
+ * leaves it out of the release affordance exactly as a fork's inherited tint
311
+ * is left out.
312
+ *
313
+ * The cache is reconciled from the answer the same way `set` reconciles this
314
+ * one id: the colour when the answer carries it, and the id in `_overrides`
315
+ * only when the answer names it there. Answers false when the answer does
316
+ * not carry the colour. */
317
+ async inherit(sessionId: string, colour: string): Promise<boolean> {
318
+ if (!sessionId) {
319
+ return false;
320
+ }
321
+ const ids = [sessionId];
322
+ // Marked before the op is queued, not inside it - see `isPending`.
323
+ this._openWrite(ids);
324
+ return this._run(async () => {
325
+ try {
326
+ const data = await requestProvider<IColourStoreResponse>(
327
+ this._providerId,
328
+ 'colours',
329
+ this._serverSettings,
330
+ {
331
+ method: 'POST',
332
+ body: JSON.stringify({
333
+ session_id: sessionId,
334
+ colour,
335
+ hand_set: false
336
+ })
337
+ }
338
+ );
339
+ if ((data.colours ?? {})[sessionId] !== colour) {
340
+ return false;
341
+ }
342
+ this._colours.set(sessionId, colour);
343
+ if ((data.overrides ?? []).includes(sessionId)) {
344
+ this._overrides.add(sessionId);
345
+ } else {
346
+ this._overrides.delete(sessionId);
347
+ }
348
+ return true;
349
+ } catch (_err) {
350
+ return false;
351
+ } finally {
352
+ this._closeWrite(ids);
353
+ }
354
+ });
355
+ }
356
+
301
357
  /** Drop stored colours - conversations that no longer exist, or overrides
302
358
  * the user released. One request for the whole list, so a release is never
303
359
  * left half applied. Same contract as `set`: the answer is checked for the
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
@@ -151,6 +151,9 @@ export class TerminalManager {
151
151
  // running. Kept past dispose it would be filed by nothing and would hold a
152
152
  // widget reference for the life of the page.
153
153
  this._pendingChoices.clear();
154
+ // Nothing is painted from here on, so the record would only hold widget
155
+ // ids for the life of the page.
156
+ this._lastPainted.clear();
154
157
  }
155
158
 
156
159
  /**
@@ -508,15 +511,44 @@ export class TerminalManager {
508
511
  ? (this._sessions.find(s => s.session_id === sessionId)?.colour ??
509
512
  null)
510
513
  : null);
511
- this._applyColour(
512
- widget,
513
- effectiveColour(
514
- sessionId,
515
- this._descriptor.colourSource,
516
- this._colours.get(sessionId),
517
- native
518
- )
514
+
515
+ // The probe follows the pidfile, so an in-place `/clear` answers the
516
+ // SAME terminal with a NEW conversation id. The replacement continues
517
+ // the conversation it replaced and takes over the effective colour
518
+ // that one was wearing - a pick made on the tab or a colour the
519
+ // assistant chose. A stored colour or an assistant-chosen colour on
520
+ // the new conversation is its own answer, though: only a bare new id
521
+ // is filled in, so the old colour never overwrites it.
522
+ let colour = effectiveColour(
523
+ sessionId,
524
+ this._descriptor.colourSource,
525
+ this._colours.get(sessionId),
526
+ native
519
527
  );
528
+ if (sessionId) {
529
+ const last = this._lastPainted.get(widget.id);
530
+ if (
531
+ last &&
532
+ last.sessionId !== sessionId &&
533
+ last.colour !== null &&
534
+ this._colours.get(sessionId) === null &&
535
+ native === null
536
+ ) {
537
+ await this._colours.inherit(sessionId, last.colour);
538
+ if (
539
+ !this._colouredTabs ||
540
+ this._disposed ||
541
+ this._pass !== generation
542
+ ) {
543
+ // Colouring went off, the panel went away or a newer pass took
544
+ // over while the write was queued. That pass paints the tabs.
545
+ return;
546
+ }
547
+ colour = this._colours.get(sessionId) ?? last.colour;
548
+ }
549
+ this._lastPainted.set(widget.id, { sessionId, colour });
550
+ }
551
+ this._applyColour(widget, colour);
520
552
  },
521
553
  widget => disowned.add(widget.id)
522
554
  );
@@ -561,6 +593,10 @@ export class TerminalManager {
561
593
  // drawing, which is the same thing `_captureColour` refuses to do while
562
594
  // colouring is off.
563
595
  this._pendingChoices.clear();
596
+ // Every terminal this manager still sees is about to be painted null, so
597
+ // no recorded paint describes what is on screen any more - and a `/clear`
598
+ // that happened while colouring was off must not be inherited from.
599
+ this._lastPainted.clear();
564
600
  const generation = ++this._pass;
565
601
  try {
566
602
  await this._eachAssistantTerminal(async widget => {
@@ -884,6 +920,17 @@ export class TerminalManager {
884
920
  // pass can read the conversation and file it - and while an entry stands, its
885
921
  // terminal is not painted, because painting is what destroys the pick.
886
922
  private readonly _pendingChoices: Map<string, string | null> = new Map();
923
+ // The conversation each terminal was last PAINTED from, and the effective
924
+ // colour it was painted. The probe follows the pidfile, so `/clear` answers
925
+ // the same widget with a new conversation id; a difference against this
926
+ // record is what tells an in-place replacement from a terminal that simply
927
+ // started later, and is the colour the replacement inherits. Pruned by
928
+ // `clearColours` and `dispose` so a tab that is no longer drawn leaves no
929
+ // entry behind.
930
+ private readonly _lastPainted: Map<
931
+ string,
932
+ { sessionId: string; colour: string | null }
933
+ > = new Map();
887
934
  // Most recent terminal per project, tagged with the conversation it runs so
888
935
  // reuse can tell a project's branches apart.
889
936
  private readonly _byProject: Map<
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;