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.
@@ -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
  }
@@ -777,23 +786,23 @@ export class AssistantSessionsPanel extends Widget {
777
786
  };
778
787
  askCount(extra);
779
788
  confirmBody.node.appendChild(question);
780
- // One sentence, twelve words or fewer: what happens, not the detail.
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
- hint.textContent = this._deleteToTrash
784
- ? 'The current conversation is kept; the rest go to the trash.'
785
- : 'The current conversation is kept; the rest are deleted permanently.';
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
- // 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 and hint are
823
+ // restated and the warning joins it.
813
824
  const nameDialog = () => {
814
- var _a, _b, _c, _d;
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 = caution.textContent) !== null && _c !== void 0 ? _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 states its length and a conversation newer than the kept one
829
- // adds the warning, drawn last so it reads after the caution. The same
830
- // list is what the removal deletes. A failed fetch leaves the question as
831
- // the row reported it, with no warning, and the removal fetches again
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 one kept.';
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 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.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
- 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'
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(body).toContain('A permanent removal cannot be undone');
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,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 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();
2295
2318
  });
2296
2319
 
2297
- it('ACC-SESS-62 - the cleanup confirmation states how many go and that one stays', async () => {
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: [0, 1, 2].map(index => ({
2305
- session_id: `sid-branch-${index}`,
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('current conversation is kept');
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 one kept.'
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 one kept', async () => {
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('states its hint in at most twelve words, in both disposal settings', async () => {
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
- expect(hint.trim().split(/\s+/).length).toBeLessThanOrEqual(12);
2540
- expect(hint).toContain('go to the trash');
2541
- // The hint promises the trash; the caution names the refusal that deletes
2542
- // permanently.
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.querySelector('.jp-AiAssistantsPanel-cleanupCaution')!
2545
- .textContent
2546
- ).toContain('A refused trash move deletes permanently');
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.trim().split(/\s+/).length).toBeLessThanOrEqual(12);
2558
- expect(hint).toContain('deleted permanently');
2559
- // The caution is not the hint: it stays visible on its own.
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.querySelector('.jp-AiAssistantsPanel-cleanupCaution')!
2562
- .textContent
2563
- ).toContain('cannot be undone');
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).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
  }
@@ -873,23 +881,25 @@ export class AssistantSessionsPanel extends Widget {
873
881
  };
874
882
  askCount(extra);
875
883
  confirmBody.node.appendChild(question);
876
- // One sentence, twelve words or fewer: what happens, not the detail.
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
- hint.textContent = this._deleteToTrash
880
- ? 'The current conversation is kept; the rest go to the trash.'
881
- : 'The current conversation is kept; the rest are deleted permanently.';
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
- // 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.
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 states its length and a conversation newer than the kept one
929
- // adds the warning, drawn last so it reads after the caution. The same
930
- // list is what the removal deletes. A failed fetch leaves the question as
931
- // the row reported it, with no warning, and the removal fetches again
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 one kept.';
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 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 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;