jupyterlab_ai_code_assistants_extension 1.2.83 → 1.2.105
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/lib/core/panel.d.ts +7 -0
- package/lib/core/panel.js +86 -12
- package/lib/core/terminals.d.ts +16 -8
- package/lib/core/terminals.js +29 -11
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +14 -6
- package/src/__tests__/launcher.spec.ts +34 -3
- package/src/__tests__/panel.spec.ts +185 -12
- package/src/__tests__/terminals.spec.ts +53 -1
- package/src/core/panel.ts +90 -15
- package/src/core/terminals.ts +36 -11
- package/style/base.css +30 -1
package/lib/core/panel.d.ts
CHANGED
|
@@ -45,6 +45,10 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
45
45
|
protected onBeforeHide(_msg: Message): void;
|
|
46
46
|
protected onCloseRequest(msg: Message): void;
|
|
47
47
|
private _buildShell;
|
|
48
|
+
/** Raise the click-time busy overlay. Counted, because an open and a branch
|
|
49
|
+
* can overlap; the overlay clears when the last one ends. */
|
|
50
|
+
private _beginBusy;
|
|
51
|
+
private _endBusy;
|
|
48
52
|
/** Show / hide the filter input. Hiding also clears the active filter, so
|
|
49
53
|
* the user never returns to an "invisible" filter narrowing the rows. */
|
|
50
54
|
private _toggleFilterBar;
|
|
@@ -302,6 +306,9 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
302
306
|
private readonly _removingPaths;
|
|
303
307
|
private _bodyEl;
|
|
304
308
|
private _loadingEl;
|
|
309
|
+
private _busyEl;
|
|
310
|
+
private _busyTextEl;
|
|
311
|
+
private _busyCount;
|
|
305
312
|
private _errorEl;
|
|
306
313
|
private _refreshBtn;
|
|
307
314
|
private _filterBtn;
|
package/lib/core/panel.js
CHANGED
|
@@ -112,6 +112,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
112
112
|
this._deleteToTrash = true;
|
|
113
113
|
this._removingPaths = new Set();
|
|
114
114
|
this._loadingEl = null;
|
|
115
|
+
this._busyEl = null;
|
|
116
|
+
this._busyTextEl = null;
|
|
117
|
+
this._busyCount = 0;
|
|
115
118
|
this._errorEl = null;
|
|
116
119
|
this._refreshBtn = null;
|
|
117
120
|
this._filterBtn = null;
|
|
@@ -183,7 +186,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
183
186
|
return this._descriptor;
|
|
184
187
|
}
|
|
185
188
|
dispose() {
|
|
189
|
+
var _a;
|
|
186
190
|
this._stopPolling();
|
|
191
|
+
// The busy overlay lives on the shell, outside this widget's tree, so
|
|
192
|
+
// removing the panel does not remove it. Left behind it would show a
|
|
193
|
+
// spinner for a panel that no longer exists.
|
|
194
|
+
(_a = this._busyEl) === null || _a === void 0 ? void 0 : _a.remove();
|
|
195
|
+
this._busyEl = null;
|
|
187
196
|
// Disabling a provider must leave its running terminals alive and only
|
|
188
197
|
// drop the tint this panel applied.
|
|
189
198
|
void this._terminals.clearColours();
|
|
@@ -389,6 +398,31 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
389
398
|
loadingSpinner.className =
|
|
390
399
|
'jp-aiAssistants-spinner jp-AiAssistantsPanel-loadingSpinner';
|
|
391
400
|
loading.appendChild(loadingSpinner);
|
|
401
|
+
// Click-time busy overlay: raised synchronously when a row is opened or
|
|
402
|
+
// branched, before the terminal probe or the branch request, so the click
|
|
403
|
+
// never reads as ignored (DEF-PANE-318). It sits on the LAB SHELL, not on
|
|
404
|
+
// the panel: the feedback belongs in the main working area, where the
|
|
405
|
+
// launch spinner appears, and a panel that is hidden or scrolled must not
|
|
406
|
+
// keep it out of sight (DEF-PANE-318-1). It takes no pointer events and no
|
|
407
|
+
// focus: the context menu that launched a branch stays open until its
|
|
408
|
+
// command runs, and a focus change would close it.
|
|
409
|
+
const busy = document.createElement('div');
|
|
410
|
+
busy.className = 'jp-AiAssistants-busyOverlay';
|
|
411
|
+
busy.hidden = true;
|
|
412
|
+
const busySpinner = document.createElement('div');
|
|
413
|
+
busySpinner.className =
|
|
414
|
+
'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
|
|
415
|
+
busySpinner.setAttribute('aria-hidden', 'true');
|
|
416
|
+
busy.appendChild(busySpinner);
|
|
417
|
+
// The spinner is decoration; a screen-reader user hears the action
|
|
418
|
+
// instead. The region sits on the root, always rendered and outside the
|
|
419
|
+
// overlay and the aria-busy body: a live region created inside a
|
|
420
|
+
// display:none container, or one whose update lands in an aria-busy
|
|
421
|
+
// subtree, can be withheld by assistive technology.
|
|
422
|
+
const busyText = document.createElement('div');
|
|
423
|
+
busyText.className = 'jp-AiAssistantsPanel-srOnly';
|
|
424
|
+
busyText.setAttribute('role', 'status');
|
|
425
|
+
busyText.setAttribute('aria-live', 'polite');
|
|
392
426
|
// Inline error strip - a failed poll reports here and keeps retrying,
|
|
393
427
|
// rather than tearing the panel down or raising a modal that would also
|
|
394
428
|
// interrupt the other providers' panels.
|
|
@@ -403,10 +437,44 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
403
437
|
root.appendChild(error);
|
|
404
438
|
root.appendChild(body);
|
|
405
439
|
root.appendChild(loading);
|
|
440
|
+
root.appendChild(busyText);
|
|
441
|
+
// The shell carries the visible feedback; the panel carries the
|
|
442
|
+
// announcement.
|
|
443
|
+
this._app.shell.node.appendChild(busy);
|
|
406
444
|
this._bodyEl = body;
|
|
407
445
|
this._loadingEl = loading;
|
|
446
|
+
this._busyEl = busy;
|
|
447
|
+
this._busyTextEl = busyText;
|
|
408
448
|
this._errorEl = error;
|
|
409
449
|
}
|
|
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. */
|
|
452
|
+
_beginBusy(message) {
|
|
453
|
+
this._busyCount += 1;
|
|
454
|
+
if (this._busyEl) {
|
|
455
|
+
this._busyEl.hidden = false;
|
|
456
|
+
if (this._busyTextEl) {
|
|
457
|
+
this._busyTextEl.textContent = message;
|
|
458
|
+
}
|
|
459
|
+
// The body is what the action changes; the root stays outside the
|
|
460
|
+
// busy subtree so the live region above is not withheld.
|
|
461
|
+
if (this._bodyEl) {
|
|
462
|
+
this._bodyEl.setAttribute('aria-busy', 'true');
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
_endBusy() {
|
|
467
|
+
this._busyCount = Math.max(0, this._busyCount - 1);
|
|
468
|
+
if (this._busyCount === 0 && this._busyEl) {
|
|
469
|
+
this._busyEl.hidden = true;
|
|
470
|
+
if (this._busyTextEl) {
|
|
471
|
+
this._busyTextEl.textContent = '';
|
|
472
|
+
}
|
|
473
|
+
if (this._bodyEl) {
|
|
474
|
+
this._bodyEl.removeAttribute('aria-busy');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
410
478
|
/** Show / hide the filter input. Hiding also clears the active filter, so
|
|
411
479
|
* the user never returns to an "invisible" filter narrowing the rows. */
|
|
412
480
|
_toggleFilterBar() {
|
|
@@ -746,6 +814,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
746
814
|
return resolveLaunchMode(this._descriptor.launchModes, this._modes, force);
|
|
747
815
|
}
|
|
748
816
|
async _openSession(session, force, sessionId) {
|
|
817
|
+
// The click's own feedback, raised before the terminal walk and the
|
|
818
|
+
// launch - both can take seconds (DEF-PANE-318).
|
|
819
|
+
this._beginBusy(this._trans.__('Opening conversation'));
|
|
749
820
|
try {
|
|
750
821
|
await this._terminals.openSession(session, this._launchMode(force), sessionId);
|
|
751
822
|
// Not awaited: the open has succeeded, and the sync is not part of it.
|
|
@@ -760,6 +831,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
760
831
|
}
|
|
761
832
|
}
|
|
762
833
|
finally {
|
|
834
|
+
this._endBusy();
|
|
763
835
|
// Reuse or fresh launch, either way the picture changed. Pull fresh
|
|
764
836
|
// state; a poll tick retries if this one fails.
|
|
765
837
|
void this._fetch().catch(() => undefined);
|
|
@@ -894,7 +966,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
894
966
|
if (row) {
|
|
895
967
|
// A terminal already holding that conversation IS the answer: a second
|
|
896
968
|
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
897
|
-
|
|
969
|
+
// The folder is required as well: a transcript re-homed into another
|
|
970
|
+
// project keeps its id, and that terminal is not this folder's
|
|
971
|
+
// (DEF-PANE-319).
|
|
972
|
+
const found = await this._terminals.findForSession(row.session_id, folder);
|
|
898
973
|
if (found) {
|
|
899
974
|
this._terminals.focus(found.widget);
|
|
900
975
|
return found.widget;
|
|
@@ -985,7 +1060,16 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
985
1060
|
if (!session) {
|
|
986
1061
|
return;
|
|
987
1062
|
}
|
|
988
|
-
|
|
1063
|
+
// Raised on the click: the branch POST mints the fork server-side (for
|
|
1064
|
+
// Claude and Codex by running their own CLI) and can take seconds
|
|
1065
|
+
// (DEF-PANE-318).
|
|
1066
|
+
this._beginBusy(this._trans.__('Branching conversation'));
|
|
1067
|
+
try {
|
|
1068
|
+
await this._branchByServerCopy(session, undefined, force);
|
|
1069
|
+
}
|
|
1070
|
+
finally {
|
|
1071
|
+
this._endBusy();
|
|
1072
|
+
}
|
|
989
1073
|
}
|
|
990
1074
|
/** The server mints the new id and answers with it before the terminal
|
|
991
1075
|
* opens - `server-copy` copies the store entry, `server-native` drives the
|
|
@@ -1147,7 +1231,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1147
1231
|
}
|
|
1148
1232
|
/** Make another conversation the row's current one. */
|
|
1149
1233
|
async _switchBranch(session, sessionId) {
|
|
1150
|
-
var _a;
|
|
1151
1234
|
try {
|
|
1152
1235
|
const result = await requestProvider(this._descriptor.id, 'switch', this._serverSettings, {
|
|
1153
1236
|
method: 'POST',
|
|
@@ -1159,15 +1242,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1159
1242
|
if (result.current !== result.requested) {
|
|
1160
1243
|
Notification.warning('That conversation could not be made the current one.', { autoClose: 4000 });
|
|
1161
1244
|
}
|
|
1162
|
-
else {
|
|
1163
|
-
// The re-render is the only other signal, and it can read as the
|
|
1164
|
-
// opposite of success: the row now reports the conversation's true
|
|
1165
|
-
// last activity, so switching to an old one dims the row and drops
|
|
1166
|
-
// it out of Recent (DEF-PANE-181).
|
|
1167
|
-
const target = this._lastBranches.find(b => b.session_id === sessionId);
|
|
1168
|
-
const label = (_a = target === null || target === void 0 ? void 0 : target.label) !== null && _a !== void 0 ? _a : shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
|
|
1169
|
-
Notification.success(`${this._lookupName(session)}: switched to ${label}`, { autoClose: 3000 });
|
|
1170
|
-
}
|
|
1171
1245
|
}
|
|
1172
1246
|
catch (err) {
|
|
1173
1247
|
Notification.error(isResponseStatus(err, 404)
|
package/lib/core/terminals.d.ts
CHANGED
|
@@ -56,18 +56,26 @@ export declare class TerminalManager {
|
|
|
56
56
|
* widget is attached and visible.
|
|
57
57
|
*/
|
|
58
58
|
focus(widget: any): void;
|
|
59
|
-
/** Find an open terminal running exactly this conversation
|
|
59
|
+
/** Find an open terminal running exactly this conversation in this project,
|
|
60
|
+
* or null.
|
|
60
61
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
62
|
+
* The server reads the true id from the running process, so a session the
|
|
63
|
+
* user started by hand is recognised too, not only launches that carry an
|
|
64
|
+
* argv id. The terminal must ALSO work in the requested project. An id is
|
|
65
|
+
* not globally unique in the store: Claude re-homes a transcript into a
|
|
66
|
+
* second project under the same id, so an id match alone can name a process
|
|
67
|
+
* running it from another tree, and reusing that one opens nothing in the
|
|
68
|
+
* folder the user clicked (DEF-PANE-319). A process that changed directory
|
|
69
|
+
* WITHIN the project still matches - the probe lists every cwd in its
|
|
70
|
+
* tree. */
|
|
71
|
+
findForSession(wantedSessionId: string | undefined, projectPath: string): Promise<{
|
|
68
72
|
widget: any;
|
|
69
73
|
runningId: string | null;
|
|
70
74
|
} | null>;
|
|
75
|
+
/** Whether any process in the terminal's tree works in `projectPath` or a
|
|
76
|
+
* directory under it. No cwds means nothing to prove the terminal belongs
|
|
77
|
+
* to this project, so it is not reused. */
|
|
78
|
+
private _runsInProject;
|
|
71
79
|
/** Ask the server what a terminal is running. Null when the widget has no
|
|
72
80
|
* session name yet, or the probe fails - a terminal can vanish between
|
|
73
81
|
* enumeration and probe, which is a retry, not an error. */
|
package/lib/core/terminals.js
CHANGED
|
@@ -184,8 +184,9 @@ export class TerminalManager {
|
|
|
184
184
|
this.focus(cached.widget);
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
|
-
// 2. Walk every live terminal, asking the server what each is running
|
|
188
|
-
|
|
187
|
+
// 2. Walk every live terminal, asking the server what each is running in
|
|
188
|
+
// this project.
|
|
189
|
+
const found = await this.findForSession(wanted, session.project_path);
|
|
189
190
|
if (found) {
|
|
190
191
|
// Tag with the OBSERVED conversation, so a later reuse trusts a
|
|
191
192
|
// confirmed identity rather than a wish.
|
|
@@ -255,26 +256,43 @@ export class TerminalManager {
|
|
|
255
256
|
}
|
|
256
257
|
});
|
|
257
258
|
}
|
|
258
|
-
/** Find an open terminal running exactly this conversation
|
|
259
|
+
/** Find an open terminal running exactly this conversation in this project,
|
|
260
|
+
* or null.
|
|
259
261
|
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
|
|
262
|
+
* The server reads the true id from the running process, so a session the
|
|
263
|
+
* user started by hand is recognised too, not only launches that carry an
|
|
264
|
+
* argv id. The terminal must ALSO work in the requested project. An id is
|
|
265
|
+
* not globally unique in the store: Claude re-homes a transcript into a
|
|
266
|
+
* second project under the same id, so an id match alone can name a process
|
|
267
|
+
* running it from another tree, and reusing that one opens nothing in the
|
|
268
|
+
* folder the user clicked (DEF-PANE-319). A process that changed directory
|
|
269
|
+
* WITHIN the project still matches - the probe lists every cwd in its
|
|
270
|
+
* tree. */
|
|
271
|
+
async findForSession(wantedSessionId, projectPath) {
|
|
267
272
|
if (!this._tracker || !wantedSessionId) {
|
|
268
273
|
return null;
|
|
269
274
|
}
|
|
270
275
|
for (const widget of this._liveTerminals()) {
|
|
271
276
|
const info = await this.probe(widget);
|
|
272
|
-
if (info &&
|
|
277
|
+
if (info &&
|
|
278
|
+
info.session_id &&
|
|
279
|
+
info.session_id === wantedSessionId &&
|
|
280
|
+
this._runsInProject(info.cwds, projectPath)) {
|
|
273
281
|
return { widget, runningId: info.session_id };
|
|
274
282
|
}
|
|
275
283
|
}
|
|
276
284
|
return null;
|
|
277
285
|
}
|
|
286
|
+
/** Whether any process in the terminal's tree works in `projectPath` or a
|
|
287
|
+
* directory under it. No cwds means nothing to prove the terminal belongs
|
|
288
|
+
* to this project, so it is not reused. */
|
|
289
|
+
_runsInProject(cwds, projectPath) {
|
|
290
|
+
const root = projectPath.replace(/\/+$/, '');
|
|
291
|
+
return (cwds !== null && cwds !== void 0 ? cwds : []).some(cwd => {
|
|
292
|
+
const path = cwd.replace(/\/+$/, '');
|
|
293
|
+
return path === root || path.startsWith(`${root}/`);
|
|
294
|
+
});
|
|
295
|
+
}
|
|
278
296
|
/** Ask the server what a terminal is running. Null when the widget has no
|
|
279
297
|
* session name yet, or the probe fails - a terminal can vanish between
|
|
280
298
|
* enumeration and probe, which is a retry, not an error. */
|
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.105",
|
|
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",
|
|
@@ -110,7 +110,8 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
|
|
|
110
110
|
const app = {
|
|
111
111
|
serviceManager: { serverSettings: {} },
|
|
112
112
|
commands: new CommandRegistry(),
|
|
113
|
-
hasPlugin: () => false
|
|
113
|
+
hasPlugin: () => false,
|
|
114
|
+
shell: { node: document.createElement('div') }
|
|
114
115
|
} as any;
|
|
115
116
|
const settings = {
|
|
116
117
|
raw: '{}',
|
|
@@ -213,7 +214,8 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
213
214
|
const app = {
|
|
214
215
|
serviceManager: { serverSettings: {} },
|
|
215
216
|
commands: new CommandRegistry(),
|
|
216
|
-
hasPlugin: () => false
|
|
217
|
+
hasPlugin: () => false,
|
|
218
|
+
shell: { node: document.createElement('div') }
|
|
217
219
|
} as any;
|
|
218
220
|
const labShell = {
|
|
219
221
|
add: (widget: AssistantSessionsPanel) => docked.push(widget)
|
|
@@ -288,7 +290,8 @@ describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () =>
|
|
|
288
290
|
{
|
|
289
291
|
serviceManager: { serverSettings: {} },
|
|
290
292
|
commands: new CommandRegistry(),
|
|
291
|
-
hasPlugin: () => false
|
|
293
|
+
hasPlugin: () => false,
|
|
294
|
+
shell: { node: document.createElement('div') }
|
|
292
295
|
} as any,
|
|
293
296
|
{ add: (widget: AssistantSessionsPanel) => docked.push(widget) } as any,
|
|
294
297
|
null,
|
|
@@ -416,7 +419,8 @@ describe('ACC-PROV - the activation contract', () => {
|
|
|
416
419
|
const app = {
|
|
417
420
|
serviceManager: { serverSettings },
|
|
418
421
|
commands: new CommandRegistry(),
|
|
419
|
-
hasPlugin: () => false
|
|
422
|
+
hasPlugin: () => false,
|
|
423
|
+
shell: { node: document.createElement('div') }
|
|
420
424
|
} as any;
|
|
421
425
|
|
|
422
426
|
const composite = options.composite;
|
|
@@ -878,6 +882,8 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
878
882
|
const shellMoves: [string, string][] = [];
|
|
879
883
|
const sequence: string[] = [];
|
|
880
884
|
const labShell = {
|
|
885
|
+
// The panel mounts its click-time overlay on the shell node.
|
|
886
|
+
node: document.createElement('div'),
|
|
881
887
|
// As the real sidebar does: a widget is hidden when it is added, and a
|
|
882
888
|
// placement the user made with JupyterLab's own command overrides the
|
|
883
889
|
// area the caller asks for.
|
|
@@ -934,7 +940,8 @@ describe('ACC-SETT - settings drive the panels', () => {
|
|
|
934
940
|
const app = {
|
|
935
941
|
serviceManager: { serverSettings },
|
|
936
942
|
commands,
|
|
937
|
-
hasPlugin: () => false
|
|
943
|
+
hasPlugin: () => false,
|
|
944
|
+
shell: { node: document.createElement('div') }
|
|
938
945
|
} as any;
|
|
939
946
|
|
|
940
947
|
const changed = new Signal<unknown, void>({});
|
|
@@ -1758,7 +1765,8 @@ describe('ACC-RETI-82..84 - a retired standalone extension still installed', ()
|
|
|
1758
1765
|
const app = {
|
|
1759
1766
|
serviceManager: { serverSettings },
|
|
1760
1767
|
commands: new CommandRegistry(),
|
|
1761
|
-
hasPlugin: (id: string) => installed.includes(id)
|
|
1768
|
+
hasPlugin: (id: string) => installed.includes(id),
|
|
1769
|
+
shell: { node: document.createElement('div') }
|
|
1762
1770
|
} as any;
|
|
1763
1771
|
await (plugin.activate as any)(
|
|
1764
1772
|
app,
|
|
@@ -192,7 +192,10 @@ function harness(
|
|
|
192
192
|
}
|
|
193
193
|
},
|
|
194
194
|
commands,
|
|
195
|
-
shell: {
|
|
195
|
+
shell: {
|
|
196
|
+
node: document.createElement('div'),
|
|
197
|
+
activateById: (id: string) => activated.push(id)
|
|
198
|
+
}
|
|
196
199
|
} as any;
|
|
197
200
|
const panel = new AssistantSessionsPanel({
|
|
198
201
|
app,
|
|
@@ -279,7 +282,8 @@ describe('the tile click path resumes a folder that has a conversation', () => {
|
|
|
279
282
|
'terminal/term-1': {
|
|
280
283
|
terminal_name: 'term-1',
|
|
281
284
|
running: true,
|
|
282
|
-
session_id: 'sid-proj'
|
|
285
|
+
session_id: 'sid-proj',
|
|
286
|
+
cwds: ['/srv/lab/proj']
|
|
283
287
|
}
|
|
284
288
|
});
|
|
285
289
|
const h = harness({ tracker: true });
|
|
@@ -291,6 +295,32 @@ describe('the tile click path resumes a folder that has a conversation', () => {
|
|
|
291
295
|
expect(callsTo('launch-argv')).toHaveLength(0);
|
|
292
296
|
});
|
|
293
297
|
|
|
298
|
+
it('launches here when the terminal holds the conversation in another folder', async () => {
|
|
299
|
+
// DEF-PANE-319: a transcript re-homed into another project keeps its id,
|
|
300
|
+
// so the id alone cannot prove the terminal is this folder's.
|
|
301
|
+
routes({
|
|
302
|
+
sessions: { sessions: [session()] },
|
|
303
|
+
'terminal/term-1': {
|
|
304
|
+
terminal_name: 'term-1',
|
|
305
|
+
running: true,
|
|
306
|
+
session_id: 'sid-proj',
|
|
307
|
+
cwds: ['/srv/other']
|
|
308
|
+
},
|
|
309
|
+
'launch-argv': { argv: ['/bin/testbed', '--resume', 'sid-proj'] }
|
|
310
|
+
});
|
|
311
|
+
const h = harness({ tracker: true });
|
|
312
|
+
await flush();
|
|
313
|
+
|
|
314
|
+
expect(await h.panel.launchHere('proj')).toBe(LAUNCHED_WIDGET);
|
|
315
|
+
expect(h.activated).not.toContain('terminal-open-1');
|
|
316
|
+
expect(h.launches).toEqual([
|
|
317
|
+
{
|
|
318
|
+
argv: ['/bin/testbed', '--resume', 'sid-proj'],
|
|
319
|
+
cwd: '/srv/lab/proj'
|
|
320
|
+
}
|
|
321
|
+
]);
|
|
322
|
+
});
|
|
323
|
+
|
|
294
324
|
it('asks for resume argv when no terminal holds it, and launches that', async () => {
|
|
295
325
|
// ACC-LNCH-151 / ACC-LNCH-154: the row's own conversation, resumed through
|
|
296
326
|
// the sibling's terminal.
|
|
@@ -489,7 +519,8 @@ describe('the tiles the plugin adds to the Launcher', () => {
|
|
|
489
519
|
const app = {
|
|
490
520
|
serviceManager: { serverSettings: {} },
|
|
491
521
|
commands: new CommandRegistry(),
|
|
492
|
-
hasPlugin: () => false
|
|
522
|
+
hasPlugin: () => false,
|
|
523
|
+
shell: { node: document.createElement('div') }
|
|
493
524
|
} as any;
|
|
494
525
|
const labShell = {
|
|
495
526
|
add: (widget: AssistantSessionsPanel) => docked.push(widget)
|
|
@@ -119,16 +119,23 @@ function session(over: Partial<ISession> = {}): ISession {
|
|
|
119
119
|
function makePanel(
|
|
120
120
|
descriptor: IProviderDescriptor = DESCRIPTOR
|
|
121
121
|
): AssistantSessionsPanel {
|
|
122
|
+
const shellNode = document.createElement('div');
|
|
122
123
|
return new AssistantSessionsPanel({
|
|
123
124
|
app: {
|
|
124
125
|
serviceManager: { serverSettings: {} },
|
|
125
|
-
commands: { execute: jest.fn() }
|
|
126
|
+
commands: { execute: jest.fn() },
|
|
127
|
+
shell: { node: shellNode, activateById: jest.fn() }
|
|
126
128
|
} as any,
|
|
127
129
|
descriptor,
|
|
128
130
|
rootDir: '/home/user'
|
|
129
131
|
});
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/** The lab-shell node a panel mounts its click-time overlay on. */
|
|
135
|
+
function shellNodeOf(panel: AssistantSessionsPanel): HTMLElement {
|
|
136
|
+
return (panel as any)._app.shell.node as HTMLElement;
|
|
137
|
+
}
|
|
138
|
+
|
|
132
139
|
function render(panel: AssistantSessionsPanel, rows: ISession[]): void {
|
|
133
140
|
(panel as any)._sessions = rows;
|
|
134
141
|
(panel as any)._render();
|
|
@@ -455,6 +462,148 @@ describe('ACC-SESS-203 - automatic branch names', () => {
|
|
|
455
462
|
);
|
|
456
463
|
});
|
|
457
464
|
|
|
465
|
+
describe('click-time busy state (DEF-PANE-318)', () => {
|
|
466
|
+
it('raises the overlay and the body busy state on the click, before the open settles', async () => {
|
|
467
|
+
const target = makePanel(DESCRIPTOR);
|
|
468
|
+
const parent = session();
|
|
469
|
+
let release!: () => void;
|
|
470
|
+
const pending = new Promise<void>(resolve => {
|
|
471
|
+
release = resolve;
|
|
472
|
+
});
|
|
473
|
+
(target as any)._terminals.openSession = jest.fn(() => pending);
|
|
474
|
+
(target as any)._fetch = jest.fn().mockResolvedValue(undefined);
|
|
475
|
+
const overlay = (): HTMLElement =>
|
|
476
|
+
shellNodeOf(target).querySelector('.jp-AiAssistants-busyOverlay')!;
|
|
477
|
+
const body = (): HTMLElement =>
|
|
478
|
+
target.node.querySelector('.jp-AiAssistantsPanel-body')!;
|
|
479
|
+
|
|
480
|
+
const live = target.node.querySelector(
|
|
481
|
+
'.jp-AiAssistantsPanel-srOnly'
|
|
482
|
+
) as HTMLElement;
|
|
483
|
+
// Rendered at build, not created on demand: the region must be in the
|
|
484
|
+
// accessibility tree before the click, so its first update is announced.
|
|
485
|
+
expect(live).not.toBeNull();
|
|
486
|
+
expect(live.textContent).toBe('');
|
|
487
|
+
expect(live.getAttribute('role')).toBe('status');
|
|
488
|
+
expect(live.getAttribute('aria-live')).toBe('polite');
|
|
489
|
+
|
|
490
|
+
expect(overlay().hidden).toBe(true);
|
|
491
|
+
// Shell placement is load-bearing: the feedback belongs in the main
|
|
492
|
+
// working area, and a panel that is hidden or scrolled must not keep it
|
|
493
|
+
// out of sight (DEF-PANE-318-1).
|
|
494
|
+
expect(overlay().parentElement).toBe(shellNodeOf(target));
|
|
495
|
+
expect(target.node.contains(overlay())).toBe(false);
|
|
496
|
+
// The overlay takes no focus, so the context menu that launched an action
|
|
497
|
+
// cannot be closed by an element inside it taking the focus.
|
|
498
|
+
expect(overlay().hasAttribute('tabindex')).toBe(false);
|
|
499
|
+
expect(
|
|
500
|
+
overlay().querySelectorAll(
|
|
501
|
+
'a[href], button, input, select, textarea, [tabindex]'
|
|
502
|
+
)
|
|
503
|
+
).toHaveLength(0);
|
|
504
|
+
|
|
505
|
+
const call = (target as any)._openSession(parent);
|
|
506
|
+
// The click's own task, before the open settles.
|
|
507
|
+
expect(overlay().hidden).toBe(false);
|
|
508
|
+
expect(body().getAttribute('aria-busy')).toBe('true');
|
|
509
|
+
// Outside both the hidden overlay and the busy body, so assistive
|
|
510
|
+
// technology is not told to withhold the announcement.
|
|
511
|
+
expect(live.parentElement).toBe(target.node);
|
|
512
|
+
expect(body().contains(live)).toBe(false);
|
|
513
|
+
expect(overlay().contains(live)).toBe(false);
|
|
514
|
+
expect(live.textContent).toBe('Opening conversation');
|
|
515
|
+
|
|
516
|
+
release();
|
|
517
|
+
await call;
|
|
518
|
+
expect(overlay().hidden).toBe(true);
|
|
519
|
+
expect(body().getAttribute('aria-busy')).toBeNull();
|
|
520
|
+
// Cleared, so an identical action can announce again.
|
|
521
|
+
expect(live.textContent).toBe('');
|
|
522
|
+
|
|
523
|
+
target.dispose();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('keeps the overlay up while a second action is still running', async () => {
|
|
527
|
+
const target = makePanel(DESCRIPTOR);
|
|
528
|
+
let releaseFirst!: () => void;
|
|
529
|
+
let releaseSecond!: () => void;
|
|
530
|
+
const first = new Promise<void>(resolve => {
|
|
531
|
+
releaseFirst = resolve;
|
|
532
|
+
});
|
|
533
|
+
const second = new Promise<void>(resolve => {
|
|
534
|
+
releaseSecond = resolve;
|
|
535
|
+
});
|
|
536
|
+
const requests = [first, second];
|
|
537
|
+
(target as any)._terminals.openSession = jest.fn(() => requests.shift());
|
|
538
|
+
(target as any)._fetch = jest.fn().mockResolvedValue(undefined);
|
|
539
|
+
const overlay = (): HTMLElement =>
|
|
540
|
+
shellNodeOf(target).querySelector('.jp-AiAssistants-busyOverlay')!;
|
|
541
|
+
const body = (): HTMLElement =>
|
|
542
|
+
target.node.querySelector('.jp-AiAssistantsPanel-body')!;
|
|
543
|
+
|
|
544
|
+
const callOne = (target as any)._openSession(
|
|
545
|
+
session({ session_id: 'one' })
|
|
546
|
+
);
|
|
547
|
+
const callTwo = (target as any)._openSession(
|
|
548
|
+
session({ session_id: 'two' })
|
|
549
|
+
);
|
|
550
|
+
expect(overlay().hidden).toBe(false);
|
|
551
|
+
|
|
552
|
+
releaseFirst();
|
|
553
|
+
await callOne;
|
|
554
|
+
expect(overlay().hidden).toBe(false);
|
|
555
|
+
expect(body().getAttribute('aria-busy')).toBe('true');
|
|
556
|
+
|
|
557
|
+
releaseSecond();
|
|
558
|
+
await callTwo;
|
|
559
|
+
expect(overlay().hidden).toBe(true);
|
|
560
|
+
expect(body().getAttribute('aria-busy')).toBeNull();
|
|
561
|
+
|
|
562
|
+
target.dispose();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('announces the branch action while the request is pending', async () => {
|
|
566
|
+
const target = makePanel(DESCRIPTOR);
|
|
567
|
+
const parent = session({ name: 'analysis', name_source: 'session' });
|
|
568
|
+
const launch = jest.fn().mockResolvedValue({});
|
|
569
|
+
(target as any)._terminals.launch = launch;
|
|
570
|
+
(target as any)._activeSession = parent;
|
|
571
|
+
(target as any)._fetch = jest.fn().mockResolvedValue(undefined);
|
|
572
|
+
let release!: (value: unknown) => void;
|
|
573
|
+
const pending = new Promise(resolve => {
|
|
574
|
+
release = resolve;
|
|
575
|
+
});
|
|
576
|
+
request.mockImplementation((_provider, path) =>
|
|
577
|
+
path === 'branch' ? pending : Promise.resolve({})
|
|
578
|
+
);
|
|
579
|
+
const body = (): HTMLElement =>
|
|
580
|
+
target.node.querySelector('.jp-AiAssistantsPanel-body')!;
|
|
581
|
+
const live = target.node.querySelector(
|
|
582
|
+
'.jp-AiAssistantsPanel-srOnly'
|
|
583
|
+
) as HTMLElement;
|
|
584
|
+
let call: Promise<void> | undefined;
|
|
585
|
+
try {
|
|
586
|
+
call = (target as any)._branchSession();
|
|
587
|
+
expect(body().getAttribute('aria-busy')).toBe('true');
|
|
588
|
+
expect(live.textContent).toBe('Branching conversation');
|
|
589
|
+
|
|
590
|
+
release({ session_id: 'child' });
|
|
591
|
+
await call;
|
|
592
|
+
expect(body().getAttribute('aria-busy')).toBeNull();
|
|
593
|
+
expect(live.textContent).toBe('');
|
|
594
|
+
} finally {
|
|
595
|
+
// A failed assertion must not leave the mock unresolved for the next
|
|
596
|
+
// test; resolving twice is harmless.
|
|
597
|
+
release({ session_id: 'child' });
|
|
598
|
+
if (call) {
|
|
599
|
+
await call.catch(() => undefined);
|
|
600
|
+
}
|
|
601
|
+
target.dispose();
|
|
602
|
+
request.mockImplementation(() => Promise.resolve({}));
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
|
|
458
607
|
describe('a branch whose terminal launch was refused after the mint', () => {
|
|
459
608
|
it('says the branch was created and still lists it when the launch is refused', async () => {
|
|
460
609
|
// The DeepSeek preflight refuses the follow-up launch with a 409 whose
|
|
@@ -493,6 +642,15 @@ describe('a branch whose terminal launch was refused after the mint', () => {
|
|
|
493
642
|
try {
|
|
494
643
|
await (target as any)._branchSession();
|
|
495
644
|
|
|
645
|
+
// The refused launch still clears the click-time busy state.
|
|
646
|
+
expect(
|
|
647
|
+
(
|
|
648
|
+
shellNodeOf(target).querySelector(
|
|
649
|
+
'.jp-AiAssistants-busyOverlay'
|
|
650
|
+
) as HTMLElement
|
|
651
|
+
).hidden
|
|
652
|
+
).toBe(true);
|
|
653
|
+
|
|
496
654
|
// What the user is told: the branch was created, its terminal did not
|
|
497
655
|
// open, and the refusal's own words - neither sentence that blames the
|
|
498
656
|
// branch itself.
|
|
@@ -560,7 +718,8 @@ describe('DEF-132 - a panel docked before the first roster has no root', () => {
|
|
|
560
718
|
return new AssistantSessionsPanel({
|
|
561
719
|
app: {
|
|
562
720
|
serviceManager: { serverSettings: {} },
|
|
563
|
-
commands: { execute: jest.fn() }
|
|
721
|
+
commands: { execute: jest.fn() },
|
|
722
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
564
723
|
} as any,
|
|
565
724
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-panel-spec' },
|
|
566
725
|
rootDir: '',
|
|
@@ -749,15 +908,12 @@ describe('DEF-PANE-183 - the status dot follows the name in the DOM', () => {
|
|
|
749
908
|
});
|
|
750
909
|
});
|
|
751
910
|
|
|
752
|
-
describe('DEF-PANE-
|
|
753
|
-
it('
|
|
911
|
+
describe('DEF-PANE-321 - a successful switch stays quiet', () => {
|
|
912
|
+
it('issues the switch and raises no notification', async () => {
|
|
754
913
|
const success = jest
|
|
755
914
|
.spyOn(Notification, 'success')
|
|
756
915
|
.mockReturnValue('' as any);
|
|
757
916
|
const target = session({ name: 'proj' });
|
|
758
|
-
(panel as any)._lastBranches = [
|
|
759
|
-
{ session_id: 'sid-other', file_mtime: 1, label: 'Second thoughts' }
|
|
760
|
-
];
|
|
761
917
|
request.mockImplementation((_id: string, route: string) =>
|
|
762
918
|
Promise.resolve(
|
|
763
919
|
route === 'switch'
|
|
@@ -766,8 +922,9 @@ describe('DEF-PANE-181 - a successful switch says so', () => {
|
|
|
766
922
|
)
|
|
767
923
|
);
|
|
768
924
|
await (panel as any)._switchBranch(target, 'sid-other');
|
|
769
|
-
|
|
770
|
-
expect(
|
|
925
|
+
// The row's own re-render is the only signal a switch earns.
|
|
926
|
+
expect(request.mock.calls.map(call => call[1])).toContain('switch');
|
|
927
|
+
expect(success).not.toHaveBeenCalled();
|
|
771
928
|
});
|
|
772
929
|
|
|
773
930
|
it('stays quiet when the store could not make it current', async () => {
|
|
@@ -1703,7 +1860,10 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1703
1860
|
const p = new AssistantSessionsPanel({
|
|
1704
1861
|
app: {
|
|
1705
1862
|
serviceManager: { serverSettings: {} },
|
|
1706
|
-
shell: {
|
|
1863
|
+
shell: {
|
|
1864
|
+
node: document.createElement('div'),
|
|
1865
|
+
activateById: jest.fn()
|
|
1866
|
+
},
|
|
1707
1867
|
commands: {
|
|
1708
1868
|
execute: jest.fn(async (id: string) =>
|
|
1709
1869
|
id === 'terminal:open'
|
|
@@ -1787,7 +1947,8 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1787
1947
|
const p = new AssistantSessionsPanel({
|
|
1788
1948
|
app: {
|
|
1789
1949
|
serviceManager: { serverSettings: {} },
|
|
1790
|
-
commands: { execute: jest.fn() }
|
|
1950
|
+
commands: { execute: jest.fn() },
|
|
1951
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
1791
1952
|
} as any,
|
|
1792
1953
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-sync-spec' },
|
|
1793
1954
|
rootDir: ''
|
|
@@ -1820,6 +1981,14 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1820
1981
|
|
|
1821
1982
|
expect(opened).toHaveBeenCalledTimes(1);
|
|
1822
1983
|
expect(moves(panel)).toEqual([]);
|
|
1984
|
+
// The failed open clears the click-time busy state.
|
|
1985
|
+
expect(
|
|
1986
|
+
(
|
|
1987
|
+
shellNodeOf(panel).querySelector(
|
|
1988
|
+
'.jp-AiAssistants-busyOverlay'
|
|
1989
|
+
) as HTMLElement
|
|
1990
|
+
).hidden
|
|
1991
|
+
).toBe(true);
|
|
1823
1992
|
// One notification, and it is the one the failed open raises itself.
|
|
1824
1993
|
expect(notified()).toHaveLength(1);
|
|
1825
1994
|
expect(notified()[0]).toContain('Could not open that conversation');
|
|
@@ -1912,7 +2081,11 @@ describe('ACC-PANE-193 - the panel header has a context menu of its own', () =>
|
|
|
1912
2081
|
}
|
|
1913
2082
|
});
|
|
1914
2083
|
p = new AssistantSessionsPanel({
|
|
1915
|
-
app: {
|
|
2084
|
+
app: {
|
|
2085
|
+
serviceManager: { serverSettings: {} },
|
|
2086
|
+
commands,
|
|
2087
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
2088
|
+
} as any,
|
|
1916
2089
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-header-menu-spec' },
|
|
1917
2090
|
rootDir: '/home/user'
|
|
1918
2091
|
});
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
const request = requestProvider as jest.MockedFunction<typeof requestProvider>;
|
|
27
27
|
|
|
28
28
|
const PROJECT = '/home/user/proj';
|
|
29
|
+
const OTHER_PROJECT = '/home/user/other';
|
|
29
30
|
const CURRENT = 'session-11111111-1111-4111-8111-111111111111';
|
|
30
31
|
|
|
31
32
|
const DESCRIPTOR: IProviderDescriptor = {
|
|
@@ -85,6 +86,8 @@ const RUNNING_PROBE: ITerminalProbeResponse = {
|
|
|
85
86
|
let terminals: any[];
|
|
86
87
|
let launches: any[];
|
|
87
88
|
let manager: TerminalManager;
|
|
89
|
+
/** Per-terminal probe answers a test wants instead of the defaults below. */
|
|
90
|
+
let probes: Record<string, ITerminalProbeResponse>;
|
|
88
91
|
|
|
89
92
|
function makeManager(): TerminalManager {
|
|
90
93
|
return new TerminalManager({
|
|
@@ -111,6 +114,7 @@ function makeManager(): TerminalManager {
|
|
|
111
114
|
beforeEach(() => {
|
|
112
115
|
terminals = [];
|
|
113
116
|
launches = [];
|
|
117
|
+
probes = {};
|
|
114
118
|
request.mockReset();
|
|
115
119
|
request.mockImplementation((async (
|
|
116
120
|
_providerId: string,
|
|
@@ -120,6 +124,9 @@ beforeEach(() => {
|
|
|
120
124
|
) => {
|
|
121
125
|
if (path.startsWith('terminal/')) {
|
|
122
126
|
const name = decodeURIComponent(path.slice('terminal/'.length));
|
|
127
|
+
if (probes[name]) {
|
|
128
|
+
return probes[name];
|
|
129
|
+
}
|
|
123
130
|
return name === 'running'
|
|
124
131
|
? RUNNING_PROBE
|
|
125
132
|
: { ...RUNNING_PROBE, terminal_name: name, running: false, cwds: [] };
|
|
@@ -140,6 +147,14 @@ afterEach(() => {
|
|
|
140
147
|
});
|
|
141
148
|
|
|
142
149
|
describe('terminal reuse', () => {
|
|
150
|
+
const READABLE: ITerminalProbeResponse = {
|
|
151
|
+
terminal_name: 'running',
|
|
152
|
+
running: true,
|
|
153
|
+
session_id: CURRENT,
|
|
154
|
+
cwds: [PROJECT],
|
|
155
|
+
colour: null
|
|
156
|
+
};
|
|
157
|
+
|
|
143
158
|
it('never reuses a terminal whose conversation the server cannot read', async () => {
|
|
144
159
|
manager = makeManager();
|
|
145
160
|
manager.setSessions([session()]);
|
|
@@ -148,6 +163,43 @@ describe('terminal reuse', () => {
|
|
|
148
163
|
await manager.openSession(session(), undefined, CURRENT);
|
|
149
164
|
|
|
150
165
|
expect(launches).toHaveLength(1);
|
|
151
|
-
expect(await manager.findForSession(CURRENT)).toBeNull();
|
|
166
|
+
expect(await manager.findForSession(CURRENT, PROJECT)).toBeNull();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('reuses a terminal running this conversation in this project', async () => {
|
|
170
|
+
manager = makeManager();
|
|
171
|
+
manager.setSessions([session()]);
|
|
172
|
+
terminals.push(terminal('running'));
|
|
173
|
+
probes.running = READABLE;
|
|
174
|
+
|
|
175
|
+
await manager.openSession(session(), undefined, CURRENT);
|
|
176
|
+
|
|
177
|
+
expect(launches).toHaveLength(0);
|
|
178
|
+
expect(await manager.findForSession(CURRENT, PROJECT)).not.toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('starts a terminal here when the conversation runs in another project', async () => {
|
|
182
|
+
manager = makeManager();
|
|
183
|
+
manager.setSessions([session()]);
|
|
184
|
+
terminals.push(terminal('running'));
|
|
185
|
+
// Same conversation id, different tree: Claude re-homed a copy of the
|
|
186
|
+
// transcript into another project, which is DEF-PANE-319's report.
|
|
187
|
+
probes.running = { ...READABLE, cwds: [OTHER_PROJECT] };
|
|
188
|
+
|
|
189
|
+
await manager.openSession(session(), undefined, CURRENT);
|
|
190
|
+
|
|
191
|
+
expect(launches).toHaveLength(1);
|
|
192
|
+
expect(launches[0].project_path).toBe(PROJECT);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('reuses a terminal that changed directory inside the project', async () => {
|
|
196
|
+
manager = makeManager();
|
|
197
|
+
manager.setSessions([session()]);
|
|
198
|
+
terminals.push(terminal('running'));
|
|
199
|
+
probes.running = { ...READABLE, cwds: [PROJECT, `${PROJECT}/sub`] };
|
|
200
|
+
|
|
201
|
+
await manager.openSession(session(), undefined, CURRENT);
|
|
202
|
+
|
|
203
|
+
expect(launches).toHaveLength(0);
|
|
152
204
|
});
|
|
153
205
|
});
|
package/src/core/panel.ts
CHANGED
|
@@ -235,6 +235,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
235
235
|
|
|
236
236
|
dispose(): void {
|
|
237
237
|
this._stopPolling();
|
|
238
|
+
// The busy overlay lives on the shell, outside this widget's tree, so
|
|
239
|
+
// removing the panel does not remove it. Left behind it would show a
|
|
240
|
+
// spinner for a panel that no longer exists.
|
|
241
|
+
this._busyEl?.remove();
|
|
242
|
+
this._busyEl = null;
|
|
238
243
|
// Disabling a provider must leave its running terminals alive and only
|
|
239
244
|
// drop the tint this panel applied.
|
|
240
245
|
void this._terminals.clearColours();
|
|
@@ -467,6 +472,32 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
467
472
|
'jp-aiAssistants-spinner jp-AiAssistantsPanel-loadingSpinner';
|
|
468
473
|
loading.appendChild(loadingSpinner);
|
|
469
474
|
|
|
475
|
+
// Click-time busy overlay: raised synchronously when a row is opened or
|
|
476
|
+
// branched, before the terminal probe or the branch request, so the click
|
|
477
|
+
// never reads as ignored (DEF-PANE-318). It sits on the LAB SHELL, not on
|
|
478
|
+
// the panel: the feedback belongs in the main working area, where the
|
|
479
|
+
// launch spinner appears, and a panel that is hidden or scrolled must not
|
|
480
|
+
// keep it out of sight (DEF-PANE-318-1). It takes no pointer events and no
|
|
481
|
+
// focus: the context menu that launched a branch stays open until its
|
|
482
|
+
// command runs, and a focus change would close it.
|
|
483
|
+
const busy = document.createElement('div');
|
|
484
|
+
busy.className = 'jp-AiAssistants-busyOverlay';
|
|
485
|
+
busy.hidden = true;
|
|
486
|
+
const busySpinner = document.createElement('div');
|
|
487
|
+
busySpinner.className =
|
|
488
|
+
'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
|
|
489
|
+
busySpinner.setAttribute('aria-hidden', 'true');
|
|
490
|
+
busy.appendChild(busySpinner);
|
|
491
|
+
// The spinner is decoration; a screen-reader user hears the action
|
|
492
|
+
// instead. The region sits on the root, always rendered and outside the
|
|
493
|
+
// overlay and the aria-busy body: a live region created inside a
|
|
494
|
+
// display:none container, or one whose update lands in an aria-busy
|
|
495
|
+
// subtree, can be withheld by assistive technology.
|
|
496
|
+
const busyText = document.createElement('div');
|
|
497
|
+
busyText.className = 'jp-AiAssistantsPanel-srOnly';
|
|
498
|
+
busyText.setAttribute('role', 'status');
|
|
499
|
+
busyText.setAttribute('aria-live', 'polite');
|
|
500
|
+
|
|
470
501
|
// Inline error strip - a failed poll reports here and keeps retrying,
|
|
471
502
|
// rather than tearing the panel down or raising a modal that would also
|
|
472
503
|
// interrupt the other providers' panels.
|
|
@@ -482,12 +513,48 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
482
513
|
root.appendChild(error);
|
|
483
514
|
root.appendChild(body);
|
|
484
515
|
root.appendChild(loading);
|
|
516
|
+
root.appendChild(busyText);
|
|
517
|
+
// The shell carries the visible feedback; the panel carries the
|
|
518
|
+
// announcement.
|
|
519
|
+
this._app.shell.node.appendChild(busy);
|
|
485
520
|
|
|
486
521
|
this._bodyEl = body;
|
|
487
522
|
this._loadingEl = loading;
|
|
523
|
+
this._busyEl = busy;
|
|
524
|
+
this._busyTextEl = busyText;
|
|
488
525
|
this._errorEl = error;
|
|
489
526
|
}
|
|
490
527
|
|
|
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;
|
|
532
|
+
if (this._busyEl) {
|
|
533
|
+
this._busyEl.hidden = false;
|
|
534
|
+
if (this._busyTextEl) {
|
|
535
|
+
this._busyTextEl.textContent = message;
|
|
536
|
+
}
|
|
537
|
+
// The body is what the action changes; the root stays outside the
|
|
538
|
+
// busy subtree so the live region above is not withheld.
|
|
539
|
+
if (this._bodyEl) {
|
|
540
|
+
this._bodyEl.setAttribute('aria-busy', 'true');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private _endBusy(): void {
|
|
546
|
+
this._busyCount = Math.max(0, this._busyCount - 1);
|
|
547
|
+
if (this._busyCount === 0 && this._busyEl) {
|
|
548
|
+
this._busyEl.hidden = true;
|
|
549
|
+
if (this._busyTextEl) {
|
|
550
|
+
this._busyTextEl.textContent = '';
|
|
551
|
+
}
|
|
552
|
+
if (this._bodyEl) {
|
|
553
|
+
this._bodyEl.removeAttribute('aria-busy');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
491
558
|
/** Show / hide the filter input. Hiding also clears the active filter, so
|
|
492
559
|
* the user never returns to an "invisible" filter narrowing the rows. */
|
|
493
560
|
private _toggleFilterBar(): void {
|
|
@@ -885,6 +952,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
885
952
|
force?: string,
|
|
886
953
|
sessionId?: string
|
|
887
954
|
): Promise<void> {
|
|
955
|
+
// The click's own feedback, raised before the terminal walk and the
|
|
956
|
+
// launch - both can take seconds (DEF-PANE-318).
|
|
957
|
+
this._beginBusy(this._trans.__('Opening conversation'));
|
|
888
958
|
try {
|
|
889
959
|
await this._terminals.openSession(
|
|
890
960
|
session,
|
|
@@ -905,6 +975,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
905
975
|
this._notifyLaunchError('Could not open that conversation', err);
|
|
906
976
|
}
|
|
907
977
|
} finally {
|
|
978
|
+
this._endBusy();
|
|
908
979
|
// Reuse or fresh launch, either way the picture changed. Pull fresh
|
|
909
980
|
// state; a poll tick retries if this one fails.
|
|
910
981
|
void this._fetch().catch(() => undefined);
|
|
@@ -1057,7 +1128,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1057
1128
|
if (row) {
|
|
1058
1129
|
// A terminal already holding that conversation IS the answer: a second
|
|
1059
1130
|
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
1060
|
-
|
|
1131
|
+
// The folder is required as well: a transcript re-homed into another
|
|
1132
|
+
// project keeps its id, and that terminal is not this folder's
|
|
1133
|
+
// (DEF-PANE-319).
|
|
1134
|
+
const found = await this._terminals.findForSession(
|
|
1135
|
+
row.session_id,
|
|
1136
|
+
folder
|
|
1137
|
+
);
|
|
1061
1138
|
if (found) {
|
|
1062
1139
|
this._terminals.focus(found.widget);
|
|
1063
1140
|
return found.widget;
|
|
@@ -1159,7 +1236,15 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1159
1236
|
if (!session) {
|
|
1160
1237
|
return;
|
|
1161
1238
|
}
|
|
1162
|
-
|
|
1239
|
+
// Raised on the click: the branch POST mints the fork server-side (for
|
|
1240
|
+
// Claude and Codex by running their own CLI) and can take seconds
|
|
1241
|
+
// (DEF-PANE-318).
|
|
1242
|
+
this._beginBusy(this._trans.__('Branching conversation'));
|
|
1243
|
+
try {
|
|
1244
|
+
await this._branchByServerCopy(session, undefined, force);
|
|
1245
|
+
} finally {
|
|
1246
|
+
this._endBusy();
|
|
1247
|
+
}
|
|
1163
1248
|
}
|
|
1164
1249
|
|
|
1165
1250
|
/** The server mints the new id and answers with it before the terminal
|
|
@@ -1369,19 +1454,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1369
1454
|
'That conversation could not be made the current one.',
|
|
1370
1455
|
{ autoClose: 4000 }
|
|
1371
1456
|
);
|
|
1372
|
-
} else {
|
|
1373
|
-
// The re-render is the only other signal, and it can read as the
|
|
1374
|
-
// opposite of success: the row now reports the conversation's true
|
|
1375
|
-
// last activity, so switching to an old one dims the row and drops
|
|
1376
|
-
// it out of Recent (DEF-PANE-181).
|
|
1377
|
-
const target = this._lastBranches.find(b => b.session_id === sessionId);
|
|
1378
|
-
const label =
|
|
1379
|
-
target?.label ??
|
|
1380
|
-
shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
|
|
1381
|
-
Notification.success(
|
|
1382
|
-
`${this._lookupName(session)}: switched to ${label}`,
|
|
1383
|
-
{ autoClose: 3000 }
|
|
1384
|
-
);
|
|
1385
1457
|
}
|
|
1386
1458
|
} catch (err) {
|
|
1387
1459
|
Notification.error(
|
|
@@ -2785,6 +2857,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
2785
2857
|
private readonly _removingPaths: Set<string> = new Set();
|
|
2786
2858
|
private _bodyEl!: HTMLDivElement;
|
|
2787
2859
|
private _loadingEl: HTMLElement | null = null;
|
|
2860
|
+
private _busyEl: HTMLElement | null = null;
|
|
2861
|
+
private _busyTextEl: HTMLElement | null = null;
|
|
2862
|
+
private _busyCount = 0;
|
|
2788
2863
|
private _errorEl: HTMLElement | null = null;
|
|
2789
2864
|
private _refreshBtn: HTMLButtonElement | null = null;
|
|
2790
2865
|
private _filterBtn: HTMLButtonElement | null = null;
|
package/src/core/terminals.ts
CHANGED
|
@@ -199,8 +199,9 @@ export class TerminalManager {
|
|
|
199
199
|
return;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
// 2. Walk every live terminal, asking the server what each is running
|
|
203
|
-
|
|
202
|
+
// 2. Walk every live terminal, asking the server what each is running in
|
|
203
|
+
// this project.
|
|
204
|
+
const found = await this.findForSession(wanted, session.project_path);
|
|
204
205
|
if (found) {
|
|
205
206
|
// Tag with the OBSERVED conversation, so a later reuse trusts a
|
|
206
207
|
// confirmed identity rather than a wish.
|
|
@@ -282,29 +283,53 @@ export class TerminalManager {
|
|
|
282
283
|
});
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
/** Find an open terminal running exactly this conversation
|
|
286
|
+
/** Find an open terminal running exactly this conversation in this project,
|
|
287
|
+
* or null.
|
|
286
288
|
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
289
|
+
* The server reads the true id from the running process, so a session the
|
|
290
|
+
* user started by hand is recognised too, not only launches that carry an
|
|
291
|
+
* argv id. The terminal must ALSO work in the requested project. An id is
|
|
292
|
+
* not globally unique in the store: Claude re-homes a transcript into a
|
|
293
|
+
* second project under the same id, so an id match alone can name a process
|
|
294
|
+
* running it from another tree, and reusing that one opens nothing in the
|
|
295
|
+
* folder the user clicked (DEF-PANE-319). A process that changed directory
|
|
296
|
+
* WITHIN the project still matches - the probe lists every cwd in its
|
|
297
|
+
* tree. */
|
|
293
298
|
async findForSession(
|
|
294
|
-
wantedSessionId: string | undefined
|
|
299
|
+
wantedSessionId: string | undefined,
|
|
300
|
+
projectPath: string
|
|
295
301
|
): Promise<{ widget: any; runningId: string | null } | null> {
|
|
296
302
|
if (!this._tracker || !wantedSessionId) {
|
|
297
303
|
return null;
|
|
298
304
|
}
|
|
299
305
|
for (const widget of this._liveTerminals()) {
|
|
300
306
|
const info = await this.probe(widget);
|
|
301
|
-
if (
|
|
307
|
+
if (
|
|
308
|
+
info &&
|
|
309
|
+
info.session_id &&
|
|
310
|
+
info.session_id === wantedSessionId &&
|
|
311
|
+
this._runsInProject(info.cwds, projectPath)
|
|
312
|
+
) {
|
|
302
313
|
return { widget, runningId: info.session_id };
|
|
303
314
|
}
|
|
304
315
|
}
|
|
305
316
|
return null;
|
|
306
317
|
}
|
|
307
318
|
|
|
319
|
+
/** Whether any process in the terminal's tree works in `projectPath` or a
|
|
320
|
+
* directory under it. No cwds means nothing to prove the terminal belongs
|
|
321
|
+
* to this project, so it is not reused. */
|
|
322
|
+
private _runsInProject(
|
|
323
|
+
cwds: string[] | undefined,
|
|
324
|
+
projectPath: string
|
|
325
|
+
): boolean {
|
|
326
|
+
const root = projectPath.replace(/\/+$/, '');
|
|
327
|
+
return (cwds ?? []).some(cwd => {
|
|
328
|
+
const path = cwd.replace(/\/+$/, '');
|
|
329
|
+
return path === root || path.startsWith(`${root}/`);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
308
333
|
/** Ask the server what a terminal is running. Null when the widget has no
|
|
309
334
|
* session name yet, or the probe fails - a terminal can vanish between
|
|
310
335
|
* enumeration and probe, which is a retry, not an error. */
|
package/style/base.css
CHANGED
|
@@ -559,6 +559,13 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
559
559
|
animation: none;
|
|
560
560
|
opacity: 0.5;
|
|
561
561
|
}
|
|
562
|
+
|
|
563
|
+
/* The click-time busy spinner is the only thing on screen while a click is
|
|
564
|
+
being handled, so it keeps full opacity: dimmed, its brand arc measures
|
|
565
|
+
1.74:1 on the light panel, below the 3:1 minimum for a non-text cue. */
|
|
566
|
+
.jp-AiAssistants-busySpinner {
|
|
567
|
+
opacity: 1;
|
|
568
|
+
}
|
|
562
569
|
}
|
|
563
570
|
|
|
564
571
|
/* Full-panel veil shown only during an explicit refresh (refresh button or
|
|
@@ -581,6 +588,28 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
581
588
|
border-width: 3px;
|
|
582
589
|
}
|
|
583
590
|
|
|
591
|
+
/* Click-time busy overlay (DEF-PANE-318): raised on the click, before the
|
|
592
|
+
terminal probe or the branch request. It lives on the lab shell so the
|
|
593
|
+
feedback appears in the main working area, not inside the panel
|
|
594
|
+
(DEF-PANE-318-1). No pointer events and no focus - the context menu that
|
|
595
|
+
launched a branch stays open until its command runs, and a focus change
|
|
596
|
+
would close it. */
|
|
597
|
+
.jp-AiAssistants-busyOverlay:not([hidden]) {
|
|
598
|
+
position: fixed;
|
|
599
|
+
inset: 0;
|
|
600
|
+
z-index: 9;
|
|
601
|
+
display: flex;
|
|
602
|
+
align-items: center;
|
|
603
|
+
justify-content: center;
|
|
604
|
+
pointer-events: none;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
.jp-AiAssistants-busySpinner {
|
|
608
|
+
width: 24px;
|
|
609
|
+
height: 24px;
|
|
610
|
+
border-width: 3px;
|
|
611
|
+
}
|
|
612
|
+
|
|
584
613
|
.jp-AiAssistantsPanel-launchOverlay {
|
|
585
614
|
display: flex;
|
|
586
615
|
flex-direction: column;
|
|
@@ -740,7 +769,7 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
740
769
|
border-radius: var(--aica-radius-lg);
|
|
741
770
|
}
|
|
742
771
|
|
|
743
|
-
/* Visually hidden polite live region (
|
|
772
|
+
/* Visually hidden polite live region (polite announcements). */
|
|
744
773
|
.jp-AiAssistantsPanel-srOnly {
|
|
745
774
|
position: absolute;
|
|
746
775
|
width: 1px;
|