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 +15 -3
- package/lib/icons.d.ts +1 -0
- package/lib/icons.js +9 -0
- package/lib/widget.d.ts +14 -0
- package/lib/widget.js +124 -13
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +89 -3
- package/src/icons.ts +11 -0
- package/src/widget.ts +145 -12
- package/style/base.css +54 -8
package/README.md
CHANGED
|
@@ -8,10 +8,21 @@
|
|
|
8
8
|
[](https://kolomolo.com)
|
|
9
9
|
[](https://www.paypal.com/donate/?hosted_button_id=B4KPBJDLLXTSA)
|
|
10
10
|
|
|
11
|
-
|
|
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
|

|
|
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
|
|
24
|
-
- **
|
|
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
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
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
const
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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.
|
|
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
|
|
142
|
-
|
|
143
|
-
/
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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:
|
|
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:
|
|
329
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|