jupyterlab_ai_code_assistants_extension 1.2.271 → 1.2.288

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
@@ -10,7 +10,7 @@
10
10
 
11
11
  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. One install replaces the separate `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension` packages and migrates their settings and favourites automatically.
12
12
 
13
- ![Claude Code Sessions panel](.resources/screenshot.png)
13
+ ![Claude Code panel](.resources/screenshot.png)
14
14
 
15
15
  ## Why this extension
16
16
 
@@ -25,30 +25,31 @@ 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
+ - **Command line** - `jupyterlab-ai-code-assistants` lists, renames, moves, deletes, branches and switches the sessions of every assistant and refreshes the panels, from a terminal or an agent; every open panel shows the change at once
40
+ - **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
41
+ - **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
42
+ - **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
43
+ - **Favorites** - star projects from the right-click menu; favourites from the standalone extensions are migrated on first run
44
+ - **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
45
+ - **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
46
+ - **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
47
+ - **Search** - a fuzzy filter per panel, toggled by the funnel button
47
48
  - **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
49
+ - **Conflict-safe upgrade** - if a retired standalone extension is still installed, its panel wins and this extension stands down for that assistant
50
+ - **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
51
+ - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` shows no panel
52
+ - **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
53
 
53
54
  ## Requirements
54
55
 
@@ -76,6 +77,27 @@ pip install jupyterlab_ai_code_assistants_extension
76
77
  > [!WARNING]
77
78
  > `package.json` pins `webpack: 5.106.0` and `chalk: 4.1.2` in both `resolutions` and `overrides`. Do not remove these. webpack `>= 5.106.1` changed its module-federation share identifier format and crashes the unmaintained `license-webpack-plugin` (`split('=')[1].trim()`) that `@jupyterlab/builder` injects into every production build; the duplicate `chalk@2.4.2` pulled by `duplicate-package-checker-webpack-plugin` crashes on Node 24+ in the build-isolation install. Without the pins, `make publish` and CI fail on `python -m build`.
78
79
 
80
+ ## Command line
81
+
82
+ The wheel installs `jupyterlab-ai-code-assistants`. It finds the running Jupyter server and its token, and changes sessions only through that server, so every open panel shows the change at once.
83
+
84
+ ```bash
85
+ jupyterlab-ai-code-assistants # the server and every assistant (status)
86
+ jupyterlab-ai-code-assistants sessions list -a claude -p ~/project
87
+ jupyterlab-ai-code-assistants sessions rename <id> "New name" -a kimi
88
+ jupyterlab-ai-code-assistants sessions move <id> --to ~/other-project -a gemini
89
+ jupyterlab-ai-code-assistants sessions delete <id> -a claude
90
+ jupyterlab-ai-code-assistants sessions branch <id> -a claude --name "Experiment"
91
+ jupyterlab-ai-code-assistants sessions switch <id> -a codex
92
+ jupyterlab-ai-code-assistants refresh # after installing or removing an assistant CLI
93
+ ```
94
+
95
+ - `--url` and `--json` go before the subcommand; `--url` chooses the server when several run
96
+ - There is no token option: the token comes from `JUPYTERLAB_AI_CODE_ASSISTANTS_TOKEN`, else the server's runtime record, else `JUPYTERHUB_API_TOKEN`, `JPY_API_TOKEN` or `JUPYTER_TOKEN` for a loopback address
97
+ - A session a running terminal holds is never moved or deleted, and a project's current session is never deleted
98
+ - A Codex move forks the session into the target under a new id, which the command prints, and archives the source
99
+ - `jupyterlab-ai-code-assistants --help` lists the exit codes; `--help` after a subcommand lists its options
100
+
79
101
  ## Agent skill
80
102
 
81
103
  The wheel ships a session-operations skill for coding agents at `share/jupyter/agents/skills/jupyterlab-ai-code-assistants-extension/SKILL.md`. It covers finding, moving, splitting, renaming and promoting sessions, copying a session into another project under a fresh id, the task-list environment variables, and running Claude Code against Kimi K3 or DeepSeek, with two references for the rest - `claude-operations.md` for how `claude -c` and `--resume` choose a session and how to repair, promote and pin one, and `cross-assistant-sessions.md` for reading, inventorying, taking over and resuming sessions of Claude Code, Codex, Kimi, Gemini and DeepSeek.
@@ -87,6 +87,7 @@ export declare class AssistantSessionsPanel extends Widget {
87
87
  * the launch REACHED the server and was refused, and nothing retries it. */
88
88
  private _notifyLaunchError;
89
89
  private _logError;
90
+ /** `keepSame`: an answer equal to what is drawn leaves the rows in place. */
90
91
  private _fetch;
91
92
  private _toggleFavourite;
92
93
  /** The warning a deletion earns: with the trash setting on, a refused move
@@ -316,6 +317,13 @@ export declare class AssistantSessionsPanel extends Widget {
316
317
  * all. Returning null lets the popup fall back to its fork marker. */
317
318
  private _bgBadgeFor;
318
319
  private _startPolling;
320
+ /** The server says this assistant's sessions changed (ACC-CLI-227): read
321
+ * them through the poll's single-flight fetch. Unlike a poll tick it is
322
+ * never dropped. It waits for an open row context menu to close, and one
323
+ * that lands while a read is in flight reads once more after it, because
324
+ * that read may have been answered before the change. */
325
+ sessionsChanged(): void;
326
+ private _poll;
319
327
  private _stopPolling;
320
328
  private readonly _app;
321
329
  private readonly _descriptor;
@@ -375,9 +383,13 @@ export declare class AssistantSessionsPanel extends Widget {
375
383
  private _activeSession;
376
384
  private _activeRowEl;
377
385
  private _pollHandle;
378
- /** The poll's own in-flight tick. Only the poll reads it - an explicit
379
- * Refresh must always issue, however long the last poll is taking. */
386
+ /** The poll's own in-flight tick. Only the poll and a pushed change read it
387
+ * - an explicit Refresh must always issue, however long the last poll is
388
+ * taking. */
380
389
  private _polling;
390
+ /** A pushed change waits for the context menu to close or for the read in
391
+ * flight to answer - see `sessionsChanged`. */
392
+ private _changeWaits;
381
393
  /** Listing requests sent, and the number of the newest one drawn - see
382
394
  * `_fetch`. */
383
395
  private _fetchSent;
package/lib/core/panel.js CHANGED
@@ -40,6 +40,18 @@ const INLINE_BRANCH_LIMIT = 5;
40
40
  * exports no token, so its absence is something to ask the command registry
41
41
  * about at click time rather than a build-time dependency (ACC-LNCH-155). */
42
42
  const BASIC_TERMINAL_LAUNCH = 'basic-terminal:launch';
43
+ /** What a delete says about the conversations the server kept because a live
44
+ * terminal runs them (ACC-SESS-57); empty when it kept none. */
45
+ function keptCopy(liveIds) {
46
+ var _a;
47
+ const kept = (_a = liveIds === null || liveIds === void 0 ? void 0 : liveIds.length) !== null && _a !== void 0 ? _a : 0;
48
+ if (kept === 0) {
49
+ return '';
50
+ }
51
+ return kept === 1
52
+ ? 'One conversation was kept, because a live terminal runs it.'
53
+ : `${kept} conversations were kept, because live terminals run them.`;
54
+ }
43
55
  export const DEFAULT_PRESENTATION_MODE = 'name';
44
56
  const SECTION_LABELS = {
45
57
  favourites: 'Favorites',
@@ -173,9 +185,13 @@ export class AssistantSessionsPanel extends Widget {
173
185
  this._activeSession = null;
174
186
  this._activeRowEl = null;
175
187
  this._pollHandle = null;
176
- /** The poll's own in-flight tick. Only the poll reads it - an explicit
177
- * Refresh must always issue, however long the last poll is taking. */
188
+ /** The poll's own in-flight tick. Only the poll and a pushed change read it
189
+ * - an explicit Refresh must always issue, however long the last poll is
190
+ * taking. */
178
191
  this._polling = null;
192
+ /** A pushed change waits for the context menu to close or for the read in
193
+ * flight to answer - see `sessionsChanged`. */
194
+ this._changeWaits = false;
179
195
  /** Listing requests sent, and the number of the newest one drawn - see
180
196
  * `_fetch`. */
181
197
  this._fetchSent = 0;
@@ -633,7 +649,8 @@ export class AssistantSessionsPanel extends Widget {
633
649
  console.error(`${LOG_PREFIX}[${this._descriptor.id}]`, message);
634
650
  }
635
651
  // ------------------------------------------------------------------- data
636
- async _fetch() {
652
+ /** `keepSame`: an answer equal to what is drawn leaves the rows in place. */
653
+ async _fetch(keepSame = false) {
637
654
  var _a;
638
655
  // This request's place in the order. Requests answer in any order, and an
639
656
  // answer older than the one already drawn is dropped (DEF-PANE-344).
@@ -647,10 +664,16 @@ export class AssistantSessionsPanel extends Widget {
647
664
  this._fetchDrawn = request;
648
665
  // A project removed at or after this listing was sent must not be drawn
649
666
  // back from the answer (DEF-PANE-351-WRITE-ORDER).
650
- this._sessions = ((_a = data.sessions) !== null && _a !== void 0 ? _a : []).filter(s => { var _a; return ((_a = this._removedAt.get(s.encoded_path)) !== null && _a !== void 0 ? _a : 0) < request; });
667
+ const sessions = ((_a = data.sessions) !== null && _a !== void 0 ? _a : []).filter(s => { var _a; return ((_a = this._removedAt.get(s.encoded_path)) !== null && _a !== void 0 ? _a : 0) < request; });
651
668
  this._setInlineError(null);
652
- this._terminals.setSessions(this._sessions);
653
- this._render();
669
+ // A redraw replaces every row, and the row a click is on with it
670
+ // (DEF-PANE-370-OWN-PUSH).
671
+ if (!keepSame ||
672
+ JSON.stringify(sessions) !== JSON.stringify(this._sessions)) {
673
+ this._sessions = sessions;
674
+ this._terminals.setSessions(this._sessions);
675
+ this._render();
676
+ }
654
677
  // Best-effort tinting; never let a colour failure break the fetch.
655
678
  void this._colours.load().then(() => this._terminals.reconcileColours());
656
679
  }
@@ -668,8 +691,9 @@ export class AssistantSessionsPanel extends Widget {
668
691
  });
669
692
  // A listing sent before the write may still be drawn, and a poll may
670
693
  // have replaced the row this wrote; this one is answered after the write
671
- // (DEF-PANE-351).
672
- void this._fetch().catch(() => undefined);
694
+ // (DEF-PANE-351). When it answers what the star above drew, the rows
695
+ // stay: the star is redrawn once.
696
+ void this._fetch(true).catch(() => undefined);
673
697
  }
674
698
  catch (err) {
675
699
  session.favourite = !next; // roll back
@@ -737,14 +761,44 @@ export class AssistantSessionsPanel extends Widget {
737
761
  }
738
762
  }
739
763
  async _cleanupParallel(session) {
740
- var _a;
741
- const extra = session.extra_sessions;
764
+ var _a, _b, _c, _d;
742
765
  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}`,
766
+ const confirmBody = new Widget();
767
+ confirmBody.node.className = 'jp-AiAssistantsPanel-cleanupConfirm';
768
+ const question = document.createElement('p');
769
+ // Announced politely when the fetch restates it; the warning keeps
770
+ // role=alert for the case that needs attention.
771
+ question.setAttribute('role', 'status');
772
+ let extra = session.extra_sessions;
773
+ const askCount = (count) => {
774
+ question.textContent =
775
+ `Remove ${count} parallel session${count === 1 ? '' : 's'} from ` +
776
+ `"${name}"?`;
777
+ };
778
+ askCount(extra);
779
+ confirmBody.node.appendChild(question);
780
+ // One sentence, twelve words or fewer: what happens, not the detail.
781
+ const hint = document.createElement('p');
782
+ 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.';
786
+ 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
+ const title = this._trans.__('Clean Up Parallel Sessions');
798
+ let warning = null;
799
+ const confirmDialog = new Dialog({
800
+ title,
801
+ body: confirmBody,
748
802
  buttons: [
749
803
  Dialog.cancelButton(),
750
804
  Dialog.warnButton({ label: this._trans.__('Remove') })
@@ -753,6 +807,58 @@ export class AssistantSessionsPanel extends Widget {
753
807
  defaultButton: 0,
754
808
  renderer: dialogRenderer
755
809
  });
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.
813
+ const nameDialog = () => {
814
+ var _a, _b, _c, _d;
815
+ confirmDialog.node.setAttribute('aria-label', [
816
+ title,
817
+ (_a = question.textContent) !== null && _a !== void 0 ? _a : '',
818
+ (_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 : ''
821
+ ]
822
+ .filter(part => part)
823
+ .join(' '));
824
+ };
825
+ nameDialog();
826
+ // Alongside the question, never in front of it: the request layer allows
827
+ // 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.
833
+ const branchesReady = this._fetchBranches(session).then(fetched => {
834
+ const list = fetched === null || fetched === void 0 ? void 0 : fetched.branches;
835
+ if (list && list.length === 0) {
836
+ // The row is up to one poll old, and a second client may have
837
+ // cleaned the project since (DEF-GUARD-96): a red Remove with zero
838
+ // targets is a dead end. End the confirmation through the accept
839
+ // path, whose zero-id guard states the empty outcome and refreshes
840
+ // the list (DEF-PANE-365); resolving after the user already
841
+ // accepted or cancelled is a no-op.
842
+ confirmDialog.resolve(1);
843
+ }
844
+ else if (list) {
845
+ extra = list.length;
846
+ askCount(extra);
847
+ const newer = list.filter(branch => branch.file_mtime > session.file_mtime).length;
848
+ if (newer > 0) {
849
+ warning = document.createElement('div');
850
+ warning.className = 'jp-AiAssistantsPanel-cleanupWarning';
851
+ warning.setAttribute('role', 'alert');
852
+ warning.textContent =
853
+ `${newer} parallel session${newer === 1 ? ' was' : 's were'} ` +
854
+ 'active after the one kept.';
855
+ confirmBody.node.appendChild(warning);
856
+ }
857
+ nameDialog();
858
+ }
859
+ return fetched;
860
+ }, () => null);
861
+ const confirm = await confirmDialog.launch();
756
862
  if (!confirm.button.accept) {
757
863
  return;
758
864
  }
@@ -760,6 +866,7 @@ export class AssistantSessionsPanel extends Widget {
760
866
  body.node.className = 'jp-AiAssistantsPanel-cleanupBody';
761
867
  const message = document.createElement('div');
762
868
  message.className = 'jp-AiAssistantsPanel-cleanupMessage';
869
+ message.setAttribute('role', 'status');
763
870
  message.textContent = `Removing ${extra} parallel session${extra === 1 ? '' : 's'}...`;
764
871
  body.node.appendChild(message);
765
872
  // No `value` attribute -> indeterminate (animated) while the request is in
@@ -774,6 +881,7 @@ export class AssistantSessionsPanel extends Widget {
774
881
  buttons: [Dialog.okButton({ label: this._trans.__('Close') })],
775
882
  renderer: dialogRenderer
776
883
  });
884
+ dialog.node.setAttribute('aria-label', title);
777
885
  // Close stays live while the work runs. Hiding it took away the only exit
778
886
  // a keyboard user has: the dialog listens for Escape on its own node, it
779
887
  // puts focus on this button and nowhere else, and focusing a hidden button
@@ -784,9 +892,11 @@ export class AssistantSessionsPanel extends Widget {
784
892
  void dialog.launch();
785
893
  try {
786
894
  // 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);
895
+ // it - so cleaning up is deleting exactly those ids. Reuse the list the
896
+ // question was built from, waiting for it here where the progress
897
+ // dialog is already up; only a failed fetch re-reads.
898
+ const fetched = (_a = (await branchesReady)) !== null && _a !== void 0 ? _a : (await this._fetchBranches(session));
899
+ const ids = ((_b = fetched === null || fetched === void 0 ? void 0 : fetched.branches) !== null && _b !== void 0 ? _b : []).map(b => b.session_id);
790
900
  if (ids.length === 0) {
791
901
  // The menu item is gated on a row up to one poll old, so by the time
792
902
  // the refetch lands another tab may have cleaned the project already.
@@ -795,6 +905,7 @@ export class AssistantSessionsPanel extends Widget {
795
905
  // to remove N.
796
906
  bar.remove();
797
907
  message.textContent = 'Nothing left to clean up.';
908
+ dialog.node.setAttribute('aria-label', `${title}. ${(_c = message.textContent) !== null && _c !== void 0 ? _c : ''}`);
798
909
  this._branchCache = null;
799
910
  await this._fetch();
800
911
  return;
@@ -808,11 +919,16 @@ export class AssistantSessionsPanel extends Widget {
808
919
  });
809
920
  // Only the conversations that ACTUALLY went lose their colour - one the
810
921
  // server could not delete keeps its tint.
811
- await this._colours.forget((_a = data.removed_ids) !== null && _a !== void 0 ? _a : ids);
922
+ await this._colours.forget((_d = data.removed_ids) !== null && _d !== void 0 ? _d : ids);
812
923
  // The kept listing names conversations that may have just gone.
813
924
  this._branchCache = null;
814
925
  bar.value = 1;
815
- message.textContent = `Removed ${data.removed_count} parallel session${data.removed_count === 1 ? '' : 's'}.`;
926
+ message.textContent = [
927
+ `Removed ${data.removed_count} parallel session${data.removed_count === 1 ? '' : 's'}.`,
928
+ keptCopy(data.live_ids)
929
+ ]
930
+ .filter(part => part)
931
+ .join(' ');
816
932
  // Refresh so the row's conversation count and menu label update.
817
933
  await this._fetch();
818
934
  }
@@ -1415,6 +1531,10 @@ export class AssistantSessionsPanel extends Widget {
1415
1531
  await this._colours.forget((_a = result.removed_ids) !== null && _a !== void 0 ? _a : sessionIds);
1416
1532
  // The kept listing names conversations that may have just gone.
1417
1533
  this._branchCache = null;
1534
+ const kept = keptCopy(result.live_ids);
1535
+ if (kept) {
1536
+ Notification.info(kept, { autoClose: 4000 });
1537
+ }
1418
1538
  return result.removed_count;
1419
1539
  }
1420
1540
  catch (err) {
@@ -2333,6 +2453,11 @@ export class AssistantSessionsPanel extends Widget {
2333
2453
  // callback still needs to read it. The field is overwritten on the next
2334
2454
  // contextmenu open.
2335
2455
  this._setActiveRow(null);
2456
+ // A change pushed while the menu was open reads now. A microtask later,
2457
+ // because the menu is still attached while this signal fires.
2458
+ if (this._changeWaits) {
2459
+ queueMicrotask(() => this.sessionsChanged());
2460
+ }
2336
2461
  });
2337
2462
  }
2338
2463
  _menu(commands = this._commands) {
@@ -2611,13 +2736,36 @@ export class AssistantSessionsPanel extends Widget {
2611
2736
  if (this._polling !== null) {
2612
2737
  return;
2613
2738
  }
2614
- this._polling = this._fetch()
2615
- .catch(err => this._showError(err))
2616
- .finally(() => {
2617
- this._polling = null;
2618
- });
2739
+ this._poll();
2619
2740
  }, POLL_INTERVAL_MS);
2620
2741
  }
2742
+ /** The server says this assistant's sessions changed (ACC-CLI-227): read
2743
+ * them through the poll's single-flight fetch. Unlike a poll tick it is
2744
+ * never dropped. It waits for an open row context menu to close, and one
2745
+ * that lands while a read is in flight reads once more after it, because
2746
+ * that read may have been answered before the change. */
2747
+ sessionsChanged() {
2748
+ if (this.isDisposed) {
2749
+ return;
2750
+ }
2751
+ if (this._contextMenu.isAttached || this._polling !== null) {
2752
+ this._changeWaits = true;
2753
+ return;
2754
+ }
2755
+ this._poll();
2756
+ }
2757
+ _poll() {
2758
+ // A read sent now answers every change pushed before it.
2759
+ this._changeWaits = false;
2760
+ this._polling = this._fetch()
2761
+ .catch(err => this._showError(err))
2762
+ .finally(() => {
2763
+ this._polling = null;
2764
+ if (this._changeWaits) {
2765
+ this.sessionsChanged();
2766
+ }
2767
+ });
2768
+ }
2621
2769
  _stopPolling() {
2622
2770
  if (this._pollHandle !== null) {
2623
2771
  window.clearInterval(this._pollHandle);
@@ -1,6 +1,11 @@
1
1
  import { ServerConnection } from '@jupyterlab/services';
2
2
  /** The server extension's API namespace. Every route lives under it. */
3
3
  export declare const API_NAMESPACE = "jupyterlab-ai-code-assistants-extension";
4
+ /** This window's name, sent with every request in {@link WINDOW_HEADER}. The
5
+ * server names it in the push a change causes, and this window skips that
6
+ * push: its panel re-reads after every write it makes (DEF-PANE-370-OWN-PUSH). */
7
+ export declare const WINDOW_ID: string;
8
+ export declare const WINDOW_HEADER = "X-AI-Assistants-Window";
4
9
  /**
5
10
  * DEF-72: how long a single server call may run before the client gives up.
6
11
  *
@@ -1,8 +1,14 @@
1
1
  import { URLExt } from '@jupyterlab/coreutils';
2
2
  import { ServerConnection } from '@jupyterlab/services';
3
+ import { UUID } from '@lumino/coreutils';
3
4
  import { LOG_PREFIX } from './registry';
4
5
  /** The server extension's API namespace. Every route lives under it. */
5
6
  export const API_NAMESPACE = 'jupyterlab-ai-code-assistants-extension';
7
+ /** This window's name, sent with every request in {@link WINDOW_HEADER}. The
8
+ * server names it in the push a change causes, and this window skips that
9
+ * push: its panel re-reads after every write it makes (DEF-PANE-370-OWN-PUSH). */
10
+ export const WINDOW_ID = UUID.uuid4();
11
+ export const WINDOW_HEADER = 'X-AI-Assistants-Window';
6
12
  /**
7
13
  * DEF-72: how long a single server call may run before the client gives up.
8
14
  *
@@ -68,6 +74,15 @@ export function isRequestTimeout(err) {
68
74
  export async function requestAPI(endPoint, serverSettings, init = {}) {
69
75
  var _a, _b;
70
76
  const requestUrl = URLExt.join(serverSettings.baseUrl, API_NAMESPACE, endPoint);
77
+ // shortcut: a `Headers` instance in `init.headers` would be dropped here; no
78
+ // caller passes one.
79
+ init = {
80
+ ...init,
81
+ headers: {
82
+ ...init.headers,
83
+ [WINDOW_HEADER]: WINDOW_ID
84
+ }
85
+ };
71
86
  // DEF-72: `ServerConnection.makeRequest` sets no deadline of its own, so
72
87
  // without this a wedged server holds the call for as long as the browser
73
88
  // will hold the socket. Hand-rolled rather than `AbortSignal.timeout`, which
@@ -163,8 +163,12 @@ export interface IDisposalReport {
163
163
  removed_ids: string[];
164
164
  }
165
165
  export type IRemoveResponse = IDisposalReport;
166
- export type ICleanupResponse = IDisposalReport;
167
- export type IDeleteSessionsResponse = IDisposalReport;
166
+ /** A delete of chosen conversations also names the ones it kept because a
167
+ * live terminal runs them (ACC-SESS-57). */
168
+ export interface IDeleteSessionsResponse extends IDisposalReport {
169
+ live_ids: string[];
170
+ }
171
+ export type ICleanupResponse = IDeleteSessionsResponse;
168
172
  export interface IBranch {
169
173
  session_id: string;
170
174
  file_mtime: number;