jupyterlab_ai_code_assistants_extension 1.2.199 → 1.2.271

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
@@ -32,7 +32,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
32
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
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
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) - switching to a conversation makes plain `claude -c` in that project resume that same conversation, including one that has been compacted, which Claude Code otherwise refuses to continue. Launching repairs a compacted conversation the same way, but only switching moves what `claude -c` picks
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
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
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
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
@@ -45,6 +45,9 @@ export declare class AssistantSessionsPanel extends Widget {
45
45
  protected onBeforeHide(_msg: Message): void;
46
46
  protected onCloseRequest(msg: Message): void;
47
47
  private _buildShell;
48
+ /** The press in the list has ended. The click it sends is dispatched in the
49
+ * same task as this release, so the waiting redraw runs in the next one. */
50
+ private _onRelease;
48
51
  /** Show / hide the filter input. Hiding also clears the active filter, so
49
52
  * the user never returns to an "invisible" filter narrowing the rows. */
50
53
  private _toggleFilterBar;
@@ -134,13 +137,19 @@ export declare class AssistantSessionsPanel extends Widget {
134
137
  * The tile's whole click path lives here rather than in `src/index.ts`
135
138
  * because every input it needs is already the panel's: the root join, the
136
139
  * listing fetch, the terminal reuse ladder and the settings-resolved launch
137
- * mode. Nothing here retries and nothing is scheduled: a guard that refuses
138
- * shows one notification and launches nothing, and a request that fails
139
- * writes nothing.
140
+ * mode. Nothing here retries: a guard that refuses shows one notification
141
+ * and launches nothing, and a request that fails writes nothing. Past the
142
+ * guards the path waits for the tile launches of every assistant and the
143
+ * launches of this panel clicked before it, and every launch of any panel
144
+ * clicked after it waits for it (DEF-PANE-345).
140
145
  *
141
146
  * `cwd` is a Contents-API path, `''` for the root.
142
147
  */
143
148
  launchHere(cwd?: string): Promise<any>;
149
+ /** The tile's click path past its guards, run after the tile launches of
150
+ * every assistant and the launches of this panel clicked before it, and
151
+ * before every launch clicked after it (DEF-PANE-345). */
152
+ private _launchIn;
144
153
  /** Start a brand-new conversation in the file browser's current folder. */
145
154
  private _newSession;
146
155
  /** Branch the active row's current conversation. Every `forkStrategy` has
@@ -180,8 +189,28 @@ export declare class AssistantSessionsPanel extends Widget {
180
189
  * per conversation may have none to rewrite yet - so it gets words rather
181
190
  * than the raw code. */
182
191
  private _renameError;
183
- /** Make another conversation the row's current one. */
192
+ /** Make another conversation the row's current one. `click` is the number
193
+ * of the action that asked: a row click carries the number it took when it
194
+ * was pressed, so when its open resolves after a newer row click, an
195
+ * explicit switch or a fork, it leaves that newer decision in place and
196
+ * posts nothing (DEF-PANE-349). The decision is posted through the project's
197
+ * chain, so its write lands after every switch enqueued before it. */
184
198
  private _switchBranch;
199
+ /** Queue a switch post for `session`'s project behind every post already
200
+ * queued for it, so the server receives the writes in the order the user
201
+ * acted and the last one to land is the newest decision (DEF-PANE-349).
202
+ *
203
+ * A task reads the newest recorded decision when it runs: a row click's
204
+ * task posts it only if no task has posted that click yet, so clicks that
205
+ * queue behind one another collapse to the newest. A fork correction is
206
+ * forced - the fork's own pin write landed after the last post, so the
207
+ * newest decision has to be written again even when it is already marked
208
+ * posted. A chain entry is removed when its last task settles. */
209
+ private _enqueueSwitch;
210
+ /** Post `sessionId` as the project's current conversation and report the
211
+ * outcome. Neither reads nor writes the project's token: the project's
212
+ * chain chooses the id from the recorded decision - see `_enqueueSwitch`. */
213
+ private _postSwitch;
185
214
  /** Delete conversations of the active row's project. Resolves with the
186
215
  * removed count, or null on a failure already reported to the user. */
187
216
  private _deleteBranches;
@@ -332,12 +361,35 @@ export declare class AssistantSessionsPanel extends Widget {
332
361
  /** The last branch listing fetched, and the project it belongs to. Read only
333
362
  * when this open's own fetch did not arrive in time. */
334
363
  private _branchCache;
364
+ /** The number and session id of the newest action that decided each
365
+ * project's current conversation, by encoded path - see `_switchBranch`
366
+ * (DEF-PANE-349). The id is kept so a fork's late server writes can be
367
+ * corrected by re-posting the newer decision. */
368
+ private _currentTokens;
369
+ /** The switch posts queued per project, by encoded path, and the click
370
+ * number each project's chain posted last - see `_enqueueSwitch`. A chain
371
+ * entry is removed when its last task settles; the posted number stays, so a
372
+ * later task can tell an already-posted decision from a new one. */
373
+ private _switchChains;
374
+ private _postedClicks;
335
375
  private _activeSession;
336
376
  private _activeRowEl;
337
377
  private _pollHandle;
338
378
  /** The poll's own in-flight tick. Only the poll reads it - an explicit
339
379
  * Refresh must always issue, however long the last poll is taking. */
340
380
  private _polling;
381
+ /** Listing requests sent, and the number of the newest one drawn - see
382
+ * `_fetch`. */
383
+ private _fetchSent;
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;
389
+ /** A button is pressed in the list, and a redraw waits for its release -
390
+ * see `_onRelease`. */
391
+ private _pressed;
392
+ private _redrawWaits;
341
393
  private _presentationMode;
342
394
  private _recentLimit;
343
395
  private _modes;
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';
@@ -71,6 +71,16 @@ export const MOVE_PANELS_COMMAND = 'ai-code-assistants:move-panels';
71
71
  * order the answers arrived, and the file browser could end on the session
72
72
  * opened first. */
73
73
  let fileBrowserMoves = Promise.resolve();
74
+ /** The session opens of every panel, numbered in click order, and the number
75
+ * of the newest one whose move ran. An open that finishes after the open of a
76
+ * later click does not move the file browser, so it ends on the folder of the
77
+ * newest click that opened (DEF-PANE-343). The same counter orders the writes
78
+ * that decide a project's current conversation: a row click, an explicit
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). */
82
+ let openClicks = 0;
83
+ let movedClick = 0;
74
84
  function loadExpanded(storageKey) {
75
85
  try {
76
86
  const raw = window.localStorage.getItem(storageKey);
@@ -109,6 +119,19 @@ export class AssistantSessionsPanel extends Widget {
109
119
  constructor(options) {
110
120
  var _a, _b, _c, _d, _e;
111
121
  super();
122
+ /** The press in the list has ended. The click it sends is dispatched in the
123
+ * same task as this release, so the waiting redraw runs in the next one. */
124
+ this._onRelease = () => {
125
+ if (!this._pressed) {
126
+ return;
127
+ }
128
+ this._pressed = false;
129
+ window.setTimeout(() => {
130
+ if (this._redrawWaits && !this.isDisposed) {
131
+ this._render();
132
+ }
133
+ }, 0);
134
+ };
112
135
  this._rootDir = '';
113
136
  this._deleteToTrash = true;
114
137
  this._removingPaths = new Set();
@@ -136,12 +159,35 @@ export class AssistantSessionsPanel extends Widget {
136
159
  /** The last branch listing fetched, and the project it belongs to. Read only
137
160
  * when this open's own fetch did not arrive in time. */
138
161
  this._branchCache = null;
162
+ /** The number and session id of the newest action that decided each
163
+ * project's current conversation, by encoded path - see `_switchBranch`
164
+ * (DEF-PANE-349). The id is kept so a fork's late server writes can be
165
+ * corrected by re-posting the newer decision. */
166
+ this._currentTokens = new Map();
167
+ /** The switch posts queued per project, by encoded path, and the click
168
+ * number each project's chain posted last - see `_enqueueSwitch`. A chain
169
+ * entry is removed when its last task settles; the posted number stays, so a
170
+ * later task can tell an already-posted decision from a new one. */
171
+ this._switchChains = new Map();
172
+ this._postedClicks = new Map();
139
173
  this._activeSession = null;
140
174
  this._activeRowEl = null;
141
175
  this._pollHandle = null;
142
176
  /** The poll's own in-flight tick. Only the poll reads it - an explicit
143
177
  * Refresh must always issue, however long the last poll is taking. */
144
178
  this._polling = null;
179
+ /** Listing requests sent, and the number of the newest one drawn - see
180
+ * `_fetch`. */
181
+ this._fetchSent = 0;
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();
187
+ /** A button is pressed in the list, and a redraw waits for its release -
188
+ * see `_onRelease`. */
189
+ this._pressed = false;
190
+ this._redrawWaits = false;
145
191
  this._presentationMode = DEFAULT_PRESENTATION_MODE;
146
192
  this._recentLimit = DEFAULT_RECENT_LIMIT;
147
193
  this._modes = {};
@@ -184,6 +230,8 @@ export class AssistantSessionsPanel extends Widget {
184
230
  return this._descriptor;
185
231
  }
186
232
  dispose() {
233
+ document.removeEventListener('pointerup', this._onRelease, true);
234
+ document.removeEventListener('pointercancel', this._onRelease, true);
187
235
  this._stopPolling();
188
236
  // Disabling a provider must leave its running terminals alive and only
189
237
  // drop the tint this panel applied.
@@ -407,6 +455,15 @@ export class AssistantSessionsPanel extends Widget {
407
455
  this._bodyEl = body;
408
456
  this._loadingEl = loading;
409
457
  this._errorEl = error;
458
+ // A row drawn again between the press and the release of a click loses
459
+ // the click: the browser sends it to the list the two rows share, not to
460
+ // the row (DEF-PANE-346). A redraw asked for while a button is pressed in
461
+ // the list waits until that click has been sent.
462
+ body.addEventListener('pointerdown', () => {
463
+ this._pressed = true;
464
+ });
465
+ document.addEventListener('pointerup', this._onRelease, true);
466
+ document.addEventListener('pointercancel', this._onRelease, true);
410
467
  }
411
468
  /** Show / hide the filter input. Hiding also clears the active filter, so
412
469
  * the user never returns to an "invisible" filter narrowing the rows. */
@@ -578,10 +635,19 @@ export class AssistantSessionsPanel extends Widget {
578
635
  // ------------------------------------------------------------------- data
579
636
  async _fetch() {
580
637
  var _a;
638
+ // This request's place in the order. Requests answer in any order, and an
639
+ // answer older than the one already drawn is dropped (DEF-PANE-344).
640
+ const request = ++this._fetchSent;
581
641
  // `cache: 'no-store'` so a manual refresh always re-reads the server's
582
642
  // view rather than a possibly stale browser-cached response.
583
643
  const data = await requestProvider(this._descriptor.id, 'sessions', this._serverSettings, { cache: 'no-store' });
584
- this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
644
+ if (request < this._fetchDrawn) {
645
+ return;
646
+ }
647
+ this._fetchDrawn = request;
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; });
585
651
  this._setInlineError(null);
586
652
  this._terminals.setSessions(this._sessions);
587
653
  this._render();
@@ -600,6 +666,10 @@ export class AssistantSessionsPanel extends Widget {
600
666
  favourite: next
601
667
  })
602
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);
603
673
  }
604
674
  catch (err) {
605
675
  session.favourite = !next; // roll back
@@ -652,6 +722,11 @@ export class AssistantSessionsPanel extends Widget {
652
722
  });
653
723
  // Drop locally; the next poll reconciles against disk.
654
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);
655
730
  }
656
731
  catch (err) {
657
732
  this._showActionError(`Could not remove "${name}" - try again.`, err);
@@ -753,15 +828,32 @@ export class AssistantSessionsPanel extends Widget {
753
828
  _launchMode(force) {
754
829
  return resolveLaunchMode(this._descriptor.launchModes, this._modes, force);
755
830
  }
756
- async _openSession(session, force, sessionId) {
831
+ async _openSession(session, force, sessionId, makeCurrent = false) {
757
832
  // The click's own feedback, raised before the terminal walk and the
758
833
  // launch - both can take seconds (DEF-PANE-318). The launch's title; the
759
834
  // launch joins this dialog through the token.
760
835
  const busy = beginSpinner(this._trans.__('Opening %1', this._descriptor.label), this._trans);
836
+ const click = ++openClicks;
837
+ const mode = this._launchMode(force);
761
838
  try {
762
- await this._terminals.openSession(session, this._launchMode(force), sessionId, busy);
839
+ await this._terminals.inClickOrder(() => this._terminals.openSession(session, mode, sessionId, busy));
840
+ if (makeCurrent) {
841
+ // The row's own controls make that conversation the project's current
842
+ // one - the pin and the `claude -c` mtime lead (DEF-PANE-349). Not
843
+ // awaited: the open has succeeded, and the switch is not part of it.
844
+ // It carries this click's number, so a switch or fork made after this
845
+ // click wins even when this open finishes last. Other launches leave
846
+ // `claude -c` alone.
847
+ void this._switchBranch(session, session.session_id, click);
848
+ }
763
849
  // Not awaited: the open has succeeded, and the sync is not part of it.
764
- fileBrowserMoves = fileBrowserMoves.then(() => this._moveFileBrowserTo(session));
850
+ fileBrowserMoves = fileBrowserMoves.then(() => {
851
+ if (click < movedClick) {
852
+ return;
853
+ }
854
+ movedClick = click;
855
+ return this._moveFileBrowserTo(session);
856
+ });
765
857
  }
766
858
  catch (err) {
767
859
  if (isResponseStatus(err, 404)) {
@@ -861,14 +953,15 @@ export class AssistantSessionsPanel extends Widget {
861
953
  * The tile's whole click path lives here rather than in `src/index.ts`
862
954
  * because every input it needs is already the panel's: the root join, the
863
955
  * listing fetch, the terminal reuse ladder and the settings-resolved launch
864
- * mode. Nothing here retries and nothing is scheduled: a guard that refuses
865
- * shows one notification and launches nothing, and a request that fails
866
- * writes nothing.
956
+ * mode. Nothing here retries: a guard that refuses shows one notification
957
+ * and launches nothing, and a request that fails writes nothing. Past the
958
+ * guards the path waits for the tile launches of every assistant and the
959
+ * launches of this panel clicked before it, and every launch of any panel
960
+ * clicked after it waits for it (DEF-PANE-345).
867
961
  *
868
962
  * `cwd` is a Contents-API path, `''` for the root.
869
963
  */
870
964
  async launchHere(cwd) {
871
- var _a, _b;
872
965
  // No roster yet, so the root is unknown. Joining onto an empty one names a
873
966
  // folder at the filesystem root, which the server accepts whenever it
874
967
  // happens to exist (ACC-LNCH-148, DEF-132).
@@ -891,7 +984,16 @@ export class AssistantSessionsPanel extends Widget {
891
984
  return undefined;
892
985
  }
893
986
  const folder = this._currentFolder(cwd);
987
+ return this._terminals.tileInClickOrder(() => this._launchIn(folder));
988
+ }
989
+ /** The tile's click path past its guards, run after the tile launches of
990
+ * every assistant and the launches of this panel clicked before it, and
991
+ * before every launch clicked after it (DEF-PANE-345). */
992
+ async _launchIn(folder) {
993
+ var _a, _b, _c, _d, _e;
894
994
  let argv;
995
+ // The conversation the new terminal runs, when its id is known here.
996
+ let conversation;
895
997
  try {
896
998
  // Every click asks the server for the listing (ACC-LNCH-151). `_sessions`
897
999
  // is written by `_fetch` and by the optimistic remove in
@@ -910,10 +1012,13 @@ export class AssistantSessionsPanel extends Widget {
910
1012
  // The folder is required as well: a transcript re-homed into another
911
1013
  // project keeps its id, and that terminal is not this folder's
912
1014
  // (DEF-PANE-319).
913
- const found = await this._terminals.findForSession(row.session_id, folder);
1015
+ // The terminal a launch before this click opened for it counts too,
1016
+ // before the server can read the conversation off its process
1017
+ // (DEF-PANE-345).
1018
+ 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;
914
1019
  if (found) {
915
- this._terminals.focus(found.widget);
916
- return found.widget;
1020
+ this._terminals.focus(found);
1021
+ return found;
917
1022
  }
918
1023
  }
919
1024
  const request = {
@@ -929,6 +1034,7 @@ export class AssistantSessionsPanel extends Widget {
929
1034
  };
930
1035
  const answer = await requestProvider(this._descriptor.id, 'launch-argv', this._serverSettings, { method: 'POST', body: JSON.stringify(request) });
931
1036
  argv = answer.argv;
1037
+ conversation = (_e = request.session_id) !== null && _e !== void 0 ? _e : request.new_session_id;
932
1038
  }
933
1039
  catch (err) {
934
1040
  const message = err instanceof Error ? err.message : String(err);
@@ -952,10 +1058,14 @@ export class AssistantSessionsPanel extends Widget {
952
1058
  // and the Launcher keeps its tab (ACC-LNCH-157 covers the success path
953
1059
  // only).
954
1060
  try {
955
- return await this._app.commands.execute(BASIC_TERMINAL_LAUNCH, {
1061
+ const widget = await this._app.commands.execute(BASIC_TERMINAL_LAUNCH, {
956
1062
  argv,
957
1063
  cwd: folder
958
1064
  });
1065
+ if (widget === null || widget === void 0 ? void 0 : widget.id) {
1066
+ this._terminals.remember(folder, widget, conversation);
1067
+ }
1068
+ return widget;
959
1069
  }
960
1070
  catch (err) {
961
1071
  this._notifyLaunchError('Could not open a terminal for this folder', err);
@@ -977,13 +1087,26 @@ export class AssistantSessionsPanel extends Widget {
977
1087
  // store key lets the server clear that pin so the new conversation is not
978
1088
  // shadowed by a stale one (DEF-9).
979
1089
  const existing = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).find(s => s.project_path === projectPath);
1090
+ if (existing) {
1091
+ // The + click is the project's newest decision: a row open still
1092
+ // finishing posts no switch over it (DEF-PANE-355). It has no id to post;
1093
+ // the launch route clears the pin.
1094
+ this._currentTokens.set(existing.encoded_path, {
1095
+ click: ++openClicks,
1096
+ sessionId: ''
1097
+ });
1098
+ }
1099
+ // Raised on the click, not by the launch: the launch waits for a tile
1100
+ // launch clicked before it (DEF-PANE-345).
1101
+ const busy = beginSpinner(this._trans.__('Opening %1', this._descriptor.label), this._trans);
1102
+ const request = {
1103
+ project_path: projectPath,
1104
+ encoded_path: existing === null || existing === void 0 ? void 0 : existing.encoded_path,
1105
+ new_session_id: newId,
1106
+ mode: this._launchMode(force)
1107
+ };
980
1108
  try {
981
- await this._terminals.launch({
982
- project_path: projectPath,
983
- encoded_path: existing === null || existing === void 0 ? void 0 : existing.encoded_path,
984
- new_session_id: newId,
985
- mode: this._launchMode(force)
986
- }, newId);
1109
+ await this._terminals.inClickOrder(() => this._terminals.launch(request, newId, busy));
987
1110
  }
988
1111
  catch (err) {
989
1112
  // Same shape as `_openSession` above - the `finally` refetches, so an
@@ -991,6 +1114,7 @@ export class AssistantSessionsPanel extends Widget {
991
1114
  this._notifyLaunchError('Could not start a new session', err);
992
1115
  }
993
1116
  finally {
1117
+ endSpinner(busy);
994
1118
  void this._fetch().catch(() => undefined);
995
1119
  }
996
1120
  }
@@ -1006,8 +1130,9 @@ export class AssistantSessionsPanel extends Widget {
1006
1130
  // (DEF-PANE-318). The menu that ran this command closed before running it,
1007
1131
  // so the dialog taking the focus closes nothing.
1008
1132
  const busy = beginSpinner(this._trans.__('Branching %1 session', this._descriptor.label), this._trans);
1133
+ const click = ++openClicks;
1009
1134
  try {
1010
- await this._branchByServerCopy(session, undefined, force, busy);
1135
+ await this._terminals.inClickOrder(() => this._branchByServerCopy(session, undefined, force, busy, click));
1011
1136
  }
1012
1137
  finally {
1013
1138
  endSpinner(busy);
@@ -1017,7 +1142,8 @@ export class AssistantSessionsPanel extends Widget {
1017
1142
  * opens - `server-copy` copies the store entry, `server-native` drives the
1018
1143
  * assistant's own non-interactive fork path - so one refresh surfaces the
1019
1144
  * branch. `spinner` is the click's token, which the launch joins. */
1020
- async _branchByServerCopy(session, name, force, spinner) {
1145
+ async _branchByServerCopy(session, name, force, spinner, click = ++openClicks) {
1146
+ var _a;
1021
1147
  // Let the parent's colour settle before the server reads it (DEF-74). The
1022
1148
  // server copies the store entry while minting the fork, so a colour picked
1023
1149
  // on the parent's tab moments earlier - still queued in this store's
@@ -1045,6 +1171,32 @@ export class AssistantSessionsPanel extends Widget {
1045
1171
  })
1046
1172
  });
1047
1173
  minted = true;
1174
+ // The fork route has already written the child's pin, so a row click
1175
+ // still opening from before the Branch must not put the parent back
1176
+ // (DEF-PANE-349). The number is the Branch click's: a row clicked after
1177
+ // it outranks it and a row clicked before it stays suppressed. A newer
1178
+ // click has already switched the project, and the fork's own writes
1179
+ // landed after the user chose that conversation, so the correction is
1180
+ // queued on the project's chain - forced, because that decision may
1181
+ // already be marked posted - and it re-posts the newest decision after
1182
+ // the fork's writes. Otherwise the guard stops a fork that answers
1183
+ // after that later row's switch from lowering the recorded number.
1184
+ const recorded = this._currentTokens.get(session.encoded_path);
1185
+ if (recorded && recorded.click > click) {
1186
+ void this._enqueueSwitch(session, true);
1187
+ }
1188
+ else if (click > ((_a = recorded === null || recorded === void 0 ? void 0 : recorded.click) !== null && _a !== void 0 ? _a : 0)) {
1189
+ this._currentTokens.set(session.encoded_path, {
1190
+ click,
1191
+ sessionId: fork.session_id
1192
+ });
1193
+ // A switch post dispatched before this fork can still land after the
1194
+ // fork route's pin write, so the fork is queued on the project's chain
1195
+ // behind it and written again once that post settles. The chain reads
1196
+ // the newest recorded decision when it runs, so a row clicked later
1197
+ // still wins.
1198
+ void this._enqueueSwitch(session, true);
1199
+ }
1048
1200
  // No colour write here. `BranchHandler` already inherited it while
1049
1201
  // minting this fork, from a read taken server-side at request time -
1050
1202
  // strictly fresher than the click-time read this would repeat, and the
@@ -1172,8 +1324,57 @@ export class AssistantSessionsPanel extends Widget {
1172
1324
  }
1173
1325
  return `Rename failed: ${err}`;
1174
1326
  }
1175
- /** Make another conversation the row's current one. */
1176
- async _switchBranch(session, sessionId) {
1327
+ /** Make another conversation the row's current one. `click` is the number
1328
+ * of the action that asked: a row click carries the number it took when it
1329
+ * was pressed, so when its open resolves after a newer row click, an
1330
+ * explicit switch or a fork, it leaves that newer decision in place and
1331
+ * posts nothing (DEF-PANE-349). The decision is posted through the project's
1332
+ * chain, so its write lands after every switch enqueued before it. */
1333
+ async _switchBranch(session, sessionId, click = ++openClicks) {
1334
+ var _a, _b;
1335
+ if (click <= ((_b = (_a = this._currentTokens.get(session.encoded_path)) === null || _a === void 0 ? void 0 : _a.click) !== null && _b !== void 0 ? _b : 0)) {
1336
+ return;
1337
+ }
1338
+ this._currentTokens.set(session.encoded_path, { click, sessionId });
1339
+ await this._enqueueSwitch(session);
1340
+ }
1341
+ /** Queue a switch post for `session`'s project behind every post already
1342
+ * queued for it, so the server receives the writes in the order the user
1343
+ * acted and the last one to land is the newest decision (DEF-PANE-349).
1344
+ *
1345
+ * A task reads the newest recorded decision when it runs: a row click's
1346
+ * task posts it only if no task has posted that click yet, so clicks that
1347
+ * queue behind one another collapse to the newest. A fork correction is
1348
+ * forced - the fork's own pin write landed after the last post, so the
1349
+ * newest decision has to be written again even when it is already marked
1350
+ * posted. A chain entry is removed when its last task settles. */
1351
+ _enqueueSwitch(session, force = false) {
1352
+ var _a;
1353
+ const key = session.encoded_path;
1354
+ const run = ((_a = this._switchChains.get(key)) !== null && _a !== void 0 ? _a : Promise.resolve()).then(async () => {
1355
+ const newest = this._currentTokens.get(key);
1356
+ // An empty id is a New Session (+) decision, which posts nothing.
1357
+ if (!(newest === null || newest === void 0 ? void 0 : newest.sessionId)) {
1358
+ return;
1359
+ }
1360
+ if (!force && this._postedClicks.get(key) === newest.click) {
1361
+ return;
1362
+ }
1363
+ this._postedClicks.set(key, newest.click);
1364
+ await this._postSwitch(session, newest.sessionId);
1365
+ });
1366
+ this._switchChains.set(key, run);
1367
+ void run.then(() => {
1368
+ if (this._switchChains.get(key) === run) {
1369
+ this._switchChains.delete(key);
1370
+ }
1371
+ });
1372
+ return run;
1373
+ }
1374
+ /** Post `sessionId` as the project's current conversation and report the
1375
+ * outcome. Neither reads nor writes the project's token: the project's
1376
+ * chain chooses the id from the recorded decision - see `_enqueueSwitch`. */
1377
+ async _postSwitch(session, sessionId) {
1177
1378
  try {
1178
1379
  const result = await requestProvider(this._descriptor.id, 'switch', this._serverSettings, {
1179
1380
  method: 'POST',
@@ -1192,8 +1393,8 @@ export class AssistantSessionsPanel extends Widget {
1192
1393
  : `Switch failed: ${err}`, { autoClose: 4000 });
1193
1394
  }
1194
1395
  finally {
1195
- // Best-effort refresh - never let a transient failure reject this
1196
- // fire-and-forget switch (callers do not await it).
1396
+ // Best-effort refresh - never let a transient failure reject the switch:
1397
+ // the user action that asked for it is fire-and-forget.
1197
1398
  await this._fetch().catch(() => undefined);
1198
1399
  }
1199
1400
  }
@@ -1299,6 +1500,11 @@ export class AssistantSessionsPanel extends Widget {
1299
1500
  }
1300
1501
  _render() {
1301
1502
  var _a, _b, _c, _d, _e;
1503
+ if (this._pressed) {
1504
+ this._redrawWaits = true;
1505
+ return;
1506
+ }
1507
+ this._redrawWaits = false;
1302
1508
  const sessions = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
1303
1509
  // Capture scrollTop per section so a polling refresh does not reset the
1304
1510
  // user's place. The body scrolls too in short windows - clearing innerHTML
@@ -1542,7 +1748,9 @@ export class AssistantSessionsPanel extends Widget {
1542
1748
  if (removing) {
1543
1749
  return;
1544
1750
  }
1545
- void this._openSession(session);
1751
+ // The row's own controls also make the conversation current, rather
1752
+ // than only opening it (DEF-PANE-349).
1753
+ void this._openSession(session, undefined, undefined, true);
1546
1754
  });
1547
1755
  row.addEventListener('contextmenu', e => {
1548
1756
  e.preventDefault();
@@ -1591,7 +1799,9 @@ export class AssistantSessionsPanel extends Widget {
1591
1799
  if (e.key === 'Enter' || e.key === ' ') {
1592
1800
  // Space would scroll the list otherwise.
1593
1801
  e.preventDefault();
1594
- void this._openSession(session);
1802
+ // The keyboard's row click, so it makes the conversation current
1803
+ // exactly as the pointer's does (DEF-PANE-349).
1804
+ void this._openSession(session, undefined, undefined, true);
1595
1805
  return;
1596
1806
  }
1597
1807
  // The two keyboard routes to a context menu. Anchored on the row's own
@@ -1913,6 +2123,7 @@ export class AssistantSessionsPanel extends Widget {
1913
2123
  });
1914
2124
  this._commands.addCommand(this._cmd('copy-path'), {
1915
2125
  label: this._trans.__('Copy Path'),
2126
+ icon: copyIcon,
1916
2127
  execute: () => {
1917
2128
  const s = active();
1918
2129
  if (s) {
@@ -1922,6 +2133,7 @@ export class AssistantSessionsPanel extends Widget {
1922
2133
  });
1923
2134
  this._commands.addCommand(this._cmd('copy-session-id'), {
1924
2135
  label: this._trans.__('Copy Session ID'),
2136
+ icon: copyIcon,
1925
2137
  execute: () => {
1926
2138
  var _a;
1927
2139
  const id = (_a = active()) === null || _a === void 0 ? void 0 : _a.session_id;
@@ -1961,6 +2173,9 @@ export class AssistantSessionsPanel extends Widget {
1961
2173
  // item beside an ordinary one (DEF-45). The sweep variant of the trash
1962
2174
  // glyph, because this one deletes several at once.
1963
2175
  icon: cleanupIcon,
2176
+ // Both history-deleting entries draw their icon in the error colour
2177
+ // (base.css).
2178
+ className: 'jp-AiAssistantsContextMenu-danger',
1964
2179
  isVisible: () => { var _a, _b; return ((_b = (_a = active()) === null || _a === void 0 ? void 0 : _a.extra_sessions) !== null && _b !== void 0 ? _b : 0) > 0; },
1965
2180
  execute: () => {
1966
2181
  const s = active();
@@ -2057,6 +2272,7 @@ export class AssistantSessionsPanel extends Widget {
2057
2272
  this._commands.addCommand(this._cmd('remove'), {
2058
2273
  label: `Remove from ${this._descriptor.label}`,
2059
2274
  icon: removeIcon,
2275
+ className: 'jp-AiAssistantsContextMenu-danger',
2060
2276
  execute: () => {
2061
2277
  const s = active();
2062
2278
  if (s) {