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 +18 -1
- package/lib/core/colour.d.ts +20 -4
- package/lib/core/colour.js +60 -6
- package/lib/core/panel.d.ts +12 -6
- package/lib/core/panel.js +58 -19
- package/lib/core/popup.js +13 -13
- package/lib/core/terminals.d.ts +1 -0
- package/lib/core/terminals.js +44 -2
- package/lib/index.js +3 -3
- package/package.json +1 -1
- package/src/__tests__/colour.spec.ts +27 -1
- package/src/__tests__/panel.spec.ts +32 -4
- package/src/__tests__/popup.spec.ts +15 -2
- package/src/__tests__/tab-colour-capture.spec.ts +96 -0
- package/src/core/colour.ts +62 -6
- package/src/core/panel.ts +60 -20
- package/src/core/popup.ts +13 -13
- package/src/core/terminals.ts +55 -8
- package/src/index.ts +3 -3
- package/style/base.css +13 -1
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:
|
package/lib/core/colour.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 `
|
|
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
|
package/lib/core/colour.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Terminal tab tint per conversation.
|
|
2
2
|
//
|
|
3
3
|
// Effective colour, highest wins:
|
|
4
|
-
// 1.
|
|
5
|
-
// Only entries the server still
|
|
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
|
|
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
|
|
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 `
|
|
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
|
package/lib/core/panel.d.ts
CHANGED
|
@@ -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.
|
|
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
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
465
|
+
const token = ++this._busySeq;
|
|
466
|
+
this._busyMessages.set(token, message);
|
|
454
467
|
if (this._busyEl) {
|
|
455
468
|
this._busyEl.hidden = false;
|
|
456
|
-
|
|
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.
|
|
468
|
-
if (this.
|
|
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
|
|
678
|
-
*
|
|
679
|
-
*
|
|
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
|
|
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
|
|
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
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
|
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.
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
if (
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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/core/terminals.d.ts
CHANGED
|
@@ -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;
|
package/lib/core/terminals.js
CHANGED
|
@@ -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
|
-
|
|
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"
|
|
178
|
-
//
|
|
179
|
-
//
|
|
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.
|
|
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
|
|
217
|
-
// Default: trash is on,
|
|
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(
|
|
222
|
-
|
|
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('
|
|
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()
|
|
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 {
|
package/src/core/colour.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Terminal tab tint per conversation.
|
|
2
2
|
//
|
|
3
3
|
// Effective colour, highest wins:
|
|
4
|
-
// 1.
|
|
5
|
-
// Only entries the server still
|
|
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
|
|
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
|
|
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 `
|
|
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.
|
|
529
|
-
* can overlap; the overlay clears when the last one ends
|
|
530
|
-
|
|
531
|
-
|
|
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
|
-
|
|
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.
|
|
547
|
-
if (this.
|
|
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
|
|
785
|
-
*
|
|
786
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
|
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.
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
if (
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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/core/terminals.ts
CHANGED
|
@@ -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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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"
|
|
254
|
-
//
|
|
255
|
-
//
|
|
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;
|