jupyterlab_ai_code_assistants_extension 1.2.99 → 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.js +21 -17
- 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 +37 -21
- package/src/__tests__/terminals.spec.ts +53 -1
- package/src/core/panel.ts +23 -20
- package/src/core/terminals.ts +36 -11
- package/style/base.css +9 -7
package/lib/core/panel.js
CHANGED
|
@@ -186,7 +186,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
186
186
|
return this._descriptor;
|
|
187
187
|
}
|
|
188
188
|
dispose() {
|
|
189
|
+
var _a;
|
|
189
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;
|
|
190
196
|
// Disabling a provider must leave its running terminals alive and only
|
|
191
197
|
// drop the tint this panel applied.
|
|
192
198
|
void this._terminals.clearColours();
|
|
@@ -394,15 +400,18 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
394
400
|
loading.appendChild(loadingSpinner);
|
|
395
401
|
// Click-time busy overlay: raised synchronously when a row is opened or
|
|
396
402
|
// branched, before the terminal probe or the branch request, so the click
|
|
397
|
-
// never reads as ignored (DEF-PANE-318).
|
|
398
|
-
//
|
|
399
|
-
//
|
|
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.
|
|
400
409
|
const busy = document.createElement('div');
|
|
401
|
-
busy.className = 'jp-
|
|
410
|
+
busy.className = 'jp-AiAssistants-busyOverlay';
|
|
402
411
|
busy.hidden = true;
|
|
403
412
|
const busySpinner = document.createElement('div');
|
|
404
413
|
busySpinner.className =
|
|
405
|
-
'jp-aiAssistants-spinner jp-
|
|
414
|
+
'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
|
|
406
415
|
busySpinner.setAttribute('aria-hidden', 'true');
|
|
407
416
|
busy.appendChild(busySpinner);
|
|
408
417
|
// The spinner is decoration; a screen-reader user hears the action
|
|
@@ -428,8 +437,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
428
437
|
root.appendChild(error);
|
|
429
438
|
root.appendChild(body);
|
|
430
439
|
root.appendChild(loading);
|
|
431
|
-
root.appendChild(busy);
|
|
432
440
|
root.appendChild(busyText);
|
|
441
|
+
// The shell carries the visible feedback; the panel carries the
|
|
442
|
+
// announcement.
|
|
443
|
+
this._app.shell.node.appendChild(busy);
|
|
433
444
|
this._bodyEl = body;
|
|
434
445
|
this._loadingEl = loading;
|
|
435
446
|
this._busyEl = busy;
|
|
@@ -955,7 +966,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
955
966
|
if (row) {
|
|
956
967
|
// A terminal already holding that conversation IS the answer: a second
|
|
957
968
|
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
958
|
-
|
|
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);
|
|
959
973
|
if (found) {
|
|
960
974
|
this._terminals.focus(found.widget);
|
|
961
975
|
return found.widget;
|
|
@@ -1217,7 +1231,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1217
1231
|
}
|
|
1218
1232
|
/** Make another conversation the row's current one. */
|
|
1219
1233
|
async _switchBranch(session, sessionId) {
|
|
1220
|
-
var _a;
|
|
1221
1234
|
try {
|
|
1222
1235
|
const result = await requestProvider(this._descriptor.id, 'switch', this._serverSettings, {
|
|
1223
1236
|
method: 'POST',
|
|
@@ -1229,15 +1242,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1229
1242
|
if (result.current !== result.requested) {
|
|
1230
1243
|
Notification.warning('That conversation could not be made the current one.', { autoClose: 4000 });
|
|
1231
1244
|
}
|
|
1232
|
-
else {
|
|
1233
|
-
// The re-render is the only other signal, and it can read as the
|
|
1234
|
-
// opposite of success: the row now reports the conversation's true
|
|
1235
|
-
// last activity, so switching to an old one dims the row and drops
|
|
1236
|
-
// it out of Recent (DEF-PANE-181).
|
|
1237
|
-
const target = this._lastBranches.find(b => b.session_id === sessionId);
|
|
1238
|
-
const label = (_a = target === null || target === void 0 ? void 0 : target.label) !== null && _a !== void 0 ? _a : shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
|
|
1239
|
-
Notification.success(`${this._lookupName(session)}: switched to ${label}`, { autoClose: 3000 });
|
|
1240
|
-
}
|
|
1241
1245
|
}
|
|
1242
1246
|
catch (err) {
|
|
1243
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();
|
|
@@ -466,7 +473,7 @@ describe('click-time busy state (DEF-PANE-318)', () => {
|
|
|
466
473
|
(target as any)._terminals.openSession = jest.fn(() => pending);
|
|
467
474
|
(target as any)._fetch = jest.fn().mockResolvedValue(undefined);
|
|
468
475
|
const overlay = (): HTMLElement =>
|
|
469
|
-
target.
|
|
476
|
+
shellNodeOf(target).querySelector('.jp-AiAssistants-busyOverlay')!;
|
|
470
477
|
const body = (): HTMLElement =>
|
|
471
478
|
target.node.querySelector('.jp-AiAssistantsPanel-body')!;
|
|
472
479
|
|
|
@@ -481,9 +488,11 @@ describe('click-time busy state (DEF-PANE-318)', () => {
|
|
|
481
488
|
expect(live.getAttribute('aria-live')).toBe('polite');
|
|
482
489
|
|
|
483
490
|
expect(overlay().hidden).toBe(true);
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
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);
|
|
487
496
|
// The overlay takes no focus, so the context menu that launched an action
|
|
488
497
|
// cannot be closed by an element inside it taking the focus.
|
|
489
498
|
expect(overlay().hasAttribute('tabindex')).toBe(false);
|
|
@@ -528,7 +537,7 @@ describe('click-time busy state (DEF-PANE-318)', () => {
|
|
|
528
537
|
(target as any)._terminals.openSession = jest.fn(() => requests.shift());
|
|
529
538
|
(target as any)._fetch = jest.fn().mockResolvedValue(undefined);
|
|
530
539
|
const overlay = (): HTMLElement =>
|
|
531
|
-
target.
|
|
540
|
+
shellNodeOf(target).querySelector('.jp-AiAssistants-busyOverlay')!;
|
|
532
541
|
const body = (): HTMLElement =>
|
|
533
542
|
target.node.querySelector('.jp-AiAssistantsPanel-body')!;
|
|
534
543
|
|
|
@@ -636,8 +645,8 @@ describe('a branch whose terminal launch was refused after the mint', () => {
|
|
|
636
645
|
// The refused launch still clears the click-time busy state.
|
|
637
646
|
expect(
|
|
638
647
|
(
|
|
639
|
-
target.
|
|
640
|
-
'.jp-
|
|
648
|
+
shellNodeOf(target).querySelector(
|
|
649
|
+
'.jp-AiAssistants-busyOverlay'
|
|
641
650
|
) as HTMLElement
|
|
642
651
|
).hidden
|
|
643
652
|
).toBe(true);
|
|
@@ -709,7 +718,8 @@ describe('DEF-132 - a panel docked before the first roster has no root', () => {
|
|
|
709
718
|
return new AssistantSessionsPanel({
|
|
710
719
|
app: {
|
|
711
720
|
serviceManager: { serverSettings: {} },
|
|
712
|
-
commands: { execute: jest.fn() }
|
|
721
|
+
commands: { execute: jest.fn() },
|
|
722
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
713
723
|
} as any,
|
|
714
724
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-panel-spec' },
|
|
715
725
|
rootDir: '',
|
|
@@ -898,15 +908,12 @@ describe('DEF-PANE-183 - the status dot follows the name in the DOM', () => {
|
|
|
898
908
|
});
|
|
899
909
|
});
|
|
900
910
|
|
|
901
|
-
describe('DEF-PANE-
|
|
902
|
-
it('
|
|
911
|
+
describe('DEF-PANE-321 - a successful switch stays quiet', () => {
|
|
912
|
+
it('issues the switch and raises no notification', async () => {
|
|
903
913
|
const success = jest
|
|
904
914
|
.spyOn(Notification, 'success')
|
|
905
915
|
.mockReturnValue('' as any);
|
|
906
916
|
const target = session({ name: 'proj' });
|
|
907
|
-
(panel as any)._lastBranches = [
|
|
908
|
-
{ session_id: 'sid-other', file_mtime: 1, label: 'Second thoughts' }
|
|
909
|
-
];
|
|
910
917
|
request.mockImplementation((_id: string, route: string) =>
|
|
911
918
|
Promise.resolve(
|
|
912
919
|
route === 'switch'
|
|
@@ -915,8 +922,9 @@ describe('DEF-PANE-181 - a successful switch says so', () => {
|
|
|
915
922
|
)
|
|
916
923
|
);
|
|
917
924
|
await (panel as any)._switchBranch(target, 'sid-other');
|
|
918
|
-
|
|
919
|
-
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();
|
|
920
928
|
});
|
|
921
929
|
|
|
922
930
|
it('stays quiet when the store could not make it current', async () => {
|
|
@@ -1852,7 +1860,10 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1852
1860
|
const p = new AssistantSessionsPanel({
|
|
1853
1861
|
app: {
|
|
1854
1862
|
serviceManager: { serverSettings: {} },
|
|
1855
|
-
shell: {
|
|
1863
|
+
shell: {
|
|
1864
|
+
node: document.createElement('div'),
|
|
1865
|
+
activateById: jest.fn()
|
|
1866
|
+
},
|
|
1856
1867
|
commands: {
|
|
1857
1868
|
execute: jest.fn(async (id: string) =>
|
|
1858
1869
|
id === 'terminal:open'
|
|
@@ -1936,7 +1947,8 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1936
1947
|
const p = new AssistantSessionsPanel({
|
|
1937
1948
|
app: {
|
|
1938
1949
|
serviceManager: { serverSettings: {} },
|
|
1939
|
-
commands: { execute: jest.fn() }
|
|
1950
|
+
commands: { execute: jest.fn() },
|
|
1951
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
1940
1952
|
} as any,
|
|
1941
1953
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-rootless-sync-spec' },
|
|
1942
1954
|
rootDir: ''
|
|
@@ -1972,8 +1984,8 @@ describe('ACC-SESS-190..192 - opening a session moves the file browser', () => {
|
|
|
1972
1984
|
// The failed open clears the click-time busy state.
|
|
1973
1985
|
expect(
|
|
1974
1986
|
(
|
|
1975
|
-
panel.
|
|
1976
|
-
'.jp-
|
|
1987
|
+
shellNodeOf(panel).querySelector(
|
|
1988
|
+
'.jp-AiAssistants-busyOverlay'
|
|
1977
1989
|
) as HTMLElement
|
|
1978
1990
|
).hidden
|
|
1979
1991
|
).toBe(true);
|
|
@@ -2069,7 +2081,11 @@ describe('ACC-PANE-193 - the panel header has a context menu of its own', () =>
|
|
|
2069
2081
|
}
|
|
2070
2082
|
});
|
|
2071
2083
|
p = new AssistantSessionsPanel({
|
|
2072
|
-
app: {
|
|
2084
|
+
app: {
|
|
2085
|
+
serviceManager: { serverSettings: {} },
|
|
2086
|
+
commands,
|
|
2087
|
+
shell: { node: document.createElement('div'), activateById: jest.fn() }
|
|
2088
|
+
} as any,
|
|
2073
2089
|
descriptor: { ...DESCRIPTOR, iconName: 'testbed-header-menu-spec' },
|
|
2074
2090
|
rootDir: '/home/user'
|
|
2075
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();
|
|
@@ -469,15 +474,18 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
469
474
|
|
|
470
475
|
// Click-time busy overlay: raised synchronously when a row is opened or
|
|
471
476
|
// branched, before the terminal probe or the branch request, so the click
|
|
472
|
-
// never reads as ignored (DEF-PANE-318).
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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.
|
|
475
483
|
const busy = document.createElement('div');
|
|
476
|
-
busy.className = 'jp-
|
|
484
|
+
busy.className = 'jp-AiAssistants-busyOverlay';
|
|
477
485
|
busy.hidden = true;
|
|
478
486
|
const busySpinner = document.createElement('div');
|
|
479
487
|
busySpinner.className =
|
|
480
|
-
'jp-aiAssistants-spinner jp-
|
|
488
|
+
'jp-aiAssistants-spinner jp-AiAssistants-busySpinner';
|
|
481
489
|
busySpinner.setAttribute('aria-hidden', 'true');
|
|
482
490
|
busy.appendChild(busySpinner);
|
|
483
491
|
// The spinner is decoration; a screen-reader user hears the action
|
|
@@ -505,8 +513,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
505
513
|
root.appendChild(error);
|
|
506
514
|
root.appendChild(body);
|
|
507
515
|
root.appendChild(loading);
|
|
508
|
-
root.appendChild(busy);
|
|
509
516
|
root.appendChild(busyText);
|
|
517
|
+
// The shell carries the visible feedback; the panel carries the
|
|
518
|
+
// announcement.
|
|
519
|
+
this._app.shell.node.appendChild(busy);
|
|
510
520
|
|
|
511
521
|
this._bodyEl = body;
|
|
512
522
|
this._loadingEl = loading;
|
|
@@ -1118,7 +1128,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1118
1128
|
if (row) {
|
|
1119
1129
|
// A terminal already holding that conversation IS the answer: a second
|
|
1120
1130
|
// process on one history is never what the click meant (ACC-LNCH-152).
|
|
1121
|
-
|
|
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
|
+
);
|
|
1122
1138
|
if (found) {
|
|
1123
1139
|
this._terminals.focus(found.widget);
|
|
1124
1140
|
return found.widget;
|
|
@@ -1438,19 +1454,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
1438
1454
|
'That conversation could not be made the current one.',
|
|
1439
1455
|
{ autoClose: 4000 }
|
|
1440
1456
|
);
|
|
1441
|
-
} else {
|
|
1442
|
-
// The re-render is the only other signal, and it can read as the
|
|
1443
|
-
// opposite of success: the row now reports the conversation's true
|
|
1444
|
-
// last activity, so switching to an old one dims the row and drops
|
|
1445
|
-
// it out of Recent (DEF-PANE-181).
|
|
1446
|
-
const target = this._lastBranches.find(b => b.session_id === sessionId);
|
|
1447
|
-
const label =
|
|
1448
|
-
target?.label ??
|
|
1449
|
-
shortSessionId(sessionId, this._descriptor.sessionIdPrefix);
|
|
1450
|
-
Notification.success(
|
|
1451
|
-
`${this._lookupName(session)}: switched to ${label}`,
|
|
1452
|
-
{ autoClose: 3000 }
|
|
1453
|
-
);
|
|
1454
1457
|
}
|
|
1455
1458
|
} catch (err) {
|
|
1456
1459
|
Notification.error(
|
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
|
@@ -563,7 +563,7 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
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
565
|
1.74:1 on the light panel, below the 3:1 minimum for a non-text cue. */
|
|
566
|
-
.jp-
|
|
566
|
+
.jp-AiAssistants-busySpinner {
|
|
567
567
|
opacity: 1;
|
|
568
568
|
}
|
|
569
569
|
}
|
|
@@ -589,11 +589,13 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
589
589
|
}
|
|
590
590
|
|
|
591
591
|
/* Click-time busy overlay (DEF-PANE-318): raised on the click, before the
|
|
592
|
-
terminal probe or the branch request.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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;
|
|
597
599
|
inset: 0;
|
|
598
600
|
z-index: 9;
|
|
599
601
|
display: flex;
|
|
@@ -602,7 +604,7 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
602
604
|
pointer-events: none;
|
|
603
605
|
}
|
|
604
606
|
|
|
605
|
-
.jp-
|
|
607
|
+
.jp-AiAssistants-busySpinner {
|
|
606
608
|
width: 24px;
|
|
607
609
|
height: 24px;
|
|
608
610
|
border-width: 3px;
|