jupyterlab_claude_code_extension 1.2.7 → 1.2.9

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
@@ -8,10 +8,21 @@
8
8
  [![Brought To You By KOLOMOLO](https://img.shields.io/badge/Brought%20To%20You%20By-KOLOMOLO-00ffff?style=flat)](https://kolomolo.com)
9
9
  [![Donate PayPal](https://img.shields.io/badge/Donate-PayPal-blue?style=flat)](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
10
10
 
11
- Browse, resume, and manage your Claude Code sessions from a JupyterLab side panel. One click reactivates the right terminal, no duplicate tabs, with a live indicator showing which sessions are currently active.
11
+ A full Claude Code launcher and manager for JupyterLab. Start, resume, fork, switch, and clean up Claude Code CLI sessions from a side panel - one click lands you in the right terminal with Claude already running, no duplicate tabs, no UUID hunting, with a live indicator showing which sessions are active right now.
12
12
 
13
13
  ![Claude Code Sessions panel](.resources/screenshot.png)
14
14
 
15
+ ## Why this extension
16
+
17
+ One principle: **Anthropic knows best how to build the agent harness; we know best how to make it work in JupyterLab.**
18
+
19
+ Chat-panel extensions re-implement the agent loop and trail the real tool. This one runs the genuine, unmodified Claude Code CLI in JupyterLab terminals - skills, subagents, MCP, hooks, plan mode, every release the day it lands. The extension owns the JupyterLab side:
20
+
21
+ - **Launching** - new, resumed, or forked sessions, with or without permission prompts, no wrapper shell, correctly sized before Claude draws its first frame
22
+ - **Finding** - every Claude project in one panel: favourites, search, live activity
23
+ - **Reusing** - clicking a session focuses its existing terminal, never a duplicate
24
+ - **Managing** - parallel conversations: switch, fork with a name, delete - no `--resume` pickers, no raw UUIDs
25
+
15
26
  ## Features
16
27
 
17
28
  - **Three-section side panel** - Favorites, Recent, and All projects, each scrolling independently
@@ -20,8 +31,9 @@ Browse, resume, and manage your Claude Code sessions from a JupyterLab side pane
20
31
  - **Favorites** - star projects you keep coming back to via the right-click menu
21
32
  - **Remove** - drop a project's Claude history from the panel via the right-click menu; the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
22
33
  - **Clean up parallel sessions** - when a project has accumulated extra sessions beyond the main one, a right-click menu item (showing the count in brackets) removes them all, keeping only the main session; removed files honour the same trash setting
23
- - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup over the full list where conversations can also be deleted - select one, many, or all via checkboxes, then confirm with a two-step Delete button (removed files honour the trash setting). Rows with multiple conversations show the count in brackets after the name, e.g. `workspace (2)`
24
- - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`); rows active within the last minute light up in the theme's brand colour, rows idle for over a week dim slightly
34
+ - **Conversation switcher** - a right-click "Switch and Manage Sessions" submenu lists a project's other conversations by name and short session id, e.g. `home (3f2a1b9c)`, with last-activity time; pick one and it becomes the row's current conversation - the next click resumes exactly that one. The submenu shows the 5 most recent; "Manage Sessions..." opens a searchable popup over the full list where conversations can also be deleted - select one, many, or all via checkboxes, then confirm with a two-step Delete button (removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
35
+ - **Branch session** - fork the current conversation into a new named session via the right-click menu (normal or skip-permissions mode); uses Claude's native `--fork-session`, opens in a new terminal, the chosen name is stamped automatically, and the fork becomes the row's current conversation
36
+ - **Activity at a glance** - each row shows its last activity (`now`, `5m ago`, `2h ago`, `3d ago`) in an aligned column, with the favourite star in its own column beside it; rows active within the last minute light up in the theme's brand colour (including the `now` label), rows idle for over a week dim slightly
25
37
  - **Search** - fuzzy filter toggled by the funnel button next to refresh
26
38
  - **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
27
39
  - **Hover tooltip** with project path, last activity, message count, branch, and session id
package/lib/icons.d.ts CHANGED
@@ -5,4 +5,5 @@ export declare const refreshIcon: LabIcon;
5
5
  export declare const removeIcon: LabIcon;
6
6
  export declare const shieldIcon: LabIcon;
7
7
  export declare const addIcon: LabIcon;
8
+ export declare const branchIcon: LabIcon;
8
9
  export declare const filterIcon: LabIcon;
package/lib/icons.js CHANGED
@@ -58,6 +58,15 @@ export const addIcon = new LabIcon({
58
58
  name: 'jupyterlab_claude_code_extension:add',
59
59
  svgstr: addSvgStr
60
60
  });
61
+ // Git-branch glyph (Octicons git-branch-16, MIT) - marks rows that carry
62
+ // parallel conversations and the Branch Session menu entries.
63
+ const branchSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
64
+ <path class="jp-icon3" fill="#616161" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
65
+ </svg>`;
66
+ export const branchIcon = new LabIcon({
67
+ name: 'jupyterlab_claude_code_extension:branch',
68
+ svgstr: branchSvgStr
69
+ });
61
70
  // Funnel copied verbatim from @jupyterlab/ui-components'
62
71
  // `search/filter.svg` - the same image the file browser's filter
63
72
  // toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
package/lib/widget.d.ts CHANGED
@@ -118,6 +118,20 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
118
118
  * Returns the removed count, or null on failure (after notifying).
119
119
  * Always resyncs the panel so the row's conversation count drops. */
120
120
  private _deleteBranches;
121
+ /** Fork the active row's current conversation into a new named branch.
122
+ *
123
+ * Asks for a name, then launches a terminal running
124
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
125
+ * the uuid is generated here so the forked JSONL is known up front. Once
126
+ * claude materialises the file (polled via sessions/set-title) the chosen
127
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
128
+ * so the recency resolution makes it the row's current conversation
129
+ * without an explicit switch.
130
+ */
131
+ private _branchSession;
132
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
133
+ * does not), then refresh so the row shows the named fork as current. */
134
+ private _stampForkTitle;
121
135
  /** Switch the active row's project to another conversation branch.
122
136
  * The backend touches the branch JSONL's mtime; a refresh then shows
123
137
  * the selected conversation as the row's current one. */
package/lib/widget.js CHANGED
@@ -1,10 +1,11 @@
1
- import { Clipboard, Dialog, Notification, showDialog } from '@jupyterlab/apputils';
1
+ import { Clipboard, Dialog, InputDialog, Notification, showDialog } from '@jupyterlab/apputils';
2
2
  import { ServerConnection } from '@jupyterlab/services';
3
3
  import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
4
4
  import { CommandRegistry } from '@lumino/commands';
5
+ import { UUID } from '@lumino/coreutils';
5
6
  import { Menu, Widget } from '@lumino/widgets';
6
7
  import { requestAPI } from './request';
7
- import { addIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
8
+ import { addIcon, branchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
8
9
  const POLL_INTERVAL_MS = 30000;
9
10
  const DEFAULT_RECENT_LIMIT = 10;
10
11
  const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
@@ -818,20 +819,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
818
819
  }
819
820
  const name = document.createElement('span');
820
821
  name.className = 'jp-ClaudeSessionsPanel-name';
821
- // Conversation count in brackets - only when the project has branches.
822
- name.textContent =
823
- session.extra_sessions > 0
824
- ? `${this._lookupName(session)} (${session.extra_sessions + 1})`
825
- : this._lookupName(session);
826
- row.appendChild(name);
827
- if (session.file_mtime) {
828
- const time = document.createElement('span');
829
- time.className = 'jp-ClaudeSessionsPanel-rowTime';
830
- time.textContent = this._formatRelativeTime(session.file_mtime);
831
- row.appendChild(time);
822
+ name.textContent = this._lookupName(session);
823
+ // Branch icon + total conversation count - only when the project has
824
+ // branches. Lives inside the name span so it hugs the label text
825
+ // instead of being flexed to the row's right edge.
826
+ if (session.extra_sessions > 0) {
827
+ const badge = document.createElement('span');
828
+ badge.className = 'jp-ClaudeSessionsPanel-branchBadge';
829
+ const icon = document.createElement('span');
830
+ icon.className = 'jp-ClaudeSessionsPanel-branchBadgeIcon';
831
+ branchIcon.element({ container: icon });
832
+ badge.appendChild(icon);
833
+ badge.appendChild(document.createTextNode(String(session.extra_sessions + 1)));
834
+ name.appendChild(badge);
832
835
  }
836
+ row.appendChild(name);
833
837
  // No star in the Favorites section - every row there is a favorite
834
838
  // by definition; stars are an indicator only useful in Recent/All.
839
+ // Star sits before the time so the fixed-width time column stays the
840
+ // rightmost alignment anchor across all rows.
835
841
  if (session.favourite && sectionKey !== 'favourites') {
836
842
  const star = document.createElement('span');
837
843
  star.className = 'jp-ClaudeSessionsPanel-favStar';
@@ -839,6 +845,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
839
845
  starFilledIcon.element({ container: star });
840
846
  row.appendChild(star);
841
847
  }
848
+ if (session.file_mtime) {
849
+ const time = document.createElement('span');
850
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
851
+ time.textContent = this._formatRelativeTime(session.file_mtime);
852
+ row.appendChild(time);
853
+ }
842
854
  row.addEventListener('click', () => {
843
855
  if (removing) {
844
856
  return;
@@ -1060,6 +1072,16 @@ export class ClaudeCodeSessionsWidget extends Widget {
1060
1072
  void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
1061
1073
  }
1062
1074
  });
1075
+ this._commands.addCommand('claude-code-sessions:branch-session', {
1076
+ label: 'Branch Session...',
1077
+ icon: branchIcon,
1078
+ execute: () => void this._branchSession(false)
1079
+ });
1080
+ this._commands.addCommand('claude-code-sessions:branch-session-dangerous', {
1081
+ label: 'Branch Session (Skip Permissions)...',
1082
+ icon: shieldIcon,
1083
+ execute: () => void this._branchSession(true)
1084
+ });
1063
1085
  this._commands.addCommand('claude-code-sessions:remove', {
1064
1086
  label: 'Remove from Claude',
1065
1087
  icon: removeIcon,
@@ -1131,6 +1153,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1131
1153
  submenu: this._branchSubmenu
1132
1154
  });
1133
1155
  }
1156
+ this._contextMenu.addItem({
1157
+ command: 'claude-code-sessions:branch-session'
1158
+ });
1159
+ this._contextMenu.addItem({
1160
+ command: 'claude-code-sessions:branch-session-dangerous'
1161
+ });
1134
1162
  this._contextMenu.addItem({
1135
1163
  command: 'claude-code-sessions:cleanup-parallel'
1136
1164
  });
@@ -1378,6 +1406,89 @@ export class ClaudeCodeSessionsWidget extends Widget {
1378
1406
  await this._fetch();
1379
1407
  }
1380
1408
  }
1409
+ /** Fork the active row's current conversation into a new named branch.
1410
+ *
1411
+ * Asks for a name, then launches a terminal running
1412
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
1413
+ * the uuid is generated here so the forked JSONL is known up front. Once
1414
+ * claude materialises the file (polled via sessions/set-title) the chosen
1415
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
1416
+ * so the recency resolution makes it the row's current conversation
1417
+ * without an explicit switch.
1418
+ */
1419
+ async _branchSession(forceDangerous) {
1420
+ const session = this._activeSession;
1421
+ if (!session) {
1422
+ return;
1423
+ }
1424
+ const named = await InputDialog.getText({
1425
+ title: 'Branch Session',
1426
+ label: 'Name for the new session',
1427
+ placeholder: this._lookupName(session)
1428
+ });
1429
+ if (!named.button.accept || !named.value || !named.value.trim()) {
1430
+ return;
1431
+ }
1432
+ const title = named.value.trim();
1433
+ const forkId = UUID.uuid4();
1434
+ const spinner = this._showLaunchSpinner();
1435
+ try {
1436
+ const launched = await requestAPI('launch-terminal', this._serverSettings, {
1437
+ method: 'POST',
1438
+ body: JSON.stringify({
1439
+ project_path: session.project_path,
1440
+ session_id: session.session_id,
1441
+ fork_session_id: forkId,
1442
+ dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
1443
+ })
1444
+ });
1445
+ const widget = await this._app.commands.execute('terminal:open', {
1446
+ name: launched.terminal_name
1447
+ });
1448
+ if (widget === null || widget === void 0 ? void 0 : widget.id) {
1449
+ this._terminalsByPath.set(session.project_path, widget);
1450
+ this._wireTerminalDisposal(session.project_path, widget);
1451
+ this._focusTerminal(widget);
1452
+ }
1453
+ }
1454
+ catch (err) {
1455
+ this._showError(err);
1456
+ return;
1457
+ }
1458
+ finally {
1459
+ spinner.dispose();
1460
+ }
1461
+ // Stamp the name in the background once the forked JSONL appears -
1462
+ // claude writes it on its first record, typically within seconds.
1463
+ void this._stampForkTitle(session.encoded_path, forkId, title);
1464
+ }
1465
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
1466
+ * does not), then refresh so the row shows the named fork as current. */
1467
+ async _stampForkTitle(encodedPath, sessionId, title) {
1468
+ for (let attempt = 0; attempt < 30; attempt++) {
1469
+ try {
1470
+ await requestAPI('sessions/set-title', this._serverSettings, {
1471
+ method: 'POST',
1472
+ body: JSON.stringify({
1473
+ encoded_path: encodedPath,
1474
+ session_id: sessionId,
1475
+ title
1476
+ })
1477
+ });
1478
+ await this._fetch();
1479
+ return;
1480
+ }
1481
+ catch (err) {
1482
+ const notYet = err instanceof ServerConnection.ResponseError &&
1483
+ err.response.status === 404;
1484
+ if (!notYet) {
1485
+ break;
1486
+ }
1487
+ await new Promise(resolve => setTimeout(resolve, 1000));
1488
+ }
1489
+ }
1490
+ Notification.warning(`Branched session started, but the name "${title}" could not be applied - use /rename in the session.`, { autoClose: 6000 });
1491
+ }
1381
1492
  /** Switch the active row's project to another conversation branch.
1382
1493
  * The backend touches the branch JSONL's mtime; a refresh then shows
1383
1494
  * the selected conversation as the row's current one. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jupyterlab_claude_code_extension",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Browse, resume, and manage your Claude Code CLI sessions from a JupyterLab side panel. One click reactivates the right terminal - no duplicate tabs, live remote-control indicator, and favourites for the projects you keep coming back to.",
5
5
  "keywords": [
6
6
  "jupyter",
@@ -138,10 +138,15 @@ describe('launch spinner dismiss contract', () => {
138
138
  /private async _switchBranch[\s\S]*?\n \}/
139
139
  ) ?? [''])[0];
140
140
 
141
- it('shows the conversation count only when the project has branches', () => {
142
- expect(widgetSrc).toMatch(
143
- /session\.extra_sessions > 0\s*\?\s*`\$\{this\._lookupName\(session\)\} \(\$\{session\.extra_sessions \+ 1\}\)`/
141
+ it('shows a branch icon + count badge only when the project has branches', () => {
142
+ const renderRow = (widgetSrc.match(
143
+ /private _renderRow[\s\S]*?\n \}/
144
+ ) ?? [''])[0];
145
+ expect(renderRow).toMatch(
146
+ /session\.extra_sessions > 0[\s\S]*?jp-ClaudeSessionsPanel-branchBadge/
144
147
  );
148
+ expect(renderRow).toMatch(/branchIcon\.element/);
149
+ expect(renderRow).toMatch(/String\(session\.extra_sessions \+ 1\)/);
145
150
  });
146
151
 
147
152
  it('rebuilds submenu items from a fresh branches fetch on open', () => {
@@ -293,6 +298,87 @@ describe('launch spinner dismiss contract', () => {
293
298
  /result\.current !== result\.requested[\s\S]*?Notification\.warning/
294
299
  );
295
300
  });
301
+
302
+ it('renders the favourite star before the time column', () => {
303
+ const renderRow = (widgetSrc.match(
304
+ /private _renderRow[\s\S]*?\n \}/
305
+ ) ?? [''])[0];
306
+ const starAt = renderRow.indexOf('jp-ClaudeSessionsPanel-favStar');
307
+ const timeAt = renderRow.indexOf('jp-ClaudeSessionsPanel-rowTime');
308
+ expect(starAt).toBeGreaterThan(-1);
309
+ expect(timeAt).toBeGreaterThan(-1);
310
+ expect(starAt).toBeLessThan(timeAt);
311
+ });
312
+
313
+ it('time labels form fixed-width right-aligned columns', () => {
314
+ const css: string = fs.readFileSync(
315
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
316
+ 'utf-8'
317
+ );
318
+ const rowTime = (css.match(
319
+ /\.jp-ClaudeSessionsPanel-rowTime \{[\s\S]*?\}/
320
+ ) ?? [''])[0];
321
+ expect(rowTime).toMatch(/width: 52px/);
322
+ expect(rowTime).toMatch(/text-align: right/);
323
+ const branchTime = (css.match(
324
+ /\.jp-ClaudeSessionsPanel-branchTime \{[\s\S]*?\}/
325
+ ) ?? [''])[0];
326
+ expect(branchTime).toMatch(/width: 52px/);
327
+ expect(branchTime).toMatch(/text-align: right/);
328
+ });
329
+
330
+ it('now label shares the recently-active emphasis colour', () => {
331
+ const css: string = fs.readFileSync(
332
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
333
+ 'utf-8'
334
+ );
335
+ expect(css).toMatch(
336
+ /jp-mod-recentlyActive[\s\S]{0,200}?jp-ClaudeSessionsPanel-rowTime[\s\S]{0,80}?--jp-brand-color1/
337
+ );
338
+ });
339
+
340
+ it('branch session commands exist in normal and skip-permissions modes', () => {
341
+ expect(widgetSrc).toMatch(/claude-code-sessions:branch-session'/);
342
+ expect(widgetSrc).toMatch(
343
+ /claude-code-sessions:branch-session-dangerous/
344
+ );
345
+ expect(widgetSrc).toMatch(
346
+ /Branch Session \(Skip Permissions\)\.\.\.[\s\S]{0,80}?icon: shieldIcon/
347
+ );
348
+ });
349
+
350
+ it('branch session asks for a name and launches a known fork id', () => {
351
+ const branch = (widgetSrc.match(
352
+ /private async _branchSession[\s\S]*?\n \}/
353
+ ) ?? [''])[0];
354
+ expect(branch).toMatch(/InputDialog\.getText/);
355
+ expect(branch).toMatch(/UUID\.uuid4\(\)/);
356
+ expect(branch).toMatch(/fork_session_id: forkId/);
357
+ expect(branch).toMatch(/session_id: session\.session_id/);
358
+ expect(branch).toMatch(/_stampForkTitle/);
359
+ });
360
+
361
+ it('fork title stamping retries on 404 until the JSONL appears', () => {
362
+ const stamp = (widgetSrc.match(
363
+ /private async _stampForkTitle[\s\S]*?\n \}/
364
+ ) ?? [''])[0];
365
+ expect(stamp).toMatch(/sessions\/set-title/);
366
+ expect(stamp).toMatch(/status === 404/);
367
+ expect(stamp).toMatch(/await this\._fetch\(\)/);
368
+ expect(stamp).toMatch(/Notification\.warning/);
369
+ });
370
+
371
+ it('live dot is softened with reduced opacity', () => {
372
+ const css: string = fs.readFileSync(
373
+ path.join(__dirname, '..', '..', 'style', 'base.css'),
374
+ 'utf-8'
375
+ );
376
+ const dot = (css.match(/\.jp-ClaudeSessionsPanel-dot \{[\s\S]*?\}/) ?? [
377
+ ''
378
+ ])[0];
379
+ expect(dot).toMatch(/--jp-success-color1/);
380
+ expect(dot).toMatch(/opacity: 0\.75/);
381
+ });
296
382
  });
297
383
 
298
384
  it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
package/src/icons.ts CHANGED
@@ -71,6 +71,17 @@ export const addIcon = new LabIcon({
71
71
  svgstr: addSvgStr
72
72
  });
73
73
 
74
+ // Git-branch glyph (Octicons git-branch-16, MIT) - marks rows that carry
75
+ // parallel conversations and the Branch Session menu entries.
76
+ const branchSvgStr = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
77
+ <path class="jp-icon3" fill="#616161" d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/>
78
+ </svg>`;
79
+
80
+ export const branchIcon = new LabIcon({
81
+ name: 'jupyterlab_claude_code_extension:branch',
82
+ svgstr: branchSvgStr
83
+ });
84
+
74
85
  // Funnel copied verbatim from @jupyterlab/ui-components'
75
86
  // `search/filter.svg` - the same image the file browser's filter
76
87
  // toggle uses. The `class="jp-icon3"` lets JupyterLab's theme drive
package/src/widget.ts CHANGED
@@ -2,6 +2,7 @@ import { JupyterFrontEnd } from '@jupyterlab/application';
2
2
  import {
3
3
  Clipboard,
4
4
  Dialog,
5
+ InputDialog,
5
6
  Notification,
6
7
  showDialog
7
8
  } from '@jupyterlab/apputils';
@@ -10,12 +11,14 @@ import { IDefaultFileBrowser } from '@jupyterlab/filebrowser';
10
11
  import { ITerminalTracker } from '@jupyterlab/terminal';
11
12
  import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
12
13
  import { CommandRegistry } from '@lumino/commands';
14
+ import { UUID } from '@lumino/coreutils';
13
15
  import { Menu, Widget } from '@lumino/widgets';
14
16
  import { Message } from '@lumino/messaging';
15
17
 
16
18
  import { requestAPI } from './request';
17
19
  import {
18
20
  addIcon,
21
+ branchIcon,
19
22
  claudeIcon,
20
23
  filterIcon,
21
24
  refreshIcon,
@@ -963,22 +966,28 @@ export class ClaudeCodeSessionsWidget extends Widget {
963
966
 
964
967
  const name = document.createElement('span');
965
968
  name.className = 'jp-ClaudeSessionsPanel-name';
966
- // Conversation count in brackets - only when the project has branches.
967
- name.textContent =
968
- session.extra_sessions > 0
969
- ? `${this._lookupName(session)} (${session.extra_sessions + 1})`
970
- : this._lookupName(session);
971
- row.appendChild(name);
972
-
973
- if (session.file_mtime) {
974
- const time = document.createElement('span');
975
- time.className = 'jp-ClaudeSessionsPanel-rowTime';
976
- time.textContent = this._formatRelativeTime(session.file_mtime);
977
- row.appendChild(time);
969
+ name.textContent = this._lookupName(session);
970
+ // Branch icon + total conversation count - only when the project has
971
+ // branches. Lives inside the name span so it hugs the label text
972
+ // instead of being flexed to the row's right edge.
973
+ if (session.extra_sessions > 0) {
974
+ const badge = document.createElement('span');
975
+ badge.className = 'jp-ClaudeSessionsPanel-branchBadge';
976
+ const icon = document.createElement('span');
977
+ icon.className = 'jp-ClaudeSessionsPanel-branchBadgeIcon';
978
+ branchIcon.element({ container: icon });
979
+ badge.appendChild(icon);
980
+ badge.appendChild(
981
+ document.createTextNode(String(session.extra_sessions + 1))
982
+ );
983
+ name.appendChild(badge);
978
984
  }
985
+ row.appendChild(name);
979
986
 
980
987
  // No star in the Favorites section - every row there is a favorite
981
988
  // by definition; stars are an indicator only useful in Recent/All.
989
+ // Star sits before the time so the fixed-width time column stays the
990
+ // rightmost alignment anchor across all rows.
982
991
  if (session.favourite && sectionKey !== 'favourites') {
983
992
  const star = document.createElement('span');
984
993
  star.className = 'jp-ClaudeSessionsPanel-favStar';
@@ -987,6 +996,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
987
996
  row.appendChild(star);
988
997
  }
989
998
 
999
+ if (session.file_mtime) {
1000
+ const time = document.createElement('span');
1001
+ time.className = 'jp-ClaudeSessionsPanel-rowTime';
1002
+ time.textContent = this._formatRelativeTime(session.file_mtime);
1003
+ row.appendChild(time);
1004
+ }
1005
+
990
1006
  row.addEventListener('click', () => {
991
1007
  if (removing) {
992
1008
  return;
@@ -1235,6 +1251,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
1235
1251
  }
1236
1252
  });
1237
1253
 
1254
+ this._commands.addCommand('claude-code-sessions:branch-session', {
1255
+ label: 'Branch Session...',
1256
+ icon: branchIcon,
1257
+ execute: () => void this._branchSession(false)
1258
+ });
1259
+
1260
+ this._commands.addCommand('claude-code-sessions:branch-session-dangerous', {
1261
+ label: 'Branch Session (Skip Permissions)...',
1262
+ icon: shieldIcon,
1263
+ execute: () => void this._branchSession(true)
1264
+ });
1265
+
1238
1266
  this._commands.addCommand('claude-code-sessions:remove', {
1239
1267
  label: 'Remove from Claude',
1240
1268
  icon: removeIcon,
@@ -1313,6 +1341,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
1313
1341
  submenu: this._branchSubmenu
1314
1342
  });
1315
1343
  }
1344
+ this._contextMenu.addItem({
1345
+ command: 'claude-code-sessions:branch-session'
1346
+ });
1347
+ this._contextMenu.addItem({
1348
+ command: 'claude-code-sessions:branch-session-dangerous'
1349
+ });
1316
1350
  this._contextMenu.addItem({
1317
1351
  command: 'claude-code-sessions:cleanup-parallel'
1318
1352
  });
@@ -1594,6 +1628,105 @@ export class ClaudeCodeSessionsWidget extends Widget {
1594
1628
  }
1595
1629
  }
1596
1630
 
1631
+ /** Fork the active row's current conversation into a new named branch.
1632
+ *
1633
+ * Asks for a name, then launches a terminal running
1634
+ * ``claude --resume <current> --fork-session --session-id <new uuid>`` -
1635
+ * the uuid is generated here so the forked JSONL is known up front. Once
1636
+ * claude materialises the file (polled via sessions/set-title) the chosen
1637
+ * name is stamped as a custom-title record. The fork is the newest JSONL,
1638
+ * so the recency resolution makes it the row's current conversation
1639
+ * without an explicit switch.
1640
+ */
1641
+ private async _branchSession(forceDangerous: boolean): Promise<void> {
1642
+ const session = this._activeSession;
1643
+ if (!session) {
1644
+ return;
1645
+ }
1646
+ const named = await InputDialog.getText({
1647
+ title: 'Branch Session',
1648
+ label: 'Name for the new session',
1649
+ placeholder: this._lookupName(session)
1650
+ });
1651
+ if (!named.button.accept || !named.value || !named.value.trim()) {
1652
+ return;
1653
+ }
1654
+ const title = named.value.trim();
1655
+ const forkId = UUID.uuid4();
1656
+ const spinner = this._showLaunchSpinner();
1657
+ try {
1658
+ const launched = await requestAPI<ILaunchTerminalResponse>(
1659
+ 'launch-terminal',
1660
+ this._serverSettings,
1661
+ {
1662
+ method: 'POST',
1663
+ body: JSON.stringify({
1664
+ project_path: session.project_path,
1665
+ session_id: session.session_id,
1666
+ fork_session_id: forkId,
1667
+ dangerously_skip_permissions:
1668
+ forceDangerous || this._dangerouslySkip
1669
+ })
1670
+ }
1671
+ );
1672
+ const widget: any = await this._app.commands.execute('terminal:open', {
1673
+ name: launched.terminal_name
1674
+ });
1675
+ if (widget?.id) {
1676
+ this._terminalsByPath.set(session.project_path, widget);
1677
+ this._wireTerminalDisposal(session.project_path, widget);
1678
+ this._focusTerminal(widget);
1679
+ }
1680
+ } catch (err) {
1681
+ this._showError(err);
1682
+ return;
1683
+ } finally {
1684
+ spinner.dispose();
1685
+ }
1686
+ // Stamp the name in the background once the forked JSONL appears -
1687
+ // claude writes it on its first record, typically within seconds.
1688
+ void this._stampForkTitle(session.encoded_path, forkId, title);
1689
+ }
1690
+
1691
+ /** Retry sessions/set-title until the forked JSONL exists (404 while it
1692
+ * does not), then refresh so the row shows the named fork as current. */
1693
+ private async _stampForkTitle(
1694
+ encodedPath: string,
1695
+ sessionId: string,
1696
+ title: string
1697
+ ): Promise<void> {
1698
+ for (let attempt = 0; attempt < 30; attempt++) {
1699
+ try {
1700
+ await requestAPI<{ ok: boolean }>(
1701
+ 'sessions/set-title',
1702
+ this._serverSettings,
1703
+ {
1704
+ method: 'POST',
1705
+ body: JSON.stringify({
1706
+ encoded_path: encodedPath,
1707
+ session_id: sessionId,
1708
+ title
1709
+ })
1710
+ }
1711
+ );
1712
+ await this._fetch();
1713
+ return;
1714
+ } catch (err) {
1715
+ const notYet =
1716
+ err instanceof ServerConnection.ResponseError &&
1717
+ err.response.status === 404;
1718
+ if (!notYet) {
1719
+ break;
1720
+ }
1721
+ await new Promise(resolve => setTimeout(resolve, 1000));
1722
+ }
1723
+ }
1724
+ Notification.warning(
1725
+ `Branched session started, but the name "${title}" could not be applied - use /rename in the session.`,
1726
+ { autoClose: 6000 }
1727
+ );
1728
+ }
1729
+
1597
1730
  /** Switch the active row's project to another conversation branch.
1598
1731
  * The backend touches the branch JSONL's mtime; a refresh then shows
1599
1732
  * the selected conversation as the row's current one. */
package/style/base.css CHANGED
@@ -199,12 +199,37 @@
199
199
  .jp-ClaudeSessionsPanel-dot {
200
200
  background-color: var(--jp-success-color1);
201
201
  box-shadow: 0 0 4px var(--jp-success-color1);
202
+
203
+ /* Softened a touch - full saturation reads too loud next to row text. */
204
+ opacity: 0.75;
202
205
  }
203
206
 
204
207
  .jp-ClaudeSessionsPanel-dotPlaceholder {
205
208
  background-color: transparent;
206
209
  }
207
210
 
211
+ /* Branch icon + conversation count, hugging the name text inside the
212
+ ellipsised name span (not flexed to the row edge). */
213
+ .jp-ClaudeSessionsPanel-branchBadge {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ gap: 2px;
217
+ margin-left: 6px;
218
+ color: var(--jp-ui-font-color2);
219
+ font-size: var(--jp-ui-font-size0);
220
+ vertical-align: middle;
221
+ }
222
+
223
+ .jp-ClaudeSessionsPanel-branchBadgeIcon {
224
+ display: inline-flex;
225
+ align-items: center;
226
+ }
227
+
228
+ .jp-ClaudeSessionsPanel-branchBadgeIcon svg {
229
+ width: 12px;
230
+ height: 12px;
231
+ }
232
+
208
233
  .jp-ClaudeSessionsPanel-favStar {
209
234
  flex: 0 0 auto;
210
235
  display: inline-flex;
@@ -313,7 +338,18 @@
313
338
  .jp-ClaudeSessionsPanel-branchSearch {
314
339
  width: 100%;
315
340
  box-sizing: border-box;
316
- padding: 4px 6px;
341
+ padding: 2px 6px;
342
+ background: var(--jp-input-background, var(--jp-layout-color1));
343
+ color: var(--jp-ui-font-color1);
344
+ border: 1px solid var(--jp-border-color2);
345
+ border-radius: 2px;
346
+ font-family: inherit;
347
+ font-size: var(--jp-ui-font-size1);
348
+ outline: none;
349
+ }
350
+
351
+ .jp-ClaudeSessionsPanel-branchSearch:focus {
352
+ border-color: var(--jp-brand-color1);
317
353
  }
318
354
 
319
355
  .jp-ClaudeSessionsPanel-branchList {
@@ -325,10 +361,13 @@
325
361
  display: flex;
326
362
  align-items: center;
327
363
  justify-content: space-between;
328
- gap: 12px;
329
- padding: 4px 6px;
364
+ gap: 6px;
365
+ height: 24px;
366
+ line-height: 24px;
367
+ padding: 0 8px;
330
368
  cursor: pointer;
331
369
  border-radius: 2px;
370
+ font-size: var(--jp-ui-font-size1);
332
371
  }
333
372
 
334
373
  .jp-ClaudeSessionsPanel-branchRow:hover {
@@ -343,6 +382,8 @@
343
382
 
344
383
  .jp-ClaudeSessionsPanel-branchTime {
345
384
  flex: none;
385
+ width: 52px;
386
+ text-align: right;
346
387
  color: var(--jp-ui-font-color2);
347
388
  font-size: var(--jp-ui-font-size0);
348
389
  }
@@ -394,7 +435,7 @@
394
435
  }
395
436
 
396
437
  .jp-ClaudeSessionsPanel-branchDelete {
397
- padding: 4px 12px;
438
+ padding: 2px 8px;
398
439
  border: 1px solid var(--jp-border-color1);
399
440
  border-radius: 2px;
400
441
  background: var(--jp-layout-color1);
@@ -413,17 +454,22 @@
413
454
  color: var(--jp-error-color1);
414
455
  }
415
456
 
416
- /* Last-activity time on session rows - dim, right of the name. */
457
+ /* Last-activity time on session rows - dim, fixed-width right-aligned
458
+ column so values line up across rows; the star column sits before it. */
417
459
  .jp-ClaudeSessionsPanel-rowTime {
418
460
  flex: 0 0 auto;
419
- margin-left: 6px;
461
+ width: 52px;
462
+ text-align: right;
420
463
  color: var(--jp-ui-font-color2);
421
464
  font-size: var(--jp-ui-font-size0);
422
465
  }
423
466
 
424
467
  /* Age emphasis: a row active within the last minute reads bright, a row
425
- idle for over a week dims; both decay/promote on the next refresh. */
426
- .jp-ClaudeSessionsPanel-row.jp-mod-recentlyActive .jp-ClaudeSessionsPanel-name {
468
+ idle for over a week dims; both decay/promote on the next refresh. The
469
+ "now" time label shares the active colour - same <60s threshold. */
470
+ .jp-ClaudeSessionsPanel-row.jp-mod-recentlyActive .jp-ClaudeSessionsPanel-name,
471
+ .jp-ClaudeSessionsPanel-row.jp-mod-recentlyActive
472
+ .jp-ClaudeSessionsPanel-rowTime {
427
473
  color: var(--jp-brand-color1);
428
474
  }
429
475