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.
@@ -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, showDialog } from '@jupyterlab/apputils';
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 confirm = await showDialog({
721
- title: `Remove from ${this._descriptor.label}`,
722
- body: `Remove "${name}" from ${this._descriptor.label}? This drops the ` +
723
- `entire project history and every conversation it holds - ` +
724
- `${this._disposalVerb}.${this._irreversibleNote}`,
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
- : this._irreversibleNote.trim();
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
- // showDialog names a dialog only when its body is a string, and this body
811
- // is a Widget: name it here, and again when the question is restated and
812
- // the warning joins it.
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 move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
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.288",
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
- let body = document.querySelector('.jp-Dialog-body')!.textContent ?? '';
224
- expect(body).toContain(
225
- 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
226
- );
227
- expect(body).toContain('A permanent removal cannot be undone');
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
- body = document.querySelector('.jp-Dialog-body')!.textContent ?? '';
235
- expect(body).toContain('deleted permanently. This cannot be undone.');
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
- expect(buttons.map(b => b.textContent)).toEqual(['Cancel', 'Remove']);
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 an entire history.
2312
+ // this dialog drops every conversation in it.
2290
2313
  expect(body).toContain('ledger-tools');
2291
- expect(body).toContain('entire project history');
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).toContain(
236
- 'a refused move may remove them permanently or leave them in place'
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 confirm = await showDialog({
810
- title: `Remove from ${this._descriptor.label}`,
811
- body:
812
- `Remove "${name}" from ${this._descriptor.label}? This drops the ` +
813
- `entire project history and every conversation it holds - ` +
814
- `${this._disposalVerb}.${this._irreversibleNote}`,
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
- : this._irreversibleNote.trim();
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
- // showDialog names a dialog only when its body is a string, and this body
908
- // is a Widget: name it here, and again when the question is restated and
909
- // the warning joins it.
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 move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
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 cleanup confirmation's question, hint, caution and warning. The warning
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;