jupyterlab_claude_code_extension 1.2.25 → 1.2.26
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 +1 -0
- package/lib/widget.d.ts +34 -0
- package/lib/widget.js +210 -28
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +135 -0
- package/src/widget.ts +242 -31
- package/style/base.css +19 -0
package/README.md
CHANGED
|
@@ -32,6 +32,7 @@ Chat-panel extensions re-implement the agent loop and trail the real tool. This
|
|
|
32
32
|
- **Remove** - drop a project's Claude history from the panel via the right-click menu; a confirmation dialog names the project and warns it removes the whole project and all its conversations before anything is touched, then the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
|
|
33
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; a confirmation dialog naming the project and the count appears first, and removed files honour the same trash setting
|
|
34
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 - a scrollable table over the full list with the current conversation pinned at the top and accented. Conversations can also be deleted here - select one, many, or all via checkboxes, then click Delete; they move to trash immediately (no confirmation, with an "N moved to trash" message; removed files honour the trash setting). Rows with multiple conversations show a branch icon with the count after the name
|
|
35
|
+
- **Open branched conversation** - a separate right-click "Open Branched Conversation" submenu (and an "Open" button on every row of the Manage Sessions popup) opens any conversation directly in its own terminal, so several branches of one project can run side by side; clicking a row reuses an open terminal only when it is already running that exact conversation, so switching a branch and clicking lands you in the right one rather than the original
|
|
35
36
|
- **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
37
|
- **Copy session id** - a right-click "Copy Session ID" item copies the row's current conversation id to the clipboard; the Manage Sessions popup adds a copy button on every row, so any parallel conversation's id is one click away
|
|
37
38
|
- **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
|
package/lib/widget.d.ts
CHANGED
|
@@ -47,6 +47,21 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
47
47
|
private _remove;
|
|
48
48
|
private _cleanupParallel;
|
|
49
49
|
private _resumeInTerminal;
|
|
50
|
+
/**
|
|
51
|
+
* Reuse an open terminal only when it is running the conversation the
|
|
52
|
+
* caller wants; otherwise launch a fresh ``claude --resume <id>``.
|
|
53
|
+
*
|
|
54
|
+
* ``strict`` selects how a cwd-matching claude terminal whose conversation
|
|
55
|
+
* is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
|
|
56
|
+
* - lenient (row resume): reuse it. A fresh session's id is not in its
|
|
57
|
+
* argv, so this is the only way to refocus a just-started session, and
|
|
58
|
+
* it avoids ``claude --resume`` erroring with "already in use".
|
|
59
|
+
* - strict (Open Branched Conversation): never reuse an unknown terminal -
|
|
60
|
+
* the user picked a specific branch and must land in THAT conversation,
|
|
61
|
+
* so a fresh/foreign terminal is left alone and a new one is launched.
|
|
62
|
+
* A terminal whose resume id is KNOWN and differs is never reused in
|
|
63
|
+
* either mode - that is the switch-then-click bug this fixes.
|
|
64
|
+
*/
|
|
50
65
|
private _doResumeInTerminal;
|
|
51
66
|
/** Absolute path of the file browser's current folder; falls back to the
|
|
52
67
|
* server root when no file browser is available. */
|
|
@@ -143,6 +158,24 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
143
158
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
144
159
|
* the selected conversation as the row's current one. */
|
|
145
160
|
private _switchBranch;
|
|
161
|
+
/** Open a specific conversation branch in its own terminal.
|
|
162
|
+
*
|
|
163
|
+
* Strict reuse: only a terminal already running THIS conversation is
|
|
164
|
+
* refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
|
|
165
|
+
* several branches of one project can be open independently and side by
|
|
166
|
+
* side - opening branch B never disturbs branch A's terminal. Honours the
|
|
167
|
+
* global skip-permissions toggle like a normal resume. */
|
|
168
|
+
private _openBranch;
|
|
169
|
+
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
170
|
+
*
|
|
171
|
+
* claude writes a forked session's JSONL lazily (on its first turn), so a
|
|
172
|
+
* just-requested branch does not exist at launch and would otherwise only
|
|
173
|
+
* surface on the next 30s poll. This watches the project's branch list
|
|
174
|
+
* every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
|
|
175
|
+
* or in the list) - then does one full refresh and stops - or until a
|
|
176
|
+
* bounded number of attempts elapses. The panel is never updated before
|
|
177
|
+
* the branch genuinely exists. */
|
|
178
|
+
private _watchForBranch;
|
|
146
179
|
private _startPolling;
|
|
147
180
|
private _stopPolling;
|
|
148
181
|
private readonly _app;
|
|
@@ -157,6 +190,7 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
157
190
|
private _commands;
|
|
158
191
|
private _contextMenu;
|
|
159
192
|
private _branchSubmenu;
|
|
193
|
+
private _openBranchSubmenu;
|
|
160
194
|
private _branchSessionMenu;
|
|
161
195
|
private _lastBranches;
|
|
162
196
|
private _lastBranchesCurrent;
|
package/lib/widget.js
CHANGED
|
@@ -7,6 +7,10 @@ import { Menu, Widget } from '@lumino/widgets';
|
|
|
7
7
|
import { requestAPI } from './request';
|
|
8
8
|
import { addIcon, branchIcon, switchIcon, claudeIcon, filterIcon, refreshIcon, removeIcon, shieldIcon, starFilledIcon } from './icons';
|
|
9
9
|
const POLL_INTERVAL_MS = 30000;
|
|
10
|
+
// After a fork is requested, watch for its lazily-written JSONL at this fast
|
|
11
|
+
// cadence (bounded) so a new branch surfaces in seconds, not on the slow poll.
|
|
12
|
+
const BRANCH_WATCH_INTERVAL_MS = 2000;
|
|
13
|
+
const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
|
|
10
14
|
const DEFAULT_RECENT_LIMIT = 10;
|
|
11
15
|
const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
|
|
12
16
|
const DEFAULT_PRESENTATION_MODE = 'folder';
|
|
@@ -72,7 +76,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
72
76
|
this._activeRowEl = null;
|
|
73
77
|
this._pollHandle = null;
|
|
74
78
|
this._removingPaths = new Set();
|
|
79
|
+
// Microcache of the most-recent terminal per project, tagged with the
|
|
80
|
+
// conversation it is running so reuse can tell a project's branches apart.
|
|
75
81
|
this._terminalsByPath = new Map();
|
|
82
|
+
// In-flight launches, keyed per CONVERSATION (path + session id) so two
|
|
83
|
+
// different branches of one project can open independently and concurrently.
|
|
76
84
|
this._pendingByPath = new Map();
|
|
77
85
|
this._presentationMode = DEFAULT_PRESENTATION_MODE;
|
|
78
86
|
this._recentLimit = DEFAULT_RECENT_LIMIT;
|
|
@@ -441,44 +449,75 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
441
449
|
}
|
|
442
450
|
}
|
|
443
451
|
// -------------------------------------------------------------- terminal
|
|
444
|
-
async _resumeInTerminal(session, forceDangerous = false) {
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
452
|
+
async _resumeInTerminal(session, forceDangerous = false, strict = false) {
|
|
453
|
+
var _a;
|
|
454
|
+
// Coalesce concurrent clicks on the SAME conversation - subsequent clicks
|
|
455
|
+
// attach to the in-flight promise instead of creating their own terminal.
|
|
456
|
+
// The key is per-conversation, so opening a different branch of the same
|
|
457
|
+
// project launches independently rather than coalescing onto this one.
|
|
458
|
+
const key = `${session.project_path}\n${(_a = session.session_id) !== null && _a !== void 0 ? _a : ''}`;
|
|
459
|
+
const inFlight = this._pendingByPath.get(key);
|
|
448
460
|
if (inFlight) {
|
|
449
461
|
return inFlight;
|
|
450
462
|
}
|
|
451
|
-
const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
|
|
452
|
-
this._pendingByPath.delete(
|
|
463
|
+
const promise = this._doResumeInTerminal(session, forceDangerous, strict).finally(() => {
|
|
464
|
+
this._pendingByPath.delete(key);
|
|
453
465
|
});
|
|
454
|
-
this._pendingByPath.set(
|
|
466
|
+
this._pendingByPath.set(key, promise);
|
|
455
467
|
return promise;
|
|
456
468
|
}
|
|
457
|
-
|
|
469
|
+
/**
|
|
470
|
+
* Reuse an open terminal only when it is running the conversation the
|
|
471
|
+
* caller wants; otherwise launch a fresh ``claude --resume <id>``.
|
|
472
|
+
*
|
|
473
|
+
* ``strict`` selects how a cwd-matching claude terminal whose conversation
|
|
474
|
+
* is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
|
|
475
|
+
* - lenient (row resume): reuse it. A fresh session's id is not in its
|
|
476
|
+
* argv, so this is the only way to refocus a just-started session, and
|
|
477
|
+
* it avoids ``claude --resume`` erroring with "already in use".
|
|
478
|
+
* - strict (Open Branched Conversation): never reuse an unknown terminal -
|
|
479
|
+
* the user picked a specific branch and must land in THAT conversation,
|
|
480
|
+
* so a fresh/foreign terminal is left alone and a new one is launched.
|
|
481
|
+
* A terminal whose resume id is KNOWN and differs is never reused in
|
|
482
|
+
* either mode - that is the switch-then-click bug this fixes.
|
|
483
|
+
*/
|
|
484
|
+
async _doResumeInTerminal(session, forceDangerous, strict) {
|
|
485
|
+
var _a;
|
|
458
486
|
try {
|
|
459
|
-
// Always prefer reusing an open terminal for this
|
|
487
|
+
// Always prefer reusing an open terminal for this conversation. The
|
|
460
488
|
// skip-permissions flag can only be applied to a fresh pty, never
|
|
461
489
|
// retroactively. So if the user wants dangerous mode but an open
|
|
462
490
|
// terminal already exists, show a modal asking them to close it
|
|
463
491
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
464
|
-
// 1. In-memory microcache.
|
|
492
|
+
// 1. In-memory microcache (most-recent terminal for this project).
|
|
465
493
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
466
|
-
if (cached && !cached.isDisposed) {
|
|
467
|
-
|
|
468
|
-
|
|
494
|
+
if (cached && !cached.widget.isDisposed) {
|
|
495
|
+
const sameConversation = cached.sessionId === session.session_id;
|
|
496
|
+
const unknownConversation = !cached.sessionId;
|
|
497
|
+
if (sameConversation || (!strict && unknownConversation)) {
|
|
498
|
+
if (forceDangerous) {
|
|
499
|
+
await this._showCloseExistingDialog();
|
|
500
|
+
}
|
|
501
|
+
this._focusTerminal(cached.widget);
|
|
502
|
+
return;
|
|
469
503
|
}
|
|
470
|
-
this._focusTerminal(cached);
|
|
471
|
-
return;
|
|
472
504
|
}
|
|
473
505
|
// 2. Walk every live terminal widget JL knows about.
|
|
474
|
-
const found = await this._findTerminalForCwd(session.project_path);
|
|
506
|
+
const found = await this._findTerminalForCwd(session.project_path, session.session_id, strict);
|
|
475
507
|
if (found) {
|
|
476
|
-
|
|
477
|
-
|
|
508
|
+
// Tag the cache with the OBSERVED conversation (null when the reused
|
|
509
|
+
// terminal is a fresh no-resume one), not the wanted id - otherwise a
|
|
510
|
+
// later strict reuse would trust a fabricated tag and focus the wrong
|
|
511
|
+
// conversation.
|
|
512
|
+
this._terminalsByPath.set(session.project_path, {
|
|
513
|
+
widget: found.widget,
|
|
514
|
+
sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
|
|
515
|
+
});
|
|
516
|
+
this._wireTerminalDisposal(session.project_path, found.widget);
|
|
478
517
|
if (forceDangerous) {
|
|
479
518
|
await this._showCloseExistingDialog();
|
|
480
519
|
}
|
|
481
|
-
this._focusTerminal(found);
|
|
520
|
+
this._focusTerminal(found.widget);
|
|
482
521
|
return;
|
|
483
522
|
}
|
|
484
523
|
// 3. No matching terminal - spawn a new one with `claude --resume <id>`
|
|
@@ -502,7 +541,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
502
541
|
name: launched.terminal_name
|
|
503
542
|
});
|
|
504
543
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
505
|
-
this._terminalsByPath.set(session.project_path,
|
|
544
|
+
this._terminalsByPath.set(session.project_path, {
|
|
545
|
+
widget,
|
|
546
|
+
sessionId: session.session_id
|
|
547
|
+
});
|
|
506
548
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
507
549
|
this._focusTerminal(widget);
|
|
508
550
|
}
|
|
@@ -552,7 +594,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
552
594
|
name: launched.terminal_name
|
|
553
595
|
});
|
|
554
596
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
555
|
-
|
|
597
|
+
// A fresh session's conversation id is not in its argv, so leave
|
|
598
|
+
// sessionId undefined - reuse treats it as the project's current.
|
|
599
|
+
this._terminalsByPath.set(projectPath, {
|
|
600
|
+
widget,
|
|
601
|
+
sessionId: undefined
|
|
602
|
+
});
|
|
556
603
|
this._wireTerminalDisposal(projectPath, widget);
|
|
557
604
|
this._focusTerminal(widget);
|
|
558
605
|
}
|
|
@@ -592,8 +639,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
592
639
|
}
|
|
593
640
|
});
|
|
594
641
|
}
|
|
595
|
-
async _findTerminalForCwd(projectPath) {
|
|
596
|
-
var _a, _b;
|
|
642
|
+
async _findTerminalForCwd(projectPath, wantedSessionId, strict) {
|
|
643
|
+
var _a, _b, _c;
|
|
597
644
|
if (!this._terminalTracker) {
|
|
598
645
|
return null;
|
|
599
646
|
}
|
|
@@ -617,10 +664,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
617
664
|
if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
|
|
618
665
|
continue;
|
|
619
666
|
}
|
|
667
|
+
// Conversation gate: reuse only a terminal running the wanted
|
|
668
|
+
// conversation. A known, different resume id is never reused (the
|
|
669
|
+
// switch-then-click bug). An unknown id (fresh session, no --resume)
|
|
670
|
+
// is reused in lenient mode but skipped in strict mode, where the
|
|
671
|
+
// user picked a specific branch to open.
|
|
672
|
+
const runningId = (_c = data.session_id) !== null && _c !== void 0 ? _c : null;
|
|
673
|
+
const sameConversation = runningId === wantedSessionId;
|
|
674
|
+
const unknownConversation = runningId === null;
|
|
675
|
+
if (!(sameConversation || (!strict && unknownConversation))) {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
620
678
|
const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
|
|
621
679
|
for (const cwd of cwds) {
|
|
622
680
|
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
623
|
-
|
|
681
|
+
// Return the OBSERVED running id (may be null for a fresh
|
|
682
|
+
// terminal), never the wanted id - the caller tags its cache
|
|
683
|
+
// with this so a later strict reuse trusts the process, not a
|
|
684
|
+
// wish.
|
|
685
|
+
return { widget, runningId };
|
|
624
686
|
}
|
|
625
687
|
}
|
|
626
688
|
}
|
|
@@ -667,7 +729,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
667
729
|
return;
|
|
668
730
|
}
|
|
669
731
|
widget.disposed.connect(() => {
|
|
670
|
-
|
|
732
|
+
var _a;
|
|
733
|
+
if (((_a = this._terminalsByPath.get(projectPath)) === null || _a === void 0 ? void 0 : _a.widget) === widget) {
|
|
671
734
|
this._terminalsByPath.delete(projectPath);
|
|
672
735
|
}
|
|
673
736
|
});
|
|
@@ -1127,6 +1190,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1127
1190
|
void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
|
|
1128
1191
|
}
|
|
1129
1192
|
});
|
|
1193
|
+
this._commands.addCommand('claude-code-sessions:open-branch', {
|
|
1194
|
+
label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
|
|
1195
|
+
icon: terminalIcon,
|
|
1196
|
+
execute: args => {
|
|
1197
|
+
var _a;
|
|
1198
|
+
const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
|
|
1199
|
+
if (sessionId) {
|
|
1200
|
+
void this._openBranch(sessionId);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1130
1204
|
this._commands.addCommand('claude-code-sessions:branch-session', {
|
|
1131
1205
|
label: 'Normal',
|
|
1132
1206
|
execute: () => void this._branchSession(false)
|
|
@@ -1171,6 +1245,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1171
1245
|
this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1172
1246
|
this._branchSubmenu.title.label = 'Switch and Manage Sessions';
|
|
1173
1247
|
this._branchSubmenu.title.icon = switchIcon;
|
|
1248
|
+
// Submenu that OPENS a conversation directly in its own terminal (vs the
|
|
1249
|
+
// switch submenu, which only changes which branch the row points at).
|
|
1250
|
+
// Several branches can be open at once, independently.
|
|
1251
|
+
this._openBranchSubmenu = new Menu({ commands: this._commands });
|
|
1252
|
+
this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1253
|
+
this._openBranchSubmenu.title.label = 'Open Branched Conversation';
|
|
1254
|
+
this._openBranchSubmenu.title.icon = terminalIcon;
|
|
1174
1255
|
// Submenu grouping the two branch-session launch modes.
|
|
1175
1256
|
this._branchSessionMenu = new Menu({ commands: this._commands });
|
|
1176
1257
|
this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
@@ -1217,6 +1298,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1217
1298
|
});
|
|
1218
1299
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1219
1300
|
if (withBranches) {
|
|
1301
|
+
this._contextMenu.addItem({
|
|
1302
|
+
type: 'submenu',
|
|
1303
|
+
submenu: this._openBranchSubmenu
|
|
1304
|
+
});
|
|
1220
1305
|
this._contextMenu.addItem({
|
|
1221
1306
|
type: 'submenu',
|
|
1222
1307
|
submenu: this._branchSubmenu
|
|
@@ -1259,6 +1344,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1259
1344
|
this._branchSubmenu.addItem({
|
|
1260
1345
|
command: 'claude-code-sessions:switch-branch-more'
|
|
1261
1346
|
});
|
|
1347
|
+
// Open submenu: same top-5 branches, but each launches its own
|
|
1348
|
+
// terminal directly. Falls through to the Manage Sessions popup for
|
|
1349
|
+
// the full list (from which any conversation can also be opened).
|
|
1350
|
+
this._openBranchSubmenu.clearItems();
|
|
1351
|
+
this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
|
|
1352
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1353
|
+
this._openBranchSubmenu.addItem({
|
|
1354
|
+
command: 'claude-code-sessions:open-branch',
|
|
1355
|
+
args: {
|
|
1356
|
+
session_id: b.session_id,
|
|
1357
|
+
label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1358
|
+
}
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
this._openBranchSubmenu.addItem({ type: 'separator' });
|
|
1362
|
+
this._openBranchSubmenu.addItem({
|
|
1363
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1364
|
+
});
|
|
1262
1365
|
hasBranches = data.branches.length > 0;
|
|
1263
1366
|
}
|
|
1264
1367
|
catch (_a) {
|
|
@@ -1346,6 +1449,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1346
1449
|
body: bodyWidget,
|
|
1347
1450
|
buttons: [Dialog.cancelButton()]
|
|
1348
1451
|
});
|
|
1452
|
+
// Per-row "Open" launches that conversation in its own terminal (strict
|
|
1453
|
+
// reuse via _openBranch) and closes the popup. stopPropagation keeps the
|
|
1454
|
+
// click from toggling selection or switching the row.
|
|
1455
|
+
const openButton = (sessionId) => {
|
|
1456
|
+
const btn = document.createElement('button');
|
|
1457
|
+
btn.type = 'button';
|
|
1458
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
|
|
1459
|
+
btn.textContent = 'Open';
|
|
1460
|
+
btn.title = 'Open this conversation in its own terminal';
|
|
1461
|
+
btn.addEventListener('click', e => {
|
|
1462
|
+
e.stopPropagation();
|
|
1463
|
+
dialog.dispose();
|
|
1464
|
+
void this._openBranch(sessionId);
|
|
1465
|
+
});
|
|
1466
|
+
return btn;
|
|
1467
|
+
};
|
|
1349
1468
|
const visibleMatches = () => {
|
|
1350
1469
|
const needle = search.value.trim().toLowerCase();
|
|
1351
1470
|
return items.filter(b => !needle ||
|
|
@@ -1393,6 +1512,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1393
1512
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1394
1513
|
badge.textContent = 'current';
|
|
1395
1514
|
currentRow.appendChild(badge);
|
|
1515
|
+
currentRow.appendChild(openButton(current));
|
|
1396
1516
|
currentRow.appendChild(this._branchCopyButton(current));
|
|
1397
1517
|
list.appendChild(currentRow);
|
|
1398
1518
|
const matches = visibleMatches();
|
|
@@ -1450,6 +1570,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1450
1570
|
time.className = 'jp-ClaudeSessionsPanel-branchTime';
|
|
1451
1571
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1452
1572
|
row.appendChild(time);
|
|
1573
|
+
row.appendChild(openButton(b.session_id));
|
|
1453
1574
|
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1454
1575
|
row.addEventListener('click', () => {
|
|
1455
1576
|
// Selection mode: while anything is ticked, row clicks toggle
|
|
@@ -1660,7 +1781,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1660
1781
|
name: launched.terminal_name
|
|
1661
1782
|
});
|
|
1662
1783
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
1663
|
-
|
|
1784
|
+
// The terminal runs the FORK (forkId), so tag it with that id - a
|
|
1785
|
+
// later click on the now-current forked row reuses this terminal.
|
|
1786
|
+
this._terminalsByPath.set(session.project_path, {
|
|
1787
|
+
widget,
|
|
1788
|
+
sessionId: forkId
|
|
1789
|
+
});
|
|
1664
1790
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
1665
1791
|
this._focusTerminal(widget);
|
|
1666
1792
|
}
|
|
@@ -1673,8 +1799,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1673
1799
|
spinner.dispose();
|
|
1674
1800
|
}
|
|
1675
1801
|
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1676
|
-
// on the fork's first turn.
|
|
1677
|
-
//
|
|
1802
|
+
// on the fork's first turn. claude materialises the fork's JSONL lazily,
|
|
1803
|
+
// so watch for it at a fast cadence and refresh the moment it appears -
|
|
1804
|
+
// the branch surfaces in seconds instead of on the next slow poll.
|
|
1805
|
+
this._watchForBranch(session.encoded_path, forkId);
|
|
1678
1806
|
}
|
|
1679
1807
|
/** Switch the active row's project to another conversation branch.
|
|
1680
1808
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
@@ -1709,6 +1837,60 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1709
1837
|
await this._fetch();
|
|
1710
1838
|
}
|
|
1711
1839
|
}
|
|
1840
|
+
/** Open a specific conversation branch in its own terminal.
|
|
1841
|
+
*
|
|
1842
|
+
* Strict reuse: only a terminal already running THIS conversation is
|
|
1843
|
+
* refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
|
|
1844
|
+
* several branches of one project can be open independently and side by
|
|
1845
|
+
* side - opening branch B never disturbs branch A's terminal. Honours the
|
|
1846
|
+
* global skip-permissions toggle like a normal resume. */
|
|
1847
|
+
async _openBranch(sessionId) {
|
|
1848
|
+
const active = this._activeSession;
|
|
1849
|
+
if (!active || !sessionId) {
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
// Opening the row's CURRENT conversation behaves like a normal row resume
|
|
1853
|
+
// (lenient): a fresh terminal already running it is reused, avoiding a
|
|
1854
|
+
// `claude --resume` "already in use" collision. Opening a DIFFERENT branch
|
|
1855
|
+
// is strict so the user lands in that specific conversation, even if some
|
|
1856
|
+
// other (fresh) terminal sits at the same cwd.
|
|
1857
|
+
const strict = sessionId !== active.session_id;
|
|
1858
|
+
await this._resumeInTerminal({ ...active, session_id: sessionId }, false, strict);
|
|
1859
|
+
}
|
|
1860
|
+
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
1861
|
+
*
|
|
1862
|
+
* claude writes a forked session's JSONL lazily (on its first turn), so a
|
|
1863
|
+
* just-requested branch does not exist at launch and would otherwise only
|
|
1864
|
+
* surface on the next 30s poll. This watches the project's branch list
|
|
1865
|
+
* every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
|
|
1866
|
+
* or in the list) - then does one full refresh and stops - or until a
|
|
1867
|
+
* bounded number of attempts elapses. The panel is never updated before
|
|
1868
|
+
* the branch genuinely exists. */
|
|
1869
|
+
_watchForBranch(encodedPath, forkId) {
|
|
1870
|
+
let attempts = 0;
|
|
1871
|
+
const tick = async () => {
|
|
1872
|
+
if (this.isDisposed) {
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
attempts += 1;
|
|
1876
|
+
try {
|
|
1877
|
+
const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`, this._serverSettings, { cache: 'no-store' });
|
|
1878
|
+
const appeared = data.current === forkId ||
|
|
1879
|
+
data.branches.some(b => b.session_id === forkId);
|
|
1880
|
+
if (appeared) {
|
|
1881
|
+
await this._fetch();
|
|
1882
|
+
return;
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
catch (_a) {
|
|
1886
|
+
// Transient failure - keep watching until the attempt budget runs out.
|
|
1887
|
+
}
|
|
1888
|
+
if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
|
|
1889
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
1893
|
+
}
|
|
1712
1894
|
// --------------------------------------------------------------- polling
|
|
1713
1895
|
_startPolling() {
|
|
1714
1896
|
if (this._pollHandle !== null) {
|
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.26",
|
|
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",
|
|
@@ -506,6 +506,141 @@ describe('launch spinner dismiss contract', () => {
|
|
|
506
506
|
});
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Contract for conversation-aware terminal reuse and the Open Branched
|
|
511
|
+
* Conversation feature. The reuse path must refuse to focus a terminal
|
|
512
|
+
* running a DIFFERENT known conversation (the switch-then-click bug), and
|
|
513
|
+
* opening a specific branch must launch its own terminal (strict) so
|
|
514
|
+
* several branches stay open independently.
|
|
515
|
+
*/
|
|
516
|
+
describe('conversation-aware reuse + open-branch contract', () => {
|
|
517
|
+
const findTerm = (widgetSrc.match(
|
|
518
|
+
/private async _findTerminalForCwd[\s\S]*?\n \}/
|
|
519
|
+
) ?? [''])[0];
|
|
520
|
+
const doResume = (widgetSrc.match(
|
|
521
|
+
/private async _doResumeInTerminal[\s\S]*?\n \}/
|
|
522
|
+
) ?? [''])[0];
|
|
523
|
+
const resume = (widgetSrc.match(
|
|
524
|
+
/private async _resumeInTerminal[\s\S]*?\n \}/
|
|
525
|
+
) ?? [''])[0];
|
|
526
|
+
const openBranch = (widgetSrc.match(
|
|
527
|
+
/private async _openBranch[\s\S]*?\n \}/
|
|
528
|
+
) ?? [''])[0];
|
|
529
|
+
const watch = (widgetSrc.match(/private _watchForBranch[\s\S]*?\n \}/) ?? [
|
|
530
|
+
''
|
|
531
|
+
])[0];
|
|
532
|
+
|
|
533
|
+
it('terminal-cwd response carries the running conversation id', () => {
|
|
534
|
+
const iface = (widgetSrc.match(
|
|
535
|
+
/interface ITerminalCwdResponse \{[\s\S]*?\}/
|
|
536
|
+
) ?? [''])[0];
|
|
537
|
+
expect(iface).toMatch(/session_id\?: string \| null/);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('_findTerminalForCwd takes the wanted id and a strict flag', () => {
|
|
541
|
+
expect(findTerm).toMatch(
|
|
542
|
+
/_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined,\s*strict: boolean/
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('reuse rejects a terminal running a known different conversation', () => {
|
|
547
|
+
// Same conversation, or unknown (fresh) only in lenient mode, may reuse.
|
|
548
|
+
expect(findTerm).toMatch(/const runningId = data\.session_id \?\? null/);
|
|
549
|
+
expect(findTerm).toMatch(
|
|
550
|
+
/sameConversation = runningId === wantedSessionId/
|
|
551
|
+
);
|
|
552
|
+
expect(findTerm).toMatch(/unknownConversation = runningId === null/);
|
|
553
|
+
expect(findTerm).toMatch(
|
|
554
|
+
/if \(!\(sameConversation \|\| \(!strict && unknownConversation\)\)\) \{\s*continue;/
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('microcache reuse is gated on the conversation id', () => {
|
|
559
|
+
expect(doResume).toMatch(/cached\.sessionId === session\.session_id/);
|
|
560
|
+
expect(doResume).toMatch(/!strict && unknownConversation/);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('microcache is tagged with the OBSERVED running id, not the wanted one', () => {
|
|
564
|
+
// Storing the wanted id would let a later strict reuse trust a
|
|
565
|
+
// fabricated tag and focus the wrong conversation (review finding 1).
|
|
566
|
+
expect(doResume).toMatch(
|
|
567
|
+
/widget: found\.widget,\s*sessionId: found\.runningId \?\? undefined/
|
|
568
|
+
);
|
|
569
|
+
expect(findTerm).toMatch(/return \{ widget, runningId \}/);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('in-flight launches are keyed per conversation, not per project', () => {
|
|
573
|
+
expect(resume).toMatch(
|
|
574
|
+
/const key = `\$\{session\.project_path\}\\n\$\{session\.session_id \?\? ''\}`/
|
|
575
|
+
);
|
|
576
|
+
expect(resume).toMatch(/this\._pendingByPath\.(get|set)\(key/);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('_openBranch is strict for a different branch, lenient for the current', () => {
|
|
580
|
+
// Strict on a non-current branch lands the user in THAT conversation;
|
|
581
|
+
// lenient on the current avoids a `claude --resume` "already in use"
|
|
582
|
+
// collision with a fresh terminal already running it (review finding 2).
|
|
583
|
+
expect(openBranch).toMatch(
|
|
584
|
+
/const strict = sessionId !== active\.session_id/
|
|
585
|
+
);
|
|
586
|
+
expect(openBranch).toMatch(
|
|
587
|
+
/this\._resumeInTerminal\(\s*\{ \.\.\.active, session_id: sessionId \},\s*false,\s*strict\s*\)/
|
|
588
|
+
);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('open-branch command and submenu are wired up', () => {
|
|
592
|
+
expect(widgetSrc).toMatch(/'claude-code-sessions:open-branch'/);
|
|
593
|
+
expect(widgetSrc).toMatch(
|
|
594
|
+
/this\._openBranchSubmenu\.title\.label = 'Open Branched Conversation'/
|
|
595
|
+
);
|
|
596
|
+
// Top 5 branches populate the open submenu, each via the open command.
|
|
597
|
+
const populate = (widgetSrc.match(
|
|
598
|
+
/this\._openBranchSubmenu\.clearItems[\s\S]*?switch-branch-more'\s*\}\);/
|
|
599
|
+
) ?? [''])[0];
|
|
600
|
+
expect(populate).toMatch(/data\.branches\.slice\(0, 5\)/);
|
|
601
|
+
expect(populate).toMatch(/command: 'claude-code-sessions:open-branch'/);
|
|
602
|
+
// The context menu shows the open submenu when the row has branches.
|
|
603
|
+
const rebuild = (widgetSrc.match(
|
|
604
|
+
/private _rebuildContextMenu[\s\S]*?\n \}/
|
|
605
|
+
) ?? [''])[0];
|
|
606
|
+
expect(rebuild).toMatch(/submenu: this\._openBranchSubmenu/);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('popup rows carry an Open button that launches the branch', () => {
|
|
610
|
+
const popup = (widgetSrc.match(
|
|
611
|
+
/private _showBranchPopup[\s\S]*?\n \}\n/
|
|
612
|
+
) ?? [''])[0];
|
|
613
|
+
expect(popup).toMatch(/jp-ClaudeSessionsPanel-branchOpen/);
|
|
614
|
+
expect(popup).toMatch(/void this\._openBranch\(sessionId\)/);
|
|
615
|
+
// Open appears on both the current row and each branch row.
|
|
616
|
+
expect(popup).toMatch(/openButton\(current\)/);
|
|
617
|
+
expect(popup).toMatch(/openButton\(b\.session_id\)/);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('a new branch is watched for and surfaces fast, not on the slow poll', () => {
|
|
621
|
+
expect(watch).toMatch(/data\.current === forkId/);
|
|
622
|
+
expect(watch).toMatch(/b\.session_id === forkId/);
|
|
623
|
+
expect(watch).toMatch(/await this\._fetch\(\)/);
|
|
624
|
+
expect(watch).toMatch(/BRANCH_WATCH_MAX_ATTEMPTS/);
|
|
625
|
+
// Fork launch arms the watcher.
|
|
626
|
+
const fork = (widgetSrc.match(
|
|
627
|
+
/private async _branchSession[\s\S]*?\n \}/
|
|
628
|
+
) ?? [''])[0];
|
|
629
|
+
expect(fork).toMatch(
|
|
630
|
+
/this\._watchForBranch\(session\.encoded_path, forkId\)/
|
|
631
|
+
);
|
|
632
|
+
expect(widgetSrc).toMatch(/BRANCH_WATCH_INTERVAL_MS = 2_000/);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('the Open button is styled in base.css', () => {
|
|
636
|
+
const css: string = fs.readFileSync(
|
|
637
|
+
path.join(__dirname, '..', '..', 'style', 'base.css'),
|
|
638
|
+
'utf-8'
|
|
639
|
+
);
|
|
640
|
+
expect(css).toMatch(/\.jp-ClaudeSessionsPanel-branchOpen \{/);
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
509
644
|
it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
|
|
510
645
|
expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
|
|
511
646
|
expect(widgetSrc).not.toMatch(/spinner\.resolve\(\)/);
|
package/src/widget.ts
CHANGED
|
@@ -41,6 +41,10 @@ import {
|
|
|
41
41
|
} from './types';
|
|
42
42
|
|
|
43
43
|
const POLL_INTERVAL_MS = 30_000;
|
|
44
|
+
// After a fork is requested, watch for its lazily-written JSONL at this fast
|
|
45
|
+
// cadence (bounded) so a new branch surfaces in seconds, not on the slow poll.
|
|
46
|
+
const BRANCH_WATCH_INTERVAL_MS = 2_000;
|
|
47
|
+
const BRANCH_WATCH_MAX_ATTEMPTS = 90; // ~3 minutes
|
|
44
48
|
const DEFAULT_RECENT_LIMIT = 10;
|
|
45
49
|
const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
|
|
46
50
|
|
|
@@ -97,6 +101,9 @@ interface ITerminalCwdResponse {
|
|
|
97
101
|
terminal_name: string;
|
|
98
102
|
cwds: string[];
|
|
99
103
|
has_claude: boolean;
|
|
104
|
+
// Conversation id the running claude is resuming, or null for a fresh
|
|
105
|
+
// (non-resumed) session. Lets reuse tell branches of one project apart.
|
|
106
|
+
session_id?: string | null;
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
// Drop any pre-v0.6.18 localStorage entries from previous schemes - they
|
|
@@ -544,53 +551,90 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
544
551
|
|
|
545
552
|
private async _resumeInTerminal(
|
|
546
553
|
session: ISession,
|
|
547
|
-
forceDangerous: boolean = false
|
|
554
|
+
forceDangerous: boolean = false,
|
|
555
|
+
strict: boolean = false
|
|
548
556
|
): Promise<void> {
|
|
549
|
-
// Coalesce concurrent clicks on the
|
|
550
|
-
// to the in-flight promise instead of creating their own terminal.
|
|
551
|
-
|
|
557
|
+
// Coalesce concurrent clicks on the SAME conversation - subsequent clicks
|
|
558
|
+
// attach to the in-flight promise instead of creating their own terminal.
|
|
559
|
+
// The key is per-conversation, so opening a different branch of the same
|
|
560
|
+
// project launches independently rather than coalescing onto this one.
|
|
561
|
+
const key = `${session.project_path}\n${session.session_id ?? ''}`;
|
|
562
|
+
const inFlight = this._pendingByPath.get(key);
|
|
552
563
|
if (inFlight) {
|
|
553
564
|
return inFlight;
|
|
554
565
|
}
|
|
555
|
-
const promise = this._doResumeInTerminal(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
)
|
|
560
|
-
|
|
566
|
+
const promise = this._doResumeInTerminal(
|
|
567
|
+
session,
|
|
568
|
+
forceDangerous,
|
|
569
|
+
strict
|
|
570
|
+
).finally(() => {
|
|
571
|
+
this._pendingByPath.delete(key);
|
|
572
|
+
});
|
|
573
|
+
this._pendingByPath.set(key, promise);
|
|
561
574
|
return promise;
|
|
562
575
|
}
|
|
563
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Reuse an open terminal only when it is running the conversation the
|
|
579
|
+
* caller wants; otherwise launch a fresh ``claude --resume <id>``.
|
|
580
|
+
*
|
|
581
|
+
* ``strict`` selects how a cwd-matching claude terminal whose conversation
|
|
582
|
+
* is UNKNOWN (started fresh, no ``--resume`` in its argv) is treated:
|
|
583
|
+
* - lenient (row resume): reuse it. A fresh session's id is not in its
|
|
584
|
+
* argv, so this is the only way to refocus a just-started session, and
|
|
585
|
+
* it avoids ``claude --resume`` erroring with "already in use".
|
|
586
|
+
* - strict (Open Branched Conversation): never reuse an unknown terminal -
|
|
587
|
+
* the user picked a specific branch and must land in THAT conversation,
|
|
588
|
+
* so a fresh/foreign terminal is left alone and a new one is launched.
|
|
589
|
+
* A terminal whose resume id is KNOWN and differs is never reused in
|
|
590
|
+
* either mode - that is the switch-then-click bug this fixes.
|
|
591
|
+
*/
|
|
564
592
|
private async _doResumeInTerminal(
|
|
565
593
|
session: ISession,
|
|
566
|
-
forceDangerous: boolean
|
|
594
|
+
forceDangerous: boolean,
|
|
595
|
+
strict: boolean
|
|
567
596
|
): Promise<void> {
|
|
568
597
|
try {
|
|
569
|
-
// Always prefer reusing an open terminal for this
|
|
598
|
+
// Always prefer reusing an open terminal for this conversation. The
|
|
570
599
|
// skip-permissions flag can only be applied to a fresh pty, never
|
|
571
600
|
// retroactively. So if the user wants dangerous mode but an open
|
|
572
601
|
// terminal already exists, show a modal asking them to close it
|
|
573
602
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
574
603
|
|
|
575
|
-
// 1. In-memory microcache.
|
|
604
|
+
// 1. In-memory microcache (most-recent terminal for this project).
|
|
576
605
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
577
|
-
if (cached && !cached.isDisposed) {
|
|
578
|
-
|
|
579
|
-
|
|
606
|
+
if (cached && !cached.widget.isDisposed) {
|
|
607
|
+
const sameConversation = cached.sessionId === session.session_id;
|
|
608
|
+
const unknownConversation = !cached.sessionId;
|
|
609
|
+
if (sameConversation || (!strict && unknownConversation)) {
|
|
610
|
+
if (forceDangerous) {
|
|
611
|
+
await this._showCloseExistingDialog();
|
|
612
|
+
}
|
|
613
|
+
this._focusTerminal(cached.widget);
|
|
614
|
+
return;
|
|
580
615
|
}
|
|
581
|
-
this._focusTerminal(cached);
|
|
582
|
-
return;
|
|
583
616
|
}
|
|
584
617
|
|
|
585
618
|
// 2. Walk every live terminal widget JL knows about.
|
|
586
|
-
const found = await this._findTerminalForCwd(
|
|
619
|
+
const found = await this._findTerminalForCwd(
|
|
620
|
+
session.project_path,
|
|
621
|
+
session.session_id,
|
|
622
|
+
strict
|
|
623
|
+
);
|
|
587
624
|
if (found) {
|
|
588
|
-
|
|
589
|
-
|
|
625
|
+
// Tag the cache with the OBSERVED conversation (null when the reused
|
|
626
|
+
// terminal is a fresh no-resume one), not the wanted id - otherwise a
|
|
627
|
+
// later strict reuse would trust a fabricated tag and focus the wrong
|
|
628
|
+
// conversation.
|
|
629
|
+
this._terminalsByPath.set(session.project_path, {
|
|
630
|
+
widget: found.widget,
|
|
631
|
+
sessionId: found.runningId ?? undefined
|
|
632
|
+
});
|
|
633
|
+
this._wireTerminalDisposal(session.project_path, found.widget);
|
|
590
634
|
if (forceDangerous) {
|
|
591
635
|
await this._showCloseExistingDialog();
|
|
592
636
|
}
|
|
593
|
-
this._focusTerminal(found);
|
|
637
|
+
this._focusTerminal(found.widget);
|
|
594
638
|
return;
|
|
595
639
|
}
|
|
596
640
|
|
|
@@ -620,7 +664,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
620
664
|
name: launched.terminal_name
|
|
621
665
|
});
|
|
622
666
|
if (widget?.id) {
|
|
623
|
-
this._terminalsByPath.set(session.project_path,
|
|
667
|
+
this._terminalsByPath.set(session.project_path, {
|
|
668
|
+
widget,
|
|
669
|
+
sessionId: session.session_id
|
|
670
|
+
});
|
|
624
671
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
625
672
|
this._focusTerminal(widget);
|
|
626
673
|
}
|
|
@@ -673,7 +720,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
673
720
|
name: launched.terminal_name
|
|
674
721
|
});
|
|
675
722
|
if (widget?.id) {
|
|
676
|
-
|
|
723
|
+
// A fresh session's conversation id is not in its argv, so leave
|
|
724
|
+
// sessionId undefined - reuse treats it as the project's current.
|
|
725
|
+
this._terminalsByPath.set(projectPath, {
|
|
726
|
+
widget,
|
|
727
|
+
sessionId: undefined
|
|
728
|
+
});
|
|
677
729
|
this._wireTerminalDisposal(projectPath, widget);
|
|
678
730
|
this._focusTerminal(widget);
|
|
679
731
|
}
|
|
@@ -711,7 +763,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
711
763
|
});
|
|
712
764
|
}
|
|
713
765
|
|
|
714
|
-
private async _findTerminalForCwd(
|
|
766
|
+
private async _findTerminalForCwd(
|
|
767
|
+
projectPath: string,
|
|
768
|
+
wantedSessionId: string | undefined,
|
|
769
|
+
strict: boolean
|
|
770
|
+
): Promise<{ widget: any; runningId: string | null } | null> {
|
|
715
771
|
if (!this._terminalTracker) {
|
|
716
772
|
return null;
|
|
717
773
|
}
|
|
@@ -738,10 +794,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
738
794
|
if (!data?.has_claude) {
|
|
739
795
|
continue;
|
|
740
796
|
}
|
|
797
|
+
// Conversation gate: reuse only a terminal running the wanted
|
|
798
|
+
// conversation. A known, different resume id is never reused (the
|
|
799
|
+
// switch-then-click bug). An unknown id (fresh session, no --resume)
|
|
800
|
+
// is reused in lenient mode but skipped in strict mode, where the
|
|
801
|
+
// user picked a specific branch to open.
|
|
802
|
+
const runningId = data.session_id ?? null;
|
|
803
|
+
const sameConversation = runningId === wantedSessionId;
|
|
804
|
+
const unknownConversation = runningId === null;
|
|
805
|
+
if (!(sameConversation || (!strict && unknownConversation))) {
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
741
808
|
const cwds = Array.isArray(data?.cwds) ? data.cwds : [];
|
|
742
809
|
for (const cwd of cwds) {
|
|
743
810
|
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
744
|
-
|
|
811
|
+
// Return the OBSERVED running id (may be null for a fresh
|
|
812
|
+
// terminal), never the wanted id - the caller tags its cache
|
|
813
|
+
// with this so a later strict reuse trusts the process, not a
|
|
814
|
+
// wish.
|
|
815
|
+
return { widget, runningId };
|
|
745
816
|
}
|
|
746
817
|
}
|
|
747
818
|
} catch (_err) {
|
|
@@ -792,7 +863,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
792
863
|
return;
|
|
793
864
|
}
|
|
794
865
|
widget.disposed.connect(() => {
|
|
795
|
-
if (this._terminalsByPath.get(projectPath) === widget) {
|
|
866
|
+
if (this._terminalsByPath.get(projectPath)?.widget === widget) {
|
|
796
867
|
this._terminalsByPath.delete(projectPath);
|
|
797
868
|
}
|
|
798
869
|
});
|
|
@@ -1311,6 +1382,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1311
1382
|
}
|
|
1312
1383
|
});
|
|
1313
1384
|
|
|
1385
|
+
this._commands.addCommand('claude-code-sessions:open-branch', {
|
|
1386
|
+
label: args => String(args.label ?? ''),
|
|
1387
|
+
icon: terminalIcon,
|
|
1388
|
+
execute: args => {
|
|
1389
|
+
const sessionId = String(args.session_id ?? '');
|
|
1390
|
+
if (sessionId) {
|
|
1391
|
+
void this._openBranch(sessionId);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
|
|
1314
1396
|
this._commands.addCommand('claude-code-sessions:branch-session', {
|
|
1315
1397
|
label: 'Normal',
|
|
1316
1398
|
execute: () => void this._branchSession(false)
|
|
@@ -1362,6 +1444,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1362
1444
|
this._branchSubmenu.title.label = 'Switch and Manage Sessions';
|
|
1363
1445
|
this._branchSubmenu.title.icon = switchIcon;
|
|
1364
1446
|
|
|
1447
|
+
// Submenu that OPENS a conversation directly in its own terminal (vs the
|
|
1448
|
+
// switch submenu, which only changes which branch the row points at).
|
|
1449
|
+
// Several branches can be open at once, independently.
|
|
1450
|
+
this._openBranchSubmenu = new Menu({ commands: this._commands });
|
|
1451
|
+
this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1452
|
+
this._openBranchSubmenu.title.label = 'Open Branched Conversation';
|
|
1453
|
+
this._openBranchSubmenu.title.icon = terminalIcon;
|
|
1454
|
+
|
|
1365
1455
|
// Submenu grouping the two branch-session launch modes.
|
|
1366
1456
|
this._branchSessionMenu = new Menu({ commands: this._commands });
|
|
1367
1457
|
this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
@@ -1411,6 +1501,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1411
1501
|
});
|
|
1412
1502
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1413
1503
|
if (withBranches) {
|
|
1504
|
+
this._contextMenu.addItem({
|
|
1505
|
+
type: 'submenu',
|
|
1506
|
+
submenu: this._openBranchSubmenu
|
|
1507
|
+
});
|
|
1414
1508
|
this._contextMenu.addItem({
|
|
1415
1509
|
type: 'submenu',
|
|
1416
1510
|
submenu: this._branchSubmenu
|
|
@@ -1462,6 +1556,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1462
1556
|
this._branchSubmenu.addItem({
|
|
1463
1557
|
command: 'claude-code-sessions:switch-branch-more'
|
|
1464
1558
|
});
|
|
1559
|
+
|
|
1560
|
+
// Open submenu: same top-5 branches, but each launches its own
|
|
1561
|
+
// terminal directly. Falls through to the Manage Sessions popup for
|
|
1562
|
+
// the full list (from which any conversation can also be opened).
|
|
1563
|
+
this._openBranchSubmenu.clearItems();
|
|
1564
|
+
this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
|
|
1565
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1566
|
+
this._openBranchSubmenu.addItem({
|
|
1567
|
+
command: 'claude-code-sessions:open-branch',
|
|
1568
|
+
args: {
|
|
1569
|
+
session_id: b.session_id,
|
|
1570
|
+
label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1571
|
+
}
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
this._openBranchSubmenu.addItem({ type: 'separator' });
|
|
1575
|
+
this._openBranchSubmenu.addItem({
|
|
1576
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1577
|
+
});
|
|
1465
1578
|
hasBranches = data.branches.length > 0;
|
|
1466
1579
|
} catch {
|
|
1467
1580
|
hasBranches = false;
|
|
@@ -1558,6 +1671,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1558
1671
|
buttons: [Dialog.cancelButton()]
|
|
1559
1672
|
});
|
|
1560
1673
|
|
|
1674
|
+
// Per-row "Open" launches that conversation in its own terminal (strict
|
|
1675
|
+
// reuse via _openBranch) and closes the popup. stopPropagation keeps the
|
|
1676
|
+
// click from toggling selection or switching the row.
|
|
1677
|
+
const openButton = (sessionId: string): HTMLButtonElement => {
|
|
1678
|
+
const btn = document.createElement('button');
|
|
1679
|
+
btn.type = 'button';
|
|
1680
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
|
|
1681
|
+
btn.textContent = 'Open';
|
|
1682
|
+
btn.title = 'Open this conversation in its own terminal';
|
|
1683
|
+
btn.addEventListener('click', e => {
|
|
1684
|
+
e.stopPropagation();
|
|
1685
|
+
dialog.dispose();
|
|
1686
|
+
void this._openBranch(sessionId);
|
|
1687
|
+
});
|
|
1688
|
+
return btn;
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1561
1691
|
const visibleMatches = (): IBranch[] => {
|
|
1562
1692
|
const needle = search.value.trim().toLowerCase();
|
|
1563
1693
|
return items.filter(
|
|
@@ -1613,6 +1743,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1613
1743
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1614
1744
|
badge.textContent = 'current';
|
|
1615
1745
|
currentRow.appendChild(badge);
|
|
1746
|
+
currentRow.appendChild(openButton(current));
|
|
1616
1747
|
currentRow.appendChild(this._branchCopyButton(current));
|
|
1617
1748
|
list.appendChild(currentRow);
|
|
1618
1749
|
|
|
@@ -1677,6 +1808,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1677
1808
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1678
1809
|
row.appendChild(time);
|
|
1679
1810
|
|
|
1811
|
+
row.appendChild(openButton(b.session_id));
|
|
1680
1812
|
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1681
1813
|
|
|
1682
1814
|
row.addEventListener('click', () => {
|
|
@@ -1902,7 +2034,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1902
2034
|
name: launched.terminal_name
|
|
1903
2035
|
});
|
|
1904
2036
|
if (widget?.id) {
|
|
1905
|
-
|
|
2037
|
+
// The terminal runs the FORK (forkId), so tag it with that id - a
|
|
2038
|
+
// later click on the now-current forked row reuses this terminal.
|
|
2039
|
+
this._terminalsByPath.set(session.project_path, {
|
|
2040
|
+
widget,
|
|
2041
|
+
sessionId: forkId
|
|
2042
|
+
});
|
|
1906
2043
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
1907
2044
|
this._focusTerminal(widget);
|
|
1908
2045
|
}
|
|
@@ -1913,8 +2050,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1913
2050
|
spinner.dispose();
|
|
1914
2051
|
}
|
|
1915
2052
|
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1916
|
-
// on the fork's first turn.
|
|
1917
|
-
//
|
|
2053
|
+
// on the fork's first turn. claude materialises the fork's JSONL lazily,
|
|
2054
|
+
// so watch for it at a fast cadence and refresh the moment it appears -
|
|
2055
|
+
// the branch surfaces in seconds instead of on the next slow poll.
|
|
2056
|
+
this._watchForBranch(session.encoded_path, forkId);
|
|
1918
2057
|
}
|
|
1919
2058
|
|
|
1920
2059
|
/** Switch the active row's project to another conversation branch.
|
|
@@ -1960,6 +2099,70 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1960
2099
|
}
|
|
1961
2100
|
}
|
|
1962
2101
|
|
|
2102
|
+
/** Open a specific conversation branch in its own terminal.
|
|
2103
|
+
*
|
|
2104
|
+
* Strict reuse: only a terminal already running THIS conversation is
|
|
2105
|
+
* refocused; otherwise a fresh ``claude --resume <id>`` is launched. So
|
|
2106
|
+
* several branches of one project can be open independently and side by
|
|
2107
|
+
* side - opening branch B never disturbs branch A's terminal. Honours the
|
|
2108
|
+
* global skip-permissions toggle like a normal resume. */
|
|
2109
|
+
private async _openBranch(sessionId: string): Promise<void> {
|
|
2110
|
+
const active = this._activeSession;
|
|
2111
|
+
if (!active || !sessionId) {
|
|
2112
|
+
return;
|
|
2113
|
+
}
|
|
2114
|
+
// Opening the row's CURRENT conversation behaves like a normal row resume
|
|
2115
|
+
// (lenient): a fresh terminal already running it is reused, avoiding a
|
|
2116
|
+
// `claude --resume` "already in use" collision. Opening a DIFFERENT branch
|
|
2117
|
+
// is strict so the user lands in that specific conversation, even if some
|
|
2118
|
+
// other (fresh) terminal sits at the same cwd.
|
|
2119
|
+
const strict = sessionId !== active.session_id;
|
|
2120
|
+
await this._resumeInTerminal(
|
|
2121
|
+
{ ...active, session_id: sessionId },
|
|
2122
|
+
false,
|
|
2123
|
+
strict
|
|
2124
|
+
);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
2128
|
+
*
|
|
2129
|
+
* claude writes a forked session's JSONL lazily (on its first turn), so a
|
|
2130
|
+
* just-requested branch does not exist at launch and would otherwise only
|
|
2131
|
+
* surface on the next 30s poll. This watches the project's branch list
|
|
2132
|
+
* every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
|
|
2133
|
+
* or in the list) - then does one full refresh and stops - or until a
|
|
2134
|
+
* bounded number of attempts elapses. The panel is never updated before
|
|
2135
|
+
* the branch genuinely exists. */
|
|
2136
|
+
private _watchForBranch(encodedPath: string, forkId: string): void {
|
|
2137
|
+
let attempts = 0;
|
|
2138
|
+
const tick = async (): Promise<void> => {
|
|
2139
|
+
if (this.isDisposed) {
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
attempts += 1;
|
|
2143
|
+
try {
|
|
2144
|
+
const data = await requestAPI<IBranchesResponse>(
|
|
2145
|
+
`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`,
|
|
2146
|
+
this._serverSettings,
|
|
2147
|
+
{ cache: 'no-store' }
|
|
2148
|
+
);
|
|
2149
|
+
const appeared =
|
|
2150
|
+
data.current === forkId ||
|
|
2151
|
+
data.branches.some(b => b.session_id === forkId);
|
|
2152
|
+
if (appeared) {
|
|
2153
|
+
await this._fetch();
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
} catch {
|
|
2157
|
+
// Transient failure - keep watching until the attempt budget runs out.
|
|
2158
|
+
}
|
|
2159
|
+
if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
|
|
2160
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
2161
|
+
}
|
|
2162
|
+
};
|
|
2163
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
2164
|
+
}
|
|
2165
|
+
|
|
1963
2166
|
// --------------------------------------------------------------- polling
|
|
1964
2167
|
|
|
1965
2168
|
private _startPolling(): void {
|
|
@@ -1994,6 +2197,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1994
2197
|
private _commands!: CommandRegistry;
|
|
1995
2198
|
private _contextMenu!: Menu;
|
|
1996
2199
|
private _branchSubmenu!: Menu;
|
|
2200
|
+
private _openBranchSubmenu!: Menu;
|
|
1997
2201
|
private _branchSessionMenu!: Menu;
|
|
1998
2202
|
private _lastBranches: IBranch[] = [];
|
|
1999
2203
|
private _lastBranchesCurrent = '';
|
|
@@ -2004,7 +2208,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
2004
2208
|
private readonly _removingPaths: Set<string> = new Set();
|
|
2005
2209
|
private readonly _terminalTracker: ITerminalTracker | null;
|
|
2006
2210
|
private readonly _fileBrowser: IDefaultFileBrowser | null;
|
|
2007
|
-
|
|
2211
|
+
// Microcache of the most-recent terminal per project, tagged with the
|
|
2212
|
+
// conversation it is running so reuse can tell a project's branches apart.
|
|
2213
|
+
private readonly _terminalsByPath: Map<
|
|
2214
|
+
string,
|
|
2215
|
+
{ widget: any; sessionId?: string }
|
|
2216
|
+
> = new Map();
|
|
2217
|
+
// In-flight launches, keyed per CONVERSATION (path + session id) so two
|
|
2218
|
+
// different branches of one project can open independently and concurrently.
|
|
2008
2219
|
private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
|
|
2009
2220
|
private readonly _rootDir: string;
|
|
2010
2221
|
private _presentationMode: PresentationMode = DEFAULT_PRESENTATION_MODE;
|
package/style/base.css
CHANGED
|
@@ -463,6 +463,25 @@
|
|
|
463
463
|
color: var(--jp-ui-font-color1);
|
|
464
464
|
}
|
|
465
465
|
|
|
466
|
+
/* Per-row "Open" launches the conversation in its own terminal. Compact
|
|
467
|
+
brand-outlined button matching the popup design language (2px radius). */
|
|
468
|
+
.jp-ClaudeSessionsPanel-branchOpen {
|
|
469
|
+
flex: none;
|
|
470
|
+
padding: 0 8px;
|
|
471
|
+
border: 1px solid var(--jp-brand-color1);
|
|
472
|
+
border-radius: 2px;
|
|
473
|
+
background: transparent;
|
|
474
|
+
color: var(--jp-brand-color1);
|
|
475
|
+
font-size: var(--jp-ui-font-size0);
|
|
476
|
+
cursor: pointer;
|
|
477
|
+
transition: background 120ms ease;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.jp-ClaudeSessionsPanel-branchOpen:hover {
|
|
481
|
+
background: var(--jp-brand-color1);
|
|
482
|
+
color: var(--jp-ui-inverse-font-color1);
|
|
483
|
+
}
|
|
484
|
+
|
|
466
485
|
.jp-ClaudeSessionsPanel-branchTime {
|
|
467
486
|
flex: none;
|
|
468
487
|
width: 4em;
|