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.
@@ -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
- 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);
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
- await this._branchByServerCopy(session, undefined, force);
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)
@@ -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.83",
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();
@@ -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-181 - a successful switch says so', () => {
753
- 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 () => {
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
- expect(success).toHaveBeenCalledTimes(1);
770
- 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();
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: { activateById: jest.fn() },
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: { serviceManager: { serverSettings: {} }, commands } as any,
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
- 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
+ );
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
- await this._branchByServerCopy(session, undefined, force);
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;
@@ -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
@@ -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 (delete announcements). */
772
+ /* Visually hidden polite live region (polite announcements). */
744
773
  .jp-AiAssistantsPanel-srOnly {
745
774
  position: absolute;
746
775
  width: 1px;