jupyterlab_ai_code_assistants_extension 1.2.236 → 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
 
@@ -382,6 +382,10 @@ export declare class AssistantSessionsPanel extends Widget {
382
382
  * `_fetch`. */
383
383
  private _fetchSent;
384
384
  private _fetchDrawn;
385
+ /** The `_fetchSent` number at each removal's DELETE, by encoded path. A
386
+ * listing sent before that number may still carry the removed project, so
387
+ * `_fetch` drops it (DEF-PANE-351-WRITE-ORDER). */
388
+ private readonly _removedAt;
385
389
  /** A button is pressed in the list, and a redraw waits for its release -
386
390
  * see `_onRelease`. */
387
391
  private _pressed;
package/lib/core/panel.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
2
- import { MenuSvg, closeIcon, editIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
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';
5
5
  import { UUID } from '@lumino/coreutils';
@@ -76,8 +76,9 @@ let fileBrowserMoves = Promise.resolve();
76
76
  * later click does not move the file browser, so it ends on the folder of the
77
77
  * newest click that opened (DEF-PANE-343). The same counter orders the writes
78
78
  * that decide a project's current conversation: a row click, an explicit
79
- * switch and a fork each take a number, and a row click posts its deferred
80
- * switch only while it is the newest (DEF-PANE-349). */
79
+ * switch, a fork and a New Session (+) each take a number, and a row click
80
+ * posts its deferred switch only while it is the newest (DEF-PANE-349,
81
+ * DEF-PANE-355). */
81
82
  let openClicks = 0;
82
83
  let movedClick = 0;
83
84
  function loadExpanded(storageKey) {
@@ -179,6 +180,10 @@ export class AssistantSessionsPanel extends Widget {
179
180
  * `_fetch`. */
180
181
  this._fetchSent = 0;
181
182
  this._fetchDrawn = 0;
183
+ /** The `_fetchSent` number at each removal's DELETE, by encoded path. A
184
+ * listing sent before that number may still carry the removed project, so
185
+ * `_fetch` drops it (DEF-PANE-351-WRITE-ORDER). */
186
+ this._removedAt = new Map();
182
187
  /** A button is pressed in the list, and a redraw waits for its release -
183
188
  * see `_onRelease`. */
184
189
  this._pressed = false;
@@ -640,7 +645,9 @@ export class AssistantSessionsPanel extends Widget {
640
645
  return;
641
646
  }
642
647
  this._fetchDrawn = request;
643
- this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
648
+ // A project removed at or after this listing was sent must not be drawn
649
+ // 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; });
644
651
  this._setInlineError(null);
645
652
  this._terminals.setSessions(this._sessions);
646
653
  this._render();
@@ -659,6 +666,10 @@ export class AssistantSessionsPanel extends Widget {
659
666
  favourite: next
660
667
  })
661
668
  });
669
+ // A listing sent before the write may still be drawn, and a poll may
670
+ // have replaced the row this wrote; this one is answered after the write
671
+ // (DEF-PANE-351).
672
+ void this._fetch().catch(() => undefined);
662
673
  }
663
674
  catch (err) {
664
675
  session.favourite = !next; // roll back
@@ -711,6 +722,11 @@ export class AssistantSessionsPanel extends Widget {
711
722
  });
712
723
  // Drop locally; the next poll reconciles against disk.
713
724
  this._sessions = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).filter(s => s.encoded_path !== session.encoded_path);
725
+ // A listing sent before the delete may still be drawn (DEF-PANE-351),
726
+ // and it carries the removed project: the number taken here marks the
727
+ // answer for `_fetch` to drop (DEF-PANE-351-WRITE-ORDER).
728
+ this._removedAt.set(session.encoded_path, this._fetchSent);
729
+ void this._fetch().catch(() => undefined);
714
730
  }
715
731
  catch (err) {
716
732
  this._showActionError(`Could not remove "${name}" - try again.`, err);
@@ -721,14 +737,44 @@ export class AssistantSessionsPanel extends Widget {
721
737
  }
722
738
  }
723
739
  async _cleanupParallel(session) {
724
- var _a;
725
- const extra = session.extra_sessions;
740
+ var _a, _b, _c, _d;
726
741
  const name = this._lookupName(session);
727
- const confirm = await showDialog({
728
- title: this._trans.__('Clean Up Parallel Sessions'),
729
- body: `Remove ${extra} parallel session${extra === 1 ? '' : 's'} from ` +
730
- `"${name}"? The current conversation is kept; the rest are ` +
731
- `${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,
732
778
  buttons: [
733
779
  Dialog.cancelButton(),
734
780
  Dialog.warnButton({ label: this._trans.__('Remove') })
@@ -737,6 +783,58 @@ export class AssistantSessionsPanel extends Widget {
737
783
  defaultButton: 0,
738
784
  renderer: dialogRenderer
739
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();
740
838
  if (!confirm.button.accept) {
741
839
  return;
742
840
  }
@@ -744,6 +842,7 @@ export class AssistantSessionsPanel extends Widget {
744
842
  body.node.className = 'jp-AiAssistantsPanel-cleanupBody';
745
843
  const message = document.createElement('div');
746
844
  message.className = 'jp-AiAssistantsPanel-cleanupMessage';
845
+ message.setAttribute('role', 'status');
747
846
  message.textContent = `Removing ${extra} parallel session${extra === 1 ? '' : 's'}...`;
748
847
  body.node.appendChild(message);
749
848
  // No `value` attribute -> indeterminate (animated) while the request is in
@@ -758,6 +857,7 @@ export class AssistantSessionsPanel extends Widget {
758
857
  buttons: [Dialog.okButton({ label: this._trans.__('Close') })],
759
858
  renderer: dialogRenderer
760
859
  });
860
+ dialog.node.setAttribute('aria-label', title);
761
861
  // Close stays live while the work runs. Hiding it took away the only exit
762
862
  // a keyboard user has: the dialog listens for Escape on its own node, it
763
863
  // puts focus on this button and nowhere else, and focusing a hidden button
@@ -768,9 +868,11 @@ export class AssistantSessionsPanel extends Widget {
768
868
  void dialog.launch();
769
869
  try {
770
870
  // The extras ARE the branch list - the current conversation is not in
771
- // it - so cleaning up is deleting exactly those ids.
772
- const listed = await this._fetchBranches(session);
773
- 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);
774
876
  if (ids.length === 0) {
775
877
  // The menu item is gated on a row up to one poll old, so by the time
776
878
  // the refetch lands another tab may have cleaned the project already.
@@ -779,6 +881,7 @@ export class AssistantSessionsPanel extends Widget {
779
881
  // to remove N.
780
882
  bar.remove();
781
883
  message.textContent = 'Nothing left to clean up.';
884
+ dialog.node.setAttribute('aria-label', `${title}. ${(_c = message.textContent) !== null && _c !== void 0 ? _c : ''}`);
782
885
  this._branchCache = null;
783
886
  await this._fetch();
784
887
  return;
@@ -792,7 +895,7 @@ export class AssistantSessionsPanel extends Widget {
792
895
  });
793
896
  // Only the conversations that ACTUALLY went lose their colour - one the
794
897
  // server could not delete keeps its tint.
795
- 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);
796
899
  // The kept listing names conversations that may have just gone.
797
900
  this._branchCache = null;
798
901
  bar.value = 1;
@@ -999,7 +1102,7 @@ export class AssistantSessionsPanel extends Widget {
999
1102
  // The terminal a launch before this click opened for it counts too,
1000
1103
  // before the server can read the conversation off its process
1001
1104
  // (DEF-PANE-345).
1002
- const found = (_c = this._terminals.cached(folder, row.session_id)) !== null && _c !== void 0 ? _c : (_d = (await this._terminals.findForSession(row.session_id, folder))) === null || _d === void 0 ? void 0 : _d.widget;
1105
+ const found = (_c = (await this._terminals.cached(folder, row.session_id))) !== null && _c !== void 0 ? _c : (_d = (await this._terminals.findForSession(row.session_id, folder))) === null || _d === void 0 ? void 0 : _d.widget;
1003
1106
  if (found) {
1004
1107
  this._terminals.focus(found);
1005
1108
  return found;
@@ -1071,6 +1174,15 @@ export class AssistantSessionsPanel extends Widget {
1071
1174
  // store key lets the server clear that pin so the new conversation is not
1072
1175
  // shadowed by a stale one (DEF-9).
1073
1176
  const existing = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).find(s => s.project_path === projectPath);
1177
+ if (existing) {
1178
+ // The + click is the project's newest decision: a row open still
1179
+ // finishing posts no switch over it (DEF-PANE-355). It has no id to post;
1180
+ // the launch route clears the pin.
1181
+ this._currentTokens.set(existing.encoded_path, {
1182
+ click: ++openClicks,
1183
+ sessionId: ''
1184
+ });
1185
+ }
1074
1186
  // Raised on the click, not by the launch: the launch waits for a tile
1075
1187
  // launch clicked before it (DEF-PANE-345).
1076
1188
  const busy = beginSpinner(this._trans.__('Opening %1', this._descriptor.label), this._trans);
@@ -1328,7 +1440,8 @@ export class AssistantSessionsPanel extends Widget {
1328
1440
  const key = session.encoded_path;
1329
1441
  const run = ((_a = this._switchChains.get(key)) !== null && _a !== void 0 ? _a : Promise.resolve()).then(async () => {
1330
1442
  const newest = this._currentTokens.get(key);
1331
- if (!newest) {
1443
+ // An empty id is a New Session (+) decision, which posts nothing.
1444
+ if (!(newest === null || newest === void 0 ? void 0 : newest.sessionId)) {
1332
1445
  return;
1333
1446
  }
1334
1447
  if (!force && this._postedClicks.get(key) === newest.click) {
@@ -2097,6 +2210,7 @@ export class AssistantSessionsPanel extends Widget {
2097
2210
  });
2098
2211
  this._commands.addCommand(this._cmd('copy-path'), {
2099
2212
  label: this._trans.__('Copy Path'),
2213
+ icon: copyIcon,
2100
2214
  execute: () => {
2101
2215
  const s = active();
2102
2216
  if (s) {
@@ -2106,6 +2220,7 @@ export class AssistantSessionsPanel extends Widget {
2106
2220
  });
2107
2221
  this._commands.addCommand(this._cmd('copy-session-id'), {
2108
2222
  label: this._trans.__('Copy Session ID'),
2223
+ icon: copyIcon,
2109
2224
  execute: () => {
2110
2225
  var _a;
2111
2226
  const id = (_a = active()) === null || _a === void 0 ? void 0 : _a.session_id;
@@ -2145,6 +2260,9 @@ export class AssistantSessionsPanel extends Widget {
2145
2260
  // item beside an ordinary one (DEF-45). The sweep variant of the trash
2146
2261
  // glyph, because this one deletes several at once.
2147
2262
  icon: cleanupIcon,
2263
+ // Both history-deleting entries draw their icon in the error colour
2264
+ // (base.css).
2265
+ className: 'jp-AiAssistantsContextMenu-danger',
2148
2266
  isVisible: () => { var _a, _b; return ((_b = (_a = active()) === null || _a === void 0 ? void 0 : _a.extra_sessions) !== null && _b !== void 0 ? _b : 0) > 0; },
2149
2267
  execute: () => {
2150
2268
  const s = active();
@@ -2241,6 +2359,7 @@ export class AssistantSessionsPanel extends Widget {
2241
2359
  this._commands.addCommand(this._cmd('remove'), {
2242
2360
  label: `Remove from ${this._descriptor.label}`,
2243
2361
  icon: removeIcon,
2362
+ className: 'jp-AiAssistantsContextMenu-danger',
2244
2363
  execute: () => {
2245
2364
  const s = active();
2246
2365
  if (s) {
@@ -86,8 +86,10 @@ export declare class TerminalManager {
86
86
  */
87
87
  launch(request: ILaunchRequest, tagSessionId?: string, spinner?: number): Promise<any>;
88
88
  /** The terminal this manager last opened for `projectPath`, when it is
89
- * still open and was opened for the conversation `sessionId`; else null. */
90
- cached(projectPath: string, sessionId: string): any | null;
89
+ * still open, was opened for the conversation `sessionId`, and the server
90
+ * does not read another conversation off its process - which /clear starts
91
+ * in the same process (DEF-PANE-350); else null. */
92
+ cached(projectPath: string, sessionId: string): Promise<any | null>;
91
93
  /** Remember `widget` as the newest terminal of `projectPath`, opened for
92
94
  * the conversation `sessionId`. A launch is remembered before the server
93
95
  * can read the conversation off the new process, so the next open of the
@@ -383,7 +383,7 @@ export class TerminalManager {
383
383
  var _a;
384
384
  // 1. Microcache - the most recent terminal of this project, reused only
385
385
  // when it carries the wanted conversation.
386
- const cached = this.cached(session.project_path, wanted);
386
+ const cached = await this.cached(session.project_path, wanted);
387
387
  if (cached) {
388
388
  this.focus(cached, spinner);
389
389
  return;
@@ -473,12 +473,24 @@ export class TerminalManager {
473
473
  }
474
474
  }
475
475
  /** The terminal this manager last opened for `projectPath`, when it is
476
- * still open and was opened for the conversation `sessionId`; else null. */
477
- cached(projectPath, sessionId) {
476
+ * still open, was opened for the conversation `sessionId`, and the server
477
+ * does not read another conversation off its process - which /clear starts
478
+ * in the same process (DEF-PANE-350); else null. */
479
+ async cached(projectPath, sessionId) {
478
480
  const cached = this._byProject.get(projectPath);
479
- return cached && !cached.widget.isDisposed && cached.sessionId === sessionId
480
- ? cached.widget
481
- : null;
481
+ if (!cached || cached.widget.isDisposed || cached.sessionId !== sessionId) {
482
+ return null;
483
+ }
484
+ // No id read is the window before the new process is readable, which is
485
+ // what this cache covers (DEF-PANE-345).
486
+ const info = await this.probe(cached.widget);
487
+ if ((info === null || info === void 0 ? void 0 : info.session_id) && info.session_id !== sessionId) {
488
+ if (this._byProject.get(projectPath) === cached) {
489
+ this._byProject.delete(projectPath);
490
+ }
491
+ return null;
492
+ }
493
+ return cached.widget;
482
494
  }
483
495
  /** Remember `widget` as the newest terminal of `projectPath`, opened for
484
496
  * the conversation `sessionId`. A launch is remembered before the server
package/lib/index.js CHANGED
@@ -413,6 +413,31 @@ const plugin = {
413
413
  }
414
414
  }
415
415
  };
416
+ // A suspended network stack - laptop asleep, tab backgrounded - is exactly
417
+ // when the probe fails, and the slow cadence below would leave the roster
418
+ // stale for up to a minute after the machine comes back. A wake fires
419
+ // both events, so it costs two probes; deliberately, because a guard that
420
+ // let the second join the first answered the wake with a request issued
421
+ // before it, and starved the interval below of its own tick (DEF-117).
422
+ // Heard from here, before the first await: a wake before the listeners are
423
+ // armed below is only remembered, because a reconcile before the settings
424
+ // load reads the enabled-by-default fallback and docks assistants the user
425
+ // disabled; one status check answers it once they are armed (DEF-GUARD-122).
426
+ let armed = false;
427
+ let wokeDuringActivation = false;
428
+ const reprobe = () => {
429
+ if (!armed) {
430
+ wokeDuringActivation = true;
431
+ return;
432
+ }
433
+ void probeStatus().then(reconcile);
434
+ };
435
+ window.addEventListener('online', reprobe);
436
+ document.addEventListener('visibilitychange', () => {
437
+ if (document.visibilityState === 'visible') {
438
+ reprobe();
439
+ }
440
+ });
416
441
  await probeStatus();
417
442
  if (settingRegistry) {
418
443
  try {
@@ -490,21 +515,10 @@ const plugin = {
490
515
  window.setInterval(() => {
491
516
  void probeStatus().then(reconcile);
492
517
  }, STATUS_INTERVAL_MS);
493
- // A suspended network stack - laptop asleep, tab backgrounded - is exactly
494
- // when the probe fails, and the slow cadence above would leave the roster
495
- // stale for up to a minute after the machine comes back. A wake fires
496
- // both events, so it costs two probes; deliberately, because a guard that
497
- // let the second join the first answered the wake with a request issued
498
- // before it, and starved the interval above of its own tick (DEF-117).
499
- const reprobe = () => {
500
- void probeStatus().then(reconcile);
501
- };
502
- window.addEventListener('online', reprobe);
503
- document.addEventListener('visibilitychange', () => {
504
- if (document.visibilityState === 'visible') {
505
- reprobe();
506
- }
507
- });
518
+ armed = true;
519
+ if (wokeDuringActivation) {
520
+ reprobe();
521
+ }
508
522
  // Each JupyterLab window keeps its own copy of the settings and never
509
523
  // reads them again, and every save writes that whole copy. So a window
510
524
  // loaded before another window changed a setting put the old value back
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_ai_code_assistants_extension",
3
- "version": "1.2.236",
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",
@@ -168,6 +168,97 @@ describe('DEF-GUARD-139 - reconcile hands a late roster to a docked panel', () =
168
168
  });
169
169
  });
170
170
 
171
+ describe('DEF-GUARD-122 - a wake during activation', () => {
172
+ it('is remembered, reconciles nothing before the settings load, and is answered by one status check once the listeners are armed', async () => {
173
+ // Identity of this activation's probes; earlier activations' listeners
174
+ // reach the same mock (see the activation-contract harness below).
175
+ const serverSettings = {};
176
+ let probes = 0;
177
+ request.mockImplementation((path: string, settings: unknown) => {
178
+ if (path !== 'status' || settings !== serverSettings) {
179
+ return Promise.reject(new Error('not this activation'));
180
+ }
181
+ probes += 1;
182
+ return Promise.resolve({
183
+ root_dir: '/srv/lab',
184
+ delete_to_trash: true,
185
+ providers: PROVIDERS.map(m => ({
186
+ id: m.descriptor.id,
187
+ available: true
188
+ }))
189
+ });
190
+ });
191
+ const docked: AssistantSessionsPanel[] = [];
192
+ const labShell = {
193
+ add: (w: AssistantSessionsPanel) => {
194
+ docked.push(w);
195
+ }
196
+ } as any;
197
+ const app = {
198
+ serviceManager: { serverSettings },
199
+ commands: new CommandRegistry(),
200
+ hasPlugin: () => false
201
+ } as any;
202
+ // Every assistant off, so a reconcile on the enabled-by-default fallback
203
+ // would show as a docked panel.
204
+ const composite: Record<string, unknown> = Object.fromEntries(
205
+ PROVIDERS.map(m => [`providers.${m.descriptor.id}.enabled`, false])
206
+ );
207
+ const settings = {
208
+ raw: '{}',
209
+ composite,
210
+ get: (key: string) => ({
211
+ composite: composite[key],
212
+ user: composite[key]
213
+ }),
214
+ set: async () => undefined,
215
+ changed: new Signal<unknown, void>({})
216
+ };
217
+ let finishLoad!: () => void;
218
+ const loading = new Promise<void>(resolve => {
219
+ finishLoad = resolve;
220
+ });
221
+ const settingRegistry = {
222
+ connector: { fetch: async () => ({ raw: settings.raw }) },
223
+ load: async () => {
224
+ await loading;
225
+ return settings;
226
+ }
227
+ } as any;
228
+
229
+ const activation = (plugin.activate as any)(
230
+ app,
231
+ labShell,
232
+ null,
233
+ settingRegistry,
234
+ null,
235
+ null,
236
+ null
237
+ );
238
+ await flush();
239
+ expect(probes).toBe(1); // the activation probe; the settings still load
240
+
241
+ window.dispatchEvent(new Event('online'));
242
+ Object.defineProperty(document, 'visibilityState', {
243
+ configurable: true,
244
+ get: () => 'visible'
245
+ });
246
+ document.dispatchEvent(new Event('visibilitychange'));
247
+ await flush();
248
+ expect(probes).toBe(1);
249
+ expect(docked).toHaveLength(0);
250
+
251
+ finishLoad();
252
+ await activation;
253
+ await flush();
254
+ // How this fails: without the remembered wake the count stays 1 until the
255
+ // 60 s tick; a listener that probes before the settings load docks every
256
+ // assistant above, and one probe per wake makes it 3.
257
+ expect(probes).toBe(2);
258
+ expect(docked).toHaveLength(0);
259
+ });
260
+ });
261
+
171
262
  describe('ACC-LNCH-143..146 - the Launcher tile has the panel lifecycle', () => {
172
263
  /** A roster in which every assistant is present, or every one but `absent`. */
173
264
  const roster = (absent?: string): unknown => ({