jupyterlab_ai_code_assistants_extension 1.2.288 → 1.2.294
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 +140 -23
- package/lib/core/popup.js +1 -1
- package/package.json +1 -1
- package/src/__tests__/panel.spec.ts +406 -10
- package/src/__tests__/popup.spec.ts +2 -2
- package/src/core/panel.ts +157 -27
- 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
|
}
|
|
@@ -792,7 +801,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
792
801
|
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
793
802
|
caution.textContent = this._deleteToTrash
|
|
794
803
|
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
795
|
-
:
|
|
804
|
+
: 'This cannot be undone.';
|
|
796
805
|
confirmBody.node.appendChild(caution);
|
|
797
806
|
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
798
807
|
let warning = null;
|
|
@@ -807,9 +816,11 @@ 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 is restated and the
|
|
823
|
+
// warning joins it.
|
|
813
824
|
const nameDialog = () => {
|
|
814
825
|
var _a, _b, _c, _d;
|
|
815
826
|
confirmDialog.node.setAttribute('aria-label', [
|
|
@@ -823,6 +834,105 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
823
834
|
.join(' '));
|
|
824
835
|
};
|
|
825
836
|
nameDialog();
|
|
837
|
+
// The house two-step destructive action (design-language.md, "Two-step
|
|
838
|
+
// destructive action"; the branch popup's Delete is the shipped form):
|
|
839
|
+
// while the newer state is unknown or newer, the accept button arms on the
|
|
840
|
+
// first activation and removes on the second, the arm lapsing after 4 s.
|
|
841
|
+
// It starts required: the branch fetch that decides whether a removal is
|
|
842
|
+
// safe is still out when the dialog opens.
|
|
843
|
+
let armRequired = true;
|
|
844
|
+
let armed = false;
|
|
845
|
+
let armTimer = 0;
|
|
846
|
+
// The arming activation's event.timeStamp, which the confirming one is
|
|
847
|
+
// measured against.
|
|
848
|
+
let armedAt = 0;
|
|
849
|
+
/** Find and relabel the accept button. The Dialog keeps its button nodes
|
|
850
|
+
* private and puts the label in a child whose aria-label overrides its
|
|
851
|
+
* text, so both are set. */
|
|
852
|
+
const updateAccept = () => {
|
|
853
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
854
|
+
const label = button === null || button === void 0 ? void 0 : button.querySelector('.jp-Dialog-buttonLabel');
|
|
855
|
+
if (!button || !label) {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const text = armed
|
|
859
|
+
? this._trans.__('Confirm remove %1', extra)
|
|
860
|
+
: armRequired
|
|
861
|
+
? this._trans.__('Remove (%1)', extra)
|
|
862
|
+
: this._trans.__('Remove');
|
|
863
|
+
label.textContent = text;
|
|
864
|
+
label.setAttribute('aria-label', text);
|
|
865
|
+
button.classList.toggle('jp-mod-armed', armed);
|
|
866
|
+
};
|
|
867
|
+
const disarm = () => {
|
|
868
|
+
armed = false;
|
|
869
|
+
window.clearTimeout(armTimer);
|
|
870
|
+
};
|
|
871
|
+
/** Consume one accept activation. True means it only armed, or was the
|
|
872
|
+
* ignored half of a double-click, and the Dialog must not resolve; false
|
|
873
|
+
* lets the Dialog's own handler resolve it. */
|
|
874
|
+
const activate = (event) => {
|
|
875
|
+
if (!armRequired) {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
879
|
+
// a multi-click within 600 ms of arming is ignored. A later click
|
|
880
|
+
// confirms whatever its detail, since the OS double-click interval can
|
|
881
|
+
// be longer than the user's pause (DEF-PANE-338).
|
|
882
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
if (!armed) {
|
|
886
|
+
armed = true;
|
|
887
|
+
armedAt = event.timeStamp;
|
|
888
|
+
window.clearTimeout(armTimer);
|
|
889
|
+
armTimer = window.setTimeout(() => {
|
|
890
|
+
disarm();
|
|
891
|
+
updateAccept();
|
|
892
|
+
}, 4000);
|
|
893
|
+
updateAccept();
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
disarm();
|
|
897
|
+
return false;
|
|
898
|
+
};
|
|
899
|
+
// Registered before launch(): the Dialog attaches its own capture
|
|
900
|
+
// listeners on this same node when launch() attaches it, so these run
|
|
901
|
+
// first and can swallow an activation that must only arm. Space arrives
|
|
902
|
+
// as a click; Enter arrives as a keydown the Dialog would otherwise
|
|
903
|
+
// resolve from, so both are intercepted here.
|
|
904
|
+
confirmDialog.node.addEventListener('click', event => {
|
|
905
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
906
|
+
if (!button || !button.contains(event.target)) {
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (activate(event)) {
|
|
910
|
+
event.stopImmediatePropagation();
|
|
911
|
+
event.preventDefault();
|
|
912
|
+
}
|
|
913
|
+
}, true);
|
|
914
|
+
confirmDialog.node.addEventListener('keydown', event => {
|
|
915
|
+
if (event.key !== 'Enter') {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const accept = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
919
|
+
if (!accept || document.activeElement !== accept) {
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
// A held key's auto-repeat is not a second press (DEF-PANE-347): it
|
|
923
|
+
// must neither confirm nor arm, and consuming it stops the Dialog's
|
|
924
|
+
// own Enter handler from resolving the accept.
|
|
925
|
+
if (event.repeat) {
|
|
926
|
+
event.stopImmediatePropagation();
|
|
927
|
+
event.preventDefault();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (activate(event)) {
|
|
931
|
+
event.stopImmediatePropagation();
|
|
932
|
+
event.preventDefault();
|
|
933
|
+
}
|
|
934
|
+
}, true);
|
|
935
|
+
updateAccept();
|
|
826
936
|
// Alongside the question, never in front of it: the request layer allows
|
|
827
937
|
// 60 s, and the confirmation must open at once. When the list lands the
|
|
828
938
|
// question states its length and a conversation newer than the kept one
|
|
@@ -854,6 +964,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
854
964
|
'active after the one kept.';
|
|
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.294",
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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 ?? ''
|
|
235
|
+
);
|
|
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,9 +2309,9 @@ 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();
|
|
@@ -2583,6 +2606,379 @@ describe('ACC-PANE-221, ACC-PANE-222 - the cleanup confirmation warning and hint
|
|
|
2583
2606
|
});
|
|
2584
2607
|
});
|
|
2585
2608
|
|
|
2609
|
+
// ------------------------------------------- ACC-PANE-233..236 - arming Remove
|
|
2610
|
+
|
|
2611
|
+
describe('ACC-PANE-233..236 - the cleanup confirmation arms Remove while the newer state is unknown or newer', () => {
|
|
2612
|
+
const HOUR = 3_600_000;
|
|
2613
|
+
|
|
2614
|
+
const openButtons = async (): Promise<HTMLButtonElement[]> => {
|
|
2615
|
+
await flush();
|
|
2616
|
+
const dialog = document.querySelector('.jp-Dialog');
|
|
2617
|
+
expect(dialog).not.toBeNull();
|
|
2618
|
+
return Array.from(
|
|
2619
|
+
dialog!.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2620
|
+
);
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
/** The label element, whose aria-label is the part of the button's
|
|
2624
|
+
* accessible name that overrides its text. */
|
|
2625
|
+
const labelOf = (button: HTMLButtonElement): HTMLDivElement =>
|
|
2626
|
+
button.querySelector('.jp-Dialog-buttonLabel') as HTMLDivElement;
|
|
2627
|
+
|
|
2628
|
+
/** Dispatch a click with a chosen detail and timeStamp, so the double-click
|
|
2629
|
+
* guard is exercised without a 600 ms wait. */
|
|
2630
|
+
const click = (
|
|
2631
|
+
button: HTMLButtonElement,
|
|
2632
|
+
detail = 1,
|
|
2633
|
+
timeStamp?: number
|
|
2634
|
+
): void => {
|
|
2635
|
+
const event = new MouseEvent('click', {
|
|
2636
|
+
bubbles: true,
|
|
2637
|
+
cancelable: true,
|
|
2638
|
+
detail
|
|
2639
|
+
});
|
|
2640
|
+
if (timeStamp !== undefined) {
|
|
2641
|
+
Object.defineProperty(event, 'timeStamp', { value: timeStamp });
|
|
2642
|
+
}
|
|
2643
|
+
button.dispatchEvent(event);
|
|
2644
|
+
};
|
|
2645
|
+
|
|
2646
|
+
/** Dispatch Enter on the dialog and return the event, so a caller can
|
|
2647
|
+
* assert the handler consumed the browser's default activation. */
|
|
2648
|
+
const pressEnter = (repeat = false): KeyboardEvent => {
|
|
2649
|
+
const event = new KeyboardEvent('keydown', {
|
|
2650
|
+
key: 'Enter',
|
|
2651
|
+
repeat,
|
|
2652
|
+
bubbles: true,
|
|
2653
|
+
cancelable: true
|
|
2654
|
+
});
|
|
2655
|
+
document.querySelector('.jp-Dialog')!.dispatchEvent(event);
|
|
2656
|
+
return event;
|
|
2657
|
+
};
|
|
2658
|
+
|
|
2659
|
+
const deletes = (): any[] =>
|
|
2660
|
+
request.mock.calls.filter(call => call[3]?.method === 'DELETE');
|
|
2661
|
+
|
|
2662
|
+
const branches = (mtimes: number[]): any => ({
|
|
2663
|
+
current: 'sid-kept',
|
|
2664
|
+
total: mtimes.length + 1,
|
|
2665
|
+
branches: mtimes.map((file_mtime, index) => ({
|
|
2666
|
+
session_id: `sid-branch-${index}`,
|
|
2667
|
+
file_mtime,
|
|
2668
|
+
label: `Branch ${index}`
|
|
2669
|
+
}))
|
|
2670
|
+
});
|
|
2671
|
+
|
|
2672
|
+
/** Answer the branch fetch with this list and the DELETE with the same
|
|
2673
|
+
* conversations removed; every other route answers empty. */
|
|
2674
|
+
const serve = (mtimes: number[]): void => {
|
|
2675
|
+
request.mockImplementation(
|
|
2676
|
+
(_id: string, path: string, _s: unknown, init?: any) =>
|
|
2677
|
+
Promise.resolve(
|
|
2678
|
+
init?.method === 'DELETE'
|
|
2679
|
+
? {
|
|
2680
|
+
removed_count: mtimes.length,
|
|
2681
|
+
removed_ids: mtimes.map((_m, index) => `sid-branch-${index}`),
|
|
2682
|
+
live_ids: []
|
|
2683
|
+
}
|
|
2684
|
+
: path === 'branches'
|
|
2685
|
+
? branches(mtimes)
|
|
2686
|
+
: {}
|
|
2687
|
+
)
|
|
2688
|
+
);
|
|
2689
|
+
};
|
|
2690
|
+
|
|
2691
|
+
const row = (extra_sessions: number, file_mtime: number): ISession =>
|
|
2692
|
+
session({ name: 'ledger-tools', extra_sessions, file_mtime });
|
|
2693
|
+
|
|
2694
|
+
const closeProgress = async (): Promise<void> => {
|
|
2695
|
+
Array.from(
|
|
2696
|
+
document.querySelectorAll<HTMLButtonElement>('.jp-Dialog-button')
|
|
2697
|
+
)
|
|
2698
|
+
.find(button => button.textContent === 'Close')!
|
|
2699
|
+
.click();
|
|
2700
|
+
await flush();
|
|
2701
|
+
};
|
|
2702
|
+
|
|
2703
|
+
it('ACC-PANE-233 - Remove arms to Confirm remove when the warning shows, and the second activation removes', async () => {
|
|
2704
|
+
const now = Date.now();
|
|
2705
|
+
serve([now - HOUR, now - 3 * HOUR]);
|
|
2706
|
+
const done = (panel as any)._cleanupParallel(
|
|
2707
|
+
row(2, now - 2 * HOUR)
|
|
2708
|
+
) as Promise<void>;
|
|
2709
|
+
const buttons = await openButtons();
|
|
2710
|
+
|
|
2711
|
+
expect(
|
|
2712
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2713
|
+
).not.toBeNull();
|
|
2714
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2715
|
+
|
|
2716
|
+
click(buttons[1]);
|
|
2717
|
+
// Armed, not removed: the dialog is still up and no DELETE went out.
|
|
2718
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2719
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2720
|
+
// The accessible name follows the visible label, which the Dialog's own
|
|
2721
|
+
// label element would otherwise pin to Remove.
|
|
2722
|
+
expect(labelOf(buttons[1]).getAttribute('aria-label')).toBe(
|
|
2723
|
+
'Confirm remove 2'
|
|
2724
|
+
);
|
|
2725
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
2726
|
+
expect(deletes()).toHaveLength(0);
|
|
2727
|
+
|
|
2728
|
+
click(buttons[1]);
|
|
2729
|
+
await done;
|
|
2730
|
+
const sent = deletes();
|
|
2731
|
+
expect(sent).toHaveLength(1);
|
|
2732
|
+
expect(JSON.parse(sent[0][3].body).session_ids).toEqual([
|
|
2733
|
+
'sid-branch-0',
|
|
2734
|
+
'sid-branch-1'
|
|
2735
|
+
]);
|
|
2736
|
+
});
|
|
2737
|
+
|
|
2738
|
+
it('ACC-PANE-234 - an armed Remove lapses after four seconds and the next activation arms again', async () => {
|
|
2739
|
+
// The fetch fails: the newer state stays unknown, so arming stays required.
|
|
2740
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2741
|
+
const done = (panel as any)._cleanupParallel(
|
|
2742
|
+
row(3, Date.now() - 2 * HOUR)
|
|
2743
|
+
) as Promise<void>;
|
|
2744
|
+
const buttons = await openButtons();
|
|
2745
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2746
|
+
|
|
2747
|
+
jest.useFakeTimers();
|
|
2748
|
+
try {
|
|
2749
|
+
click(buttons[1], 1, 1000);
|
|
2750
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2751
|
+
|
|
2752
|
+
jest.advanceTimersByTime(4000);
|
|
2753
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2754
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2755
|
+
|
|
2756
|
+
// The next activation starts the two steps again; it does not remove.
|
|
2757
|
+
click(buttons[1], 1, 9000);
|
|
2758
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2759
|
+
expect(deletes()).toHaveLength(0);
|
|
2760
|
+
} finally {
|
|
2761
|
+
jest.useRealTimers();
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
buttons[0].click(); // Cancel
|
|
2765
|
+
await done;
|
|
2766
|
+
expect(deletes()).toHaveLength(0);
|
|
2767
|
+
});
|
|
2768
|
+
|
|
2769
|
+
it('ACC-PANE-235 - the second click of a double-click does not confirm, and a later click does', async () => {
|
|
2770
|
+
const now = Date.now();
|
|
2771
|
+
serve([now - HOUR]);
|
|
2772
|
+
const done = (panel as any)._cleanupParallel(
|
|
2773
|
+
row(1, now - 2 * HOUR)
|
|
2774
|
+
) as Promise<void>;
|
|
2775
|
+
const buttons = await openButtons();
|
|
2776
|
+
expect(buttons[1].textContent).toBe('Remove (1)');
|
|
2777
|
+
|
|
2778
|
+
click(buttons[1], 1, 1000);
|
|
2779
|
+
// 200 ms after the arming click, with detail 2: the double-click half is
|
|
2780
|
+
// ignored and the arm stands.
|
|
2781
|
+
click(buttons[1], 2, 1200);
|
|
2782
|
+
await flush();
|
|
2783
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2784
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2785
|
+
expect(deletes()).toHaveLength(0);
|
|
2786
|
+
|
|
2787
|
+
// A later click confirms whatever its detail: 700 ms after arming.
|
|
2788
|
+
click(buttons[1], 2, 1700);
|
|
2789
|
+
await done;
|
|
2790
|
+
expect(deletes()).toHaveLength(1);
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
it('ACC-PANE-236 - an unarmed activation never removes while the branch list is still being fetched', async () => {
|
|
2794
|
+
const now = Date.now();
|
|
2795
|
+
let release: ((value: any) => void) | undefined;
|
|
2796
|
+
request.mockImplementationOnce(
|
|
2797
|
+
() => new Promise(resolve => (release = resolve))
|
|
2798
|
+
);
|
|
2799
|
+
const done = (panel as any)._cleanupParallel(
|
|
2800
|
+
row(2, now - 2 * HOUR)
|
|
2801
|
+
) as Promise<void>;
|
|
2802
|
+
const buttons = await openButtons();
|
|
2803
|
+
|
|
2804
|
+
// The fetch is out: one activation arms and does not remove.
|
|
2805
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2806
|
+
click(buttons[1]);
|
|
2807
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2808
|
+
expect(deletes()).toHaveLength(0);
|
|
2809
|
+
|
|
2810
|
+
// The list lands with a conversation newer than the kept one; the earlier
|
|
2811
|
+
// activation still must not have removed anything, and the arm is dropped
|
|
2812
|
+
// so it cannot confirm this list either.
|
|
2813
|
+
release!(branches([now - HOUR]));
|
|
2814
|
+
await flush();
|
|
2815
|
+
expect(
|
|
2816
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2817
|
+
).not.toBeNull();
|
|
2818
|
+
expect(buttons[1].textContent).toBe('Remove (1)');
|
|
2819
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2820
|
+
expect(deletes()).toHaveLength(0);
|
|
2821
|
+
|
|
2822
|
+
buttons[0].click(); // Cancel
|
|
2823
|
+
await done;
|
|
2824
|
+
expect(deletes()).toHaveLength(0);
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
it('arms while the branch fetch is pending, before any newer state is known', async () => {
|
|
2828
|
+
const now = Date.now();
|
|
2829
|
+
let release: ((value: any) => void) | undefined;
|
|
2830
|
+
request.mockImplementationOnce(
|
|
2831
|
+
() => new Promise(resolve => (release = resolve))
|
|
2832
|
+
);
|
|
2833
|
+
const done = (panel as any)._cleanupParallel(
|
|
2834
|
+
row(4, now - 2 * HOUR)
|
|
2835
|
+
) as Promise<void>;
|
|
2836
|
+
const buttons = await openButtons();
|
|
2837
|
+
|
|
2838
|
+
// The row's own count while the list is unknown, and no warning yet.
|
|
2839
|
+
expect(buttons[1].textContent).toBe('Remove (4)');
|
|
2840
|
+
expect(
|
|
2841
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2842
|
+
).toBeNull();
|
|
2843
|
+
click(buttons[1]);
|
|
2844
|
+
expect(buttons[1].textContent).toBe('Confirm remove 4');
|
|
2845
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2846
|
+
expect(labelOf(buttons[1]).getAttribute('aria-label')).toBe(
|
|
2847
|
+
'Confirm remove 4'
|
|
2848
|
+
);
|
|
2849
|
+
expect(deletes()).toHaveLength(0);
|
|
2850
|
+
|
|
2851
|
+
// Let the held fetch answer, then cancel, so the dialog leaves cleanly.
|
|
2852
|
+
release!(branches([now - 3 * HOUR]));
|
|
2853
|
+
await flush();
|
|
2854
|
+
buttons[0].click();
|
|
2855
|
+
await done;
|
|
2856
|
+
});
|
|
2857
|
+
|
|
2858
|
+
it('arms after a failed branch fetch, and the second activation removes', async () => {
|
|
2859
|
+
request.mockRejectedValueOnce(new Error('offline'));
|
|
2860
|
+
// The removal's own refetch answers with the branch it deletes.
|
|
2861
|
+
request.mockResolvedValueOnce({
|
|
2862
|
+
current: 'sid-kept',
|
|
2863
|
+
total: 2,
|
|
2864
|
+
branches: [
|
|
2865
|
+
{ session_id: 'sid-branch-0', file_mtime: 1, label: 'Branch 0' }
|
|
2866
|
+
]
|
|
2867
|
+
});
|
|
2868
|
+
const done = (panel as any)._cleanupParallel(
|
|
2869
|
+
row(3, Date.now() - 2 * HOUR)
|
|
2870
|
+
) as Promise<void>;
|
|
2871
|
+
const buttons = await openButtons();
|
|
2872
|
+
|
|
2873
|
+
expect(buttons[1].textContent).toBe('Remove (3)');
|
|
2874
|
+
click(buttons[1]);
|
|
2875
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2876
|
+
expect(deletes()).toHaveLength(0);
|
|
2877
|
+
|
|
2878
|
+
click(buttons[1]);
|
|
2879
|
+
await done;
|
|
2880
|
+
const sent = deletes();
|
|
2881
|
+
expect(sent).toHaveLength(1);
|
|
2882
|
+
expect(JSON.parse(sent[0][3].body).session_ids).toEqual(['sid-branch-0']);
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
it('a restatement drops an arm given before the list landed and relabels with the new count', async () => {
|
|
2886
|
+
const now = Date.now();
|
|
2887
|
+
let release: ((value: any) => void) | undefined;
|
|
2888
|
+
request.mockImplementationOnce(
|
|
2889
|
+
() => new Promise(resolve => (release = resolve))
|
|
2890
|
+
);
|
|
2891
|
+
const done = (panel as any)._cleanupParallel(
|
|
2892
|
+
row(3, now - 2 * HOUR)
|
|
2893
|
+
) as Promise<void>;
|
|
2894
|
+
const buttons = await openButtons();
|
|
2895
|
+
|
|
2896
|
+
click(buttons[1]);
|
|
2897
|
+
expect(buttons[1].textContent).toBe('Confirm remove 3');
|
|
2898
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2899
|
+
|
|
2900
|
+
// The fetched list holds two conversations, one newer: the count changes
|
|
2901
|
+
// and the warning appears, so the arm must not survive to confirm them.
|
|
2902
|
+
release!(branches([now - HOUR, now - 3 * HOUR]));
|
|
2903
|
+
await flush();
|
|
2904
|
+
expect(buttons[1].textContent).toBe('Remove (2)');
|
|
2905
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2906
|
+
expect(deletes()).toHaveLength(0);
|
|
2907
|
+
|
|
2908
|
+
// The two steps now apply to the restated list.
|
|
2909
|
+
click(buttons[1]);
|
|
2910
|
+
expect(buttons[1].textContent).toBe('Confirm remove 2');
|
|
2911
|
+
click(buttons[1]);
|
|
2912
|
+
await done;
|
|
2913
|
+
expect(JSON.parse(deletes()[0][3].body).session_ids).toEqual([
|
|
2914
|
+
'sid-branch-0',
|
|
2915
|
+
'sid-branch-1'
|
|
2916
|
+
]);
|
|
2917
|
+
});
|
|
2918
|
+
|
|
2919
|
+
it('keeps the one-click Remove when the fetched list has no newer conversation', async () => {
|
|
2920
|
+
const now = Date.now();
|
|
2921
|
+
serve([now - 3 * HOUR]);
|
|
2922
|
+
const done = (panel as any)._cleanupParallel(
|
|
2923
|
+
row(1, now - 2 * HOUR)
|
|
2924
|
+
) as Promise<void>;
|
|
2925
|
+
const buttons = await openButtons();
|
|
2926
|
+
|
|
2927
|
+
expect(
|
|
2928
|
+
document.querySelector('.jp-AiAssistantsPanel-cleanupWarning')
|
|
2929
|
+
).toBeNull();
|
|
2930
|
+
expect(buttons[1].textContent).toBe('Remove');
|
|
2931
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(false);
|
|
2932
|
+
|
|
2933
|
+
click(buttons[1]);
|
|
2934
|
+
await done;
|
|
2935
|
+
expect(deletes()).toHaveLength(1);
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
it('Enter and Space on the accept button arm first and confirm second', async () => {
|
|
2939
|
+
const now = Date.now();
|
|
2940
|
+
serve([now - HOUR]);
|
|
2941
|
+
|
|
2942
|
+
let done = (panel as any)._cleanupParallel(
|
|
2943
|
+
row(1, now - 2 * HOUR)
|
|
2944
|
+
) as Promise<void>;
|
|
2945
|
+
let buttons = await openButtons();
|
|
2946
|
+
buttons[1].focus();
|
|
2947
|
+
// The capture handler must consume the Enter default: a real browser
|
|
2948
|
+
// would otherwise fire the focused button's activation click, and that
|
|
2949
|
+
// click would confirm in one press.
|
|
2950
|
+
const armingEnter = pressEnter();
|
|
2951
|
+
expect(armingEnter.defaultPrevented).toBe(true);
|
|
2952
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2953
|
+
expect(deletes()).toHaveLength(0);
|
|
2954
|
+
// A held Enter sends repeats; they are not a second press and must not
|
|
2955
|
+
// confirm, though the Dialog's own Enter handler would resolve the accept.
|
|
2956
|
+
const repeatEnter = pressEnter(true);
|
|
2957
|
+
expect(repeatEnter.defaultPrevented).toBe(true);
|
|
2958
|
+
expect(deletes()).toHaveLength(0);
|
|
2959
|
+
expect(document.querySelector('.jp-Dialog')).not.toBeNull();
|
|
2960
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2961
|
+
expect(buttons[1].classList.contains('jp-mod-armed')).toBe(true);
|
|
2962
|
+
pressEnter();
|
|
2963
|
+
await done;
|
|
2964
|
+
expect(deletes()).toHaveLength(1);
|
|
2965
|
+
await closeProgress();
|
|
2966
|
+
|
|
2967
|
+
// Space reaches a button as a click; the same two steps hold.
|
|
2968
|
+
request.mockClear();
|
|
2969
|
+
done = (panel as any)._cleanupParallel(
|
|
2970
|
+
row(1, now - 2 * HOUR)
|
|
2971
|
+
) as Promise<void>;
|
|
2972
|
+
buttons = await openButtons();
|
|
2973
|
+
buttons[1].click();
|
|
2974
|
+
expect(buttons[1].textContent).toBe('Confirm remove 1');
|
|
2975
|
+
expect(deletes()).toHaveLength(0);
|
|
2976
|
+
buttons[1].click();
|
|
2977
|
+
await done;
|
|
2978
|
+
expect(deletes()).toHaveLength(1);
|
|
2979
|
+
});
|
|
2980
|
+
});
|
|
2981
|
+
|
|
2586
2982
|
describe('ACC-PANE-35..45 - what a row and a section show', () => {
|
|
2587
2983
|
const MINUTE = 60_000;
|
|
2588
2984
|
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
|
}
|
|
@@ -888,7 +896,7 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
888
896
|
caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
|
|
889
897
|
caution.textContent = this._deleteToTrash
|
|
890
898
|
? 'A refused trash move deletes permanently and cannot be undone.'
|
|
891
|
-
:
|
|
899
|
+
: 'This cannot be undone.';
|
|
892
900
|
confirmBody.node.appendChild(caution);
|
|
893
901
|
|
|
894
902
|
const title = this._trans.__('Clean Up Parallel Sessions');
|
|
@@ -904,9 +912,11 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
904
912
|
defaultButton: 0,
|
|
905
913
|
renderer: dialogRenderer
|
|
906
914
|
});
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
//
|
|
915
|
+
// Scopes the armed style in base.css to this one dialog.
|
|
916
|
+
confirmDialog.addClass('jp-AiAssistantsPanel-cleanupDialog');
|
|
917
|
+
// The Dialog names itself only when its body is a string, and this body is
|
|
918
|
+
// a Widget: name it here, and again when the question is restated and the
|
|
919
|
+
// warning joins it.
|
|
910
920
|
const nameDialog = (): void => {
|
|
911
921
|
confirmDialog.node.setAttribute(
|
|
912
922
|
'aria-label',
|
|
@@ -923,6 +933,119 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
923
933
|
};
|
|
924
934
|
nameDialog();
|
|
925
935
|
|
|
936
|
+
// The house two-step destructive action (design-language.md, "Two-step
|
|
937
|
+
// destructive action"; the branch popup's Delete is the shipped form):
|
|
938
|
+
// while the newer state is unknown or newer, the accept button arms on the
|
|
939
|
+
// first activation and removes on the second, the arm lapsing after 4 s.
|
|
940
|
+
// It starts required: the branch fetch that decides whether a removal is
|
|
941
|
+
// safe is still out when the dialog opens.
|
|
942
|
+
let armRequired = true;
|
|
943
|
+
let armed = false;
|
|
944
|
+
let armTimer = 0;
|
|
945
|
+
// The arming activation's event.timeStamp, which the confirming one is
|
|
946
|
+
// measured against.
|
|
947
|
+
let armedAt = 0;
|
|
948
|
+
|
|
949
|
+
/** Find and relabel the accept button. The Dialog keeps its button nodes
|
|
950
|
+
* private and puts the label in a child whose aria-label overrides its
|
|
951
|
+
* text, so both are set. */
|
|
952
|
+
const updateAccept = (): void => {
|
|
953
|
+
const button =
|
|
954
|
+
confirmDialog.node.querySelector<HTMLButtonElement>('.jp-mod-accept');
|
|
955
|
+
const label = button?.querySelector('.jp-Dialog-buttonLabel');
|
|
956
|
+
if (!button || !label) {
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const text = armed
|
|
960
|
+
? this._trans.__('Confirm remove %1', extra)
|
|
961
|
+
: armRequired
|
|
962
|
+
? this._trans.__('Remove (%1)', extra)
|
|
963
|
+
: this._trans.__('Remove');
|
|
964
|
+
label.textContent = text;
|
|
965
|
+
label.setAttribute('aria-label', text);
|
|
966
|
+
button.classList.toggle('jp-mod-armed', armed);
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
const disarm = (): void => {
|
|
970
|
+
armed = false;
|
|
971
|
+
window.clearTimeout(armTimer);
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
/** Consume one accept activation. True means it only armed, or was the
|
|
975
|
+
* ignored half of a double-click, and the Dialog must not resolve; false
|
|
976
|
+
* lets the Dialog's own handler resolve it. */
|
|
977
|
+
const activate = (event: MouseEvent | KeyboardEvent): boolean => {
|
|
978
|
+
if (!armRequired) {
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
// The second click of a double-click is not a confirmation (DEF-PANE-336):
|
|
982
|
+
// a multi-click within 600 ms of arming is ignored. A later click
|
|
983
|
+
// confirms whatever its detail, since the OS double-click interval can
|
|
984
|
+
// be longer than the user's pause (DEF-PANE-338).
|
|
985
|
+
if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
if (!armed) {
|
|
989
|
+
armed = true;
|
|
990
|
+
armedAt = event.timeStamp;
|
|
991
|
+
window.clearTimeout(armTimer);
|
|
992
|
+
armTimer = window.setTimeout(() => {
|
|
993
|
+
disarm();
|
|
994
|
+
updateAccept();
|
|
995
|
+
}, 4000);
|
|
996
|
+
updateAccept();
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
disarm();
|
|
1000
|
+
return false;
|
|
1001
|
+
};
|
|
1002
|
+
|
|
1003
|
+
// Registered before launch(): the Dialog attaches its own capture
|
|
1004
|
+
// listeners on this same node when launch() attaches it, so these run
|
|
1005
|
+
// first and can swallow an activation that must only arm. Space arrives
|
|
1006
|
+
// as a click; Enter arrives as a keydown the Dialog would otherwise
|
|
1007
|
+
// resolve from, so both are intercepted here.
|
|
1008
|
+
confirmDialog.node.addEventListener(
|
|
1009
|
+
'click',
|
|
1010
|
+
event => {
|
|
1011
|
+
const button = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
1012
|
+
if (!button || !button.contains(event.target as Node)) {
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (activate(event)) {
|
|
1016
|
+
event.stopImmediatePropagation();
|
|
1017
|
+
event.preventDefault();
|
|
1018
|
+
}
|
|
1019
|
+
},
|
|
1020
|
+
true
|
|
1021
|
+
);
|
|
1022
|
+
confirmDialog.node.addEventListener(
|
|
1023
|
+
'keydown',
|
|
1024
|
+
event => {
|
|
1025
|
+
if (event.key !== 'Enter') {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const accept = confirmDialog.node.querySelector('.jp-mod-accept');
|
|
1029
|
+
if (!accept || document.activeElement !== accept) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
// A held key's auto-repeat is not a second press (DEF-PANE-347): it
|
|
1033
|
+
// must neither confirm nor arm, and consuming it stops the Dialog's
|
|
1034
|
+
// own Enter handler from resolving the accept.
|
|
1035
|
+
if (event.repeat) {
|
|
1036
|
+
event.stopImmediatePropagation();
|
|
1037
|
+
event.preventDefault();
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (activate(event)) {
|
|
1041
|
+
event.stopImmediatePropagation();
|
|
1042
|
+
event.preventDefault();
|
|
1043
|
+
}
|
|
1044
|
+
},
|
|
1045
|
+
true
|
|
1046
|
+
);
|
|
1047
|
+
updateAccept();
|
|
1048
|
+
|
|
926
1049
|
// Alongside the question, never in front of it: the request layer allows
|
|
927
1050
|
// 60 s, and the confirmation must open at once. When the list lands the
|
|
928
1051
|
// question states its length and a conversation newer than the kept one
|
|
@@ -956,6 +1079,13 @@ export class AssistantSessionsPanel extends Widget {
|
|
|
956
1079
|
'active after the one kept.';
|
|
957
1080
|
confirmBody.node.appendChild(warning);
|
|
958
1081
|
}
|
|
1082
|
+
// The list just decided what the button acts on: an arm given while
|
|
1083
|
+
// it was pending could confirm a different set, so drop it and
|
|
1084
|
+
// restate the label with the fetched count. With no newer branch the
|
|
1085
|
+
// button returns to the one-click Remove.
|
|
1086
|
+
disarm();
|
|
1087
|
+
armRequired = newer > 0;
|
|
1088
|
+
updateAccept();
|
|
959
1089
|
nameDialog();
|
|
960
1090
|
}
|
|
961
1091
|
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, caution 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;
|