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.
- package/lib/core/panel.d.ts +4 -0
- package/lib/core/panel.js +38 -6
- package/lib/core/terminals.d.ts +4 -2
- package/lib/core/terminals.js +18 -6
- package/lib/index.js +29 -15
- package/package.json +1 -1
- package/src/__tests__/index.spec.ts +91 -0
- package/src/__tests__/panel.spec.ts +288 -1
- package/src/__tests__/terminals.spec.ts +20 -0
- package/src/core/panel.ts +40 -5
- package/src/core/terminals.ts +18 -6
- package/src/index.ts +30 -15
- package/style/base.css +13 -0
package/lib/core/panel.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/lib/core/terminals.d.ts
CHANGED
|
@@ -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
|
|
90
|
-
|
|
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
|
package/lib/core/terminals.js
CHANGED
|
@@ -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
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/core/terminals.ts
CHANGED
|
@@ -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
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|