jupyterlab_ai_code_assistants_extension 1.2.271 → 1.2.282

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