jupyterlab_ai_code_assistants_extension 1.2.271 → 1.2.282
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/README.md +23 -23
- package/lib/core/panel.js +98 -11
- package/package.json +1 -1
- package/src/__tests__/panel.spec.ts +302 -12
- package/src/core/panel.ts +109 -10
- package/style/base.css +29 -0
package/README.md
CHANGED
|
@@ -25,30 +25,30 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
25
25
|
|
|
26
26
|
## Features
|
|
27
27
|
|
|
28
|
-
- **One install, every assistant** - Claude Code, Codex, Kimi, Gemini and DeepSeek
|
|
29
|
-
- **Provider registry** - assistant-specific behaviour lives in one module per assistant
|
|
30
|
-
- **Joint settings page** - one settings section
|
|
31
|
-
- **Three-section side panel** - Favorites, Recent
|
|
32
|
-
- **One-click resume** - click a row to
|
|
33
|
-
- **File browser follows the opened session** - opening a session moves the
|
|
34
|
-
- **Launcher tiles** -
|
|
35
|
-
- **Your terminal's `claude -c` follows the panel** (Claude) -
|
|
36
|
-
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id
|
|
37
|
-
- **Branch session** - fork the current conversation into a new named session
|
|
38
|
-
- **Rename session** - name a conversation from the right-click menu, and the
|
|
39
|
-
- **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo
|
|
40
|
-
- **Coloured terminal tabs** - each session's colour tints its terminal tab
|
|
41
|
-
- **Your newest colour choice wins** -
|
|
42
|
-
- **Favorites** - star projects
|
|
43
|
-
- **Remove and clean up** -
|
|
44
|
-
- **Activity at a glance** - each row shows its last activity in an aligned column
|
|
45
|
-
- **Remote control indicator and background agents** (Claude) - a green dot marks sessions
|
|
46
|
-
- **Search** - fuzzy filter per panel, toggled by the funnel button
|
|
28
|
+
- **One install, every assistant** - Claude Code, Codex, Kimi, Gemini and DeepSeek in one package, each with its own right-side panel and official mark
|
|
29
|
+
- **Provider registry** - assistant-specific behaviour lives in one module per assistant, so adding one changes no core file
|
|
30
|
+
- **Joint settings page** - one settings section covers all assistants with a per-assistant toggle, all on by default; toggling takes effect without a JupyterLab reload
|
|
31
|
+
- **Three-section side panel** - Favorites, Recent and All projects, each scrolling independently
|
|
32
|
+
- **One-click resume** - click a row to resume its session in a terminal, reusing an open terminal for the project. A Kimi or DeepSeek conversation started with `+` opens a second terminal when its first is still open
|
|
33
|
+
- **File browser follows the opened session** - opening a session moves the file browser to its project folder, a setting on by default; a right-click on a panel header toggles this and moves the panels between the left and right sidebars
|
|
34
|
+
- **Launcher tiles** - each enabled assistant has a tile in the Launcher's AI Assistants section; a click opens the assistant in the file browser's current folder, resuming that folder's conversation when one exists
|
|
35
|
+
- **Your terminal's `claude -c` follows the panel** (Claude) - selecting a conversation row makes plain `claude -c` in that project resume the same conversation, even one that has been compacted
|
|
36
|
+
- **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short id. "Manage Sessions..." opens a searchable list with multi-select delete and copy-id
|
|
37
|
+
- **Branch session** - fork the current conversation into a new named session from the right-click menu
|
|
38
|
+
- **Rename session** - name a conversation from the right-click menu, and the panel shows the name the assistant itself kept
|
|
39
|
+
- **Launch modes under each assistant's own name** - Claude's skip-permissions, Codex's approval bypass, Kimi's `--yolo` and Gemini's YOLO; unsafe variants carry a shield glyph
|
|
40
|
+
- **Coloured terminal tabs** - each session's colour tints its terminal tab, and a branched session inherits its parent's colour. Claude's `/color` supplies a default and Kimi derives a stable one; Codex, Gemini and DeepSeek have no colour until you set one
|
|
41
|
+
- **Your newest colour choice wins** - a colour set on a terminal tab is remembered for that conversation; a Kimi or DeepSeek conversation that has never been resumed, including one started with `+`, is the exception, because its id cannot be read yet. In Claude Code whichever of the tab colour and `/color` you used last wins, and Reset Tab Colour in the right-click menu hands back the colours you set by hand
|
|
42
|
+
- **Favorites** - star projects from the right-click menu; favourites from the standalone extensions are migrated on first run
|
|
43
|
+
- **Remove and clean up** - remove a project's history or its extra parallel sessions from the right-click menu, with a confirmation. Removed files honour JupyterLab's "move files to trash" setting
|
|
44
|
+
- **Activity at a glance** - each row shows its last activity in an aligned column. Recent rows light up, stale rows dim, and parallel conversations show a branch icon and count
|
|
45
|
+
- **Remote control indicator and background agents** (Claude) - a green dot marks sessions under remote control, and a `bg` chip marks a conversation held by a background agent. Clicking stops the agent and resumes the conversation in your terminal
|
|
46
|
+
- **Search** - a fuzzy filter per panel, toggled by the funnel button
|
|
47
47
|
- **Presentation modes** - label rows by session name or by path relative to the JupyterLab root
|
|
48
|
-
- **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant
|
|
49
|
-
- **DeepSeek in the terminal** - the
|
|
50
|
-
- **Auto-disabled when absent** - an assistant whose CLI is not on `PATH`
|
|
51
|
-
- **Open Callback** - when an assistant's login
|
|
48
|
+
- **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant
|
|
49
|
+
- **DeepSeek in the terminal** - the panel runs the community TUI bundle, because the DeepSeek Harness ships no terminal chat. Without the bundle a launch is refused before a terminal opens
|
|
50
|
+
- **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` shows no panel
|
|
51
|
+
- **Open Callback** - when an assistant's login redirects to a loopback address that will not load, run Open Callback from the command palette and paste the link. The server makes the request and says whether the login accepted it
|
|
52
52
|
|
|
53
53
|
## Requirements
|
|
54
54
|
|
package/lib/core/panel.js
CHANGED
|
@@ -737,14 +737,44 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
737
737
|
}
|
|
738
738
|
}
|
|
739
739
|
async _cleanupParallel(session) {
|
|
740
|
-
var _a;
|
|
741
|
-
const extra = session.extra_sessions;
|
|
740
|
+
var _a, _b, _c, _d;
|
|
742
741
|
const name = this._lookupName(session);
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
742
|
+
const confirmBody = new Widget();
|
|
743
|
+
confirmBody.node.className = 'jp-AiAssistantsPanel-cleanupConfirm';
|
|
744
|
+
const question = document.createElement('p');
|
|
745
|
+
// Announced politely when the fetch restates it; the warning keeps
|
|
746
|
+
// role=alert for the case that needs attention.
|
|
747
|
+
question.setAttribute('role', 'status');
|
|
748
|
+
let extra = session.extra_sessions;
|
|
749
|
+
const askCount = (count) => {
|
|
750
|
+
question.textContent =
|
|
751
|
+
`Remove ${count} parallel session${count === 1 ? '' : 's'} from ` +
|
|
752
|
+
`"${name}"?`;
|
|
753
|
+
};
|
|
754
|
+
askCount(extra);
|
|
755
|
+
confirmBody.node.appendChild(question);
|
|
756
|
+
// One sentence, twelve words or fewer: what happens, not the detail.
|
|
757
|
+
const hint = document.createElement('p');
|
|
758
|
+
hint.className = 'jp-AiAssistantsPanel-cleanupHint';
|
|
759
|
+
hint.textContent = this._deleteToTrash
|
|
760
|
+
? 'The current conversation is kept; the rest go to the trash.'
|
|
761
|
+
: 'The current conversation is kept; the rest are deleted permanently.';
|
|
762
|
+
confirmBody.node.appendChild(hint);
|
|
763
|
+
// The irreversible note is a safety statement, not the hint, so it keeps
|
|
764
|
+
// its own line. With the trash on it names the refusal: a refused move
|
|
765
|
+
// deletes permanently, while the hint promises the trash
|
|
766
|
+
// (DEF-PANE-363-CAUTION-REFUSAL).
|
|
767
|
+
const caution = document.createElement('p');
|
|
768
|
+
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
769
|
+
caution.textContent = this._deleteToTrash
|
|
770
|
+
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
771
|
+
: this._irreversibleNote.trim();
|
|
772
|
+
confirmBody.node.appendChild(caution);
|
|
773
|
+
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
774
|
+
let warning = null;
|
|
775
|
+
const confirmDialog = new Dialog({
|
|
776
|
+
title,
|
|
777
|
+
body: confirmBody,
|
|
748
778
|
buttons: [
|
|
749
779
|
Dialog.cancelButton(),
|
|
750
780
|
Dialog.warnButton({ label: this._trans.__('Remove') })
|
|
@@ -753,6 +783,58 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
753
783
|
defaultButton: 0,
|
|
754
784
|
renderer: dialogRenderer
|
|
755
785
|
});
|
|
786
|
+
// showDialog names a dialog only when its body is a string, and this body
|
|
787
|
+
// is a Widget: name it here, and again when the question is restated and
|
|
788
|
+
// the warning joins it.
|
|
789
|
+
const nameDialog = () => {
|
|
790
|
+
var _a, _b, _c, _d;
|
|
791
|
+
confirmDialog.node.setAttribute('aria-label', [
|
|
792
|
+
title,
|
|
793
|
+
(_a = question.textContent) !== null && _a !== void 0 ? _a : '',
|
|
794
|
+
(_b = hint.textContent) !== null && _b !== void 0 ? _b : '',
|
|
795
|
+
(_c = caution.textContent) !== null && _c !== void 0 ? _c : '',
|
|
796
|
+
(_d = warning === null || warning === void 0 ? void 0 : warning.textContent) !== null && _d !== void 0 ? _d : ''
|
|
797
|
+
]
|
|
798
|
+
.filter(part => part)
|
|
799
|
+
.join(' '));
|
|
800
|
+
};
|
|
801
|
+
nameDialog();
|
|
802
|
+
// Alongside the question, never in front of it: the request layer allows
|
|
803
|
+
// 60 s, and the confirmation must open at once. When the list lands the
|
|
804
|
+
// question states its length and a conversation newer than the kept one
|
|
805
|
+
// adds the warning, drawn last so it reads after the caution. The same
|
|
806
|
+
// list is what the removal deletes. A failed fetch leaves the question as
|
|
807
|
+
// the row reported it, with no warning, and the removal fetches again
|
|
808
|
+
// below.
|
|
809
|
+
const branchesReady = this._fetchBranches(session).then(fetched => {
|
|
810
|
+
const list = fetched === null || fetched === void 0 ? void 0 : fetched.branches;
|
|
811
|
+
if (list && list.length === 0) {
|
|
812
|
+
// The row is up to one poll old, and a second client may have
|
|
813
|
+
// cleaned the project since (DEF-GUARD-96): a red Remove with zero
|
|
814
|
+
// targets is a dead end. End the confirmation through the accept
|
|
815
|
+
// path, whose zero-id guard states the empty outcome and refreshes
|
|
816
|
+
// the list (DEF-PANE-365); resolving after the user already
|
|
817
|
+
// accepted or cancelled is a no-op.
|
|
818
|
+
confirmDialog.resolve(1);
|
|
819
|
+
}
|
|
820
|
+
else if (list) {
|
|
821
|
+
extra = list.length;
|
|
822
|
+
askCount(extra);
|
|
823
|
+
const newer = list.filter(branch => branch.file_mtime > session.file_mtime).length;
|
|
824
|
+
if (newer > 0) {
|
|
825
|
+
warning = document.createElement('div');
|
|
826
|
+
warning.className = 'jp-AiAssistantsPanel-cleanupWarning';
|
|
827
|
+
warning.setAttribute('role', 'alert');
|
|
828
|
+
warning.textContent =
|
|
829
|
+
`${newer} parallel session${newer === 1 ? ' was' : 's were'} ` +
|
|
830
|
+
'active after the one kept.';
|
|
831
|
+
confirmBody.node.appendChild(warning);
|
|
832
|
+
}
|
|
833
|
+
nameDialog();
|
|
834
|
+
}
|
|
835
|
+
return fetched;
|
|
836
|
+
}, () => null);
|
|
837
|
+
const confirm = await confirmDialog.launch();
|
|
756
838
|
if (!confirm.button.accept) {
|
|
757
839
|
return;
|
|
758
840
|
}
|
|
@@ -760,6 +842,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
760
842
|
body.node.className = 'jp-AiAssistantsPanel-cleanupBody';
|
|
761
843
|
const message = document.createElement('div');
|
|
762
844
|
message.className = 'jp-AiAssistantsPanel-cleanupMessage';
|
|
845
|
+
message.setAttribute('role', 'status');
|
|
763
846
|
message.textContent = `Removing ${extra} parallel session${extra === 1 ? '' : 's'}...`;
|
|
764
847
|
body.node.appendChild(message);
|
|
765
848
|
// No `value` attribute -> indeterminate (animated) while the request is in
|
|
@@ -774,6 +857,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
774
857
|
buttons: [Dialog.okButton({ label: this._trans.__('Close') })],
|
|
775
858
|
renderer: dialogRenderer
|
|
776
859
|
});
|
|
860
|
+
dialog.node.setAttribute('aria-label', title);
|
|
777
861
|
// Close stays live while the work runs. Hiding it took away the only exit
|
|
778
862
|
// a keyboard user has: the dialog listens for Escape on its own node, it
|
|
779
863
|
// puts focus on this button and nowhere else, and focusing a hidden button
|
|
@@ -784,9 +868,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
784
868
|
void dialog.launch();
|
|
785
869
|
try {
|
|
786
870
|
// The extras ARE the branch list - the current conversation is not in
|
|
787
|
-
// it - so cleaning up is deleting exactly those ids.
|
|
788
|
-
|
|
789
|
-
|
|
871
|
+
// it - so cleaning up is deleting exactly those ids. Reuse the list the
|
|
872
|
+
// question was built from, waiting for it here where the progress
|
|
873
|
+
// dialog is already up; only a failed fetch re-reads.
|
|
874
|
+
const fetched = (_a = (await branchesReady)) !== null && _a !== void 0 ? _a : (await this._fetchBranches(session));
|
|
875
|
+
const ids = ((_b = fetched === null || fetched === void 0 ? void 0 : fetched.branches) !== null && _b !== void 0 ? _b : []).map(b => b.session_id);
|
|
790
876
|
if (ids.length === 0) {
|
|
791
877
|
// The menu item is gated on a row up to one poll old, so by the time
|
|
792
878
|
// the refetch lands another tab may have cleaned the project already.
|
|
@@ -795,6 +881,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
795
881
|
// to remove N.
|
|
796
882
|
bar.remove();
|
|
797
883
|
message.textContent = 'Nothing left to clean up.';
|
|
884
|
+
dialog.node.setAttribute('aria-label', `${title}. ${(_c = message.textContent) !== null && _c !== void 0 ? _c : ''}`);
|
|
798
885
|
this._branchCache = null;
|
|
799
886
|
await this._fetch();
|
|
800
887
|
return;
|
|
@@ -808,7 +895,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
808
895
|
});
|
|
809
896
|
// Only the conversations that ACTUALLY went lose their colour - one the
|
|
810
897
|
// server could not delete keeps its tint.
|
|
811
|
-
await this._colours.forget((
|
|
898
|
+
await this._colours.forget((_d = data.removed_ids) !== null && _d !== void 0 ? _d : ids);
|
|
812
899
|
// The kept listing names conversations that may have just gone.
|
|
813
900
|
this._branchCache = null;
|
|
814
901
|
bar.value = 1;
|
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.282",
|
|
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",
|
|
@@ -154,7 +154,10 @@ beforeEach(async () => {
|
|
|
154
154
|
// (DEF-30), so the GET goes out after the call rather than during it.
|
|
155
155
|
// Cleared once it has, or it counts against the first test to look.
|
|
156
156
|
await flush();
|
|
157
|
-
|
|
157
|
+
// Every case starts from the module factory's empty answer; `mockClear`
|
|
158
|
+
// alone left the previous case's implementation in place for the next one.
|
|
159
|
+
request.mockReset();
|
|
160
|
+
request.mockImplementation(() => Promise.resolve({}));
|
|
158
161
|
});
|
|
159
162
|
|
|
160
163
|
afterEach(async () => {
|
|
@@ -261,28 +264,52 @@ describe('DEF-51 - the destructive dialogs open with Cancel under the keyboard',
|
|
|
261
264
|
pressEnter();
|
|
262
265
|
await done;
|
|
263
266
|
|
|
264
|
-
//
|
|
265
|
-
// progress dialog is raised.
|
|
266
|
-
expect(
|
|
267
|
+
// The question's own branches fetch has run; cancelling stops before the
|
|
268
|
+
// removal, so no DELETE is sent and no progress dialog is raised.
|
|
269
|
+
expect(
|
|
270
|
+
request.mock.calls.filter(call => call[3]?.method === 'DELETE')
|
|
271
|
+
).toHaveLength(0);
|
|
267
272
|
expect(
|
|
268
273
|
document.querySelector('.jp-AiAssistantsPanel-cleanupBody')
|
|
269
274
|
).toBeNull();
|
|
270
275
|
});
|
|
271
276
|
|
|
272
277
|
it('Clean Up Parallel Sessions: Enter on the Remove button still cleans up', async () => {
|
|
273
|
-
// Same non-vacuity guard as above
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
+
// Same non-vacuity guard as above: the harness can see the removal, so
|
|
279
|
+
// "no DELETE" above means the keyboard landed elsewhere and not that the
|
|
280
|
+
// probe is blind.
|
|
281
|
+
request.mockImplementation(() =>
|
|
282
|
+
Promise.resolve({
|
|
283
|
+
current: 'sid-proj',
|
|
284
|
+
total: 2,
|
|
285
|
+
branches: [
|
|
286
|
+
{ session_id: 'sid-branch', file_mtime: 1, label: 'Branch' }
|
|
287
|
+
],
|
|
288
|
+
removed_count: 1
|
|
289
|
+
})
|
|
290
|
+
);
|
|
291
|
+
const done = (panel as any)._cleanupParallel(
|
|
292
|
+
session({ name: 'proj', extra_sessions: 3 })
|
|
293
|
+
) as Promise<void>;
|
|
278
294
|
(await openButtons())[1].focus();
|
|
279
295
|
pressEnter();
|
|
280
296
|
await flush();
|
|
281
297
|
|
|
282
|
-
|
|
298
|
+
const deletes = request.mock.calls.filter(
|
|
299
|
+
call => call[3]?.method === 'DELETE'
|
|
300
|
+
);
|
|
301
|
+
expect(deletes).toHaveLength(1);
|
|
302
|
+
expect(JSON.parse(deletes[0][3].body).session_ids).toEqual(['sid-branch']);
|
|
283
303
|
expect(
|
|
284
304
|
document.querySelector('.jp-AiAssistantsPanel-cleanupBody')
|
|
285
305
|
).not.toBeNull();
|
|
306
|
+
// The progress dialog's body is a Widget, which leaves showDialog no
|
|
307
|
+
// string to name it from, so the panel names it from the title.
|
|
308
|
+
expect(
|
|
309
|
+
document.querySelector('.jp-Dialog')!.getAttribute('aria-label')
|
|
310
|
+
).toContain('Clean Up Parallel Sessions');
|
|
311
|
+
// Let the removal finish before the dialog is dismissed below.
|
|
312
|
+
await done;
|
|
286
313
|
});
|
|
287
314
|
});
|
|
288
315
|
|
|
@@ -2268,7 +2295,20 @@ describe('ACC-SESS-61, ACC-SESS-62 - what the destructive confirmations state',
|
|
|
2268
2295
|
});
|
|
2269
2296
|
|
|
2270
2297
|
it('ACC-SESS-62 - the cleanup confirmation states how many go and that one stays', async () => {
|
|
2271
|
-
|
|
2298
|
+
// The fetched list is what the question counts: three branches despite a
|
|
2299
|
+
// row that claims nine. It also gives Cancel a DELETE to withhold.
|
|
2300
|
+
request.mockImplementationOnce(() =>
|
|
2301
|
+
Promise.resolve({
|
|
2302
|
+
current: 'sid-ledger-tools',
|
|
2303
|
+
total: 4,
|
|
2304
|
+
branches: [0, 1, 2].map(index => ({
|
|
2305
|
+
session_id: `sid-branch-${index}`,
|
|
2306
|
+
file_mtime: 1,
|
|
2307
|
+
label: `Branch ${index}`
|
|
2308
|
+
}))
|
|
2309
|
+
})
|
|
2310
|
+
);
|
|
2311
|
+
const row = session({ name: 'ledger-tools', extra_sessions: 9 });
|
|
2272
2312
|
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2273
2313
|
await openButtons();
|
|
2274
2314
|
const body = document.querySelector('.jp-Dialog-body')!.textContent ?? '';
|
|
@@ -2278,7 +2318,10 @@ describe('ACC-SESS-61, ACC-SESS-62 - what the destructive confirmations state',
|
|
|
2278
2318
|
expect(body).toContain('current conversation is kept');
|
|
2279
2319
|
pressEnter();
|
|
2280
2320
|
await done;
|
|
2281
|
-
|
|
2321
|
+
// Cancelling after the question's branches fetch sends no DELETE.
|
|
2322
|
+
expect(
|
|
2323
|
+
request.mock.calls.filter(call => call[3]?.method === 'DELETE')
|
|
2324
|
+
).toHaveLength(0);
|
|
2282
2325
|
});
|
|
2283
2326
|
|
|
2284
2327
|
it('ACC-SESS-62 - one parallel session is counted in the singular', async () => {
|
|
@@ -2293,6 +2336,253 @@ describe('ACC-SESS-61, ACC-SESS-62 - what the destructive confirmations state',
|
|
|
2293
2336
|
});
|
|
2294
2337
|
});
|
|
2295
2338
|
|
|
2339
|
+
describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint', () => {
|
|
2340
|
+
const HOUR = 3_600_000;
|
|
2341
|
+
|
|
2342
|
+
const openButtons = async (): Promise<HTMLButtonElement[]> => {
|
|
2343
|
+
await flush();
|
|
2344
|
+
const dialog = document.querySelector('.jp-Dialog');
|
|
2345
|
+
expect(dialog).not.toBeNull();
|
|
2346
|
+
return Array.from(
|
|
2347
|
+
dialog!.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2348
|
+
);
|
|
2349
|
+
};
|
|
2350
|
+
|
|
2351
|
+
const pressEnter = (): void => {
|
|
2352
|
+
document
|
|
2353
|
+
.querySelector('.jp-Dialog')!
|
|
2354
|
+
.dispatchEvent(
|
|
2355
|
+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })
|
|
2356
|
+
);
|
|
2357
|
+
};
|
|
2358
|
+
|
|
2359
|
+
/** The branch list a provider answers with: the kept conversation is not in
|
|
2360
|
+
* it, exactly as `list_branches` returns it. */
|
|
2361
|
+
const branches = (mtimes: number[]): any => ({
|
|
2362
|
+
current: 'sid-kept',
|
|
2363
|
+
total: mtimes.length + 1,
|
|
2364
|
+
branches: mtimes.map((file_mtime, index) => ({
|
|
2365
|
+
session_id: `sid-branch-${index}`,
|
|
2366
|
+
file_mtime,
|
|
2367
|
+
label: `Branch ${index}`
|
|
2368
|
+
}))
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
it('opens at once and warns when the branch list lands later', async () => {
|
|
2372
|
+
const now = Date.now();
|
|
2373
|
+
let release: ((value: any) => void) | undefined;
|
|
2374
|
+
// The request layer allows 60 s: the question must not wait for it.
|
|
2375
|
+
request.mockImplementationOnce(
|
|
2376
|
+
() => new Promise(resolve => (release = resolve))
|
|
2377
|
+
);
|
|
2378
|
+
const row = session({
|
|
2379
|
+
name: 'ledger-tools',
|
|
2380
|
+
extra_sessions: 2,
|
|
2381
|
+
file_mtime: now - 2 * HOUR
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2385
|
+
await openButtons();
|
|
2386
|
+
// Up already, on the row's own count.
|
|
2387
|
+
expect(document.querySelector('.jp-Dialog-body')!.textContent).toContain(
|
|
2388
|
+
'2 parallel sessions'
|
|
2389
|
+
);
|
|
2390
|
+
// The restated question is announced politely.
|
|
2391
|
+
expect(
|
|
2392
|
+
document.querySelector('.jp-Dialog-body p')!.getAttribute('role')
|
|
2393
|
+
).toBe('status');
|
|
2394
|
+
// The Widget body leaves showDialog no string to name the dialog from, so
|
|
2395
|
+
// the name is set here and carries the question on screen.
|
|
2396
|
+
expect(
|
|
2397
|
+
document.querySelector('.jp-Dialog')!.getAttribute('aria-label')
|
|
2398
|
+
).toContain('Remove 2 parallel sessions from "ledger-tools"?');
|
|
2399
|
+
|
|
2400
|
+
// The list lands while the dialog is open: the count becomes the list's
|
|
2401
|
+
// and the newer conversation adds the warning.
|
|
2402
|
+
release!(branches([now - HOUR]));
|
|
2403
|
+
await flush();
|
|
2404
|
+
expect(document.querySelector('.jp-Dialog-body')!.textContent).toContain(
|
|
2405
|
+
'Remove 1 parallel session from'
|
|
2406
|
+
);
|
|
2407
|
+
const warning = document.querySelector(
|
|
2408
|
+
'.jp-AiAssistantsPanel-cleanupWarning'
|
|
2409
|
+
)!;
|
|
2410
|
+
expect(warning.textContent).toBe(
|
|
2411
|
+
'1 parallel session was active after the one kept.'
|
|
2412
|
+
);
|
|
2413
|
+
// The warning is announced, and the dialog's name follows the restated
|
|
2414
|
+
// question and the warning.
|
|
2415
|
+
expect(warning.getAttribute('role')).toBe('alert');
|
|
2416
|
+
const label = document
|
|
2417
|
+
.querySelector('.jp-Dialog')!
|
|
2418
|
+
.getAttribute('aria-label');
|
|
2419
|
+
expect(label).toContain('Remove 1 parallel session from "ledger-tools"?');
|
|
2420
|
+
expect(label).toContain(warning.textContent!);
|
|
2421
|
+
|
|
2422
|
+
pressEnter();
|
|
2423
|
+
await done;
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
it('states the empty outcome and refreshes the list when the fetched list is empty', async () => {
|
|
2427
|
+
const now = Date.now();
|
|
2428
|
+
let release: ((value: any) => void) | undefined;
|
|
2429
|
+
request.mockImplementationOnce(
|
|
2430
|
+
() => new Promise(resolve => (release = resolve))
|
|
2431
|
+
);
|
|
2432
|
+
const row = session({
|
|
2433
|
+
name: 'ledger-tools',
|
|
2434
|
+
extra_sessions: 2,
|
|
2435
|
+
file_mtime: now - 2 * HOUR
|
|
2436
|
+
});
|
|
2437
|
+
|
|
2438
|
+
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2439
|
+
await openButtons();
|
|
2440
|
+
// The row is a poll old and a second client cleaned the project: the
|
|
2441
|
+
// confirmation ends through the accept path, whose zero-id guard states
|
|
2442
|
+
// the empty outcome, instead of restating "Remove 0 parallel sessions"
|
|
2443
|
+
// with the red Remove still offered (DEF-PANE-364, DEF-PANE-365).
|
|
2444
|
+
release!(branches([]));
|
|
2445
|
+
await done;
|
|
2446
|
+
await flush();
|
|
2447
|
+
|
|
2448
|
+
// The outcome dialog, not a bare dismissal: the user is told why.
|
|
2449
|
+
expect(document.querySelector('.jp-Dialog-body')!.textContent).toContain(
|
|
2450
|
+
'Nothing left to clean up.'
|
|
2451
|
+
);
|
|
2452
|
+
expect(
|
|
2453
|
+
Array.from(
|
|
2454
|
+
document.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2455
|
+
).map(button => button.textContent)
|
|
2456
|
+
).toEqual(['Close']);
|
|
2457
|
+
// The dialog's name carries the outcome statement, so a screen reader
|
|
2458
|
+
// hears why the confirmation closed, and the message is a status region
|
|
2459
|
+
// for an outcome that lands after the dialog has been read
|
|
2460
|
+
// (DEF-PANE-366-EMPTY-OUTCOME).
|
|
2461
|
+
expect(
|
|
2462
|
+
document.querySelector('.jp-Dialog')!.getAttribute('aria-label')
|
|
2463
|
+
).toContain('Nothing left to clean up.');
|
|
2464
|
+
expect(
|
|
2465
|
+
document
|
|
2466
|
+
.querySelector('.jp-AiAssistantsPanel-cleanupMessage')!
|
|
2467
|
+
.getAttribute('role')
|
|
2468
|
+
).toBe('status');
|
|
2469
|
+
// The branches fetch only: nothing was deleted.
|
|
2470
|
+
expect(
|
|
2471
|
+
request.mock.calls.filter(call => call[3]?.method === 'DELETE')
|
|
2472
|
+
).toHaveLength(0);
|
|
2473
|
+
// The guard refreshed the list once, after the fetch it answered.
|
|
2474
|
+
const paths = request.mock.calls.map(call => call[1]);
|
|
2475
|
+
expect(paths.filter(path => path === 'branches')).toHaveLength(1);
|
|
2476
|
+
expect(paths.filter(path => path === 'sessions')).toHaveLength(1);
|
|
2477
|
+
expect(paths.indexOf('sessions')).toBeGreaterThan(
|
|
2478
|
+
paths.indexOf('branches')
|
|
2479
|
+
);
|
|
2480
|
+
});
|
|
2481
|
+
|
|
2482
|
+
it('warns when a conversation it removes was active after the one kept', async () => {
|
|
2483
|
+
const now = Date.now();
|
|
2484
|
+
const row = session({
|
|
2485
|
+
name: 'ledger-tools',
|
|
2486
|
+
extra_sessions: 2,
|
|
2487
|
+
file_mtime: now - 2 * HOUR
|
|
2488
|
+
});
|
|
2489
|
+
request.mockResolvedValueOnce(branches([now - HOUR, now - 3 * HOUR]));
|
|
2490
|
+
|
|
2491
|
+
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2492
|
+
await openButtons();
|
|
2493
|
+
|
|
2494
|
+
const warning = document.querySelector(
|
|
2495
|
+
'.jp-AiAssistantsPanel-cleanupWarning'
|
|
2496
|
+
);
|
|
2497
|
+
expect(warning).not.toBeNull();
|
|
2498
|
+
// It names the one conversation that was newer, not the whole list.
|
|
2499
|
+
expect(warning!.textContent).toContain('1 parallel session was');
|
|
2500
|
+
// The question counts the fetched list, which is the list the removal
|
|
2501
|
+
// deletes - not the row's count from an older poll.
|
|
2502
|
+
expect(document.querySelector('.jp-Dialog-body')!.textContent).toContain(
|
|
2503
|
+
'2 parallel sessions'
|
|
2504
|
+
);
|
|
2505
|
+
pressEnter();
|
|
2506
|
+
await done;
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
it('does not warn when every conversation it removes is older or equal', async () => {
|
|
2510
|
+
const now = Date.now();
|
|
2511
|
+
const row = session({
|
|
2512
|
+
name: 'ledger-tools',
|
|
2513
|
+
extra_sessions: 2,
|
|
2514
|
+
file_mtime: now - 2 * HOUR
|
|
2515
|
+
});
|
|
2516
|
+
// Equal is the boundary: only a LATER activity is a warning.
|
|
2517
|
+
request.mockResolvedValueOnce(branches([now - 2 * HOUR, now - 3 * HOUR]));
|
|
2518
|
+
|
|
2519
|
+
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2520
|
+
await openButtons();
|
|
2521
|
+
|
|
2522
|
+
expect(
|
|
2523
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2524
|
+
).toBeNull();
|
|
2525
|
+
pressEnter();
|
|
2526
|
+
await done;
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
it('states its hint in at most twelve words, in both disposal settings', async () => {
|
|
2530
|
+
const now = Date.now();
|
|
2531
|
+
const row = session({ name: 'ledger-tools', extra_sessions: 1 });
|
|
2532
|
+
request.mockResolvedValueOnce(branches([now - 3 * HOUR]));
|
|
2533
|
+
|
|
2534
|
+
let done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2535
|
+
await openButtons();
|
|
2536
|
+
let hint = document.querySelector(
|
|
2537
|
+
'.jp-AiAssistantsPanel-cleanupHint'
|
|
2538
|
+
)!.textContent!;
|
|
2539
|
+
expect(hint.trim().split(/\s+/).length).toBeLessThanOrEqual(12);
|
|
2540
|
+
expect(hint).toContain('go to the trash');
|
|
2541
|
+
// The hint promises the trash; the caution names the refusal that deletes
|
|
2542
|
+
// permanently.
|
|
2543
|
+
expect(
|
|
2544
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupCaution')!
|
|
2545
|
+
.textContent
|
|
2546
|
+
).toContain('A refused trash move deletes permanently');
|
|
2547
|
+
pressEnter();
|
|
2548
|
+
await done;
|
|
2549
|
+
|
|
2550
|
+
(panel as any)._deleteToTrash = false;
|
|
2551
|
+
request.mockResolvedValueOnce(branches([now - 3 * HOUR]));
|
|
2552
|
+
done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2553
|
+
await openButtons();
|
|
2554
|
+
hint = document.querySelector(
|
|
2555
|
+
'.jp-AiAssistantsPanel-cleanupHint'
|
|
2556
|
+
)!.textContent!;
|
|
2557
|
+
expect(hint.trim().split(/\s+/).length).toBeLessThanOrEqual(12);
|
|
2558
|
+
expect(hint).toContain('deleted permanently');
|
|
2559
|
+
// The caution is not the hint: it stays visible on its own.
|
|
2560
|
+
expect(
|
|
2561
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupCaution')!
|
|
2562
|
+
.textContent
|
|
2563
|
+
).toContain('cannot be undone');
|
|
2564
|
+
pressEnter();
|
|
2565
|
+
await done;
|
|
2566
|
+
});
|
|
2567
|
+
|
|
2568
|
+
it('shows the question without a warning when the fetch fails', async () => {
|
|
2569
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2570
|
+
const row = session({ name: 'ledger-tools', extra_sessions: 3 });
|
|
2571
|
+
|
|
2572
|
+
const done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2573
|
+
await openButtons();
|
|
2574
|
+
|
|
2575
|
+
expect(
|
|
2576
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2577
|
+
).toBeNull();
|
|
2578
|
+
expect(document.querySelector('.jp-Dialog-body')!.textContent).toContain(
|
|
2579
|
+
'3 parallel sessions'
|
|
2580
|
+
);
|
|
2581
|
+
pressEnter();
|
|
2582
|
+
await done;
|
|
2583
|
+
});
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2296
2586
|
describe('ACC-PANE-35..45 - what a row and a section show', () => {
|
|
2297
2587
|
const MINUTE = 60_000;
|
|
2298
2588
|
const HOUR = 3_600_000;
|
package/src/core/panel.ts
CHANGED
|
@@ -836,14 +836,44 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
836
836
|
}
|
|
837
837
|
|
|
838
838
|
private async _cleanupParallel(session: ISession): Promise<void> {
|
|
839
|
-
const extra = session.extra_sessions;
|
|
840
839
|
const name = this._lookupName(session);
|
|
841
|
-
const
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
840
|
+
const confirmBody = new Widget();
|
|
841
|
+
confirmBody.node.className = 'jp-AiAssistantsPanel-cleanupConfirm';
|
|
842
|
+
const question = document.createElement('p');
|
|
843
|
+
// Announced politely when the fetch restates it; the warning keeps
|
|
844
|
+
// role=alert for the case that needs attention.
|
|
845
|
+
question.setAttribute('role', 'status');
|
|
846
|
+
let extra = session.extra_sessions;
|
|
847
|
+
const askCount = (count: number): void => {
|
|
848
|
+
question.textContent =
|
|
849
|
+
`Remove ${count} parallel session${count === 1 ? '' : 's'} from ` +
|
|
850
|
+
`"${name}"?`;
|
|
851
|
+
};
|
|
852
|
+
askCount(extra);
|
|
853
|
+
confirmBody.node.appendChild(question);
|
|
854
|
+
// One sentence, twelve words or fewer: what happens, not the detail.
|
|
855
|
+
const hint = document.createElement('p');
|
|
856
|
+
hint.className = 'jp-AiAssistantsPanel-cleanupHint';
|
|
857
|
+
hint.textContent = this._deleteToTrash
|
|
858
|
+
? 'The current conversation is kept; the rest go to the trash.'
|
|
859
|
+
: 'The current conversation is kept; the rest are deleted permanently.';
|
|
860
|
+
confirmBody.node.appendChild(hint);
|
|
861
|
+
// The irreversible note is a safety statement, not the hint, so it keeps
|
|
862
|
+
// its own line. With the trash on it names the refusal: a refused move
|
|
863
|
+
// deletes permanently, while the hint promises the trash
|
|
864
|
+
// (DEF-PANE-363-CAUTION-REFUSAL).
|
|
865
|
+
const caution = document.createElement('p');
|
|
866
|
+
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
867
|
+
caution.textContent = this._deleteToTrash
|
|
868
|
+
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
869
|
+
: this._irreversibleNote.trim();
|
|
870
|
+
confirmBody.node.appendChild(caution);
|
|
871
|
+
|
|
872
|
+
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
873
|
+
let warning: HTMLElement | null = null;
|
|
874
|
+
const confirmDialog = new Dialog<unknown>({
|
|
875
|
+
title,
|
|
876
|
+
body: confirmBody,
|
|
847
877
|
buttons: [
|
|
848
878
|
Dialog.cancelButton(),
|
|
849
879
|
Dialog.warnButton({ label: this._trans.__('Remove') })
|
|
@@ -852,6 +882,66 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
852
882
|
defaultButton: 0,
|
|
853
883
|
renderer: dialogRenderer
|
|
854
884
|
});
|
|
885
|
+
// showDialog names a dialog only when its body is a string, and this body
|
|
886
|
+
// is a Widget: name it here, and again when the question is restated and
|
|
887
|
+
// the warning joins it.
|
|
888
|
+
const nameDialog = (): void => {
|
|
889
|
+
confirmDialog.node.setAttribute(
|
|
890
|
+
'aria-label',
|
|
891
|
+
[
|
|
892
|
+
title,
|
|
893
|
+
question.textContent ?? '',
|
|
894
|
+
hint.textContent ?? '',
|
|
895
|
+
caution.textContent ?? '',
|
|
896
|
+
warning?.textContent ?? ''
|
|
897
|
+
]
|
|
898
|
+
.filter(part => part)
|
|
899
|
+
.join(' ')
|
|
900
|
+
);
|
|
901
|
+
};
|
|
902
|
+
nameDialog();
|
|
903
|
+
|
|
904
|
+
// Alongside the question, never in front of it: the request layer allows
|
|
905
|
+
// 60 s, and the confirmation must open at once. When the list lands the
|
|
906
|
+
// question states its length and a conversation newer than the kept one
|
|
907
|
+
// adds the warning, drawn last so it reads after the caution. The same
|
|
908
|
+
// list is what the removal deletes. A failed fetch leaves the question as
|
|
909
|
+
// the row reported it, with no warning, and the removal fetches again
|
|
910
|
+
// below.
|
|
911
|
+
const branchesReady = this._fetchBranches(session).then(
|
|
912
|
+
fetched => {
|
|
913
|
+
const list = fetched?.branches;
|
|
914
|
+
if (list && list.length === 0) {
|
|
915
|
+
// The row is up to one poll old, and a second client may have
|
|
916
|
+
// cleaned the project since (DEF-GUARD-96): a red Remove with zero
|
|
917
|
+
// targets is a dead end. End the confirmation through the accept
|
|
918
|
+
// path, whose zero-id guard states the empty outcome and refreshes
|
|
919
|
+
// the list (DEF-PANE-365); resolving after the user already
|
|
920
|
+
// accepted or cancelled is a no-op.
|
|
921
|
+
confirmDialog.resolve(1);
|
|
922
|
+
} else if (list) {
|
|
923
|
+
extra = list.length;
|
|
924
|
+
askCount(extra);
|
|
925
|
+
const newer = list.filter(
|
|
926
|
+
branch => branch.file_mtime > session.file_mtime
|
|
927
|
+
).length;
|
|
928
|
+
if (newer > 0) {
|
|
929
|
+
warning = document.createElement('div');
|
|
930
|
+
warning.className = 'jp-AiAssistantsPanel-cleanupWarning';
|
|
931
|
+
warning.setAttribute('role', 'alert');
|
|
932
|
+
warning.textContent =
|
|
933
|
+
`${newer} parallel session${newer === 1 ? ' was' : 's were'} ` +
|
|
934
|
+
'active after the one kept.';
|
|
935
|
+
confirmBody.node.appendChild(warning);
|
|
936
|
+
}
|
|
937
|
+
nameDialog();
|
|
938
|
+
}
|
|
939
|
+
return fetched;
|
|
940
|
+
},
|
|
941
|
+
() => null
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
const confirm = await confirmDialog.launch();
|
|
855
945
|
if (!confirm.button.accept) {
|
|
856
946
|
return;
|
|
857
947
|
}
|
|
@@ -861,6 +951,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
861
951
|
|
|
862
952
|
const message = document.createElement('div');
|
|
863
953
|
message.className = 'jp-AiAssistantsPanel-cleanupMessage';
|
|
954
|
+
message.setAttribute('role', 'status');
|
|
864
955
|
message.textContent = `Removing ${extra} parallel session${
|
|
865
956
|
extra === 1 ? '' : 's'
|
|
866
957
|
}...`;
|
|
@@ -879,6 +970,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
879
970
|
buttons: [Dialog.okButton({ label: this._trans.__('Close') })],
|
|
880
971
|
renderer: dialogRenderer
|
|
881
972
|
});
|
|
973
|
+
dialog.node.setAttribute('aria-label', title);
|
|
882
974
|
// Close stays live while the work runs. Hiding it took away the only exit
|
|
883
975
|
// a keyboard user has: the dialog listens for Escape on its own node, it
|
|
884
976
|
// puts focus on this button and nowhere else, and focusing a hidden button
|
|
@@ -890,9 +982,12 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
890
982
|
|
|
891
983
|
try {
|
|
892
984
|
// The extras ARE the branch list - the current conversation is not in
|
|
893
|
-
// it - so cleaning up is deleting exactly those ids.
|
|
894
|
-
|
|
895
|
-
|
|
985
|
+
// it - so cleaning up is deleting exactly those ids. Reuse the list the
|
|
986
|
+
// question was built from, waiting for it here where the progress
|
|
987
|
+
// dialog is already up; only a failed fetch re-reads.
|
|
988
|
+
const fetched =
|
|
989
|
+
(await branchesReady) ?? (await this._fetchBranches(session));
|
|
990
|
+
const ids = (fetched?.branches ?? []).map(b => b.session_id);
|
|
896
991
|
if (ids.length === 0) {
|
|
897
992
|
// The menu item is gated on a row up to one poll old, so by the time
|
|
898
993
|
// the refetch lands another tab may have cleaned the project already.
|
|
@@ -901,6 +996,10 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
901
996
|
// to remove N.
|
|
902
997
|
bar.remove();
|
|
903
998
|
message.textContent = 'Nothing left to clean up.';
|
|
999
|
+
dialog.node.setAttribute(
|
|
1000
|
+
'aria-label',
|
|
1001
|
+
`${title}. ${message.textContent ?? ''}`
|
|
1002
|
+
);
|
|
904
1003
|
this._branchCache = null;
|
|
905
1004
|
await this._fetch();
|
|
906
1005
|
return;
|
package/style/base.css
CHANGED
|
@@ -683,6 +683,35 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
683
683
|
color: var(--jp-error-color1);
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
+
/* The cleanup confirmation's question, hint, caution and warning. The warning
|
|
687
|
+
box is separate from the body text because it answers one question - is a
|
|
688
|
+
conversation this removal deletes newer than the one kept? - and it uses the
|
|
689
|
+
warning colour, not the error colour: the removal is still the user's call.
|
|
690
|
+
The tint is mixed with the surface colour, so both themes keep the text
|
|
691
|
+
readable. */
|
|
692
|
+
.jp-AiAssistantsPanel-cleanupConfirm {
|
|
693
|
+
display: flex;
|
|
694
|
+
flex-direction: column;
|
|
695
|
+
gap: 8px;
|
|
696
|
+
max-width: 440px;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.jp-AiAssistantsPanel-cleanupConfirm p {
|
|
700
|
+
margin: 0;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.jp-AiAssistantsPanel-cleanupWarning {
|
|
704
|
+
padding: 6px 8px;
|
|
705
|
+
border-left: 3px solid var(--jp-warn-color1);
|
|
706
|
+
border-radius: 2px;
|
|
707
|
+
background: color-mix(
|
|
708
|
+
in srgb,
|
|
709
|
+
var(--jp-warn-color1) 12%,
|
|
710
|
+
var(--jp-layout-color1)
|
|
711
|
+
);
|
|
712
|
+
color: var(--jp-ui-font-color1);
|
|
713
|
+
}
|
|
714
|
+
|
|
686
715
|
.jp-AiAssistantsPanel-row.jp-mod-busy {
|
|
687
716
|
opacity: 0.55;
|
|
688
717
|
pointer-events: none;
|