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 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). Unlike the refresh veil it takes
398
- // no pointer events and no focus: the context menu that launched a branch
399
- // stays open until its command runs, and a focus change would close it.
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-AiAssistantsPanel-busyOverlay';
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-AiAssistantsPanel-busySpinner';
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
- const found = await this._terminals.findForSession(row.session_id);
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)
@@ -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, or null.
59
+ /** Find an open terminal running exactly this conversation in this project,
60
+ * or null.
60
61
  *
61
- * Matches on the conversation id ALONE. The server reads the true id from
62
- * the running process, so a session the user started by hand is recognised
63
- * too, not only launches that carry an argv id. An id is globally unique to
64
- * one conversation, so a terminal holding it IS the one to focus, whatever
65
- * cwd it reports - a process that changed directory, or whose project dir
66
- * was recreated, must still be reused rather than duplicated. */
67
- findForSession(wantedSessionId: string | undefined): Promise<{
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. */
@@ -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
- const found = await this.findForSession(wanted);
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, or null.
259
+ /** Find an open terminal running exactly this conversation in this project,
260
+ * or null.
259
261
  *
260
- * Matches on the conversation id ALONE. The server reads the true id from
261
- * the running process, so a session the user started by hand is recognised
262
- * too, not only launches that carry an argv id. An id is globally unique to
263
- * one conversation, so a terminal holding it IS the one to focus, whatever
264
- * cwd it reports - a process that changed directory, or whose project dir
265
- * was recreated, must still be reused rather than duplicated. */
266
- async findForSession(wantedSessionId) {
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 && info.session_id && info.session_id === wantedSessionId) {
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.99",
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: { activateById: (id: string) => activated.push(id) }
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.node.querySelector('.jp-AiAssistantsPanel-busyOverlay')!;
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
- // Root placement is load-bearing: _render wipes the body on every fetch,
485
- // including the 30 s poll, which would delete an overlay kept there.
486
- expect(overlay().parentElement).toBe(target.node);
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.node.querySelector('.jp-AiAssistantsPanel-busyOverlay')!;
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.node.querySelector(
640
- '.jp-AiAssistantsPanel-busyOverlay'
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-181 - a successful switch says so', () => {
902
- it('announces the conversation the project now sits on', async () => {
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
- expect(success).toHaveBeenCalledTimes(1);
919
- expect(success.mock.calls[0][0]).toBe('proj: switched to Second thoughts');
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: { activateById: jest.fn() },
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.node.querySelector(
1976
- '.jp-AiAssistantsPanel-busyOverlay'
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: { serviceManager: { serverSettings: {} }, commands } as any,
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). Unlike the refresh veil it takes
473
- // no pointer events and no focus: the context menu that launched a branch
474
- // stays open until its command runs, and a focus change would close it.
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-AiAssistantsPanel-busyOverlay';
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-AiAssistantsPanel-busySpinner';
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
- const found = await this._terminals.findForSession(row.session_id);
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(
@@ -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
- const found = await this.findForSession(wanted);
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, or null.
286
+ /** Find an open terminal running exactly this conversation in this project,
287
+ * or null.
286
288
  *
287
- * Matches on the conversation id ALONE. The server reads the true id from
288
- * the running process, so a session the user started by hand is recognised
289
- * too, not only launches that carry an argv id. An id is globally unique to
290
- * one conversation, so a terminal holding it IS the one to focus, whatever
291
- * cwd it reports - a process that changed directory, or whose project dir
292
- * was recreated, must still be reused rather than duplicated. */
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 (info && info.session_id && info.session_id === wantedSessionId) {
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-AiAssistantsPanel-busySpinner {
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. No pointer events and no focus - the
593
- context menu that launched a branch stays open until its command runs, and
594
- a focus change would close it. */
595
- .jp-AiAssistantsPanel-busyOverlay:not([hidden]) {
596
- position: absolute;
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-AiAssistantsPanel-busySpinner {
607
+ .jp-AiAssistants-busySpinner {
606
608
  width: 24px;
607
609
  height: 24px;
608
610
  border-width: 3px;