jupyterlab_ai_code_assistants_extension 1.2.288 → 1.2.299
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 +0 -5
- package/lib/core/panel.js +162 -45
- package/lib/core/popup.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/panel.spec.ts +459 -30
- package/src/__tests__/popup.spec.ts +2 -2
- package/src/core/panel.ts +179 -47
- package/src/core/popup.ts +1 -1
- package/style/base.css +16 -3
package/lib/core/panel.d.ts
CHANGED
|
@@ -90,11 +90,6 @@ export declare class AssistantSessionsPanel extends Widget {
|
|
|
90
90
|
/** `keepSame`: an answer equal to what is drawn leaves the rows in place. */
|
|
91
91
|
private _fetch;
|
|
92
92
|
private _toggleFavourite;
|
|
93
|
-
/** The warning a deletion earns: with the trash setting on, a refused move
|
|
94
|
-
* may remove the conversations permanently or leave them in place, so
|
|
95
|
-
* neither the verb nor the note may promise one outcome. */
|
|
96
|
-
private get _irreversibleNote();
|
|
97
|
-
private get _disposalVerb();
|
|
98
93
|
private _removeProject;
|
|
99
94
|
private _cleanupParallel;
|
|
100
95
|
/** This provider's launch-mode token for one action - see
|
package/lib/core/panel.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Clipboard, Dialog, InputDialog, Notification
|
|
1
|
+
import { Clipboard, Dialog, InputDialog, Notification } from '@jupyterlab/apputils';
|
|
2
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';
|
|
@@ -701,27 +701,33 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
701
701
|
this._showActionError('Could not toggle favorite - try again.', err);
|
|
702
702
|
}
|
|
703
703
|
}
|
|
704
|
-
/** The warning a deletion earns: with the trash setting on, a refused move
|
|
705
|
-
* may remove the conversations permanently or leave them in place, so
|
|
706
|
-
* neither the verb nor the note may promise one outcome. */
|
|
707
|
-
get _irreversibleNote() {
|
|
708
|
-
return this._deleteToTrash
|
|
709
|
-
? ' A permanent removal cannot be undone.'
|
|
710
|
-
: ' This cannot be undone.';
|
|
711
|
-
}
|
|
712
|
-
get _disposalVerb() {
|
|
713
|
-
return this._deleteToTrash
|
|
714
|
-
? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
|
|
715
|
-
: 'deleted permanently';
|
|
716
|
-
}
|
|
717
704
|
async _removeProject(session) {
|
|
718
705
|
var _a;
|
|
719
706
|
const name = this._lookupName(session);
|
|
720
|
-
const
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
707
|
+
const title = `Remove from ${this._descriptor.label}`;
|
|
708
|
+
// The consequence only, in two lines: the question and the outcome, then
|
|
709
|
+
// the permanent-deletion refusal on its own caution line
|
|
710
|
+
// (DEF-PANE-371-DIALOG-TEXT).
|
|
711
|
+
const outcome = this._deleteToTrash
|
|
712
|
+
? `Remove "${name}"? Every conversation in it goes to the trash.`
|
|
713
|
+
: `Remove "${name}"? Every conversation in it is deleted permanently.`;
|
|
714
|
+
const caution = this._deleteToTrash
|
|
715
|
+
? 'A refused move deletes permanently.'
|
|
716
|
+
: '';
|
|
717
|
+
const body = new Widget();
|
|
718
|
+
body.node.className = 'jp-AiAssistantsPanel-removeConfirm';
|
|
719
|
+
const question = document.createElement('p');
|
|
720
|
+
question.textContent = outcome;
|
|
721
|
+
body.node.appendChild(question);
|
|
722
|
+
if (caution) {
|
|
723
|
+
const line = document.createElement('p');
|
|
724
|
+
line.className = 'jp-AiAssistantsPanel-removeCaution';
|
|
725
|
+
line.textContent = caution;
|
|
726
|
+
body.node.appendChild(line);
|
|
727
|
+
}
|
|
728
|
+
const dialog = new Dialog({
|
|
729
|
+
title,
|
|
730
|
+
body,
|
|
725
731
|
buttons: [
|
|
726
732
|
Dialog.cancelButton(),
|
|
727
733
|
Dialog.warnButton({ label: this._trans.__('Remove') })
|
|
@@ -734,6 +740,9 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
734
740
|
defaultButton: 0,
|
|
735
741
|
renderer: dialogRenderer
|
|
736
742
|
});
|
|
743
|
+
// A Widget body leaves the Dialog no string to name itself from.
|
|
744
|
+
dialog.node.setAttribute('aria-label', [title, outcome, caution].filter(part => part).join(' '));
|
|
745
|
+
const confirm = await dialog.launch();
|
|
737
746
|
if (!confirm.button.accept) {
|
|
738
747
|
return;
|
|
739
748
|
}
|
|
@@ -777,23 +786,23 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
777
786
|
};
|
|
778
787
|
askCount(extra);
|
|
779
788
|
confirmBody.node.appendChild(question);
|
|
780
|
-
//
|
|
789
|
+
// The second line states what happens to the rest: the count, the short
|
|
790
|
+
// ids once the branch list names them, and the disposal the setting chose.
|
|
781
791
|
const hint = document.createElement('p');
|
|
782
792
|
hint.className = 'jp-AiAssistantsPanel-cleanupHint';
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
793
|
+
// Announced when restated with the short ids; an aria-label change is not.
|
|
794
|
+
hint.setAttribute('role', 'status');
|
|
795
|
+
const stateHint = (ids) => {
|
|
796
|
+
const shortIds = ids === null || ids === void 0 ? void 0 : ids.map(id => shortSessionId(id, this._descriptor.sessionIdPrefix));
|
|
797
|
+
hint.textContent =
|
|
798
|
+
`Remaining ${extra} session${extra === 1 ? '' : 's'}` +
|
|
799
|
+
((shortIds === null || shortIds === void 0 ? void 0 : shortIds.length) ? ` ${shortIds.join(', ')}` : '') +
|
|
800
|
+
(this._deleteToTrash
|
|
801
|
+
? ' will be removed.'
|
|
802
|
+
: ' will be deleted permanently.');
|
|
803
|
+
};
|
|
804
|
+
stateHint(null);
|
|
786
805
|
confirmBody.node.appendChild(hint);
|
|
787
|
-
// The irreversible note is a safety statement, not the hint, so it keeps
|
|
788
|
-
// its own line. With the trash on it names the refusal: a refused move
|
|
789
|
-
// deletes permanently, while the hint promises the trash
|
|
790
|
-
// (DEF-PANE-363-CAUTION-REFUSAL).
|
|
791
|
-
const caution = document.createElement('p');
|
|
792
|
-
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
793
|
-
caution.textContent = this._deleteToTrash
|
|
794
|
-
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
795
|
-
: this._irreversibleNote.trim();
|
|
796
|
-
confirmBody.node.appendChild(caution);
|
|
797
806
|
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
798
807
|
let warning = null;
|
|
799
808
|
const confirmDialog = new Dialog({
|
|
@@ -807,29 +816,129 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
807
816
|
defaultButton: 0,
|
|
808
817
|
renderer: dialogRenderer
|
|
809
818
|
});
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
//
|
|
819
|
+
// Scopes the armed style in base.css to this one dialog.
|
|
820
|
+
confirmDialog.addClass('jp-AiAssistantsPanel-cleanupDialog');
|
|
821
|
+
// The Dialog names itself only when its body is a string, and this body is
|
|
822
|
+
// a Widget: name it here, and again when the question and hint are
|
|
823
|
+
// restated and the warning joins it.
|
|
813
824
|
const nameDialog = () => {
|
|
814
|
-
var _a, _b, _c
|
|
825
|
+
var _a, _b, _c;
|
|
815
826
|
confirmDialog.node.setAttribute('aria-label', [
|
|
816
827
|
title,
|
|
817
828
|
(_a = question.textContent) !== null && _a !== void 0 ? _a : '',
|
|
818
829
|
(_b = hint.textContent) !== null && _b !== void 0 ? _b : '',
|
|
819
|
-
(_c =
|
|
820
|
-
(_d = warning === null || warning === void 0 ? void 0 : warning.textContent) !== null && _d !== void 0 ? _d : ''
|
|
830
|
+
(_c = warning === null || warning === void 0 ? void 0 : warning.textContent) !== null && _c !== void 0 ? _c : ''
|
|
821
831
|
]
|
|
822
832
|
.filter(part => part)
|
|
823
833
|
.join(' '));
|
|
824
834
|
};
|
|
825
835
|
nameDialog();
|
|
836
|
+
// The house two-step destructive action (design-language.md, "Two-step
|
|
837
|
+
// destructive action"; the branch popup's Delete is the shipped form):
|
|
838
|
+
// while the newer state is unknown or newer, the accept button arms on the
|
|
839
|
+
// first activation and removes on the second, the arm lapsing after 4 s.
|
|
840
|
+
// It starts required: the branch fetch that decides whether a removal is
|
|
841
|
+
// safe is still out when the dialog opens.
|
|
842
|
+
let armRequired = true;
|
|
843
|
+
let armed = false;
|
|
844
|
+
let armTimer = 0;
|
|
845
|
+
// The arming activation's event.timeStamp, which the confirming one is
|
|
846
|
+
// measured against.
|
|
847
|
+
let armedAt = 0;
|
|
848
|
+
/** Find and relabel the accept button. The Dialog keeps its button nodes
|
|
849
|
+
* private and puts the label in a child whose aria-label overrides its
|
|
850
|
+
* text, so both are set. */
|
|
851
|
+
const updateAccept = () => {
|
|
852
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
853
|
+
const label = button === null || button === void 0 ? void 0 : button.querySelector('.jp-Dialog-buttonLabel');
|
|
854
|
+
if (!button || !label) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const text = armed
|
|
858
|
+
? this._trans.__('Confirm remove %1', extra)
|
|
859
|
+
: armRequired
|
|
860
|
+
? this._trans.__('Remove (%1)', extra)
|
|
861
|
+
: this._trans.__('Remove');
|
|
862
|
+
label.textContent = text;
|
|
863
|
+
label.setAttribute('aria-label', text);
|
|
864
|
+
button.classList.toggle('jp-mod-armed', armed);
|
|
865
|
+
};
|
|
866
|
+
const disarm = () => {
|
|
867
|
+
armed = false;
|
|
868
|
+
window.clearTimeout(armTimer);
|
|
869
|
+
};
|
|
870
|
+
/** Consume one accept activation. True means it only armed, or was the
|
|
871
|
+
* ignored half of a double-click, and the Dialog must not resolve; false
|
|
872
|
+
* lets the Dialog's own handler resolve it. */
|
|
873
|
+
const activate = (event) => {
|
|
874
|
+
if (!armRequired) {
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
878
|
+
// a multi-click within 600 ms of arming is ignored. A later click
|
|
879
|
+
// confirms whatever its detail, since the OS double-click interval can
|
|
880
|
+
// be longer than the user's pause (DEF-PANE-338).
|
|
881
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
if (!armed) {
|
|
885
|
+
armed = true;
|
|
886
|
+
armedAt = event.timeStamp;
|
|
887
|
+
window.clearTimeout(armTimer);
|
|
888
|
+
armTimer = window.setTimeout(() => {
|
|
889
|
+
disarm();
|
|
890
|
+
updateAccept();
|
|
891
|
+
}, 4000);
|
|
892
|
+
updateAccept();
|
|
893
|
+
return true;
|
|
894
|
+
}
|
|
895
|
+
disarm();
|
|
896
|
+
return false;
|
|
897
|
+
};
|
|
898
|
+
// Registered before launch(): the Dialog attaches its own capture
|
|
899
|
+
// listeners on this same node when launch() attaches it, so these run
|
|
900
|
+
// first and can swallow an activation that must only arm. Space arrives
|
|
901
|
+
// as a click; Enter arrives as a keydown the Dialog would otherwise
|
|
902
|
+
// resolve from, so both are intercepted here.
|
|
903
|
+
confirmDialog.node.addEventListener('click', event => {
|
|
904
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
905
|
+
if (!button || !button.contains(event.target)) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
if (activate(event)) {
|
|
909
|
+
event.stopImmediatePropagation();
|
|
910
|
+
event.preventDefault();
|
|
911
|
+
}
|
|
912
|
+
}, true);
|
|
913
|
+
confirmDialog.node.addEventListener('keydown', event => {
|
|
914
|
+
if (event.key !== 'Enter') {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
const accept = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
918
|
+
if (!accept || document.activeElement !== accept) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
// A held key's auto-repeat is not a second press (DEF-PANE-347): it
|
|
922
|
+
// must neither confirm nor arm, and consuming it stops the Dialog's
|
|
923
|
+
// own Enter handler from resolving the accept.
|
|
924
|
+
if (event.repeat) {
|
|
925
|
+
event.stopImmediatePropagation();
|
|
926
|
+
event.preventDefault();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (activate(event)) {
|
|
930
|
+
event.stopImmediatePropagation();
|
|
931
|
+
event.preventDefault();
|
|
932
|
+
}
|
|
933
|
+
}, true);
|
|
934
|
+
updateAccept();
|
|
826
935
|
// Alongside the question, never in front of it: the request layer allows
|
|
827
936
|
// 60 s, and the confirmation must open at once. When the list lands the
|
|
828
|
-
// question
|
|
829
|
-
// adds the warning, drawn last so it reads after the
|
|
830
|
-
// list is what the removal deletes. A failed fetch leaves
|
|
831
|
-
// the row reported it, with no
|
|
832
|
-
// below.
|
|
937
|
+
// question and hint state its length and ids, and a conversation newer
|
|
938
|
+
// than the kept one adds the warning, drawn last so it reads after the
|
|
939
|
+
// hint. The same list is what the removal deletes. A failed fetch leaves
|
|
940
|
+
// the question as the row reported it, with no ids and no warning, and
|
|
941
|
+
// the removal fetches again below.
|
|
833
942
|
const branchesReady = this._fetchBranches(session).then(fetched => {
|
|
834
943
|
const list = fetched === null || fetched === void 0 ? void 0 : fetched.branches;
|
|
835
944
|
if (list && list.length === 0) {
|
|
@@ -844,6 +953,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
844
953
|
else if (list) {
|
|
845
954
|
extra = list.length;
|
|
846
955
|
askCount(extra);
|
|
956
|
+
stateHint(list.map(branch => branch.session_id));
|
|
847
957
|
const newer = list.filter(branch => branch.file_mtime > session.file_mtime).length;
|
|
848
958
|
if (newer > 0) {
|
|
849
959
|
warning = document.createElement('div');
|
|
@@ -851,9 +961,16 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
851
961
|
warning.setAttribute('role', 'alert');
|
|
852
962
|
warning.textContent =
|
|
853
963
|
`${newer} parallel session${newer === 1 ? ' was' : 's were'} ` +
|
|
854
|
-
'active after the
|
|
964
|
+
'active after the current session.';
|
|
855
965
|
confirmBody.node.appendChild(warning);
|
|
856
966
|
}
|
|
967
|
+
// The list just decided what the button acts on: an arm given while
|
|
968
|
+
// it was pending could confirm a different set, so drop it and
|
|
969
|
+
// restate the label with the fetched count. With no newer branch the
|
|
970
|
+
// button returns to the one-click Remove.
|
|
971
|
+
disarm();
|
|
972
|
+
armRequired = newer > 0;
|
|
973
|
+
updateAccept();
|
|
857
974
|
nameDialog();
|
|
858
975
|
}
|
|
859
976
|
return fetched;
|
package/lib/core/popup.js
CHANGED
|
@@ -91,7 +91,7 @@ export function showManageSessionsPopup(options) {
|
|
|
91
91
|
deleteBtn.type = 'button';
|
|
92
92
|
deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
|
|
93
93
|
deleteBtn.title = options.deleteToTrash
|
|
94
|
-
? 'Deleted conversations
|
|
94
|
+
? 'Deleted conversations go to the trash; a refused move deletes permanently.'
|
|
95
95
|
: 'Deleted conversations are removed permanently';
|
|
96
96
|
footer.appendChild(deleteBtn);
|
|
97
97
|
// Plain visual counter (selection, or the last action), beside the button
|
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.299",
|
|
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",
|
|
@@ -220,19 +220,39 @@ describe('DEF-51 - the destructive dialogs open with Cancel under the keyboard',
|
|
|
220
220
|
// when no trash accepts the move.
|
|
221
221
|
let done = (panel as any)._removeProject(session()) as Promise<void>;
|
|
222
222
|
await openButtons();
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// A Widget body leaves the Dialog no string to name itself from, so the
|
|
224
|
+
// aria-label is the dialog's only accessible name - the whole of it.
|
|
225
|
+
const label = document
|
|
226
|
+
.querySelector('.jp-Dialog')!
|
|
227
|
+
.getAttribute('aria-label')!;
|
|
228
|
+
expect(label).toContain('Remove from Testbed');
|
|
229
|
+
expect(label).toContain(
|
|
230
|
+
'Remove "proj"? Every conversation in it goes to the trash.'
|
|
231
|
+
);
|
|
232
|
+
expect(label).toContain('A refused move deletes permanently.');
|
|
233
|
+
let lines = Array.from(document.querySelectorAll('.jp-Dialog-body p')).map(
|
|
234
|
+
line => line.textContent ?? ''
|
|
226
235
|
);
|
|
227
|
-
expect(
|
|
236
|
+
expect(lines).toEqual([
|
|
237
|
+
'Remove "proj"? Every conversation in it goes to the trash.',
|
|
238
|
+
'A refused move deletes permanently.'
|
|
239
|
+
]);
|
|
240
|
+
// The body states its outcome in at most twenty words, the caution in at
|
|
241
|
+
// most twelve (DEF-PANE-371-DIALOG-TEXT).
|
|
242
|
+
expect(lines[0].trim().split(/\s+/).length).toBeLessThanOrEqual(20);
|
|
243
|
+
expect(lines[1].trim().split(/\s+/).length).toBeLessThanOrEqual(12);
|
|
228
244
|
pressEnter();
|
|
229
245
|
await done;
|
|
230
246
|
|
|
231
247
|
(panel as any)._deleteToTrash = false;
|
|
232
248
|
done = (panel as any)._removeProject(session()) as Promise<void>;
|
|
233
249
|
await openButtons();
|
|
234
|
-
|
|
235
|
-
|
|
250
|
+
lines = Array.from(document.querySelectorAll('.jp-Dialog-body p')).map(
|
|
251
|
+
line => line.textContent ?? ''
|
|
252
|
+
);
|
|
253
|
+
expect(lines).toEqual([
|
|
254
|
+
'Remove "proj"? Every conversation in it is deleted permanently.'
|
|
255
|
+
]);
|
|
236
256
|
pressEnter();
|
|
237
257
|
await done;
|
|
238
258
|
});
|
|
@@ -258,7 +278,10 @@ describe('DEF-51 - the destructive dialogs open with Cancel under the keyboard',
|
|
|
258
278
|
) as Promise<void>;
|
|
259
279
|
const buttons = await openButtons();
|
|
260
280
|
|
|
261
|
-
|
|
281
|
+
// The branch fetch has answered without a list, so the newer state stays
|
|
282
|
+
// unknown and the accept button keeps its arming label - and Cancel is
|
|
283
|
+
// still the default the fresh dialog puts under the keyboard.
|
|
284
|
+
expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Remove (3)']);
|
|
262
285
|
expect(document.activeElement).toBe(buttons[0]);
|
|
263
286
|
|
|
264
287
|
pressEnter();
|
|
@@ -2286,23 +2309,25 @@ describe('ACC-SESS-61, ACC-SESS-62 - what the destructive confirmations state',
|
|
|
2286
2309
|
const body = document.querySelector('.jp-Dialog-body')!.textContent ?? '';
|
|
2287
2310
|
|
|
2288
2311
|
// The name, not just the word "project": two rows differ only by it, and
|
|
2289
|
-
// this dialog drops
|
|
2312
|
+
// this dialog drops every conversation in it.
|
|
2290
2313
|
expect(body).toContain('ledger-tools');
|
|
2291
|
-
expect(body).toContain('
|
|
2314
|
+
expect(body).toContain('Every conversation in it goes to the trash');
|
|
2292
2315
|
pressEnter();
|
|
2293
2316
|
await done;
|
|
2294
2317
|
expect(request).not.toHaveBeenCalled();
|
|
2295
2318
|
});
|
|
2296
2319
|
|
|
2297
|
-
it('ACC-SESS-62 - the cleanup confirmation
|
|
2320
|
+
it('ACC-SESS-62 - the cleanup confirmation names the three removed sessions by short id', async () => {
|
|
2298
2321
|
// 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.
|
|
2322
|
+
// row that claims nine. It also gives Cancel a DELETE to withhold. The
|
|
2323
|
+
// ids differ in their first eight characters, so each short id names one
|
|
2324
|
+
// conversation.
|
|
2300
2325
|
request.mockImplementationOnce(() =>
|
|
2301
2326
|
Promise.resolve({
|
|
2302
2327
|
current: 'sid-ledger-tools',
|
|
2303
2328
|
total: 4,
|
|
2304
|
-
branches: [
|
|
2305
|
-
session_id: `
|
|
2329
|
+
branches: ['alpha', 'bravo', 'charlie'].map((name, index) => ({
|
|
2330
|
+
session_id: `sess-${name}`,
|
|
2306
2331
|
file_mtime: 1,
|
|
2307
2332
|
label: `Branch ${index}`
|
|
2308
2333
|
}))
|
|
@@ -2315,7 +2340,9 @@ describe('ACC-SESS-61, ACC-SESS-62 - what the destructive confirmations state',
|
|
|
2315
2340
|
|
|
2316
2341
|
expect(body).toContain('3 parallel sessions');
|
|
2317
2342
|
expect(body).toContain('ledger-tools');
|
|
2318
|
-
expect(body).toContain(
|
|
2343
|
+
expect(body).toContain(
|
|
2344
|
+
'Remaining 3 sessions sess-alp, sess-bra, sess-cha will be removed.'
|
|
2345
|
+
);
|
|
2319
2346
|
pressEnter();
|
|
2320
2347
|
await done;
|
|
2321
2348
|
// Cancelling after the question's branches fetch sends no DELETE.
|
|
@@ -2391,6 +2418,10 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2391
2418
|
expect(
|
|
2392
2419
|
document.querySelector('.jp-Dialog-body p')!.getAttribute('role')
|
|
2393
2420
|
).toBe('status');
|
|
2421
|
+
// The hint is a live region too, because the fetch restates it with the
|
|
2422
|
+
// short ids (DEF-PANE-374-HINT-ANNOUNCE).
|
|
2423
|
+
const hint = document.querySelector('.jp-AiAssistantsPanel-cleanupHint')!;
|
|
2424
|
+
expect(hint.getAttribute('role')).toBe('status');
|
|
2394
2425
|
// The Widget body leaves showDialog no string to name the dialog from, so
|
|
2395
2426
|
// the name is set here and carries the question on screen.
|
|
2396
2427
|
expect(
|
|
@@ -2408,8 +2439,12 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2408
2439
|
'.jp-AiAssistantsPanel-cleanupWarning'
|
|
2409
2440
|
)!;
|
|
2410
2441
|
expect(warning.textContent).toBe(
|
|
2411
|
-
'1 parallel session was active after the
|
|
2442
|
+
'1 parallel session was active after the current session.'
|
|
2412
2443
|
);
|
|
2444
|
+
// The restatement kept the hint a live region and put the ids in it, so
|
|
2445
|
+
// the change is announced.
|
|
2446
|
+
expect(hint.getAttribute('role')).toBe('status');
|
|
2447
|
+
expect(hint.textContent).toContain('sid-bran');
|
|
2413
2448
|
// The warning is announced, and the dialog's name follows the restated
|
|
2414
2449
|
// question and the warning.
|
|
2415
2450
|
expect(warning.getAttribute('role')).toBe('alert');
|
|
@@ -2479,7 +2514,7 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2479
2514
|
);
|
|
2480
2515
|
});
|
|
2481
2516
|
|
|
2482
|
-
it('warns when a conversation it removes was active after the
|
|
2517
|
+
it('warns when a conversation it removes was active after the current session', async () => {
|
|
2483
2518
|
const now = Date.now();
|
|
2484
2519
|
const row = session({
|
|
2485
2520
|
name: 'ledger-tools',
|
|
@@ -2526,7 +2561,7 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2526
2561
|
await done;
|
|
2527
2562
|
});
|
|
2528
2563
|
|
|
2529
|
-
it('
|
|
2564
|
+
it('lists the sessions it removes by short id in both disposal settings', async () => {
|
|
2530
2565
|
const now = Date.now();
|
|
2531
2566
|
const row = session({ name: 'ledger-tools', extra_sessions: 1 });
|
|
2532
2567
|
request.mockResolvedValueOnce(branches([now - 3 * HOUR]));
|
|
@@ -2536,14 +2571,30 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2536
2571
|
let hint = document.querySelector(
|
|
2537
2572
|
'.jp-AiAssistantsPanel-cleanupHint'
|
|
2538
2573
|
)!.textContent!;
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
//
|
|
2574
|
+
// The fetched branch is `sid-branch-0`; its short id is its first eight
|
|
2575
|
+
// characters, which for Claude is the whole display id.
|
|
2576
|
+
expect(hint).toBe('Remaining 1 session sid-bran will be removed.');
|
|
2577
|
+
// A live region from creation: the restatement with the ids is announced
|
|
2578
|
+
// (DEF-PANE-374-HINT-ANNOUNCE).
|
|
2543
2579
|
expect(
|
|
2544
|
-
document
|
|
2545
|
-
.
|
|
2546
|
-
|
|
2580
|
+
document
|
|
2581
|
+
.querySelector('.jp-AiAssistantsPanel-cleanupHint')!
|
|
2582
|
+
.getAttribute('role')
|
|
2583
|
+
).toBe('status');
|
|
2584
|
+
// The caution line is gone from the confirmation entirely.
|
|
2585
|
+
expect(
|
|
2586
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupCaution')
|
|
2587
|
+
).toBeNull();
|
|
2588
|
+
pressEnter();
|
|
2589
|
+
await done;
|
|
2590
|
+
|
|
2591
|
+
// A failed fetch leaves no ids known: the hint states the count alone.
|
|
2592
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2593
|
+
done = (panel as any)._cleanupParallel(row) as Promise<void>;
|
|
2594
|
+
await openButtons();
|
|
2595
|
+
expect(
|
|
2596
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupHint')!.textContent
|
|
2597
|
+
).toBe('Remaining 1 session will be removed.');
|
|
2547
2598
|
pressEnter();
|
|
2548
2599
|
await done;
|
|
2549
2600
|
|
|
@@ -2554,13 +2605,18 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2554
2605
|
hint = document.querySelector(
|
|
2555
2606
|
'.jp-AiAssistantsPanel-cleanupHint'
|
|
2556
2607
|
)!.textContent!;
|
|
2557
|
-
expect(hint
|
|
2558
|
-
|
|
2559
|
-
|
|
2608
|
+
expect(hint).toBe(
|
|
2609
|
+
'Remaining 1 session sid-bran will be deleted permanently.'
|
|
2610
|
+
);
|
|
2611
|
+
// The live region holds with the trash off as well.
|
|
2560
2612
|
expect(
|
|
2561
|
-
document
|
|
2562
|
-
.
|
|
2563
|
-
|
|
2613
|
+
document
|
|
2614
|
+
.querySelector('.jp-AiAssistantsPanel-cleanupHint')!
|
|
2615
|
+
.getAttribute('role')
|
|
2616
|
+
).toBe('status');
|
|
2617
|
+
expect(
|
|
2618
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupCaution')
|
|
2619
|
+
).toBeNull();
|
|
2564
2620
|
pressEnter();
|
|
2565
2621
|
await done;
|
|
2566
2622
|
});
|
|
@@ -2583,6 +2639,379 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2583
2639
|
});
|
|
2584
2640
|
});
|
|
2585
2641
|
|
|
2642
|
+
// ------------------------------------------- ACC-PANE-233..236 - arming Remove
|
|
2643
|
+
|
|
2644
|
+
describe('ACC-PANE-233..236 - the cleanup confirmation arms Remove while the newer state is unknown or newer', () => {
|
|
2645
|
+
const HOUR = 3_600_000;
|
|
2646
|
+
|
|
2647
|
+
const openButtons = async (): Promise<HTMLButtonElement[]> => {
|
|
2648
|
+
await flush();
|
|
2649
|
+
const dialog = document.querySelector('.jp-Dialog');
|
|
2650
|
+
expect(dialog).not.toBeNull();
|
|
2651
|
+
return Array.from(
|
|
2652
|
+
dialog!.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2653
|
+
);
|
|
2654
|
+
};
|
|
2655
|
+
|
|
2656
|
+
/** The label element, whose aria-label is the part of the button's
|
|
2657
|
+
* accessible name that overrides its text. */
|
|
2658
|
+
const labelOf = (button: HTMLButtonElement): HTMLDivElement =>
|
|
2659
|
+
button.querySelector('.jp-Dialog-buttonLabel') as HTMLDivElement;
|
|
2660
|
+
|
|
2661
|
+
/** Dispatch a click with a chosen detail and timeStamp, so the double-click
|
|
2662
|
+
* guard is exercised without a 600 ms wait. */
|
|
2663
|
+
const click = (
|
|
2664
|
+
button: HTMLButtonElement,
|
|
2665
|
+
detail = 1,
|
|
2666
|
+
timeStamp?: number
|
|
2667
|
+
): void => {
|
|
2668
|
+
const event = new MouseEvent('click', {
|
|
2669
|
+
bubbles: true,
|
|
2670
|
+
cancelable: true,
|
|
2671
|
+
detail
|
|
2672
|
+
});
|
|
2673
|
+
if (timeStamp !== undefined) {
|
|
2674
|
+
Object.defineProperty(event, 'timeStamp', { value: timeStamp });
|
|
2675
|
+
}
|
|
2676
|
+
button.dispatchEvent(event);
|
|
2677
|
+
};
|
|
2678
|
+
|
|
2679
|
+
/** Dispatch Enter on the dialog and return the event, so a caller can
|
|
2680
|
+
* assert the handler consumed the browser's default activation. */
|
|
2681
|
+
const pressEnter = (repeat = false): KeyboardEvent => {
|
|
2682
|
+
const event = new KeyboardEvent('keydown', {
|
|
2683
|
+
key: 'Enter',
|
|
2684
|
+
repeat,
|
|
2685
|
+
bubbles: true,
|
|
2686
|
+
cancelable: true
|
|
2687
|
+
});
|
|
2688
|
+
document.querySelector('.jp-Dialog')!.dispatchEvent(event);
|
|
2689
|
+
return event;
|
|
2690
|
+
};
|
|
2691
|
+
|
|
2692
|
+
const deletes = (): any[] =>
|
|
2693
|
+
request.mock.calls.filter(call => call[3]?.method === 'DELETE');
|
|
2694
|
+
|
|
2695
|
+
const branches = (mtimes: number[]): any => ({
|
|
2696
|
+
current: 'sid-kept',
|
|
2697
|
+
total: mtimes.length + 1,
|
|
2698
|
+
branches: mtimes.map((file_mtime, index) => ({
|
|
2699
|
+
session_id: `sid-branch-${index}`,
|
|
2700
|
+
file_mtime,
|
|
2701
|
+
label: `Branch ${index}`
|
|
2702
|
+
}))
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
/** Answer the branch fetch with this list and the DELETE with the same
|
|
2706
|
+
* conversations removed; every other route answers empty. */
|
|
2707
|
+
const serve = (mtimes: number[]): void => {
|
|
2708
|
+
request.mockImplementation(
|
|
2709
|
+
(_id: string, path: string, _s: unknown, init?: any) =>
|
|
2710
|
+
Promise.resolve(
|
|
2711
|
+
init?.method === 'DELETE'
|
|
2712
|
+
? {
|
|
2713
|
+
removed_count: mtimes.length,
|
|
2714
|
+
removed_ids: mtimes.map((_m, index) => `sid-branch-${index}`),
|
|
2715
|
+
live_ids: []
|
|
2716
|
+
}
|
|
2717
|
+
: path === 'branches'
|
|
2718
|
+
? branches(mtimes)
|
|
2719
|
+
: {}
|
|
2720
|
+
)
|
|
2721
|
+
);
|
|
2722
|
+
};
|
|
2723
|
+
|
|
2724
|
+
const row = (extra_sessions: number, file_mtime: number): ISession =>
|
|
2725
|
+
session({ name: 'ledger-tools', extra_sessions, file_mtime });
|
|
2726
|
+
|
|
2727
|
+
const closeProgress = async (): Promise<void> => {
|
|
2728
|
+
Array.from(
|
|
2729
|
+
document.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2730
|
+
)
|
|
2731
|
+
.find(button => button.textContent === 'Close')!
|
|
2732
|
+
.click();
|
|
2733
|
+
await flush();
|
|
2734
|
+
};
|
|
2735
|
+
|
|
2736
|
+
it('ACC-PANE-233 - Remove arms to Confirm remove when the warning shows, and the second activation removes', async () => {
|
|
2737
|
+
const now = Date.now();
|
|
2738
|
+
serve([now - HOUR, now - 3 * HOUR]);
|
|
2739
|
+
const done = (panel as any)._cleanupParallel(
|
|
2740
|
+
row(2, now - 2 * HOUR)
|
|
2741
|
+
) as Promise<void>;
|
|
2742
|
+
const buttons = await openButtons();
|
|
2743
|
+
|
|
2744
|
+
expect(
|
|
2745
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2746
|
+
).not.toBeNull();
|
|
2747
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2748
|
+
|
|
2749
|
+
click(buttons[1]);
|
|
2750
|
+
// Armed, not removed: the dialog is still up and no DELETE went out.
|
|
2751
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2752
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2753
|
+
// The accessible name follows the visible label, which the Dialog's own
|
|
2754
|
+
// label element would otherwise pin to Remove.
|
|
2755
|
+
expect(labelOf(buttons[1]).getAttribute('aria-label')).toBe(
|
|
2756
|
+
'Confirm remove 2'
|
|
2757
|
+
);
|
|
2758
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
2759
|
+
expect(deletes()).toHaveLength(0);
|
|
2760
|
+
|
|
2761
|
+
click(buttons[1]);
|
|
2762
|
+
await done;
|
|
2763
|
+
const sent = deletes();
|
|
2764
|
+
expect(sent).toHaveLength(1);
|
|
2765
|
+
expect(JSON.parse(sent[0][3].body).session_ids).toEqual([
|
|
2766
|
+
'sid-branch-0',
|
|
2767
|
+
'sid-branch-1'
|
|
2768
|
+
]);
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
it('ACC-PANE-234 - an armed Remove lapses after four seconds and the next activation arms again', async () => {
|
|
2772
|
+
// The fetch fails: the newer state stays unknown, so arming stays required.
|
|
2773
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2774
|
+
const done = (panel as any)._cleanupParallel(
|
|
2775
|
+
row(3, Date.now() - 2 * HOUR)
|
|
2776
|
+
) as Promise<void>;
|
|
2777
|
+
const buttons = await openButtons();
|
|
2778
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2779
|
+
|
|
2780
|
+
jest.useFakeTimers();
|
|
2781
|
+
try {
|
|
2782
|
+
click(buttons[1], 1, 1000);
|
|
2783
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2784
|
+
|
|
2785
|
+
jest.advanceTimersByTime(4000);
|
|
2786
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2787
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2788
|
+
|
|
2789
|
+
// The next activation starts the two steps again; it does not remove.
|
|
2790
|
+
click(buttons[1], 1, 9000);
|
|
2791
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2792
|
+
expect(deletes()).toHaveLength(0);
|
|
2793
|
+
} finally {
|
|
2794
|
+
jest.useRealTimers();
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
buttons[0].click(); // Cancel
|
|
2798
|
+
await done;
|
|
2799
|
+
expect(deletes()).toHaveLength(0);
|
|
2800
|
+
});
|
|
2801
|
+
|
|
2802
|
+
it('ACC-PANE-235 - the second click of a double-click does not confirm, and a later click does', async () => {
|
|
2803
|
+
const now = Date.now();
|
|
2804
|
+
serve([now - HOUR]);
|
|
2805
|
+
const done = (panel as any)._cleanupParallel(
|
|
2806
|
+
row(1, now - 2 * HOUR)
|
|
2807
|
+
) as Promise<void>;
|
|
2808
|
+
const buttons = await openButtons();
|
|
2809
|
+
expect(buttons[1].textContent).toBe('Remove (1)');
|
|
2810
|
+
|
|
2811
|
+
click(buttons[1], 1, 1000);
|
|
2812
|
+
// 200 ms after the arming click, with detail 2: the double-click half is
|
|
2813
|
+
// ignored and the arm stands.
|
|
2814
|
+
click(buttons[1], 2, 1200);
|
|
2815
|
+
await flush();
|
|
2816
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2817
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2818
|
+
expect(deletes()).toHaveLength(0);
|
|
2819
|
+
|
|
2820
|
+
// A later click confirms whatever its detail: 700 ms after arming.
|
|
2821
|
+
click(buttons[1], 2, 1700);
|
|
2822
|
+
await done;
|
|
2823
|
+
expect(deletes()).toHaveLength(1);
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
it('ACC-PANE-236 - an unarmed activation never removes while the branch list is still being fetched', async () => {
|
|
2827
|
+
const now = Date.now();
|
|
2828
|
+
let release: ((value: any) => void) | undefined;
|
|
2829
|
+
request.mockImplementationOnce(
|
|
2830
|
+
() => new Promise(resolve => (release = resolve))
|
|
2831
|
+
);
|
|
2832
|
+
const done = (panel as any)._cleanupParallel(
|
|
2833
|
+
row(2, now - 2 * HOUR)
|
|
2834
|
+
) as Promise<void>;
|
|
2835
|
+
const buttons = await openButtons();
|
|
2836
|
+
|
|
2837
|
+
// The fetch is out: one activation arms and does not remove.
|
|
2838
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2839
|
+
click(buttons[1]);
|
|
2840
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2841
|
+
expect(deletes()).toHaveLength(0);
|
|
2842
|
+
|
|
2843
|
+
// The list lands with a conversation newer than the kept one; the earlier
|
|
2844
|
+
// activation still must not have removed anything, and the arm is dropped
|
|
2845
|
+
// so it cannot confirm this list either.
|
|
2846
|
+
release!(branches([now - HOUR]));
|
|
2847
|
+
await flush();
|
|
2848
|
+
expect(
|
|
2849
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2850
|
+
).not.toBeNull();
|
|
2851
|
+
expect(buttons[1].textContent).toBe('Remove (1)');
|
|
2852
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2853
|
+
expect(deletes()).toHaveLength(0);
|
|
2854
|
+
|
|
2855
|
+
buttons[0].click(); // Cancel
|
|
2856
|
+
await done;
|
|
2857
|
+
expect(deletes()).toHaveLength(0);
|
|
2858
|
+
});
|
|
2859
|
+
|
|
2860
|
+
it('arms while the branch fetch is pending, before any newer state is known', async () => {
|
|
2861
|
+
const now = Date.now();
|
|
2862
|
+
let release: ((value: any) => void) | undefined;
|
|
2863
|
+
request.mockImplementationOnce(
|
|
2864
|
+
() => new Promise(resolve => (release = resolve))
|
|
2865
|
+
);
|
|
2866
|
+
const done = (panel as any)._cleanupParallel(
|
|
2867
|
+
row(4, now - 2 * HOUR)
|
|
2868
|
+
) as Promise<void>;
|
|
2869
|
+
const buttons = await openButtons();
|
|
2870
|
+
|
|
2871
|
+
// The row's own count while the list is unknown, and no warning yet.
|
|
2872
|
+
expect(buttons[1].textContent).toBe('Remove (4)');
|
|
2873
|
+
expect(
|
|
2874
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2875
|
+
).toBeNull();
|
|
2876
|
+
click(buttons[1]);
|
|
2877
|
+
expect(buttons[1].textContent).toBe('Confirm remove 4');
|
|
2878
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2879
|
+
expect(labelOf(buttons[1]).getAttribute('aria-label')).toBe(
|
|
2880
|
+
'Confirm remove 4'
|
|
2881
|
+
);
|
|
2882
|
+
expect(deletes()).toHaveLength(0);
|
|
2883
|
+
|
|
2884
|
+
// Let the held fetch answer, then cancel, so the dialog leaves cleanly.
|
|
2885
|
+
release!(branches([now - 3 * HOUR]));
|
|
2886
|
+
await flush();
|
|
2887
|
+
buttons[0].click();
|
|
2888
|
+
await done;
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
it('arms after a failed branch fetch, and the second activation removes', async () => {
|
|
2892
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2893
|
+
// The removal's own refetch answers with the branch it deletes.
|
|
2894
|
+
request.mockResolvedValueOnce({
|
|
2895
|
+
current: 'sid-kept',
|
|
2896
|
+
total: 2,
|
|
2897
|
+
branches: [
|
|
2898
|
+
{ session_id: 'sid-branch-0', file_mtime: 1, label: 'Branch 0' }
|
|
2899
|
+
]
|
|
2900
|
+
});
|
|
2901
|
+
const done = (panel as any)._cleanupParallel(
|
|
2902
|
+
row(3, Date.now() - 2 * HOUR)
|
|
2903
|
+
) as Promise<void>;
|
|
2904
|
+
const buttons = await openButtons();
|
|
2905
|
+
|
|
2906
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2907
|
+
click(buttons[1]);
|
|
2908
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2909
|
+
expect(deletes()).toHaveLength(0);
|
|
2910
|
+
|
|
2911
|
+
click(buttons[1]);
|
|
2912
|
+
await done;
|
|
2913
|
+
const sent = deletes();
|
|
2914
|
+
expect(sent).toHaveLength(1);
|
|
2915
|
+
expect(JSON.parse(sent[0][3].body).session_ids).toEqual(['sid-branch-0']);
|
|
2916
|
+
});
|
|
2917
|
+
|
|
2918
|
+
it('a restatement drops an arm given before the list landed and relabels with the new count', async () => {
|
|
2919
|
+
const now = Date.now();
|
|
2920
|
+
let release: ((value: any) => void) | undefined;
|
|
2921
|
+
request.mockImplementationOnce(
|
|
2922
|
+
() => new Promise(resolve => (release = resolve))
|
|
2923
|
+
);
|
|
2924
|
+
const done = (panel as any)._cleanupParallel(
|
|
2925
|
+
row(3, now - 2 * HOUR)
|
|
2926
|
+
) as Promise<void>;
|
|
2927
|
+
const buttons = await openButtons();
|
|
2928
|
+
|
|
2929
|
+
click(buttons[1]);
|
|
2930
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2931
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2932
|
+
|
|
2933
|
+
// The fetched list holds two conversations, one newer: the count changes
|
|
2934
|
+
// and the warning appears, so the arm must not survive to confirm them.
|
|
2935
|
+
release!(branches([now - HOUR, now - 3 * HOUR]));
|
|
2936
|
+
await flush();
|
|
2937
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2938
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2939
|
+
expect(deletes()).toHaveLength(0);
|
|
2940
|
+
|
|
2941
|
+
// The two steps now apply to the restated list.
|
|
2942
|
+
click(buttons[1]);
|
|
2943
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2944
|
+
click(buttons[1]);
|
|
2945
|
+
await done;
|
|
2946
|
+
expect(JSON.parse(deletes()[0][3].body).session_ids).toEqual([
|
|
2947
|
+
'sid-branch-0',
|
|
2948
|
+
'sid-branch-1'
|
|
2949
|
+
]);
|
|
2950
|
+
});
|
|
2951
|
+
|
|
2952
|
+
it('keeps the one-click Remove when the fetched list has no newer conversation', async () => {
|
|
2953
|
+
const now = Date.now();
|
|
2954
|
+
serve([now - 3 * HOUR]);
|
|
2955
|
+
const done = (panel as any)._cleanupParallel(
|
|
2956
|
+
row(1, now - 2 * HOUR)
|
|
2957
|
+
) as Promise<void>;
|
|
2958
|
+
const buttons = await openButtons();
|
|
2959
|
+
|
|
2960
|
+
expect(
|
|
2961
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2962
|
+
).toBeNull();
|
|
2963
|
+
expect(buttons[1].textContent).toBe('Remove');
|
|
2964
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2965
|
+
|
|
2966
|
+
click(buttons[1]);
|
|
2967
|
+
await done;
|
|
2968
|
+
expect(deletes()).toHaveLength(1);
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
it('Enter and Space on the accept button arm first and confirm second', async () => {
|
|
2972
|
+
const now = Date.now();
|
|
2973
|
+
serve([now - HOUR]);
|
|
2974
|
+
|
|
2975
|
+
let done = (panel as any)._cleanupParallel(
|
|
2976
|
+
row(1, now - 2 * HOUR)
|
|
2977
|
+
) as Promise<void>;
|
|
2978
|
+
let buttons = await openButtons();
|
|
2979
|
+
buttons[1].focus();
|
|
2980
|
+
// The capture handler must consume the Enter default: a real browser
|
|
2981
|
+
// would otherwise fire the focused button's activation click, and that
|
|
2982
|
+
// click would confirm in one press.
|
|
2983
|
+
const armingEnter = pressEnter();
|
|
2984
|
+
expect(armingEnter.defaultPrevented).toBe(true);
|
|
2985
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2986
|
+
expect(deletes()).toHaveLength(0);
|
|
2987
|
+
// A held Enter sends repeats; they are not a second press and must not
|
|
2988
|
+
// confirm, though the Dialog's own Enter handler would resolve the accept.
|
|
2989
|
+
const repeatEnter = pressEnter(true);
|
|
2990
|
+
expect(repeatEnter.defaultPrevented).toBe(true);
|
|
2991
|
+
expect(deletes()).toHaveLength(0);
|
|
2992
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
2993
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2994
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2995
|
+
pressEnter();
|
|
2996
|
+
await done;
|
|
2997
|
+
expect(deletes()).toHaveLength(1);
|
|
2998
|
+
await closeProgress();
|
|
2999
|
+
|
|
3000
|
+
// Space reaches a button as a click; the same two steps hold.
|
|
3001
|
+
request.mockClear();
|
|
3002
|
+
done = (panel as any)._cleanupParallel(
|
|
3003
|
+
row(1, now - 2 * HOUR)
|
|
3004
|
+
) as Promise<void>;
|
|
3005
|
+
buttons = await openButtons();
|
|
3006
|
+
buttons[1].click();
|
|
3007
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
3008
|
+
expect(deletes()).toHaveLength(0);
|
|
3009
|
+
buttons[1].click();
|
|
3010
|
+
await done;
|
|
3011
|
+
expect(deletes()).toHaveLength(1);
|
|
3012
|
+
});
|
|
3013
|
+
});
|
|
3014
|
+
|
|
2586
3015
|
describe('ACC-PANE-35..45 - what a row and a section show', () => {
|
|
2587
3016
|
const MINUTE = 60_000;
|
|
2588
3017
|
const HOUR = 3_600_000;
|
|
@@ -232,8 +232,8 @@ describe('Manage Sessions popup Delete button (DEF-PANE-307)', () => {
|
|
|
232
232
|
});
|
|
233
233
|
|
|
234
234
|
it('names the trash outcome without promising recoverability', () => {
|
|
235
|
-
expect(deleteButton().title).
|
|
236
|
-
'
|
|
235
|
+
expect(deleteButton().title).toBe(
|
|
236
|
+
'Deleted conversations go to the trash; a refused move deletes permanently.'
|
|
237
237
|
);
|
|
238
238
|
});
|
|
239
239
|
|
package/src/core/panel.ts
CHANGED
|
@@ -3,8 +3,7 @@ import {
|
|
|
3
3
|
Clipboard,
|
|
4
4
|
Dialog,
|
|
5
5
|
InputDialog,
|
|
6
|
-
Notification
|
|
7
|
-
showDialog
|
|
6
|
+
Notification
|
|
8
7
|
} from '@jupyterlab/apputils';
|
|
9
8
|
import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
|
|
10
9
|
import { ServerConnection } from '@jupyterlab/services';
|
|
@@ -789,29 +788,32 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
789
788
|
}
|
|
790
789
|
}
|
|
791
790
|
|
|
792
|
-
/** The warning a deletion earns: with the trash setting on, a refused move
|
|
793
|
-
* may remove the conversations permanently or leave them in place, so
|
|
794
|
-
* neither the verb nor the note may promise one outcome. */
|
|
795
|
-
private get _irreversibleNote(): string {
|
|
796
|
-
return this._deleteToTrash
|
|
797
|
-
? ' A permanent removal cannot be undone.'
|
|
798
|
-
: ' This cannot be undone.';
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
private get _disposalVerb(): string {
|
|
802
|
-
return this._deleteToTrash
|
|
803
|
-
? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
|
|
804
|
-
: 'deleted permanently';
|
|
805
|
-
}
|
|
806
|
-
|
|
807
791
|
private async _removeProject(session: ISession): Promise<void> {
|
|
808
792
|
const name = this._lookupName(session);
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
793
|
+
const title = `Remove from ${this._descriptor.label}`;
|
|
794
|
+
// The consequence only, in two lines: the question and the outcome, then
|
|
795
|
+
// the permanent-deletion refusal on its own caution line
|
|
796
|
+
// (DEF-PANE-371-DIALOG-TEXT).
|
|
797
|
+
const outcome = this._deleteToTrash
|
|
798
|
+
? `Remove "${name}"? Every conversation in it goes to the trash.`
|
|
799
|
+
: `Remove "${name}"? Every conversation in it is deleted permanently.`;
|
|
800
|
+
const caution = this._deleteToTrash
|
|
801
|
+
? 'A refused move deletes permanently.'
|
|
802
|
+
: '';
|
|
803
|
+
const body = new Widget();
|
|
804
|
+
body.node.className = 'jp-AiAssistantsPanel-removeConfirm';
|
|
805
|
+
const question = document.createElement('p');
|
|
806
|
+
question.textContent = outcome;
|
|
807
|
+
body.node.appendChild(question);
|
|
808
|
+
if (caution) {
|
|
809
|
+
const line = document.createElement('p');
|
|
810
|
+
line.className = 'jp-AiAssistantsPanel-removeCaution';
|
|
811
|
+
line.textContent = caution;
|
|
812
|
+
body.node.appendChild(line);
|
|
813
|
+
}
|
|
814
|
+
const dialog = new Dialog<unknown>({
|
|
815
|
+
title,
|
|
816
|
+
body,
|
|
815
817
|
buttons: [
|
|
816
818
|
Dialog.cancelButton(),
|
|
817
819
|
Dialog.warnButton({ label: this._trans.__('Remove') })
|
|
@@ -824,6 +826,12 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
824
826
|
defaultButton: 0,
|
|
825
827
|
renderer: dialogRenderer
|
|
826
828
|
});
|
|
829
|
+
// A Widget body leaves the Dialog no string to name itself from.
|
|
830
|
+
dialog.node.setAttribute(
|
|
831
|
+
'aria-label',
|
|
832
|
+
[title, outcome, caution].filter(part => part).join(' ')
|
|
833
|
+
);
|
|
834
|
+
const confirm = await dialog.launch();
|
|
827
835
|
if (!confirm.button.accept) {
|
|
828
836
|
return;
|
|
829
837
|
}
|
|
@@ -873,23 +881,25 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
873
881
|
};
|
|
874
882
|
askCount(extra);
|
|
875
883
|
confirmBody.node.appendChild(question);
|
|
876
|
-
//
|
|
884
|
+
// The second line states what happens to the rest: the count, the short
|
|
885
|
+
// ids once the branch list names them, and the disposal the setting chose.
|
|
877
886
|
const hint = document.createElement('p');
|
|
878
887
|
hint.className = 'jp-AiAssistantsPanel-cleanupHint';
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
888
|
+
// Announced when restated with the short ids; an aria-label change is not.
|
|
889
|
+
hint.setAttribute('role', 'status');
|
|
890
|
+
const stateHint = (ids: string[] | null): void => {
|
|
891
|
+
const shortIds = ids?.map(id =>
|
|
892
|
+
shortSessionId(id, this._descriptor.sessionIdPrefix)
|
|
893
|
+
);
|
|
894
|
+
hint.textContent =
|
|
895
|
+
`Remaining ${extra} session${extra === 1 ? '' : 's'}` +
|
|
896
|
+
(shortIds?.length ? ` ${shortIds.join(', ')}` : '') +
|
|
897
|
+
(this._deleteToTrash
|
|
898
|
+
? ' will be removed.'
|
|
899
|
+
: ' will be deleted permanently.');
|
|
900
|
+
};
|
|
901
|
+
stateHint(null);
|
|
882
902
|
confirmBody.node.appendChild(hint);
|
|
883
|
-
// The irreversible note is a safety statement, not the hint, so it keeps
|
|
884
|
-
// its own line. With the trash on it names the refusal: a refused move
|
|
885
|
-
// deletes permanently, while the hint promises the trash
|
|
886
|
-
// (DEF-PANE-363-CAUTION-REFUSAL).
|
|
887
|
-
const caution = document.createElement('p');
|
|
888
|
-
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
889
|
-
caution.textContent = this._deleteToTrash
|
|
890
|
-
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
891
|
-
: this._irreversibleNote.trim();
|
|
892
|
-
confirmBody.node.appendChild(caution);
|
|
893
903
|
|
|
894
904
|
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
895
905
|
let warning: HTMLElement | null = null;
|
|
@@ -904,9 +914,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
904
914
|
defaultButton: 0,
|
|
905
915
|
renderer: dialogRenderer
|
|
906
916
|
});
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
//
|
|
917
|
+
// Scopes the armed style in base.css to this one dialog.
|
|
918
|
+
confirmDialog.addClass('jp-AiAssistantsPanel-cleanupDialog');
|
|
919
|
+
// The Dialog names itself only when its body is a string, and this body is
|
|
920
|
+
// a Widget: name it here, and again when the question and hint are
|
|
921
|
+
// restated and the warning joins it.
|
|
910
922
|
const nameDialog = (): void => {
|
|
911
923
|
confirmDialog.node.setAttribute(
|
|
912
924
|
'aria-label',
|
|
@@ -914,7 +926,6 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
914
926
|
title,
|
|
915
927
|
question.textContent ?? '',
|
|
916
928
|
hint.textContent ?? '',
|
|
917
|
-
caution.textContent ?? '',
|
|
918
929
|
warning?.textContent ?? ''
|
|
919
930
|
]
|
|
920
931
|
.filter(part => part)
|
|
@@ -923,13 +934,126 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
923
934
|
};
|
|
924
935
|
nameDialog();
|
|
925
936
|
|
|
937
|
+
// The house two-step destructive action (design-language.md, "Two-step
|
|
938
|
+
// destructive action"; the branch popup's Delete is the shipped form):
|
|
939
|
+
// while the newer state is unknown or newer, the accept button arms on the
|
|
940
|
+
// first activation and removes on the second, the arm lapsing after 4 s.
|
|
941
|
+
// It starts required: the branch fetch that decides whether a removal is
|
|
942
|
+
// safe is still out when the dialog opens.
|
|
943
|
+
let armRequired = true;
|
|
944
|
+
let armed = false;
|
|
945
|
+
let armTimer = 0;
|
|
946
|
+
// The arming activation's event.timeStamp, which the confirming one is
|
|
947
|
+
// measured against.
|
|
948
|
+
let armedAt = 0;
|
|
949
|
+
|
|
950
|
+
/** Find and relabel the accept button. The Dialog keeps its button nodes
|
|
951
|
+
* private and puts the label in a child whose aria-label overrides its
|
|
952
|
+
* text, so both are set. */
|
|
953
|
+
const updateAccept = (): void => {
|
|
954
|
+
const button =
|
|
955
|
+
confirmDialog.node.querySelector<HTMLButtonElement>('.jp-mod-accept');
|
|
956
|
+
const label = button?.querySelector('.jp-Dialog-buttonLabel');
|
|
957
|
+
if (!button || !label) {
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
const text = armed
|
|
961
|
+
? this._trans.__('Confirm remove %1', extra)
|
|
962
|
+
: armRequired
|
|
963
|
+
? this._trans.__('Remove (%1)', extra)
|
|
964
|
+
: this._trans.__('Remove');
|
|
965
|
+
label.textContent = text;
|
|
966
|
+
label.setAttribute('aria-label', text);
|
|
967
|
+
button.classList.toggle('jp-mod-armed', armed);
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const disarm = (): void => {
|
|
971
|
+
armed = false;
|
|
972
|
+
window.clearTimeout(armTimer);
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
/** Consume one accept activation. True means it only armed, or was the
|
|
976
|
+
* ignored half of a double-click, and the Dialog must not resolve; false
|
|
977
|
+
* lets the Dialog's own handler resolve it. */
|
|
978
|
+
const activate = (event: MouseEvent | KeyboardEvent): boolean => {
|
|
979
|
+
if (!armRequired) {
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
983
|
+
// a multi-click within 600 ms of arming is ignored. A later click
|
|
984
|
+
// confirms whatever its detail, since the OS double-click interval can
|
|
985
|
+
// be longer than the user's pause (DEF-PANE-338).
|
|
986
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
if (!armed) {
|
|
990
|
+
armed = true;
|
|
991
|
+
armedAt = event.timeStamp;
|
|
992
|
+
window.clearTimeout(armTimer);
|
|
993
|
+
armTimer = window.setTimeout(() => {
|
|
994
|
+
disarm();
|
|
995
|
+
updateAccept();
|
|
996
|
+
}, 4000);
|
|
997
|
+
updateAccept();
|
|
998
|
+
return true;
|
|
999
|
+
}
|
|
1000
|
+
disarm();
|
|
1001
|
+
return false;
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
// Registered before launch(): the Dialog attaches its own capture
|
|
1005
|
+
// listeners on this same node when launch() attaches it, so these run
|
|
1006
|
+
// first and can swallow an activation that must only arm. Space arrives
|
|
1007
|
+
// as a click; Enter arrives as a keydown the Dialog would otherwise
|
|
1008
|
+
// resolve from, so both are intercepted here.
|
|
1009
|
+
confirmDialog.node.addEventListener(
|
|
1010
|
+
'click',
|
|
1011
|
+
event => {
|
|
1012
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
1013
|
+
if (!button || !button.contains(event.target as Node)) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (activate(event)) {
|
|
1017
|
+
event.stopImmediatePropagation();
|
|
1018
|
+
event.preventDefault();
|
|
1019
|
+
}
|
|
1020
|
+
},
|
|
1021
|
+
true
|
|
1022
|
+
);
|
|
1023
|
+
confirmDialog.node.addEventListener(
|
|
1024
|
+
'keydown',
|
|
1025
|
+
event => {
|
|
1026
|
+
if (event.key !== 'Enter') {
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const accept = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
1030
|
+
if (!accept || document.activeElement !== accept) {
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
// A held key's auto-repeat is not a second press (DEF-PANE-347): it
|
|
1034
|
+
// must neither confirm nor arm, and consuming it stops the Dialog's
|
|
1035
|
+
// own Enter handler from resolving the accept.
|
|
1036
|
+
if (event.repeat) {
|
|
1037
|
+
event.stopImmediatePropagation();
|
|
1038
|
+
event.preventDefault();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (activate(event)) {
|
|
1042
|
+
event.stopImmediatePropagation();
|
|
1043
|
+
event.preventDefault();
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
true
|
|
1047
|
+
);
|
|
1048
|
+
updateAccept();
|
|
1049
|
+
|
|
926
1050
|
// Alongside the question, never in front of it: the request layer allows
|
|
927
1051
|
// 60 s, and the confirmation must open at once. When the list lands the
|
|
928
|
-
// question
|
|
929
|
-
// adds the warning, drawn last so it reads after the
|
|
930
|
-
// list is what the removal deletes. A failed fetch leaves
|
|
931
|
-
// the row reported it, with no
|
|
932
|
-
// below.
|
|
1052
|
+
// question and hint state its length and ids, and a conversation newer
|
|
1053
|
+
// than the kept one adds the warning, drawn last so it reads after the
|
|
1054
|
+
// hint. The same list is what the removal deletes. A failed fetch leaves
|
|
1055
|
+
// the question as the row reported it, with no ids and no warning, and
|
|
1056
|
+
// the removal fetches again below.
|
|
933
1057
|
const branchesReady = this._fetchBranches(session).then(
|
|
934
1058
|
fetched => {
|
|
935
1059
|
const list = fetched?.branches;
|
|
@@ -944,6 +1068,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
944
1068
|
} else if (list) {
|
|
945
1069
|
extra = list.length;
|
|
946
1070
|
askCount(extra);
|
|
1071
|
+
stateHint(list.map(branch => branch.session_id));
|
|
947
1072
|
const newer = list.filter(
|
|
948
1073
|
branch => branch.file_mtime > session.file_mtime
|
|
949
1074
|
).length;
|
|
@@ -953,9 +1078,16 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
953
1078
|
warning.setAttribute('role', 'alert');
|
|
954
1079
|
warning.textContent =
|
|
955
1080
|
`${newer} parallel session${newer === 1 ? ' was' : 's were'} ` +
|
|
956
|
-
'active after the
|
|
1081
|
+
'active after the current session.';
|
|
957
1082
|
confirmBody.node.appendChild(warning);
|
|
958
1083
|
}
|
|
1084
|
+
// The list just decided what the button acts on: an arm given while
|
|
1085
|
+
// it was pending could confirm a different set, so drop it and
|
|
1086
|
+
// restate the label with the fetched count. With no newer branch the
|
|
1087
|
+
// button returns to the one-click Remove.
|
|
1088
|
+
disarm();
|
|
1089
|
+
armRequired = newer > 0;
|
|
1090
|
+
updateAccept();
|
|
959
1091
|
nameDialog();
|
|
960
1092
|
}
|
|
961
1093
|
return fetched;
|
package/src/core/popup.ts
CHANGED
|
@@ -162,7 +162,7 @@ export function showManageSessionsPopup(
|
|
|
162
162
|
deleteBtn.type = 'button';
|
|
163
163
|
deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
|
|
164
164
|
deleteBtn.title = options.deleteToTrash
|
|
165
|
-
? 'Deleted conversations
|
|
165
|
+
? 'Deleted conversations go to the trash; a refused move deletes permanently.'
|
|
166
166
|
: 'Deleted conversations are removed permanently';
|
|
167
167
|
footer.appendChild(deleteBtn);
|
|
168
168
|
// Plain visual counter (selection, or the last action), beside the button
|
package/style/base.css
CHANGED
|
@@ -683,20 +683,23 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
683
683
|
color: var(--jp-error-color1);
|
|
684
684
|
}
|
|
685
685
|
|
|
686
|
-
/* The
|
|
686
|
+
/* The two destructive confirmations' lines: the Remove question with its
|
|
687
|
+
caution, and the cleanup question, hint and warning. The warning
|
|
687
688
|
box is separate from the body text because it answers one question - is a
|
|
688
689
|
conversation this removal deletes newer than the one kept? - and it uses the
|
|
689
690
|
warning colour, not the error colour: the removal is still the user's call.
|
|
690
691
|
The tint is mixed with the surface colour, so both themes keep the text
|
|
691
692
|
readable. */
|
|
692
|
-
.jp-AiAssistantsPanel-cleanupConfirm
|
|
693
|
+
.jp-AiAssistantsPanel-cleanupConfirm,
|
|
694
|
+
.jp-AiAssistantsPanel-removeConfirm {
|
|
693
695
|
display: flex;
|
|
694
696
|
flex-direction: column;
|
|
695
697
|
gap: 8px;
|
|
696
698
|
max-width: 440px;
|
|
697
699
|
}
|
|
698
700
|
|
|
699
|
-
.jp-AiAssistantsPanel-cleanupConfirm p
|
|
701
|
+
.jp-AiAssistantsPanel-cleanupConfirm p,
|
|
702
|
+
.jp-AiAssistantsPanel-removeConfirm p {
|
|
700
703
|
margin: 0;
|
|
701
704
|
}
|
|
702
705
|
|
|
@@ -712,6 +715,16 @@ body[data-jp-theme-light='true'] .jp-AiAssistantsPanel-dotHalo {
|
|
|
712
715
|
color: var(--jp-ui-font-color1);
|
|
713
716
|
}
|
|
714
717
|
|
|
718
|
+
/* Armed for the second step of the destructive action (design-language.md,
|
|
719
|
+
"Two-step destructive action"): the error fill, as the branch popup's
|
|
720
|
+
Delete carries it. Scoped to the Clean Up confirmation's own dialog, so no
|
|
721
|
+
other dialog changes. */
|
|
722
|
+
.jp-AiAssistantsPanel-cleanupDialog .jp-Dialog-button.jp-mod-armed,
|
|
723
|
+
.jp-AiAssistantsPanel-cleanupDialog .jp-Dialog-button.jp-mod-armed:hover {
|
|
724
|
+
background: var(--jp-error-color1);
|
|
725
|
+
color: var(--jp-ui-inverse-font-color1);
|
|
726
|
+
}
|
|
727
|
+
|
|
715
728
|
.jp-AiAssistantsPanel-row.jp-mod-busy {
|
|
716
729
|
opacity: 0.55;
|
|
717
730
|
pointer-events: none;
|