jupyterlab_ai_code_assistants_extension 1.2.71 → 1.2.83

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.
Files changed (42) hide show
  1. package/README.md +7 -6
  2. package/lib/core/colour.d.ts +2 -10
  3. package/lib/core/colour.js +8 -18
  4. package/lib/core/modes.d.ts +2 -2
  5. package/lib/core/panel.d.ts +8 -43
  6. package/lib/core/panel.js +44 -229
  7. package/lib/core/popup.d.ts +1 -1
  8. package/lib/core/popup.js +52 -11
  9. package/lib/core/request.js +13 -6
  10. package/lib/core/terminals.d.ts +2 -33
  11. package/lib/core/terminals.js +24 -100
  12. package/lib/core/types.d.ts +13 -33
  13. package/lib/index.js +8 -0
  14. package/lib/providers/claude.js +4 -11
  15. package/lib/providers/codex.js +9 -12
  16. package/lib/providers/deepseek.d.ts +1 -2
  17. package/lib/providers/deepseek.js +14 -35
  18. package/lib/providers/gemini.js +1 -9
  19. package/lib/providers/kimi.js +0 -6
  20. package/package.json +1 -1
  21. package/src/__tests__/colour.spec.ts +11 -19
  22. package/src/__tests__/index.spec.ts +38 -5
  23. package/src/__tests__/launcher.spec.ts +1 -3
  24. package/src/__tests__/panel.spec.ts +105 -248
  25. package/src/__tests__/popup.spec.ts +105 -2
  26. package/src/__tests__/registry.spec.ts +1 -4
  27. package/src/__tests__/tab-colour-capture.spec.ts +48 -3
  28. package/src/__tests__/terminals.spec.ts +29 -143
  29. package/src/core/colour.ts +8 -23
  30. package/src/core/modes.ts +2 -2
  31. package/src/core/panel.ts +48 -272
  32. package/src/core/popup.ts +58 -12
  33. package/src/core/request.ts +13 -6
  34. package/src/core/terminals.ts +30 -118
  35. package/src/core/types.ts +13 -34
  36. package/src/index.ts +10 -2
  37. package/src/providers/claude.ts +4 -11
  38. package/src/providers/codex.ts +9 -12
  39. package/src/providers/deepseek.ts +15 -37
  40. package/src/providers/gemini.ts +1 -9
  41. package/src/providers/kimi.ts +0 -6
  42. package/style/base.css +48 -36
package/README.md CHANGED
@@ -29,16 +29,16 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
29
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
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
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 Codex or Kimi conversation started with `+`, and a Codex fork, run without an id until the CLI writes one, so a click on that row meanwhile opens a second terminal
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
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
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 and per-row open and copy-id buttons
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 `codex 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 keeps its names in a database this extension only reads, and its CLI has no rename verb, so the item is absent there rather than failing
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
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
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. 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 own tab colour wins** - set a colour on a terminal tab and the extension remembers it for that conversation, overriding whatever the assistant chose. `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 Codex or Kimi conversation that has never been resumed cannot be tracked yet, so a colour set on its tab is not kept
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
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
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
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
@@ -46,7 +46,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
46
46
  - **Search** - 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
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 browser** - the DeepSeek Harness has no terminal chat; its panel lists the projects under `~/.dsh/sessions` (or `$DSH_HOME`) and `Open Web UI` starts `dsh --profile web --no-open --port 0` in the project's terminal, which prints the URL to open. The conversation is then chosen in the harness's own browser UI; branching and deleting work from the panel as for every other assistant. The URL is on the machine running JupyterLab, so a remote server needs a port forward to reach it
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
50
  - **Auto-disabled when absent** - an assistant whose CLI is not on `PATH` does not show a panel
51
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
52
52
 
@@ -55,6 +55,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
55
55
  - JupyterLab >= 4.6.0
56
56
  - Python >= 3.10
57
57
  - At least one assistant CLI on `PATH`: `claude`, `codex`, `kimi`, `gemini`, or `dsh` (the DeepSeek Harness)
58
+ - For DeepSeek: the community TUI bundle mounted as the `tui` profile, `dsh plugin --profile tui add @deepseek-harness-tui/dsh-tui` - a DeepSeek launch without it is refused with these words before any terminal opens
58
59
 
59
60
  ## Install
60
61
 
@@ -77,7 +78,7 @@ pip install jupyterlab_ai_code_assistants_extension
77
78
 
78
79
  ## Migrating from the standalone extensions
79
80
 
80
- This package supersedes `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension`. On first run it carries their settings and favourites over automatically; uninstall the standalones once you are ready:
81
+ This package supersedes `jupyterlab_claude_code_extension`, `jupyterlab_codex_extension` and `jupyterlab_kimi_code_extension`. On first run it carries their settings and favourites over automatically, and it never replaces a setting you have already saved in this extension; uninstall the standalones once you are ready:
81
82
 
82
83
  ```bash
83
84
  pip uninstall jupyterlab_claude_code_extension jupyterlab_codex_extension jupyterlab_kimi_code_extension
@@ -95,8 +95,7 @@ export declare class ColourStore {
95
95
  * times it, not it.
96
96
  *
97
97
  * An op must never call `load`, `set` or `forget` - it would wait on a chain
98
- * that is waiting on it. `inherit` delegates to `set` from OUTSIDE the queue,
99
- * which is why it is not itself queued. */
98
+ * that is waiting on it. */
100
99
  private _run;
101
100
  /** Open a write against one or more ids. Counted, not flagged: two writes to
102
101
  * one id can be queued at once, and a flag would let the first to settle
@@ -106,8 +105,6 @@ export declare class ColourStore {
106
105
  private _closeWrite;
107
106
  /** Record a colour as this conversation's own. Passing null drops the entry,
108
107
  * which is also how a deleted conversation leaves no orphan key behind.
109
- * `handSet` false marks it inherited - written on a fork's behalf rather
110
- * than chosen on its tab.
111
108
  *
112
109
  * Answers whether the store now HOLDS the colour, which is what the capture
113
110
  * of a colour picked on a tab gates its repaint on - see `_captureColour`.
@@ -121,17 +118,12 @@ export declare class ColourStore {
121
118
  * "Did not throw" is not the test - the answer carries the whole store, so
122
119
  * it is checked for the value that was just written. A server that accepted
123
120
  * the request and then failed to persist it would otherwise be believed. */
124
- set(sessionId: string, colour: string | null, handSet?: boolean): Promise<boolean>;
121
+ set(sessionId: string, colour: string | null): Promise<boolean>;
125
122
  /** Drop stored colours - conversations that no longer exist, or overrides
126
123
  * the user released. One request for the whole list, so a release is never
127
124
  * left half applied. Same contract as `set`: the answer is checked for the
128
125
  * absence of every id, and the cache is touched only once it agrees. */
129
126
  forget(sessionIds: string[]): Promise<boolean>;
130
- /** Copy the parent's EFFECTIVE colour onto a fresh branch, so a branch of a
131
- * branch inherits whatever its immediate parent actually shows, user-set
132
- * override included. Called at the fork sites, once the new id is known.
133
- */
134
- inherit(parentColour: string | null, childId: string): Promise<void>;
135
127
  private readonly _providerId;
136
128
  private readonly _serverSettings;
137
129
  private _colours;
@@ -1,7 +1,10 @@
1
1
  // Terminal tab tint per conversation.
2
2
  //
3
3
  // Effective colour, highest wins:
4
- // 1. user-set - the write-back store this module keeps, per provider
4
+ // 1. user-set - the write-back store this module keeps, per provider.
5
+ // Only entries the server still holds: it releases a pick
6
+ // that the assistant's own later colour command overtook,
7
+ // because the newer of the two channels wins (DEF-COLO-306)
5
8
  // 2. native - the conversation's own colour, when the assistant has one
6
9
  // 3. derived - a stable hash of the conversation id
7
10
  // 4. none - no tint
@@ -186,8 +189,7 @@ export class ColourStore {
186
189
  * times it, not it.
187
190
  *
188
191
  * An op must never call `load`, `set` or `forget` - it would wait on a chain
189
- * that is waiting on it. `inherit` delegates to `set` from OUTSIDE the queue,
190
- * which is why it is not itself queued. */
192
+ * that is waiting on it. */
191
193
  _run(op) {
192
194
  const result = this._chain.then(op);
193
195
  const settled = () => undefined;
@@ -218,8 +220,6 @@ export class ColourStore {
218
220
  }
219
221
  /** Record a colour as this conversation's own. Passing null drops the entry,
220
222
  * which is also how a deleted conversation leaves no orphan key behind.
221
- * `handSet` false marks it inherited - written on a fork's behalf rather
222
- * than chosen on its tab.
223
223
  *
224
224
  * Answers whether the store now HOLDS the colour, which is what the capture
225
225
  * of a colour picked on a tab gates its repaint on - see `_captureColour`.
@@ -233,7 +233,7 @@ export class ColourStore {
233
233
  * "Did not throw" is not the test - the answer carries the whole store, so
234
234
  * it is checked for the value that was just written. A server that accepted
235
235
  * the request and then failed to persist it would otherwise be believed. */
236
- async set(sessionId, colour, handSet = true) {
236
+ async set(sessionId, colour) {
237
237
  if (!sessionId) {
238
238
  return false;
239
239
  }
@@ -249,7 +249,7 @@ export class ColourStore {
249
249
  body: JSON.stringify({
250
250
  session_id: sessionId,
251
251
  colour,
252
- hand_set: handSet
252
+ hand_set: true
253
253
  })
254
254
  });
255
255
  if (((_a = data.colours) !== null && _a !== void 0 ? _a : {})[sessionId] !== (colour !== null && colour !== void 0 ? colour : undefined)) {
@@ -265,7 +265,7 @@ export class ColourStore {
265
265
  this._colours.delete(sessionId);
266
266
  }
267
267
  this._overrides.delete(sessionId);
268
- if (colour && handSet) {
268
+ if (colour) {
269
269
  this._overrides.add(sessionId);
270
270
  }
271
271
  return true;
@@ -316,14 +316,4 @@ export class ColourStore {
316
316
  }
317
317
  });
318
318
  }
319
- /** Copy the parent's EFFECTIVE colour onto a fresh branch, so a branch of a
320
- * branch inherits whatever its immediate parent actually shows, user-set
321
- * override included. Called at the fork sites, once the new id is known.
322
- */
323
- async inherit(parentColour, childId) {
324
- if (!parentColour || !childId) {
325
- return;
326
- }
327
- await this.set(childId, parentColour, false);
328
- }
329
319
  }
@@ -15,7 +15,7 @@ export interface IResolvedLaunchMode {
15
15
  * do. Otherwise the first switch that is on fires. Every mode is a boolean
16
16
  * skip-approval switch, so "on" always means the same thing.
17
17
  */
18
- export declare function resolveLaunchMode(launchModes: ILaunchMode[], values: Record<string, boolean | string>, force?: string): string | undefined;
18
+ export declare function resolveLaunchMode(launchModes: ILaunchMode[], values: Record<string, boolean>, force?: string): string | undefined;
19
19
  /**
20
20
  * The mode a PLAIN launch would carry right now, as a descriptor entry.
21
21
  *
@@ -26,4 +26,4 @@ export declare function resolveLaunchMode(launchModes: ILaunchMode[], values: Re
26
26
  * unmarked. Answers the entry rather than the token so the caller can use the
27
27
  * assistant's own wording.
28
28
  */
29
- export declare function resolvedLaunchModeEntry(launchModes: ILaunchMode[], values: Record<string, boolean | string>): IResolvedLaunchMode | null;
29
+ export declare function resolvedLaunchModeEntry(launchModes: ILaunchMode[], values: Record<string, boolean>): IResolvedLaunchMode | null;
@@ -37,7 +37,7 @@ export declare class AssistantSessionsPanel extends Widget {
37
37
  setRecentLimit(n: number): void;
38
38
  /** Current values of this provider's launch modes, read from settings. Each
39
39
  * key is an `ILaunchMode.id` in the assistant's own terminology. */
40
- setModes(modes: Record<string, boolean | string>): void;
40
+ setModes(modes: Record<string, boolean>): void;
41
41
  setColouredTabs(on: boolean): void;
42
42
  /** Whether opening a session moves the file browser to its project folder. */
43
43
  setSyncFileBrowser(on: boolean): void;
@@ -143,26 +143,13 @@ export declare class AssistantSessionsPanel extends Widget {
143
143
  launchHere(cwd?: string): Promise<any>;
144
144
  /** Start a brand-new conversation in the file browser's current folder. */
145
145
  private _newSession;
146
- /**
147
- * Branch the active row's current conversation, by the descriptor's
148
- * `forkStrategy`.
149
- *
150
- * The three strategies differ only in who mints the new id and when it
151
- * becomes knowable - so the shared part (ask for a name, launch a terminal
152
- * on the fork, inherit the parent's tint, surface the branch) lives here
153
- * once, and the strategy decides the payload and the discovery.
154
- */
146
+ /** Branch the active row's current conversation. Every `forkStrategy` has
147
+ * the server mint the fork, so this is the one path. */
155
148
  private _branchSession;
156
- /** The CLI forks from a flag pair and takes the new id from us, so the fork
157
- * is known before it exists. Its store entry is written lazily, on the first
158
- * turn, so the branch is watched for rather than expected at once. */
159
- private _branchByNativeFlag;
160
- /** The CLI owns both the fork verb and the id, so the id has to be
161
- * discovered: snapshot the branch ids first, then diff after launch. */
162
- private _branchByNativeCommand;
163
- /** The assistant has no fork at all: the server copies the store entry and
164
- * answers with the new id, so the branch exists before the terminal opens
165
- * and one refresh surfaces it - no watcher needed. */
149
+ /** The server mints the new id and answers with it before the terminal
150
+ * opens - `server-copy` copies the store entry, `server-native` drives the
151
+ * assistant's own non-interactive fork path - so one refresh surfaces the
152
+ * branch. */
166
153
  private _branchByServerCopy;
167
154
  /** Drop the hand-set colours of this project's conversations and re-tint
168
155
  * from the defaults at once, so nothing keeps showing a released colour
@@ -178,26 +165,6 @@ export declare class AssistantSessionsPanel extends Widget {
178
165
  * the conversation - but only for a conversation whose terminal is open and
179
166
  * probes as this assistant's, which is why this path exists as well. */
180
167
  private _resetColours;
181
- /** Copy the parent's tint onto a fresh branch.
182
- *
183
- * A native-colour assistant hands every conversation its own colour, so a
184
- * fork already arrives correctly tinted: only what this extension's own
185
- * store holds for the parent is inherited there - an entry the parent itself
186
- * inherited counts, which is what makes a branch of a branch take what its
187
- * parent actually shows. Copying the parent's native tint instead would pin
188
- * the fork to it and shadow the fork's own `/color` for good.
189
- * Every other assistant inherits the parent's effective colour, which is the
190
- * only tint it has. */
191
- private _inheritColour;
192
- /** Poll fast for a fork whose id we already know, then refresh once. The
193
- * panel is never updated before the branch genuinely exists - it cannot list
194
- * a store entry that has not been written. */
195
- private _watchForBranch;
196
- /** Poll fast for a fork whose id the CLI minted: whichever conversation the
197
- * snapshot did not hold is the fork. It is then pinned as current (recency
198
- * alone would not - the actively written parent overtakes it) and inherits
199
- * the parent's tint. */
200
- private _watchForNewBranch;
201
168
  /** Name the row's current conversation, in the assistant's own store.
202
169
  *
203
170
  * Only ever offered where the descriptor says the assistant keeps a name
@@ -273,9 +240,7 @@ export declare class AssistantSessionsPanel extends Widget {
273
240
  private _variantSuffix;
274
241
  /** Whether a forced variant would build the launch the plain item already
275
242
  * builds - a mode left on in settings makes the plain item carry it, so the
276
- * variant beside it would be a second entry for one outcome. Only a boolean
277
- * variant can match: a forced token is the bare mode id, so an enum sitting
278
- * on a widening value is a launch no variant duplicates. */
243
+ * variant beside it would be a second entry for one outcome. */
279
244
  private _buildsSameLaunch;
280
245
  /** The glyph, only where the mode WIDENS what runs without asking. */
281
246
  private _variantIcon;
package/lib/core/panel.js CHANGED
@@ -32,10 +32,6 @@ const MENU_STATE_BUDGET_MS = 800;
32
32
  * without a floor the spinner shows for a frame and the click reads as
33
33
  * "nothing happened". */
34
34
  const REFRESH_SPIN_FLOOR_MS = 500;
35
- // After a fork is requested, watch for its lazily-written store entry at this
36
- // faster cadence (bounded) so a new branch surfaces in seconds.
37
- const BRANCH_WATCH_INTERVAL_MS = 2000;
38
- const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
39
35
  /** Conversations listed inline in a branch submenu before the popup takes over. */
40
36
  const INLINE_BRANCH_LIMIT = 5;
41
37
  /** Command id of the sibling extension that spawns a shell-less terminal on a
@@ -898,7 +894,7 @@ export class AssistantSessionsPanel extends Widget {
898
894
  if (row) {
899
895
  // A terminal already holding that conversation IS the answer: a second
900
896
  // process on one history is never what the click meant (ACC-LNCH-152).
901
- const found = await this._terminals.findForSession(row.session_id, folder);
897
+ const found = await this._terminals.findForSession(row.session_id);
902
898
  if (found) {
903
899
  this._terminals.focus(found.widget);
904
900
  return found.widget;
@@ -982,126 +978,47 @@ export class AssistantSessionsPanel extends Widget {
982
978
  void this._fetch().catch(() => undefined);
983
979
  }
984
980
  }
985
- /**
986
- * Branch the active row's current conversation, by the descriptor's
987
- * `forkStrategy`.
988
- *
989
- * The three strategies differ only in who mints the new id and when it
990
- * becomes knowable - so the shared part (ask for a name, launch a terminal
991
- * on the fork, inherit the parent's tint, surface the branch) lives here
992
- * once, and the strategy decides the payload and the discovery.
993
- */
981
+ /** Branch the active row's current conversation. Every `forkStrategy` has
982
+ * the server mint the fork, so this is the one path. */
994
983
  async _branchSession(force) {
995
- var _a;
996
984
  const session = this._activeSession;
997
985
  if (!session) {
998
986
  return;
999
987
  }
1000
- const parentColour = this._terminals.colourFor(session);
1001
- let name;
1002
- if (this._descriptor.promptsForBranchName) {
1003
- const named = await InputDialog.getText({
1004
- title: 'Branch Session',
1005
- label: 'Name for the new session',
1006
- placeholder: this._lookupName(session)
1007
- });
1008
- if (!named.button.accept) {
1009
- return;
1010
- }
1011
- name = ((_a = named.value) !== null && _a !== void 0 ? _a : '').trim();
1012
- if (!name) {
1013
- // Reported, not swallowed. The placeholder shows the parent's name,
1014
- // which reads as the "press Ok for the default" idiom, so Ok on an
1015
- // empty field was a closed dialog with no POST and no word (DEF-43).
1016
- // A notification rather than a disabled Ok because that is how this
1017
- // panel refuses everywhere else - Open Terminal and Show in File
1018
- // Browser on a folder outside the root - and `InputDialog.getText`
1019
- // offers no hook to disable Ok while the field is empty; getting one
1020
- // means replacing the dialog with a hand-built body widget.
1021
- Notification.warning('Enter a name for the new session - nothing was branched.', { autoClose: 4000 });
1022
- return;
1023
- }
1024
- }
1025
- if (this._descriptor.forkStrategy === 'server-copy') {
1026
- await this._branchByServerCopy(session, name, force);
1027
- return;
1028
- }
1029
- if (this._descriptor.forkStrategy === 'native-command') {
1030
- await this._branchByNativeCommand(session, force, parentColour);
1031
- return;
1032
- }
1033
- await this._branchByNativeFlag(session, name, force, parentColour);
1034
- }
1035
- /** The CLI forks from a flag pair and takes the new id from us, so the fork
1036
- * is known before it exists. Its store entry is written lazily, on the first
1037
- * turn, so the branch is watched for rather than expected at once. */
1038
- async _branchByNativeFlag(session, name, force, parentColour) {
1039
- const forkId = UUID.uuid4();
1040
- const request = {
1041
- project_path: session.project_path,
1042
- // Arms the server's fork pin - without it the fork never becomes the
1043
- // row's current conversation and recency drags the row back (DEF-9).
1044
- encoded_path: session.encoded_path,
1045
- session_id: session.session_id,
1046
- fork_session_id: forkId,
1047
- name,
1048
- mode: this._launchMode(force)
1049
- };
1050
- try {
1051
- await this._terminals.launch(request, forkId);
1052
- }
1053
- catch (err) {
1054
- this._showActionError('Could not branch this session - try again.', err);
1055
- return;
1056
- }
1057
- await this._inheritColour(parentColour, forkId, session.session_id);
1058
- this._watchForBranch(session.encoded_path, forkId);
1059
- }
1060
- /** The CLI owns both the fork verb and the id, so the id has to be
1061
- * discovered: snapshot the branch ids first, then diff after launch. */
1062
- async _branchByNativeCommand(session, force, parentColour) {
1063
- var _a, _b;
1064
- const known = new Set((_b = (_a = (await this._fetchBranches(session).catch(() => null))) === null || _a === void 0 ? void 0 : _a.branches.map(b => b.session_id)) !== null && _b !== void 0 ? _b : []);
1065
- known.add(session.session_id);
1066
- try {
1067
- await this._terminals.launch({
1068
- project_path: session.project_path,
1069
- fork_from: session.session_id,
1070
- mode: this._launchMode(force)
1071
- });
1072
- }
1073
- catch (err) {
1074
- this._showActionError('Could not branch this session - try again.', err);
1075
- return;
1076
- }
1077
- this._watchForNewBranch(session, known, parentColour);
1078
- void this._fetch().catch(() => undefined);
988
+ await this._branchByServerCopy(session, undefined, force);
1079
989
  }
1080
- /** The assistant has no fork at all: the server copies the store entry and
1081
- * answers with the new id, so the branch exists before the terminal opens
1082
- * and one refresh surfaces it - no watcher needed. */
990
+ /** The server mints the new id and answers with it before the terminal
991
+ * opens - `server-copy` copies the store entry, `server-native` drives the
992
+ * assistant's own non-interactive fork path - so one refresh surfaces the
993
+ * branch. */
1083
994
  async _branchByServerCopy(session, name, force) {
1084
995
  // Let the parent's colour settle before the server reads it (DEF-74). The
1085
996
  // server copies the store entry while minting the fork, so a colour picked
1086
997
  // on the parent's tab moments earlier - still queued in this store's
1087
998
  // request chain, unsent - is not in the server's store yet and the branch
1088
- // is born wearing the PREVIOUS tint. Same gate as `_inheritColour`
1089
- // (DEF-31): a reload is a queued request like every other of this store's
1090
- // (DEF-30), so it is not sent until the capture ahead of it has been
1091
- // answered, and awaiting it is what makes the server's read fresh. Only
1092
- // inside the capture window, so an ordinary fork still costs no request.
999
+ // is born wearing the PREVIOUS tint. A reload is a queued request like
1000
+ // every other of this store's (DEF-30), so it is not sent until the
1001
+ // capture ahead of it has been answered, and awaiting it is what makes the
1002
+ // server's read fresh. Only inside the capture window, so an ordinary fork
1003
+ // still costs no request.
1093
1004
  if (this._colours.isPending(session.session_id)) {
1094
1005
  await this._colours.load();
1095
1006
  }
1007
+ // Whether the POST below returned the minted fork. Past that point the
1008
+ // branch exists server-side - row, pin and colour - and only the launch
1009
+ // of its terminal can still fail, which is a different thing to report.
1010
+ let minted = false;
1096
1011
  try {
1097
1012
  const fork = await requestProvider(this._descriptor.id, 'branch', this._serverSettings, {
1098
1013
  method: 'POST',
1099
1014
  body: JSON.stringify({
1100
1015
  encoded_path: session.encoded_path,
1101
1016
  session_id: session.session_id,
1102
- name
1017
+ name,
1018
+ base_name: session.name
1103
1019
  })
1104
1020
  });
1021
+ minted = true;
1105
1022
  // No colour write here. `BranchHandler` already inherited it while
1106
1023
  // minting this fork, from a read taken server-side at request time -
1107
1024
  // strictly fresher than the click-time read this would repeat, and the
@@ -1117,6 +1034,19 @@ export class AssistantSessionsPanel extends Widget {
1117
1034
  if (isResponseStatus(err, 400)) {
1118
1035
  Notification.error(`${this._descriptor.label} cannot branch this conversation.`, { autoClose: 4000 });
1119
1036
  }
1037
+ else if (minted) {
1038
+ // The branch was created and only its terminal was refused - the
1039
+ // launch preflight's 409 carries words for the user. "Could not
1040
+ // branch this session" would be false, and the early return would
1041
+ // skip the refresh that surfaces the new row, so the refusal keeps
1042
+ // its own message and the list is re-read all the same.
1043
+ // Kept to 41 characters for the TOAST, not for taste: ": " plus the
1044
+ // longest message a store may raise (97, the DeepSeek refusal's
1045
+ // bound in tests/test_provider_stores.py) is 41 + 2 + 97 = 140 -
1046
+ // exactly the window before JupyterLab clips the rest.
1047
+ this._notifyLaunchError('Branch created; its terminal did not open', err);
1048
+ await this._fetch().catch(() => undefined);
1049
+ }
1120
1050
  else {
1121
1051
  this._showActionError('Could not branch this session - try again.', err);
1122
1052
  }
@@ -1149,108 +1079,6 @@ export class AssistantSessionsPanel extends Widget {
1149
1079
  await this._terminals.reconcileColours();
1150
1080
  await this._fetch().catch(() => undefined);
1151
1081
  }
1152
- /** Copy the parent's tint onto a fresh branch.
1153
- *
1154
- * A native-colour assistant hands every conversation its own colour, so a
1155
- * fork already arrives correctly tinted: only what this extension's own
1156
- * store holds for the parent is inherited there - an entry the parent itself
1157
- * inherited counts, which is what makes a branch of a branch take what its
1158
- * parent actually shows. Copying the parent's native tint instead would pin
1159
- * the fork to it and shadow the fork's own `/color` for good.
1160
- * Every other assistant inherits the parent's effective colour, which is the
1161
- * only tint it has. */
1162
- async _inheritColour(parentColour, childId, parentId) {
1163
- // The project gained a conversation, so a listing kept from before it is
1164
- // short by one.
1165
- this._branchCache = null;
1166
- // Let the parent's colour settle before copying it (DEF-31). The store
1167
- // holds only server-confirmed values, so a colour picked on the parent's
1168
- // tab moments earlier is not in it yet, and a fork started in that window
1169
- // was born wearing the PREVIOUS tint - stored as inherited, so never
1170
- // offered for release and corrected by no later pass.
1171
- //
1172
- // A reload is a queued request like every other of this store's (DEF-30):
1173
- // it is not sent until the capture ahead of it has been answered, which is
1174
- // what makes the re-read below current. It runs only inside the capture
1175
- // window, so an ordinary fork still costs no request, and the wait is
1176
- // bounded - every request is capped at REQUEST_TIMEOUT_MS (DEF-72). A
1177
- // reload that fails keeps the cache, which by then holds whatever the
1178
- // capture confirmed, so the re-read is current either way.
1179
- if (this._colours.isPending(parentId)) {
1180
- await this._colours.load();
1181
- }
1182
- // Re-read rather than trust `parentColour`, which was taken at click time -
1183
- // before the name dialog. A capture in flight at click time may well have
1184
- // landed while that dialog was open, which leaves nothing pending and the
1185
- // snapshot stale all the same. It stays as the fallback everywhere but a
1186
- // `native` provider - it is the only value here in which the derived hash
1187
- // is resolved, so a parent this store holds no colour for still hands its
1188
- // fork the tint it actually shows. `native` takes no fallback at all, for
1189
- // the reason above.
1190
- const stored = this._colours.get(parentId);
1191
- const inherited = this._descriptor.colourSource === 'native'
1192
- ? stored
1193
- : (stored !== null && stored !== void 0 ? stored : parentColour);
1194
- await this._colours.inherit(inherited, childId);
1195
- }
1196
- /** Poll fast for a fork whose id we already know, then refresh once. The
1197
- * panel is never updated before the branch genuinely exists - it cannot list
1198
- * a store entry that has not been written. */
1199
- _watchForBranch(encodedPath, forkId) {
1200
- let attempts = 0;
1201
- const tick = async () => {
1202
- // dispose() can land between an await and this callback; the widget must
1203
- // not keep polling off a dead object.
1204
- if (this.isDisposed) {
1205
- return;
1206
- }
1207
- attempts += 1;
1208
- try {
1209
- const data = await requestProvider(this._descriptor.id, withQuery('branches', { encoded_path: encodedPath }), this._serverSettings, { cache: 'no-store' });
1210
- if (data.current === forkId ||
1211
- data.branches.some(b => b.session_id === forkId)) {
1212
- await this._fetch();
1213
- return;
1214
- }
1215
- }
1216
- catch (_a) {
1217
- // Transient - keep watching until the attempt budget runs out.
1218
- }
1219
- if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
1220
- window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1221
- }
1222
- };
1223
- window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1224
- }
1225
- /** Poll fast for a fork whose id the CLI minted: whichever conversation the
1226
- * snapshot did not hold is the fork. It is then pinned as current (recency
1227
- * alone would not - the actively written parent overtakes it) and inherits
1228
- * the parent's tint. */
1229
- _watchForNewBranch(session, known, parentColour) {
1230
- let attempts = 0;
1231
- const tick = async () => {
1232
- if (this.isDisposed) {
1233
- return;
1234
- }
1235
- attempts += 1;
1236
- try {
1237
- const data = await this._fetchBranches(session);
1238
- const fresh = data.branches.find(b => !known.has(b.session_id));
1239
- if (fresh) {
1240
- await this._inheritColour(parentColour, fresh.session_id, session.session_id);
1241
- await this._switchBranch(session, fresh.session_id);
1242
- return;
1243
- }
1244
- }
1245
- catch (_a) {
1246
- // Transient - keep watching.
1247
- }
1248
- if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
1249
- window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1250
- }
1251
- };
1252
- window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
1253
- }
1254
1082
  /** Name the row's current conversation, in the assistant's own store.
1255
1083
  *
1256
1084
  * Only ever offered where the descriptor says the assistant keeps a name
@@ -1273,9 +1101,8 @@ export class AssistantSessionsPanel extends Widget {
1273
1101
  }
1274
1102
  const name = ((_a = named.value) !== null && _a !== void 0 ? _a : '').trim();
1275
1103
  if (!name) {
1276
- // Refused the way Branch Session refuses an empty name (DEF-43): the
1277
- // placeholder shows the current name, which reads as "press Ok for the
1278
- // default", so an empty Ok has to say that nothing happened.
1104
+ // The placeholder shows the current name, which reads as "press Ok for
1105
+ // the default", so an empty Ok has to say that nothing happened.
1279
1106
  Notification.warning('Enter a name - nothing was renamed.', {
1280
1107
  autoClose: 4000
1281
1108
  });
@@ -1945,9 +1772,7 @@ export class AssistantSessionsPanel extends Widget {
1945
1772
  }
1946
1773
  /** Whether a forced variant would build the launch the plain item already
1947
1774
  * builds - a mode left on in settings makes the plain item carry it, so the
1948
- * variant beside it would be a second entry for one outcome. Only a boolean
1949
- * variant can match: a forced token is the bare mode id, so an enum sitting
1950
- * on a widening value is a launch no variant duplicates. */
1775
+ * variant beside it would be a second entry for one outcome. */
1951
1776
  _buildsSameLaunch(mode) {
1952
1777
  const forced = resolveLaunchMode(this._descriptor.launchModes, this._modes, mode.id);
1953
1778
  return !!forced && forced === this._launchMode();
@@ -2093,8 +1918,8 @@ export class AssistantSessionsPanel extends Widget {
2093
1918
  // colourful-tab extension as a choice with no colour and does drop the
2094
1919
  // override, but only for the conversation that terminal is running - so
2095
1920
  // without this item an override on a branch with no tab open would be
2096
- // permanent, and on a native assistant that means `/color` silently never
2097
- // showing again.
1921
+ // permanent on an assistant with no colour command, and on a native one
1922
+ // would hold until the next `/color` run in that conversation.
2098
1923
  //
2099
1924
  // It covers every conversation of the row, not just the current one: a
2100
1925
  // terminal opened on a branch files its colour under THAT branch, and this
@@ -2325,14 +2150,10 @@ export class AssistantSessionsPanel extends Widget {
2325
2150
  add('reset-colour');
2326
2151
  this._contextMenu.addItem({ type: 'separator' });
2327
2152
  if (withBranches) {
2328
- // A project-scoped assistant has one terminal for every conversation,
2329
- // so opening a branch in its own terminal is not an action it has.
2330
- if (this._descriptor.terminalScope === 'conversation') {
2331
- this._contextMenu.addItem({
2332
- type: 'submenu',
2333
- submenu: this._openBranchSubmenu
2334
- });
2335
- }
2153
+ this._contextMenu.addItem({
2154
+ type: 'submenu',
2155
+ submenu: this._openBranchSubmenu
2156
+ });
2336
2157
  this._contextMenu.addItem({
2337
2158
  type: 'submenu',
2338
2159
  submenu: this._switchSubmenu
@@ -2501,7 +2322,7 @@ export class AssistantSessionsPanel extends Widget {
2501
2322
  branchName: b => this._branchDisplayName(b),
2502
2323
  formatTime: ms => this._formatRelativeTime(ms),
2503
2324
  branchBadge: b => this._bgBadgeFor(b),
2504
- openTitle: () => this._openBranchTitle(session),
2325
+ openTitle: () => this._openBranchTitle(),
2505
2326
  onSwitch: id => void this._switchBranch(session, id),
2506
2327
  onOpen: id => void this._openSession(session, undefined, id),
2507
2328
  onDelete: ids => this._deleteBranches(session, ids),
@@ -2514,13 +2335,7 @@ export class AssistantSessionsPanel extends Widget {
2514
2335
  /** Tooltip for one Open button in the Manage Sessions popup. Same rule as the
2515
2336
  * Open Branched Conversation submenu: the button launches a terminal, so it
2516
2337
  * names the mode that launch carries. */
2517
- _openBranchTitle(session) {
2518
- var _a, _b, _c;
2519
- // One terminal serves a project-scoped assistant, so the button focuses
2520
- // or starts it, under the provider's own verb for that.
2521
- if (this._descriptor.terminalScope === 'project') {
2522
- return ((_c = (_b = (_a = this._hooks).resumeLabel) === null || _b === void 0 ? void 0 : _b.call(_a, session)) !== null && _c !== void 0 ? _c : "Open the project's terminal");
2523
- }
2338
+ _openBranchTitle() {
2524
2339
  const base = 'Open this conversation in its own terminal';
2525
2340
  const variant = this._resolvedVariant();
2526
2341
  return variant ? `${base} (${variant.label})` : base;