jupyterlab_ai_code_assistants_extension 1.2.282 → 1.2.294

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
 
@@ -36,6 +36,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
36
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
37
  - **Branch session** - fork the current conversation into a new named session from the right-click menu
38
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
39
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
40
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
41
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
@@ -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,13 +87,9 @@ 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
- /** The warning a deletion earns: with the trash setting on, a refused move
93
- * may remove the conversations permanently or leave them in place, so
94
- * neither the verb nor the note may promise one outcome. */
95
- private get _irreversibleNote();
96
- private get _disposalVerb();
97
93
  private _removeProject;
98
94
  private _cleanupParallel;
99
95
  /** This provider's launch-mode token for one action - see
@@ -316,6 +312,13 @@ export declare class AssistantSessionsPanel extends Widget {
316
312
  * all. Returning null lets the popup fall back to its fork marker. */
317
313
  private _bgBadgeFor;
318
314
  private _startPolling;
315
+ /** The server says this assistant's sessions changed (ACC-CLI-227): read
316
+ * them through the poll's single-flight fetch. Unlike a poll tick it is
317
+ * never dropped. It waits for an open row context menu to close, and one
318
+ * that lands while a read is in flight reads once more after it, because
319
+ * that read may have been answered before the change. */
320
+ sessionsChanged(): void;
321
+ private _poll;
319
322
  private _stopPolling;
320
323
  private readonly _app;
321
324
  private readonly _descriptor;
@@ -375,9 +378,13 @@ export declare class AssistantSessionsPanel extends Widget {
375
378
  private _activeSession;
376
379
  private _activeRowEl;
377
380
  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. */
381
+ /** The poll's own in-flight tick. Only the poll and a pushed change read it
382
+ * - an explicit Refresh must always issue, however long the last poll is
383
+ * taking. */
380
384
  private _polling;
385
+ /** A pushed change waits for the context menu to close or for the read in
386
+ * flight to answer - see `sessionsChanged`. */
387
+ private _changeWaits;
381
388
  /** Listing requests sent, and the number of the newest one drawn - see
382
389
  * `_fetch`. */
383
390
  private _fetchSent;
package/lib/core/panel.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
1
+ import { Clipboard, Dialog, InputDialog, Notification } from '@jupyterlab/apputils';
2
2
  import { MenuSvg, closeIcon, copyIcon, editIcon, folderIcon, terminalIcon } from '@jupyterlab/ui-components';
3
3
  import { nullTranslator } from '@jupyterlab/translation';
4
4
  import { CommandRegistry } from '@lumino/commands';
@@ -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
@@ -677,27 +701,33 @@ export class AssistantSessionsPanel extends Widget {
677
701
  this._showActionError('Could not toggle favorite - try again.', err);
678
702
  }
679
703
  }
680
- /** The warning a deletion earns: with the trash setting on, a refused move
681
- * may remove the conversations permanently or leave them in place, so
682
- * neither the verb nor the note may promise one outcome. */
683
- get _irreversibleNote() {
684
- return this._deleteToTrash
685
- ? ' A permanent removal cannot be undone.'
686
- : ' This cannot be undone.';
687
- }
688
- get _disposalVerb() {
689
- return this._deleteToTrash
690
- ? 'moved to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
691
- : 'deleted permanently';
692
- }
693
704
  async _removeProject(session) {
694
705
  var _a;
695
706
  const name = this._lookupName(session);
696
- const confirm = await showDialog({
697
- title: `Remove from ${this._descriptor.label}`,
698
- body: `Remove "${name}" from ${this._descriptor.label}? This drops the ` +
699
- `entire project history and every conversation it holds - ` +
700
- `${this._disposalVerb}.${this._irreversibleNote}`,
707
+ const title = `Remove from ${this._descriptor.label}`;
708
+ // The consequence only, in two lines: the question and the outcome, then
709
+ // the permanent-deletion refusal on its own caution line
710
+ // (DEF-PANE-371-DIALOG-TEXT).
711
+ const outcome = this._deleteToTrash
712
+ ? `Remove "${name}"? Every conversation in it goes to the trash.`
713
+ : `Remove "${name}"? Every conversation in it is deleted permanently.`;
714
+ const caution = this._deleteToTrash
715
+ ? 'A refused move deletes permanently.'
716
+ : '';
717
+ const body = new Widget();
718
+ body.node.className = 'jp-AiAssistantsPanel-removeConfirm';
719
+ const question = document.createElement('p');
720
+ question.textContent = outcome;
721
+ body.node.appendChild(question);
722
+ if (caution) {
723
+ const line = document.createElement('p');
724
+ line.className = 'jp-AiAssistantsPanel-removeCaution';
725
+ line.textContent = caution;
726
+ body.node.appendChild(line);
727
+ }
728
+ const dialog = new Dialog({
729
+ title,
730
+ body,
701
731
  buttons: [
702
732
  Dialog.cancelButton(),
703
733
  Dialog.warnButton({ label: this._trans.__('Remove') })
@@ -710,6 +740,9 @@ export class AssistantSessionsPanel extends Widget {
710
740
  defaultButton: 0,
711
741
  renderer: dialogRenderer
712
742
  });
743
+ // A Widget body leaves the Dialog no string to name itself from.
744
+ dialog.node.setAttribute('aria-label', [title, outcome, caution].filter(part => part).join(' '));
745
+ const confirm = await dialog.launch();
713
746
  if (!confirm.button.accept) {
714
747
  return;
715
748
  }
@@ -768,7 +801,7 @@ export class AssistantSessionsPanel extends Widget {
768
801
  caution.className = 'jp-AiAssistantsPanel-cleanupCaution';
769
802
  caution.textContent = this._deleteToTrash
770
803
  ? 'A refused trash move deletes permanently and cannot be undone.'
771
- : this._irreversibleNote.trim();
804
+ : 'This cannot be undone.';
772
805
  confirmBody.node.appendChild(caution);
773
806
  const title = this._trans.__('Clean Up Parallel Sessions');
774
807
  let warning = null;
@@ -783,9 +816,11 @@ export class AssistantSessionsPanel extends Widget {
783
816
  defaultButton: 0,
784
817
  renderer: dialogRenderer
785
818
  });
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.
819
+ // Scopes the armed style in base.css to this one dialog.
820
+ confirmDialog.addClass('jp-AiAssistantsPanel-cleanupDialog');
821
+ // The Dialog names itself only when its body is a string, and this body is
822
+ // a Widget: name it here, and again when the question is restated and the
823
+ // warning joins it.
789
824
  const nameDialog = () => {
790
825
  var _a, _b, _c, _d;
791
826
  confirmDialog.node.setAttribute('aria-label', [
@@ -799,6 +834,105 @@ export class AssistantSessionsPanel extends Widget {
799
834
  .join(' '));
800
835
  };
801
836
  nameDialog();
837
+ // The house two-step destructive action (design-language.md, "Two-step
838
+ // destructive action"; the branch popup's Delete is the shipped form):
839
+ // while the newer state is unknown or newer, the accept button arms on the
840
+ // first activation and removes on the second, the arm lapsing after 4 s.
841
+ // It starts required: the branch fetch that decides whether a removal is
842
+ // safe is still out when the dialog opens.
843
+ let armRequired = true;
844
+ let armed = false;
845
+ let armTimer = 0;
846
+ // The arming activation's event.timeStamp, which the confirming one is
847
+ // measured against.
848
+ let armedAt = 0;
849
+ /** Find and relabel the accept button. The Dialog keeps its button nodes
850
+ * private and puts the label in a child whose aria-label overrides its
851
+ * text, so both are set. */
852
+ const updateAccept = () => {
853
+ const button = confirmDialog.node.querySelector('.jp-mod-accept');
854
+ const label = button === null || button === void 0 ? void 0 : button.querySelector('.jp-Dialog-buttonLabel');
855
+ if (!button || !label) {
856
+ return;
857
+ }
858
+ const text = armed
859
+ ? this._trans.__('Confirm remove %1', extra)
860
+ : armRequired
861
+ ? this._trans.__('Remove (%1)', extra)
862
+ : this._trans.__('Remove');
863
+ label.textContent = text;
864
+ label.setAttribute('aria-label', text);
865
+ button.classList.toggle('jp-mod-armed', armed);
866
+ };
867
+ const disarm = () => {
868
+ armed = false;
869
+ window.clearTimeout(armTimer);
870
+ };
871
+ /** Consume one accept activation. True means it only armed, or was the
872
+ * ignored half of a double-click, and the Dialog must not resolve; false
873
+ * lets the Dialog's own handler resolve it. */
874
+ const activate = (event) => {
875
+ if (!armRequired) {
876
+ return false;
877
+ }
878
+ // The second click of a double-click is not a confirmation (DEF-PANE-336):
879
+ // a multi-click within 600 ms of arming is ignored. A later click
880
+ // confirms whatever its detail, since the OS double-click interval can
881
+ // be longer than the user's pause (DEF-PANE-338).
882
+ if (armed && event.detail > 1 && event.timeStamp - armedAt < 600) {
883
+ return true;
884
+ }
885
+ if (!armed) {
886
+ armed = true;
887
+ armedAt = event.timeStamp;
888
+ window.clearTimeout(armTimer);
889
+ armTimer = window.setTimeout(() => {
890
+ disarm();
891
+ updateAccept();
892
+ }, 4000);
893
+ updateAccept();
894
+ return true;
895
+ }
896
+ disarm();
897
+ return false;
898
+ };
899
+ // Registered before launch(): the Dialog attaches its own capture
900
+ // listeners on this same node when launch() attaches it, so these run
901
+ // first and can swallow an activation that must only arm. Space arrives
902
+ // as a click; Enter arrives as a keydown the Dialog would otherwise
903
+ // resolve from, so both are intercepted here.
904
+ confirmDialog.node.addEventListener('click', event => {
905
+ const button = confirmDialog.node.querySelector('.jp-mod-accept');
906
+ if (!button || !button.contains(event.target)) {
907
+ return;
908
+ }
909
+ if (activate(event)) {
910
+ event.stopImmediatePropagation();
911
+ event.preventDefault();
912
+ }
913
+ }, true);
914
+ confirmDialog.node.addEventListener('keydown', event => {
915
+ if (event.key !== 'Enter') {
916
+ return;
917
+ }
918
+ const accept = confirmDialog.node.querySelector('.jp-mod-accept');
919
+ if (!accept || document.activeElement !== accept) {
920
+ return;
921
+ }
922
+ // A held key's auto-repeat is not a second press (DEF-PANE-347): it
923
+ // must neither confirm nor arm, and consuming it stops the Dialog's
924
+ // own Enter handler from resolving the accept.
925
+ if (event.repeat) {
926
+ event.stopImmediatePropagation();
927
+ event.preventDefault();
928
+ return;
929
+ }
930
+ if (activate(event)) {
931
+ event.stopImmediatePropagation();
932
+ event.preventDefault();
933
+ }
934
+ }, true);
935
+ updateAccept();
802
936
  // Alongside the question, never in front of it: the request layer allows
803
937
  // 60 s, and the confirmation must open at once. When the list lands the
804
938
  // question states its length and a conversation newer than the kept one
@@ -830,6 +964,13 @@ export class AssistantSessionsPanel extends Widget {
830
964
  'active after the one kept.';
831
965
  confirmBody.node.appendChild(warning);
832
966
  }
967
+ // The list just decided what the button acts on: an arm given while
968
+ // it was pending could confirm a different set, so drop it and
969
+ // restate the label with the fetched count. With no newer branch the
970
+ // button returns to the one-click Remove.
971
+ disarm();
972
+ armRequired = newer > 0;
973
+ updateAccept();
833
974
  nameDialog();
834
975
  }
835
976
  return fetched;
@@ -899,7 +1040,12 @@ export class AssistantSessionsPanel extends Widget {
899
1040
  // The kept listing names conversations that may have just gone.
900
1041
  this._branchCache = null;
901
1042
  bar.value = 1;
902
- message.textContent = `Removed ${data.removed_count} parallel session${data.removed_count === 1 ? '' : 's'}.`;
1043
+ message.textContent = [
1044
+ `Removed ${data.removed_count} parallel session${data.removed_count === 1 ? '' : 's'}.`,
1045
+ keptCopy(data.live_ids)
1046
+ ]
1047
+ .filter(part => part)
1048
+ .join(' ');
903
1049
  // Refresh so the row's conversation count and menu label update.
904
1050
  await this._fetch();
905
1051
  }
@@ -1502,6 +1648,10 @@ export class AssistantSessionsPanel extends Widget {
1502
1648
  await this._colours.forget((_a = result.removed_ids) !== null && _a !== void 0 ? _a : sessionIds);
1503
1649
  // The kept listing names conversations that may have just gone.
1504
1650
  this._branchCache = null;
1651
+ const kept = keptCopy(result.live_ids);
1652
+ if (kept) {
1653
+ Notification.info(kept, { autoClose: 4000 });
1654
+ }
1505
1655
  return result.removed_count;
1506
1656
  }
1507
1657
  catch (err) {
@@ -2420,6 +2570,11 @@ export class AssistantSessionsPanel extends Widget {
2420
2570
  // callback still needs to read it. The field is overwritten on the next
2421
2571
  // contextmenu open.
2422
2572
  this._setActiveRow(null);
2573
+ // A change pushed while the menu was open reads now. A microtask later,
2574
+ // because the menu is still attached while this signal fires.
2575
+ if (this._changeWaits) {
2576
+ queueMicrotask(() => this.sessionsChanged());
2577
+ }
2423
2578
  });
2424
2579
  }
2425
2580
  _menu(commands = this._commands) {
@@ -2698,13 +2853,36 @@ export class AssistantSessionsPanel extends Widget {
2698
2853
  if (this._polling !== null) {
2699
2854
  return;
2700
2855
  }
2701
- this._polling = this._fetch()
2702
- .catch(err => this._showError(err))
2703
- .finally(() => {
2704
- this._polling = null;
2705
- });
2856
+ this._poll();
2706
2857
  }, POLL_INTERVAL_MS);
2707
2858
  }
2859
+ /** The server says this assistant's sessions changed (ACC-CLI-227): read
2860
+ * them through the poll's single-flight fetch. Unlike a poll tick it is
2861
+ * never dropped. It waits for an open row context menu to close, and one
2862
+ * that lands while a read is in flight reads once more after it, because
2863
+ * that read may have been answered before the change. */
2864
+ sessionsChanged() {
2865
+ if (this.isDisposed) {
2866
+ return;
2867
+ }
2868
+ if (this._contextMenu.isAttached || this._polling !== null) {
2869
+ this._changeWaits = true;
2870
+ return;
2871
+ }
2872
+ this._poll();
2873
+ }
2874
+ _poll() {
2875
+ // A read sent now answers every change pushed before it.
2876
+ this._changeWaits = false;
2877
+ this._polling = this._fetch()
2878
+ .catch(err => this._showError(err))
2879
+ .finally(() => {
2880
+ this._polling = null;
2881
+ if (this._changeWaits) {
2882
+ this.sessionsChanged();
2883
+ }
2884
+ });
2885
+ }
2708
2886
  _stopPolling() {
2709
2887
  if (this._pollHandle !== null) {
2710
2888
  window.clearInterval(this._pollHandle);
package/lib/core/popup.js CHANGED
@@ -91,7 +91,7 @@ export function showManageSessionsPopup(options) {
91
91
  deleteBtn.type = 'button';
92
92
  deleteBtn.className = 'jp-AiAssistantsPanel-branchDelete';
93
93
  deleteBtn.title = options.deleteToTrash
94
- ? 'Deleted conversations move to the trash when the system accepts them; a refused move may remove them permanently or leave them in place'
94
+ ? 'Deleted conversations go to the trash; a refused move deletes permanently.'
95
95
  : 'Deleted conversations are removed permanently';
96
96
  footer.appendChild(deleteBtn);
97
97
  // Plain visual counter (selection, or the last action), beside the button
@@ -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;