jupyterlab_ai_code_assistants_extension 1.2.236 → 1.2.271

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.
@@ -382,6 +382,10 @@ export declare class AssistantSessionsPanel extends Widget {
382
382
  * `_fetch`. */
383
383
  private _fetchSent;
384
384
  private _fetchDrawn;
385
+ /** The `_fetchSent` number at each removal's DELETE, by encoded path. A
386
+ * listing sent before that number may still carry the removed project, so
387
+ * `_fetch` drops it (DEF-PANE-351-WRITE-ORDER). */
388
+ private readonly _removedAt;
385
389
  /** A button is pressed in the list, and a redraw waits for its release -
386
390
  * see `_onRelease`. */
387
391
  private _pressed;
package/lib/core/panel.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
2
- import { MenuSvg, closeIcon, editIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
2
+ import { MenuSvg, closeIcon, copyIcon, editIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
3
3
  import { nullTranslator } from '@jupyterlab/translation';
4
4
  import { CommandRegistry } from '@lumino/commands';
5
5
  import { UUID } from '@lumino/coreutils';
@@ -76,8 +76,9 @@ let fileBrowserMoves = Promise.resolve();
76
76
  * later click does not move the file browser, so it ends on the folder of the
77
77
  * newest click that opened (DEF-PANE-343). The same counter orders the writes
78
78
  * that decide a project's current conversation: a row click, an explicit
79
- * switch and a fork each take a number, and a row click posts its deferred
80
- * switch only while it is the newest (DEF-PANE-349). */
79
+ * switch, a fork and a New Session (+) each take a number, and a row click
80
+ * posts its deferred switch only while it is the newest (DEF-PANE-349,
81
+ * DEF-PANE-355). */
81
82
  let openClicks = 0;
82
83
  let movedClick = 0;
83
84
  function loadExpanded(storageKey) {
@@ -179,6 +180,10 @@ export class AssistantSessionsPanel extends Widget {
179
180
  * `_fetch`. */
180
181
  this._fetchSent = 0;
181
182
  this._fetchDrawn = 0;
183
+ /** The `_fetchSent` number at each removal's DELETE, by encoded path. A
184
+ * listing sent before that number may still carry the removed project, so
185
+ * `_fetch` drops it (DEF-PANE-351-WRITE-ORDER). */
186
+ this._removedAt = new Map();
182
187
  /** A button is pressed in the list, and a redraw waits for its release -
183
188
  * see `_onRelease`. */
184
189
  this._pressed = false;
@@ -640,7 +645,9 @@ export class AssistantSessionsPanel extends Widget {
640
645
  return;
641
646
  }
642
647
  this._fetchDrawn = request;
643
- this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
648
+ // A project removed at or after this listing was sent must not be drawn
649
+ // back from the answer (DEF-PANE-351-WRITE-ORDER).
650
+ this._sessions = ((_a = data.sessions) !== null && _a !== void 0 ? _a : []).filter(s => { var _a; return ((_a = this._removedAt.get(s.encoded_path)) !== null && _a !== void 0 ? _a : 0) < request; });
644
651
  this._setInlineError(null);
645
652
  this._terminals.setSessions(this._sessions);
646
653
  this._render();
@@ -659,6 +666,10 @@ export class AssistantSessionsPanel extends Widget {
659
666
  favourite: next
660
667
  })
661
668
  });
669
+ // A listing sent before the write may still be drawn, and a poll may
670
+ // have replaced the row this wrote; this one is answered after the write
671
+ // (DEF-PANE-351).
672
+ void this._fetch().catch(() => undefined);
662
673
  }
663
674
  catch (err) {
664
675
  session.favourite = !next; // roll back
@@ -711,6 +722,11 @@ export class AssistantSessionsPanel extends Widget {
711
722
  });
712
723
  // Drop locally; the next poll reconciles against disk.
713
724
  this._sessions = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).filter(s => s.encoded_path !== session.encoded_path);
725
+ // A listing sent before the delete may still be drawn (DEF-PANE-351),
726
+ // and it carries the removed project: the number taken here marks the
727
+ // answer for `_fetch` to drop (DEF-PANE-351-WRITE-ORDER).
728
+ this._removedAt.set(session.encoded_path, this._fetchSent);
729
+ void this._fetch().catch(() => undefined);
714
730
  }
715
731
  catch (err) {
716
732
  this._showActionError(`Could not remove "${name}" - try again.`, err);
@@ -999,7 +1015,7 @@ export class AssistantSessionsPanel extends Widget {
999
1015
  // The terminal a launch before this click opened for it counts too,
1000
1016
  // before the server can read the conversation off its process
1001
1017
  // (DEF-PANE-345).
1002
- const found = (_c = this._terminals.cached(folder, row.session_id)) !== null && _c !== void 0 ? _c : (_d = (await this._terminals.findForSession(row.session_id, folder))) === null || _d === void 0 ? void 0 : _d.widget;
1018
+ const found = (_c = (await this._terminals.cached(folder, row.session_id))) !== null && _c !== void 0 ? _c : (_d = (await this._terminals.findForSession(row.session_id, folder))) === null || _d === void 0 ? void 0 : _d.widget;
1003
1019
  if (found) {
1004
1020
  this._terminals.focus(found);
1005
1021
  return found;
@@ -1071,6 +1087,15 @@ export class AssistantSessionsPanel extends Widget {
1071
1087
  // store key lets the server clear that pin so the new conversation is not
1072
1088
  // shadowed by a stale one (DEF-9).
1073
1089
  const existing = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).find(s => s.project_path === projectPath);
1090
+ if (existing) {
1091
+ // The + click is the project's newest decision: a row open still
1092
+ // finishing posts no switch over it (DEF-PANE-355). It has no id to post;
1093
+ // the launch route clears the pin.
1094
+ this._currentTokens.set(existing.encoded_path, {
1095
+ click: ++openClicks,
1096
+ sessionId: ''
1097
+ });
1098
+ }
1074
1099
  // Raised on the click, not by the launch: the launch waits for a tile
1075
1100
  // launch clicked before it (DEF-PANE-345).
1076
1101
  const busy = beginSpinner(this._trans.__('Opening %1', this._descriptor.label), this._trans);
@@ -1328,7 +1353,8 @@ export class AssistantSessionsPanel extends Widget {
1328
1353
  const key = session.encoded_path;
1329
1354
  const run = ((_a = this._switchChains.get(key)) !== null && _a !== void 0 ? _a : Promise.resolve()).then(async () => {
1330
1355
  const newest = this._currentTokens.get(key);
1331
- if (!newest) {
1356
+ // An empty id is a New Session (+) decision, which posts nothing.
1357
+ if (!(newest === null || newest === void 0 ? void 0 : newest.sessionId)) {
1332
1358
  return;
1333
1359
  }
1334
1360
  if (!force && this._postedClicks.get(key) === newest.click) {
@@ -2097,6 +2123,7 @@ export class AssistantSessionsPanel extends Widget {
2097
2123
  });
2098
2124
  this._commands.addCommand(this._cmd('copy-path'), {
2099
2125
  label: this._trans.__('Copy Path'),
2126
+ icon: copyIcon,
2100
2127
  execute: () => {
2101
2128
  const s = active();
2102
2129
  if (s) {
@@ -2106,6 +2133,7 @@ export class AssistantSessionsPanel extends Widget {
2106
2133
  });
2107
2134
  this._commands.addCommand(this._cmd('copy-session-id'), {
2108
2135
  label: this._trans.__('Copy Session ID'),
2136
+ icon: copyIcon,
2109
2137
  execute: () => {
2110
2138
  var _a;
2111
2139
  const id = (_a = active()) === null || _a === void 0 ? void 0 : _a.session_id;
@@ -2145,6 +2173,9 @@ export class AssistantSessionsPanel extends Widget {
2145
2173
  // item beside an ordinary one (DEF-45). The sweep variant of the trash
2146
2174
  // glyph, because this one deletes several at once.
2147
2175
  icon: cleanupIcon,
2176
+ // Both history-deleting entries draw their icon in the error colour
2177
+ // (base.css).
2178
+ className: 'jp-AiAssistantsContextMenu-danger',
2148
2179
  isVisible: () => { var _a, _b; return ((_b = (_a = active()) === null || _a === void 0 ? void 0 : _a.extra_sessions) !== null && _b !== void 0 ? _b : 0) > 0; },
2149
2180
  execute: () => {
2150
2181
  const s = active();
@@ -2241,6 +2272,7 @@ export class AssistantSessionsPanel extends Widget {
2241
2272
  this._commands.addCommand(this._cmd('remove'), {
2242
2273
  label: `Remove from ${this._descriptor.label}`,
2243
2274
  icon: removeIcon,
2275
+ className: 'jp-AiAssistantsContextMenu-danger',
2244
2276
  execute: () => {
2245
2277
  const s = active();
2246
2278
  if (s) {
@@ -86,8 +86,10 @@ export declare class TerminalManager {
86
86
  */
87
87
  launch(request: ILaunchRequest, tagSessionId?: string, spinner?: number): Promise<any>;
88
88
  /** The terminal this manager last opened for `projectPath`, when it is
89
- * still open and was opened for the conversation `sessionId`; else null. */
90
- cached(projectPath: string, sessionId: string): any | null;
89
+ * still open, was opened for the conversation `sessionId`, and the server
90
+ * does not read another conversation off its process - which /clear starts
91
+ * in the same process (DEF-PANE-350); else null. */
92
+ cached(projectPath: string, sessionId: string): Promise<any | null>;
91
93
  /** Remember `widget` as the newest terminal of `projectPath`, opened for
92
94
  * the conversation `sessionId`. A launch is remembered before the server
93
95
  * can read the conversation off the new process, so the next open of the
@@ -383,7 +383,7 @@ export class TerminalManager {
383
383
  var _a;
384
384
  // 1. Microcache - the most recent terminal of this project, reused only
385
385
  // when it carries the wanted conversation.
386
- const cached = this.cached(session.project_path, wanted);
386
+ const cached = await this.cached(session.project_path, wanted);
387
387
  if (cached) {
388
388
  this.focus(cached, spinner);
389
389
  return;
@@ -473,12 +473,24 @@ export class TerminalManager {
473
473
  }
474
474
  }
475
475
  /** The terminal this manager last opened for `projectPath`, when it is
476
- * still open and was opened for the conversation `sessionId`; else null. */
477
- cached(projectPath, sessionId) {
476
+ * still open, was opened for the conversation `sessionId`, and the server
477
+ * does not read another conversation off its process - which /clear starts
478
+ * in the same process (DEF-PANE-350); else null. */
479
+ async cached(projectPath, sessionId) {
478
480
  const cached = this._byProject.get(projectPath);
479
- return cached && !cached.widget.isDisposed && cached.sessionId === sessionId
480
- ? cached.widget
481
- : null;
481
+ if (!cached || cached.widget.isDisposed || cached.sessionId !== sessionId) {
482
+ return null;
483
+ }
484
+ // No id read is the window before the new process is readable, which is
485
+ // what this cache covers (DEF-PANE-345).
486
+ const info = await this.probe(cached.widget);
487
+ if ((info === null || info === void 0 ? void 0 : info.session_id) && info.session_id !== sessionId) {
488
+ if (this._byProject.get(projectPath) === cached) {
489
+ this._byProject.delete(projectPath);
490
+ }
491
+ return null;
492
+ }
493
+ return cached.widget;
482
494
  }
483
495
  /** Remember `widget` as the newest terminal of `projectPath`, opened for
484
496
  * the conversation `sessionId`. A launch is remembered before the server
package/lib/index.js CHANGED
@@ -413,6 +413,31 @@ const plugin = {
413
413
  }
414
414
  }
415
415
  };
416
+ // A suspended network stack - laptop asleep, tab backgrounded - is exactly
417
+ // when the probe fails, and the slow cadence below would leave the roster
418
+ // stale for up to a minute after the machine comes back. A wake fires
419
+ // both events, so it costs two probes; deliberately, because a guard that
420
+ // let the second join the first answered the wake with a request issued
421
+ // before it, and starved the interval below of its own tick (DEF-117).
422
+ // Heard from here, before the first await: a wake before the listeners are
423
+ // armed below is only remembered, because a reconcile before the settings
424
+ // load reads the enabled-by-default fallback and docks assistants the user
425
+ // disabled; one status check answers it once they are armed (DEF-GUARD-122).
426
+ let armed = false;
427
+ let wokeDuringActivation = false;
428
+ const reprobe = () => {
429
+ if (!armed) {
430
+ wokeDuringActivation = true;
431
+ return;
432
+ }
433
+ void probeStatus().then(reconcile);
434
+ };
435
+ window.addEventListener('online', reprobe);
436
+ document.addEventListener('visibilitychange', () => {
437
+ if (document.visibilityState === 'visible') {
438
+ reprobe();
439
+ }
440
+ });
416
441
  await probeStatus();
417
442
  if (settingRegistry) {
418
443
  try {
@@ -490,21 +515,10 @@ const plugin = {
490
515
  window.setInterval(() => {
491
516
  void probeStatus().then(reconcile);
492
517
  }, STATUS_INTERVAL_MS);
493
- // A suspended network stack - laptop asleep, tab backgrounded - is exactly
494
- // when the probe fails, and the slow cadence above would leave the roster
495
- // stale for up to a minute after the machine comes back. A wake fires
496
- // both events, so it costs two probes; deliberately, because a guard that
497
- // let the second join the first answered the wake with a request issued
498
- // before it, and starved the interval above of its own tick (DEF-117).
499
- const reprobe = () => {
500
- void probeStatus().then(reconcile);
501
- };
502
- window.addEventListener('online', reprobe);
503
- document.addEventListener('visibilitychange', () => {
504
- if (document.visibilityState === 'visible') {
505
- reprobe();
506
- }
507
- });
518
+ armed = true;
519
+ if (wokeDuringActivation) {
520
+ reprobe();
521
+ }
508
522
  // Each JupyterLab window keeps its own copy of the settings and never
509
523
  // reads them again, and every save writes that whole copy. So a window
510
524
  // loaded before another window changed a setting put the old value back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.236",
3
+ "version": "1.2.271",
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",
@@ -168,6 +168,97 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
168
168
  });
169
169
  });
170
170
 
171
+ describe('DEF-GUARD-122 - a wake during activation', () => {
172
+ it('is remembered, reconciles nothing before the settings load, and is answered by one status check once the listeners are armed', async () => {
173
+ // Identity of this activation's probes; earlier activations' listeners
174
+ // reach the same mock (see the activation-contract harness below).
175
+ const serverSettings = {};
176
+ let probes = 0;
177
+ request.mockImplementation((path: string, settings: unknown) => {
178
+ if (path !== 'status' || settings !== serverSettings) {
179
+ return Promise.reject(new Error('not this activation'));
180
+ }
181
+ probes += 1;
182
+ return Promise.resolve({
183
+ root_dir: '/srv/lab',
184
+ delete_to_trash: true,
185
+ providers: PROVIDERS.map(m => ({
186
+ id: m.descriptor.id,
187
+ available: true
188
+ }))
189
+ });
190
+ });
191
+ const docked: AssistantSessionsPanel[] = [];
192
+ const labShell = {
193
+ add: (w: AssistantSessionsPanel) => {
194
+ docked.push(w);
195
+ }
196
+ } as any;
197
+ const app = {
198
+ serviceManager: { serverSettings },
199
+ commands: new CommandRegistry(),
200
+ hasPlugin: () => false
201
+ } as any;
202
+ // Every assistant off, so a reconcile on the enabled-by-default fallback
203
+ // would show as a docked panel.
204
+ const composite: Record<string, unknown> = Object.fromEntries(
205
+ PROVIDERS.map(m => [`providers.${m.descriptor.id}.enabled`, false])
206
+ );
207
+ const settings = {
208
+ raw: '{}',
209
+ composite,
210
+ get: (key: string) => ({
211
+ composite: composite[key],
212
+ user: composite[key]
213
+ }),
214
+ set: async () => undefined,
215
+ changed: new Signal<unknown, void>({})
216
+ };
217
+ let finishLoad!: () => void;
218
+ const loading = new Promise<void>(resolve => {
219
+ finishLoad = resolve;
220
+ });
221
+ const settingRegistry = {
222
+ connector: { fetch: async () => ({ raw: settings.raw }) },
223
+ load: async () => {
224
+ await loading;
225
+ return settings;
226
+ }
227
+ } as any;
228
+
229
+ const activation = (plugin.activate as any)(
230
+ app,
231
+ labShell,
232
+ null,
233
+ settingRegistry,
234
+ null,
235
+ null,
236
+ null
237
+ );
238
+ await flush();
239
+ expect(probes).toBe(1); // the activation probe; the settings still load
240
+
241
+ window.dispatchEvent(new Event('online'));
242
+ Object.defineProperty(document, 'visibilityState', {
243
+ configurable: true,
244
+ get: () => 'visible'
245
+ });
246
+ document.dispatchEvent(new Event('visibilitychange'));
247
+ await flush();
248
+ expect(probes).toBe(1);
249
+ expect(docked).toHaveLength(0);
250
+
251
+ finishLoad();
252
+ await activation;
253
+ await flush();
254
+ // How this fails: without the remembered wake the count stays 1 until the
255
+ // 60 s tick; a listener that probes before the settings load docks every
256
+ // assistant above, and one probe per wake makes it 3.
257
+ expect(probes).toBe(2);
258
+ expect(docked).toHaveLength(0);
259
+ });
260
+ });
261
+
171
262
  describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () => {
172
263
  /** A roster in which every assistant is present, or every one but `absent`. */
173
264
  const roster = (absent?: string): unknown => ({
@@ -51,7 +51,12 @@ jest.mock('../core/request', () => ({
51
51
  }));
52
52
 
53
53
  import { Clipboard, Dialog, Notification } from '@jupyterlab/apputils';
54
+ import { copyIcon } from '@jupyterlab/ui-components';
54
55
  import { CommandRegistry } from '@lumino/commands';
56
+
57
+ import { readFileSync } from 'fs';
58
+ import { join } from 'path';
59
+
55
60
  import { addIcon, branchIcon, cleanupIcon, shieldIcon } from '../core/icons';
56
61
  import {
57
62
  AssistantSessionsPanel,
@@ -238,8 +243,10 @@ describe('DEF-51 - the destructive dialogs open with Cancel under the keyboard',
238
243
  pressEnter();
239
244
  await done;
240
245
 
241
- expect(request).toHaveBeenCalledTimes(1);
246
+ // The DELETE, then the listing refetched after it (DEF-PANE-351).
247
+ expect(request).toHaveBeenCalledTimes(2);
242
248
  expect(request.mock.calls[0][3]).toMatchObject({ method: 'DELETE' });
249
+ expect(request.mock.calls[1][1]).toBe('sessions');
243
250
  });
244
251
 
245
252
  it('Clean Up Parallel Sessions: Enter on the fresh dialog cancels', async () => {
@@ -425,6 +432,67 @@ describe('DEF-45 - both destructive menu entries are marked alike', () => {
425
432
  });
426
433
  });
427
434
 
435
+ describe('ACC-PANE-218 - the history-deleting entries carry the danger class', () => {
436
+ it('paints the label and the icon with the error colour and leaves the background alone', () => {
437
+ // jsdom applies no stylesheet, so the rule that makes the class visible is
438
+ // read as text. The label inherits the item's color; JupyterLab's selected
439
+ // rule sets only the background, so nothing here may set one, nor the
440
+ // inverse text colour the superseded filled row used.
441
+ const css = readFileSync(
442
+ join(__dirname, '..', '..', 'style', 'base.css'),
443
+ 'utf8'
444
+ );
445
+ expect(css).toMatch(
446
+ /\.jp-AiAssistantsContextMenu-danger\s*\{[^}]*color:\s*var\(--jp-error-color1\)/
447
+ );
448
+ expect(css).toMatch(
449
+ /\.jp-AiAssistantsContextMenu-danger \.jp-icon3\[fill\]\s*\{[^}]*fill:\s*var\(--jp-error-color1\)/
450
+ );
451
+ const rules = [
452
+ ...css.matchAll(
453
+ /([^{}]*\.jp-AiAssistantsContextMenu-danger[^{}]*)\{([^}]*)\}/g
454
+ )
455
+ ];
456
+ expect(rules.length).toBeGreaterThanOrEqual(2);
457
+ for (const [, selector, body] of rules) {
458
+ expect(selector).not.toContain('.lm-mod-active');
459
+ expect(body).not.toMatch(/\bbackground\b/);
460
+ expect(body).not.toContain('--jp-ui-inverse-font-color1');
461
+ }
462
+ });
463
+
464
+ it('puts the class on cleanup-parallel and remove, and on no other entry', () => {
465
+ // base.css draws the icon of this class in --jp-error-color1; the menu
466
+ // item takes the class from the command options.
467
+ const commands = (panel as any)._commands as CommandRegistry;
468
+ const marked = commands
469
+ .listCommands()
470
+ .filter(id => commands.className(id, {}) !== '');
471
+ expect(marked.sort()).toEqual(
472
+ [
473
+ commandId(DESCRIPTOR.id, 'cleanup-parallel'),
474
+ commandId(DESCRIPTOR.id, 'remove')
475
+ ].sort()
476
+ );
477
+ for (const id of marked) {
478
+ expect(commands.className(id, {})).toBe(
479
+ 'jp-AiAssistantsContextMenu-danger'
480
+ );
481
+ }
482
+ });
483
+ });
484
+
485
+ describe('ACC-PANE-219 - the copy entries carry the copy icon', () => {
486
+ it('gives Copy Path and Copy Session ID copyIcon', () => {
487
+ const commands = (panel as any)._commands as CommandRegistry;
488
+ for (const action of ['copy-path', 'copy-session-id']) {
489
+ expect(commands.icon(commandId(DESCRIPTOR.id, action), {})).toBe(
490
+ copyIcon
491
+ );
492
+ }
493
+ });
494
+ });
495
+
428
496
  describe('ACC-SESS-203 - automatic branch names', () => {
429
497
  it.each(['server-copy', 'server-native'] as const)(
430
498
  '%s requests a numbered name and resumes the returned child without a dialog',
@@ -1231,6 +1299,113 @@ describe('DEF-PANE-349 - a row click makes the clicked session the project curre
1231
1299
  }
1232
1300
  });
1233
1301
 
1302
+ it('drops a pending row switch after New Session (+) in the same project', async () => {
1303
+ // + starts a new conversation and the launch route clears the pin. A row
1304
+ // click still opening must not pin the old conversation over it
1305
+ // (DEF-PANE-355).
1306
+ const row = session({ name: 'proj' });
1307
+ let releaseOpen!: () => void;
1308
+ const pending = new Promise<void>(resolve => {
1309
+ releaseOpen = resolve;
1310
+ });
1311
+ (panel as any)._terminals.openSession = jest.fn(() => pending);
1312
+ const launch = jest.fn().mockResolvedValue({ id: 'term-new' });
1313
+ (panel as any)._terminals.launch = launch;
1314
+ (panel as any)._currentFolder = () => row.project_path;
1315
+ (panel as any)._fetch = jest.fn().mockResolvedValue(undefined);
1316
+ request.mockImplementation((_id: string, route: string) =>
1317
+ Promise.resolve(
1318
+ route === 'switch'
1319
+ ? { requested: row.session_id, current: row.session_id }
1320
+ : { sessions: [] }
1321
+ )
1322
+ );
1323
+ try {
1324
+ render(panel, [row]);
1325
+ panel.node
1326
+ .querySelector<HTMLElement>('.jp-AiAssistantsPanel-row')!
1327
+ .click();
1328
+ await flush();
1329
+
1330
+ await (panel as any)._newSession();
1331
+ expect(launch).toHaveBeenCalledTimes(1);
1332
+ expect(launch.mock.calls[0][0]).toMatchObject({
1333
+ encoded_path: row.encoded_path
1334
+ });
1335
+
1336
+ releaseOpen();
1337
+ await flush();
1338
+
1339
+ expect(request.mock.calls.map(call => call[1])).not.toContain('switch');
1340
+ } finally {
1341
+ releaseOpen();
1342
+ await flush();
1343
+ }
1344
+ });
1345
+
1346
+ it('posts no queued row switch once New Session (+) is the newest decision', async () => {
1347
+ // A row switch queued behind a post in flight reads the newest decision
1348
+ // when it runs; a + made meanwhile has no id to post (DEF-PANE-355).
1349
+ const first = session({
1350
+ name: 'first',
1351
+ project_path: '/home/user/shared',
1352
+ encoded_path: 'enc-shared'
1353
+ });
1354
+ const second = session({
1355
+ name: 'second',
1356
+ project_path: '/home/user/shared',
1357
+ encoded_path: 'enc-shared',
1358
+ session_id: 'sid-second'
1359
+ });
1360
+ let releasePost!: () => void;
1361
+ const heldPost = new Promise<void>(resolve => {
1362
+ releasePost = resolve;
1363
+ });
1364
+ (panel as any)._terminals.openSession = jest.fn(() => Promise.resolve());
1365
+ (panel as any)._terminals.launch = jest
1366
+ .fn()
1367
+ .mockResolvedValue({ id: 'term-new' });
1368
+ (panel as any)._currentFolder = () => first.project_path;
1369
+ (panel as any)._fetch = jest.fn().mockResolvedValue(undefined);
1370
+ request.mockImplementation(
1371
+ async (_id: string, route: string, _settings: unknown, init: any) => {
1372
+ if (route !== 'switch') {
1373
+ return { sessions: [] };
1374
+ }
1375
+ const body = JSON.parse(init.body);
1376
+ if (body.session_id === first.session_id) {
1377
+ await heldPost;
1378
+ }
1379
+ return { requested: body.session_id, current: body.session_id };
1380
+ }
1381
+ );
1382
+ try {
1383
+ render(panel, [first, second]);
1384
+ const rows = panel.node.querySelectorAll<HTMLElement>(
1385
+ '.jp-AiAssistantsPanel-row'
1386
+ );
1387
+ rows[0].click();
1388
+ await flush();
1389
+ rows[1].click();
1390
+ await flush();
1391
+
1392
+ await (panel as any)._newSession();
1393
+ releasePost();
1394
+ await flush();
1395
+
1396
+ const switches = request.mock.calls.filter(
1397
+ ([, route]) => route === 'switch'
1398
+ );
1399
+ expect(switches).toHaveLength(1);
1400
+ expect(JSON.parse((switches[0][3] as any).body).session_id).toBe(
1401
+ first.session_id
1402
+ );
1403
+ } finally {
1404
+ releasePost();
1405
+ await flush();
1406
+ }
1407
+ });
1408
+
1234
1409
  it('posts the row switch when a held Branch answers before a later row open', async () => {
1235
1410
  // The fork's number is the Branch click's, so a row clicked after it wins
1236
1411
  // even when the fork route answers first (DEF-PANE-349).
@@ -2786,6 +2961,118 @@ describe('DEF-PANE-344 - the session list', () => {
2786
2961
  ]);
2787
2962
  });
2788
2963
 
2964
+ /** Hold every listing request, in the order sent; other routes answer
2965
+ * `answer`. */
2966
+ const holdListings = (answer: unknown): Array<(v: unknown) => void> => {
2967
+ const held: Array<(v: unknown) => void> = [];
2968
+ request.mockImplementation((_id: string, path: string, _s, init?: any) =>
2969
+ path === 'sessions' && !init?.method
2970
+ ? new Promise(resolve => held.push(resolve))
2971
+ : Promise.resolve(answer)
2972
+ );
2973
+ return held;
2974
+ };
2975
+
2976
+ it('DEF-PANE-351 - a listing sent before Remove gives way to the one after it', async () => {
2977
+ const alpha = session({ name: 'alpha' });
2978
+ const beta = session({ name: 'beta' });
2979
+ render(panel, [alpha, beta]);
2980
+ const held = holdListings({});
2981
+ const poll = (panel as any)._fetch();
2982
+ const removing = (panel as any)._removeProject(alpha);
2983
+ await flush();
2984
+ Array.from(
2985
+ document.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
2986
+ )
2987
+ .find(b => b.textContent === 'Remove')!
2988
+ .click();
2989
+ await removing;
2990
+ // The listing sent before the delete answers first, then every one sent
2991
+ // after it answers with what the server holds now.
2992
+ held[0]({ sessions: [alpha, beta] });
2993
+ await poll;
2994
+ held.slice(1).forEach(answer => answer({ sessions: [beta] }));
2995
+ await flush();
2996
+ expect((panel as any)._sessions.map((s: ISession) => s.name)).toEqual([
2997
+ 'beta'
2998
+ ]);
2999
+ });
3000
+
3001
+ it('DEF-PANE-351-WRITE-ORDER - a listing sent before Remove never draws the removed project again', async () => {
3002
+ const alpha = session({ name: 'alpha' });
3003
+ const beta = session({ name: 'beta' });
3004
+ render(panel, [alpha, beta]);
3005
+ const held = holdListings({});
3006
+ const poll = (panel as any)._fetch();
3007
+ const removing = (panel as any)._removeProject(alpha);
3008
+ await flush();
3009
+ Array.from(
3010
+ document.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
3011
+ )
3012
+ .find(b => b.textContent === 'Remove')!
3013
+ .click();
3014
+ await removing;
3015
+ // The listing sent before the delete answers with the project still in
3016
+ // it, and the refetch after the delete is still held.
3017
+ held[0]({ sessions: [alpha, beta] });
3018
+ await poll;
3019
+ expect((panel as any)._sessions.map((s: ISession) => s.name)).toEqual([
3020
+ 'beta'
3021
+ ]);
3022
+ held.slice(1).forEach(answer => answer({ sessions: [beta] }));
3023
+ await flush();
3024
+ });
3025
+
3026
+ it('DEF-PANE-351 - a listing sent before a favourite toggle gives way to the one after it', async () => {
3027
+ const alpha = session({ name: 'alpha' });
3028
+ render(panel, [alpha]);
3029
+ const held = holdListings({ favourite: true });
3030
+ const poll = (panel as any)._fetch();
3031
+ await (panel as any)._toggleFavourite(alpha);
3032
+ held[0]({ sessions: [session({ name: 'alpha', favourite: false })] });
3033
+ await poll;
3034
+ held
3035
+ .slice(1)
3036
+ .forEach(answer =>
3037
+ answer({ sessions: [session({ name: 'alpha', favourite: true })] })
3038
+ );
3039
+ await flush();
3040
+ expect((panel as any)._sessions[0].favourite).toBe(true);
3041
+ });
3042
+
3043
+ it('DEF-PANE-351 - the star a toggle wrote on a row a poll had since replaced is drawn', async () => {
3044
+ // The context menu holds the row it opened on; a poll drawn while it is
3045
+ // open replaces every row object.
3046
+ const opened = session({ name: 'alpha' });
3047
+ render(panel, [opened]);
3048
+ request.mockResolvedValueOnce({ sessions: [session({ name: 'alpha' })] });
3049
+ await (panel as any)._fetch();
3050
+ const held = holdListings({ favourite: true });
3051
+ await (panel as any)._toggleFavourite(opened);
3052
+ held.forEach(answer =>
3053
+ answer({ sessions: [session({ name: 'alpha', favourite: true })] })
3054
+ );
3055
+ await flush();
3056
+ expect((panel as any)._sessions[0].favourite).toBe(true);
3057
+ });
3058
+
3059
+ it('DEF-PANE-351 - a favourite toggle keeps the listing a Launcher tile click asked for', async () => {
3060
+ const alpha = session({ name: 'alpha' });
3061
+ render(panel, [
3062
+ alpha,
3063
+ session({ name: 'beta', session_id: 'sid-beta-old' })
3064
+ ]);
3065
+ const held = holdListings({ favourite: true, argv: ['testbed'] });
3066
+ const tile = (panel as any)._launchIn('/home/user/beta');
3067
+ await (panel as any)._toggleFavourite(alpha);
3068
+ held[0]({
3069
+ sessions: [alpha, session({ name: 'beta', session_id: 'sid-beta-new' })]
3070
+ });
3071
+ await tile;
3072
+ const call = request.mock.calls.find(c => c[1] === 'launch-argv')!;
3073
+ expect(JSON.parse(call[3].body).session_id).toBe('sid-beta-new');
3074
+ });
3075
+
2789
3076
  it('DEF-PANE-346 - waits with a redraw while a button is pressed in the list, until the click has been sent', async () => {
2790
3077
  const names = (): string[] =>
2791
3078
  Array.from(
@@ -208,6 +208,26 @@ describe('terminal reuse', () => {
208
208
 
209
209
  expect(launches).toHaveLength(0);
210
210
  });
211
+
212
+ it('does not reuse the terminal it launched once its process runs another conversation', async () => {
213
+ manager = makeManager();
214
+ manager.setSessions([session()]);
215
+ await manager.openSession(session(), undefined, CURRENT);
216
+ expect(launches).toHaveLength(1);
217
+ // /clear in that terminal: its process now runs a new conversation.
218
+ probes['launched-1'] = {
219
+ ...READABLE,
220
+ terminal_name: 'launched-1',
221
+ session_id: 'session-22222222-2222-4222-8222-222222222222'
222
+ };
223
+ const activated = jest.spyOn((manager as any)._app.shell, 'activateById');
224
+
225
+ await manager.openSession(session(), undefined, CURRENT);
226
+
227
+ expect(request.mock.calls.map(c => c[1])).toContain('terminal/launched-1');
228
+ expect(launches).toHaveLength(2);
229
+ expect(activated).not.toHaveBeenCalledWith('id-0001');
230
+ });
211
231
  });
212
232
 
213
233
  describe('one spinner dialog (DEF-PANE-327-BUSY-MODAL-1)', () => {
package/src/core/panel.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  LabIcon,
15
15
  MenuSvg,
16
16
  closeIcon,
17
+ copyIcon,
17
18
  editIcon,
18
19
  folderIcon,
19
20
  terminalIcon
@@ -152,8 +153,9 @@ let fileBrowserMoves: Promise<void> = Promise.resolve();
152
153
  * later click does not move the file browser, so it ends on the folder of the
153
154
  * newest click that opened (DEF-PANE-343). The same counter orders the writes
154
155
  * that decide a project's current conversation: a row click, an explicit
155
- * switch and a fork each take a number, and a row click posts its deferred
156
- * switch only while it is the newest (DEF-PANE-349). */
156
+ * switch, a fork and a New Session (+) each take a number, and a row click
157
+ * posts its deferred switch only while it is the newest (DEF-PANE-349,
158
+ * DEF-PANE-355). */
157
159
  let openClicks = 0;
158
160
  let movedClick = 0;
159
161
 
@@ -725,7 +727,11 @@ export class AssistantSessionsPanel extends Widget {
725
727
  return;
726
728
  }
727
729
  this._fetchDrawn = request;
728
- this._sessions = data.sessions ?? [];
730
+ // A project removed at or after this listing was sent must not be drawn
731
+ // back from the answer (DEF-PANE-351-WRITE-ORDER).
732
+ this._sessions = (data.sessions ?? []).filter(
733
+ s => (this._removedAt.get(s.encoded_path) ?? 0) < request
734
+ );
729
735
  this._setInlineError(null);
730
736
  this._terminals.setSessions(this._sessions);
731
737
  this._render();
@@ -750,6 +756,10 @@ export class AssistantSessionsPanel extends Widget {
750
756
  })
751
757
  }
752
758
  );
759
+ // A listing sent before the write may still be drawn, and a poll may
760
+ // have replaced the row this wrote; this one is answered after the write
761
+ // (DEF-PANE-351).
762
+ void this._fetch().catch(() => undefined);
753
763
  } catch (err) {
754
764
  session.favourite = !next; // roll back
755
765
  this._render();
@@ -812,6 +822,11 @@ export class AssistantSessionsPanel extends Widget {
812
822
  this._sessions = (this._sessions ?? []).filter(
813
823
  s => s.encoded_path !== session.encoded_path
814
824
  );
825
+ // A listing sent before the delete may still be drawn (DEF-PANE-351),
826
+ // and it carries the removed project: the number taken here marks the
827
+ // answer for `_fetch` to drop (DEF-PANE-351-WRITE-ORDER).
828
+ this._removedAt.set(session.encoded_path, this._fetchSent);
829
+ void this._fetch().catch(() => undefined);
815
830
  } catch (err) {
816
831
  this._showActionError(`Could not remove "${name}" - try again.`, err);
817
832
  } finally {
@@ -1147,7 +1162,7 @@ export class AssistantSessionsPanel extends Widget {
1147
1162
  // before the server can read the conversation off its process
1148
1163
  // (DEF-PANE-345).
1149
1164
  const found =
1150
- this._terminals.cached(folder, row.session_id) ??
1165
+ (await this._terminals.cached(folder, row.session_id)) ??
1151
1166
  (await this._terminals.findForSession(row.session_id, folder))
1152
1167
  ?.widget;
1153
1168
  if (found) {
@@ -1230,6 +1245,15 @@ export class AssistantSessionsPanel extends Widget {
1230
1245
  const existing = (this._sessions ?? []).find(
1231
1246
  s => s.project_path === projectPath
1232
1247
  );
1248
+ if (existing) {
1249
+ // The + click is the project's newest decision: a row open still
1250
+ // finishing posts no switch over it (DEF-PANE-355). It has no id to post;
1251
+ // the launch route clears the pin.
1252
+ this._currentTokens.set(existing.encoded_path, {
1253
+ click: ++openClicks,
1254
+ sessionId: ''
1255
+ });
1256
+ }
1233
1257
  // Raised on the click, not by the launch: the launch waits for a tile
1234
1258
  // launch clicked before it (DEF-PANE-345).
1235
1259
  const busy = beginSpinner(
@@ -1527,7 +1551,8 @@ export class AssistantSessionsPanel extends Widget {
1527
1551
  const run = (this._switchChains.get(key) ?? Promise.resolve()).then(
1528
1552
  async () => {
1529
1553
  const newest = this._currentTokens.get(key);
1530
- if (!newest) {
1554
+ // An empty id is a New Session (+) decision, which posts nothing.
1555
+ if (!newest?.sessionId) {
1531
1556
  return;
1532
1557
  }
1533
1558
  if (!force && this._postedClicks.get(key) === newest.click) {
@@ -2434,6 +2459,7 @@ export class AssistantSessionsPanel extends Widget {
2434
2459
 
2435
2460
  this._commands.addCommand(this._cmd('copy-path'), {
2436
2461
  label: this._trans.__('Copy Path'),
2462
+ icon: copyIcon,
2437
2463
  execute: () => {
2438
2464
  const s = active();
2439
2465
  if (s) {
@@ -2444,6 +2470,7 @@ export class AssistantSessionsPanel extends Widget {
2444
2470
 
2445
2471
  this._commands.addCommand(this._cmd('copy-session-id'), {
2446
2472
  label: this._trans.__('Copy Session ID'),
2473
+ icon: copyIcon,
2447
2474
  execute: () => {
2448
2475
  const id = active()?.session_id;
2449
2476
  if (id) {
@@ -2485,6 +2512,9 @@ export class AssistantSessionsPanel extends Widget {
2485
2512
  // item beside an ordinary one (DEF-45). The sweep variant of the trash
2486
2513
  // glyph, because this one deletes several at once.
2487
2514
  icon: cleanupIcon,
2515
+ // Both history-deleting entries draw their icon in the error colour
2516
+ // (base.css).
2517
+ className: 'jp-AiAssistantsContextMenu-danger',
2488
2518
  isVisible: () => (active()?.extra_sessions ?? 0) > 0,
2489
2519
  execute: () => {
2490
2520
  const s = active();
@@ -2585,6 +2615,7 @@ export class AssistantSessionsPanel extends Widget {
2585
2615
  this._commands.addCommand(this._cmd('remove'), {
2586
2616
  label: `Remove from ${this._descriptor.label}`,
2587
2617
  icon: removeIcon,
2618
+ className: 'jp-AiAssistantsContextMenu-danger',
2588
2619
  execute: () => {
2589
2620
  const s = active();
2590
2621
  if (s) {
@@ -3045,6 +3076,10 @@ export class AssistantSessionsPanel extends Widget {
3045
3076
  * `_fetch`. */
3046
3077
  private _fetchSent = 0;
3047
3078
  private _fetchDrawn = 0;
3079
+ /** The `_fetchSent` number at each removal's DELETE, by encoded path. A
3080
+ * listing sent before that number may still carry the removed project, so
3081
+ * `_fetch` drops it (DEF-PANE-351-WRITE-ORDER). */
3082
+ private readonly _removedAt = new Map<string, number>();
3048
3083
  /** A button is pressed in the list, and a redraw waits for its release -
3049
3084
  * see `_onRelease`. */
3050
3085
  private _pressed = false;
@@ -417,7 +417,7 @@ export class TerminalManager {
417
417
  ): Promise<void> {
418
418
  // 1. Microcache - the most recent terminal of this project, reused only
419
419
  // when it carries the wanted conversation.
420
- const cached = this.cached(session.project_path, wanted);
420
+ const cached = await this.cached(session.project_path, wanted);
421
421
  if (cached) {
422
422
  this.focus(cached, spinner);
423
423
  return;
@@ -530,12 +530,24 @@ export class TerminalManager {
530
530
  }
531
531
 
532
532
  /** The terminal this manager last opened for `projectPath`, when it is
533
- * still open and was opened for the conversation `sessionId`; else null. */
534
- cached(projectPath: string, sessionId: string): any | null {
533
+ * still open, was opened for the conversation `sessionId`, and the server
534
+ * does not read another conversation off its process - which /clear starts
535
+ * in the same process (DEF-PANE-350); else null. */
536
+ async cached(projectPath: string, sessionId: string): Promise<any | null> {
535
537
  const cached = this._byProject.get(projectPath);
536
- return cached && !cached.widget.isDisposed && cached.sessionId === sessionId
537
- ? cached.widget
538
- : null;
538
+ if (!cached || cached.widget.isDisposed || cached.sessionId !== sessionId) {
539
+ return null;
540
+ }
541
+ // No id read is the window before the new process is readable, which is
542
+ // what this cache covers (DEF-PANE-345).
543
+ const info = await this.probe(cached.widget);
544
+ if (info?.session_id && info.session_id !== sessionId) {
545
+ if (this._byProject.get(projectPath) === cached) {
546
+ this._byProject.delete(projectPath);
547
+ }
548
+ return null;
549
+ }
550
+ return cached.widget;
539
551
  }
540
552
 
541
553
  /** Remember `widget` as the newest terminal of `projectPath`, opened for
package/src/index.ts CHANGED
@@ -524,6 +524,32 @@ const plugin: JupyterFrontEndPlugin<void> = {
524
524
  }
525
525
  };
526
526
 
527
+ // A suspended network stack - laptop asleep, tab backgrounded - is exactly
528
+ // when the probe fails, and the slow cadence below would leave the roster
529
+ // stale for up to a minute after the machine comes back. A wake fires
530
+ // both events, so it costs two probes; deliberately, because a guard that
531
+ // let the second join the first answered the wake with a request issued
532
+ // before it, and starved the interval below of its own tick (DEF-117).
533
+ // Heard from here, before the first await: a wake before the listeners are
534
+ // armed below is only remembered, because a reconcile before the settings
535
+ // load reads the enabled-by-default fallback and docks assistants the user
536
+ // disabled; one status check answers it once they are armed (DEF-GUARD-122).
537
+ let armed = false;
538
+ let wokeDuringActivation = false;
539
+ const reprobe = (): void => {
540
+ if (!armed) {
541
+ wokeDuringActivation = true;
542
+ return;
543
+ }
544
+ void probeStatus().then(reconcile);
545
+ };
546
+ window.addEventListener('online', reprobe);
547
+ document.addEventListener('visibilitychange', () => {
548
+ if (document.visibilityState === 'visible') {
549
+ reprobe();
550
+ }
551
+ });
552
+
527
553
  await probeStatus();
528
554
 
529
555
  if (settingRegistry) {
@@ -617,21 +643,10 @@ const plugin: JupyterFrontEndPlugin<void> = {
617
643
  void probeStatus().then(reconcile);
618
644
  }, STATUS_INTERVAL_MS);
619
645
 
620
- // A suspended network stack - laptop asleep, tab backgrounded - is exactly
621
- // when the probe fails, and the slow cadence above would leave the roster
622
- // stale for up to a minute after the machine comes back. A wake fires
623
- // both events, so it costs two probes; deliberately, because a guard that
624
- // let the second join the first answered the wake with a request issued
625
- // before it, and starved the interval above of its own tick (DEF-117).
626
- const reprobe = (): void => {
627
- void probeStatus().then(reconcile);
628
- };
629
- window.addEventListener('online', reprobe);
630
- document.addEventListener('visibilitychange', () => {
631
- if (document.visibilityState === 'visible') {
632
- reprobe();
633
- }
634
- });
646
+ armed = true;
647
+ if (wokeDuringActivation) {
648
+ reprobe();
649
+ }
635
650
 
636
651
  // Each JupyterLab window keeps its own copy of the settings and never
637
652
  // reads them again, and every save writes that whole copy. So a window
package/style/base.css CHANGED
@@ -713,6 +713,19 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
713
713
  margin: 0 auto;
714
714
  }
715
715
 
716
+ /* The two entries that delete history (Clean Up Parallel Sessions, Remove
717
+ from ...) carry this class from their command options. Label and glyph are
718
+ red, also while selected: JupyterLab's selected rule sets only the row's
719
+ background, so the colour holds and the selection background stays every
720
+ entry's. */
721
+ .jp-AiAssistantsContextMenu-danger {
722
+ color: var(--jp-error-color1);
723
+ }
724
+
725
+ .jp-AiAssistantsContextMenu-danger .jp-icon3[fill] {
726
+ fill: var(--jp-error-color1);
727
+ }
728
+
716
729
  /* ============================================================
717
730
  Manage Sessions popup - driven entirely by JupyterLab theme variables so a
718
731
  theme flip (light / dark / custom) restyles the whole screen at once. The