jupyterlab_claude_code_extension 1.2.25 → 1.2.33
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 +205 -22
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +153 -0
- package/src/widget.ts +226 -22
- package/style/base.css +21 -7
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,20 @@ 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 POSITIVELY running the
|
|
52
|
+
* conversation the caller wants (its claude argv carries the same
|
|
53
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
54
|
+
* --resume <id>``.
|
|
55
|
+
*
|
|
56
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
57
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
58
|
+
* is never reused - it may be running a different conversation of this
|
|
59
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
60
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
61
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
62
|
+
* necessarily one the extension did not start.
|
|
63
|
+
*/
|
|
50
64
|
private _doResumeInTerminal;
|
|
51
65
|
/** Absolute path of the file browser's current folder; falls back to the
|
|
52
66
|
* server root when no file browser is available. */
|
|
@@ -143,6 +157,25 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
143
157
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
144
158
|
* the selected conversation as the row's current one. */
|
|
145
159
|
private _switchBranch;
|
|
160
|
+
/** Open a specific conversation branch in its own terminal.
|
|
161
|
+
*
|
|
162
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
163
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
164
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
165
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
166
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
167
|
+
* 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;
|
|
@@ -442,43 +450,71 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
442
450
|
}
|
|
443
451
|
// -------------------------------------------------------------- terminal
|
|
444
452
|
async _resumeInTerminal(session, forceDangerous = false) {
|
|
445
|
-
|
|
446
|
-
//
|
|
447
|
-
|
|
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
463
|
const promise = this._doResumeInTerminal(session, forceDangerous).finally(() => {
|
|
452
|
-
this._pendingByPath.delete(
|
|
464
|
+
this._pendingByPath.delete(key);
|
|
453
465
|
});
|
|
454
|
-
this._pendingByPath.set(
|
|
466
|
+
this._pendingByPath.set(key, promise);
|
|
455
467
|
return promise;
|
|
456
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Reuse an open terminal only when it is POSITIVELY running the
|
|
471
|
+
* conversation the caller wants (its claude argv carries the same
|
|
472
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
473
|
+
* --resume <id>``.
|
|
474
|
+
*
|
|
475
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
476
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
477
|
+
* is never reused - it may be running a different conversation of this
|
|
478
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
479
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
480
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
481
|
+
* necessarily one the extension did not start.
|
|
482
|
+
*/
|
|
457
483
|
async _doResumeInTerminal(session, forceDangerous) {
|
|
484
|
+
var _a;
|
|
458
485
|
try {
|
|
459
|
-
// Always prefer reusing an open terminal for this
|
|
486
|
+
// Always prefer reusing an open terminal for this conversation. The
|
|
460
487
|
// skip-permissions flag can only be applied to a fresh pty, never
|
|
461
488
|
// retroactively. So if the user wants dangerous mode but an open
|
|
462
489
|
// terminal already exists, show a modal asking them to close it
|
|
463
490
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
464
|
-
// 1. In-memory microcache.
|
|
491
|
+
// 1. In-memory microcache (most-recent terminal for this project).
|
|
492
|
+
// Reuse it only when it is running the wanted conversation.
|
|
465
493
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
466
|
-
if (cached &&
|
|
494
|
+
if (cached &&
|
|
495
|
+
!cached.widget.isDisposed &&
|
|
496
|
+
cached.sessionId === session.session_id) {
|
|
467
497
|
if (forceDangerous) {
|
|
468
498
|
await this._showCloseExistingDialog();
|
|
469
499
|
}
|
|
470
|
-
this._focusTerminal(cached);
|
|
500
|
+
this._focusTerminal(cached.widget);
|
|
471
501
|
return;
|
|
472
502
|
}
|
|
473
503
|
// 2. Walk every live terminal widget JL knows about.
|
|
474
|
-
const found = await this._findTerminalForCwd(session.project_path);
|
|
504
|
+
const found = await this._findTerminalForCwd(session.project_path, session.session_id);
|
|
475
505
|
if (found) {
|
|
476
|
-
|
|
477
|
-
|
|
506
|
+
// Tag the cache with the OBSERVED conversation - here it equals the
|
|
507
|
+
// wanted id (the gate in _findTerminalForCwd), so a later reuse trusts
|
|
508
|
+
// a confirmed conversation rather than a wish.
|
|
509
|
+
this._terminalsByPath.set(session.project_path, {
|
|
510
|
+
widget: found.widget,
|
|
511
|
+
sessionId: (_a = found.runningId) !== null && _a !== void 0 ? _a : undefined
|
|
512
|
+
});
|
|
513
|
+
this._wireTerminalDisposal(session.project_path, found.widget);
|
|
478
514
|
if (forceDangerous) {
|
|
479
515
|
await this._showCloseExistingDialog();
|
|
480
516
|
}
|
|
481
|
-
this._focusTerminal(found);
|
|
517
|
+
this._focusTerminal(found.widget);
|
|
482
518
|
return;
|
|
483
519
|
}
|
|
484
520
|
// 3. No matching terminal - spawn a new one with `claude --resume <id>`
|
|
@@ -502,7 +538,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
502
538
|
name: launched.terminal_name
|
|
503
539
|
});
|
|
504
540
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
505
|
-
this._terminalsByPath.set(session.project_path,
|
|
541
|
+
this._terminalsByPath.set(session.project_path, {
|
|
542
|
+
widget,
|
|
543
|
+
sessionId: session.session_id
|
|
544
|
+
});
|
|
506
545
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
507
546
|
this._focusTerminal(widget);
|
|
508
547
|
}
|
|
@@ -539,12 +578,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
539
578
|
if (!projectPath) {
|
|
540
579
|
return;
|
|
541
580
|
}
|
|
581
|
+
// Launch with a frontend-chosen session id (claude --session-id <uuid>
|
|
582
|
+
// starts a fresh session with that id) so the terminal's claude is
|
|
583
|
+
// identifiable from its argv. Reuse can then refocus this exact
|
|
584
|
+
// conversation later instead of guessing from an unknown terminal.
|
|
585
|
+
const newId = UUID.uuid4();
|
|
542
586
|
const spinner = this._showLaunchSpinner();
|
|
543
587
|
try {
|
|
544
588
|
const launched = await requestAPI('launch-terminal', this._serverSettings, {
|
|
545
589
|
method: 'POST',
|
|
546
590
|
body: JSON.stringify({
|
|
547
591
|
project_path: projectPath,
|
|
592
|
+
new_session_id: newId,
|
|
548
593
|
dangerously_skip_permissions: forceDangerous || this._dangerouslySkip
|
|
549
594
|
})
|
|
550
595
|
});
|
|
@@ -552,7 +597,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
552
597
|
name: launched.terminal_name
|
|
553
598
|
});
|
|
554
599
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
555
|
-
this._terminalsByPath.set(projectPath,
|
|
600
|
+
this._terminalsByPath.set(projectPath, {
|
|
601
|
+
widget,
|
|
602
|
+
sessionId: newId
|
|
603
|
+
});
|
|
556
604
|
this._wireTerminalDisposal(projectPath, widget);
|
|
557
605
|
this._focusTerminal(widget);
|
|
558
606
|
}
|
|
@@ -592,8 +640,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
592
640
|
}
|
|
593
641
|
});
|
|
594
642
|
}
|
|
595
|
-
async _findTerminalForCwd(projectPath) {
|
|
596
|
-
var _a, _b;
|
|
643
|
+
async _findTerminalForCwd(projectPath, wantedSessionId) {
|
|
644
|
+
var _a, _b, _c;
|
|
597
645
|
if (!this._terminalTracker) {
|
|
598
646
|
return null;
|
|
599
647
|
}
|
|
@@ -617,10 +665,26 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
617
665
|
if (!(data === null || data === void 0 ? void 0 : data.has_claude)) {
|
|
618
666
|
continue;
|
|
619
667
|
}
|
|
668
|
+
// Conversation gate: reuse ONLY a terminal we can positively confirm
|
|
669
|
+
// is running the wanted conversation. A terminal whose conversation
|
|
670
|
+
// is unknown (claude started with -c/--continue or a bare claude, so
|
|
671
|
+
// its argv carries no id) is never reused - it may be running a
|
|
672
|
+
// different conversation of this project (the switch-then-click bug).
|
|
673
|
+
// Extension launches always carry an explicit id, so an unknown
|
|
674
|
+
// terminal is necessarily foreign.
|
|
675
|
+
const runningId = (_c = data.session_id) !== null && _c !== void 0 ? _c : null;
|
|
676
|
+
// A missing wanted id (undefined) or an unknown running id (null) can
|
|
677
|
+
// never be a positive match - guard so neither side's "absent" value
|
|
678
|
+
// is mistaken for equality and an unknown terminal slips through.
|
|
679
|
+
if (!wantedSessionId || runningId !== wantedSessionId) {
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
620
682
|
const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
|
|
621
683
|
for (const cwd of cwds) {
|
|
622
684
|
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
623
|
-
|
|
685
|
+
// runningId equals the wanted id here (gated above), so the caller
|
|
686
|
+
// tags its cache with a confirmed conversation id.
|
|
687
|
+
return { widget, runningId };
|
|
624
688
|
}
|
|
625
689
|
}
|
|
626
690
|
}
|
|
@@ -667,7 +731,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
667
731
|
return;
|
|
668
732
|
}
|
|
669
733
|
widget.disposed.connect(() => {
|
|
670
|
-
|
|
734
|
+
var _a;
|
|
735
|
+
if (((_a = this._terminalsByPath.get(projectPath)) === null || _a === void 0 ? void 0 : _a.widget) === widget) {
|
|
671
736
|
this._terminalsByPath.delete(projectPath);
|
|
672
737
|
}
|
|
673
738
|
});
|
|
@@ -1127,6 +1192,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1127
1192
|
void this._showBranchPopup(this._lastBranches, this._lastBranchesCurrent);
|
|
1128
1193
|
}
|
|
1129
1194
|
});
|
|
1195
|
+
this._commands.addCommand('claude-code-sessions:open-branch', {
|
|
1196
|
+
label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
|
|
1197
|
+
icon: terminalIcon,
|
|
1198
|
+
execute: args => {
|
|
1199
|
+
var _a;
|
|
1200
|
+
const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
|
|
1201
|
+
if (sessionId) {
|
|
1202
|
+
void this._openBranch(sessionId);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1130
1206
|
this._commands.addCommand('claude-code-sessions:branch-session', {
|
|
1131
1207
|
label: 'Normal',
|
|
1132
1208
|
execute: () => void this._branchSession(false)
|
|
@@ -1171,6 +1247,13 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1171
1247
|
this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1172
1248
|
this._branchSubmenu.title.label = 'Switch and Manage Sessions';
|
|
1173
1249
|
this._branchSubmenu.title.icon = switchIcon;
|
|
1250
|
+
// Submenu that OPENS a conversation directly in its own terminal (vs the
|
|
1251
|
+
// switch submenu, which only changes which branch the row points at).
|
|
1252
|
+
// Several branches can be open at once, independently.
|
|
1253
|
+
this._openBranchSubmenu = new Menu({ commands: this._commands });
|
|
1254
|
+
this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1255
|
+
this._openBranchSubmenu.title.label = 'Open Branched Conversation';
|
|
1256
|
+
this._openBranchSubmenu.title.icon = terminalIcon;
|
|
1174
1257
|
// Submenu grouping the two branch-session launch modes.
|
|
1175
1258
|
this._branchSessionMenu = new Menu({ commands: this._commands });
|
|
1176
1259
|
this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
@@ -1217,6 +1300,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1217
1300
|
});
|
|
1218
1301
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1219
1302
|
if (withBranches) {
|
|
1303
|
+
this._contextMenu.addItem({
|
|
1304
|
+
type: 'submenu',
|
|
1305
|
+
submenu: this._openBranchSubmenu
|
|
1306
|
+
});
|
|
1220
1307
|
this._contextMenu.addItem({
|
|
1221
1308
|
type: 'submenu',
|
|
1222
1309
|
submenu: this._branchSubmenu
|
|
@@ -1259,6 +1346,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1259
1346
|
this._branchSubmenu.addItem({
|
|
1260
1347
|
command: 'claude-code-sessions:switch-branch-more'
|
|
1261
1348
|
});
|
|
1349
|
+
// Open submenu: same top-5 branches, but each launches its own
|
|
1350
|
+
// terminal directly. Falls through to the Manage Sessions popup for
|
|
1351
|
+
// the full list (from which any conversation can also be opened).
|
|
1352
|
+
this._openBranchSubmenu.clearItems();
|
|
1353
|
+
this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
|
|
1354
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1355
|
+
this._openBranchSubmenu.addItem({
|
|
1356
|
+
command: 'claude-code-sessions:open-branch',
|
|
1357
|
+
args: {
|
|
1358
|
+
session_id: b.session_id,
|
|
1359
|
+
label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1360
|
+
}
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
this._openBranchSubmenu.addItem({ type: 'separator' });
|
|
1364
|
+
this._openBranchSubmenu.addItem({
|
|
1365
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1366
|
+
});
|
|
1262
1367
|
hasBranches = data.branches.length > 0;
|
|
1263
1368
|
}
|
|
1264
1369
|
catch (_a) {
|
|
@@ -1346,6 +1451,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1346
1451
|
body: bodyWidget,
|
|
1347
1452
|
buttons: [Dialog.cancelButton()]
|
|
1348
1453
|
});
|
|
1454
|
+
// Per-row "Open" launches that conversation in its own terminal (via
|
|
1455
|
+
// _openBranch, reusing only a terminal already running it) and closes the
|
|
1456
|
+
// popup. stopPropagation keeps the click from toggling selection or
|
|
1457
|
+
// switching the row.
|
|
1458
|
+
const openButton = (sessionId) => {
|
|
1459
|
+
const btn = document.createElement('button');
|
|
1460
|
+
btn.type = 'button';
|
|
1461
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
|
|
1462
|
+
btn.textContent = 'Open';
|
|
1463
|
+
btn.title = 'Open this conversation in its own terminal';
|
|
1464
|
+
btn.addEventListener('click', e => {
|
|
1465
|
+
e.stopPropagation();
|
|
1466
|
+
dialog.dispose();
|
|
1467
|
+
void this._openBranch(sessionId);
|
|
1468
|
+
});
|
|
1469
|
+
return btn;
|
|
1470
|
+
};
|
|
1349
1471
|
const visibleMatches = () => {
|
|
1350
1472
|
const needle = search.value.trim().toLowerCase();
|
|
1351
1473
|
return items.filter(b => !needle ||
|
|
@@ -1378,6 +1500,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1378
1500
|
const currentRow = document.createElement('div');
|
|
1379
1501
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1380
1502
|
currentRow.title = `Session id: ${current}`;
|
|
1503
|
+
// Expose the active state to assistive tech - the brand left-bar, tint
|
|
1504
|
+
// and the plain "current" text are visual-only cues.
|
|
1505
|
+
currentRow.setAttribute('aria-current', 'true');
|
|
1381
1506
|
// Empty select cell keeps the name column aligned with branch rows.
|
|
1382
1507
|
const currentSelect = document.createElement('span');
|
|
1383
1508
|
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
@@ -1393,6 +1518,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1393
1518
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1394
1519
|
badge.textContent = 'current';
|
|
1395
1520
|
currentRow.appendChild(badge);
|
|
1521
|
+
currentRow.appendChild(openButton(current));
|
|
1396
1522
|
currentRow.appendChild(this._branchCopyButton(current));
|
|
1397
1523
|
list.appendChild(currentRow);
|
|
1398
1524
|
const matches = visibleMatches();
|
|
@@ -1450,6 +1576,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1450
1576
|
time.className = 'jp-ClaudeSessionsPanel-branchTime';
|
|
1451
1577
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1452
1578
|
row.appendChild(time);
|
|
1579
|
+
row.appendChild(openButton(b.session_id));
|
|
1453
1580
|
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1454
1581
|
row.addEventListener('click', () => {
|
|
1455
1582
|
// Selection mode: while anything is ticked, row clicks toggle
|
|
@@ -1660,7 +1787,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1660
1787
|
name: launched.terminal_name
|
|
1661
1788
|
});
|
|
1662
1789
|
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
1663
|
-
|
|
1790
|
+
// The terminal runs the FORK (forkId), so tag it with that id - a
|
|
1791
|
+
// later click on the now-current forked row reuses this terminal.
|
|
1792
|
+
this._terminalsByPath.set(session.project_path, {
|
|
1793
|
+
widget,
|
|
1794
|
+
sessionId: forkId
|
|
1795
|
+
});
|
|
1664
1796
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
1665
1797
|
this._focusTerminal(widget);
|
|
1666
1798
|
}
|
|
@@ -1673,8 +1805,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1673
1805
|
spinner.dispose();
|
|
1674
1806
|
}
|
|
1675
1807
|
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1676
|
-
// on the fork's first turn.
|
|
1677
|
-
//
|
|
1808
|
+
// on the fork's first turn. claude materialises the fork's JSONL lazily,
|
|
1809
|
+
// so watch for it at a fast cadence and refresh the moment it appears -
|
|
1810
|
+
// the branch surfaces in seconds instead of on the next slow poll.
|
|
1811
|
+
this._watchForBranch(session.encoded_path, forkId);
|
|
1678
1812
|
}
|
|
1679
1813
|
/** Switch the active row's project to another conversation branch.
|
|
1680
1814
|
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
@@ -1709,6 +1843,55 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1709
1843
|
await this._fetch();
|
|
1710
1844
|
}
|
|
1711
1845
|
}
|
|
1846
|
+
/** Open a specific conversation branch in its own terminal.
|
|
1847
|
+
*
|
|
1848
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
1849
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
1850
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
1851
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
1852
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
1853
|
+
* resume. */
|
|
1854
|
+
async _openBranch(sessionId) {
|
|
1855
|
+
const active = this._activeSession;
|
|
1856
|
+
if (!active || !sessionId) {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
await this._resumeInTerminal({ ...active, session_id: sessionId });
|
|
1860
|
+
}
|
|
1861
|
+
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
1862
|
+
*
|
|
1863
|
+
* claude writes a forked session's JSONL lazily (on its first turn), so a
|
|
1864
|
+
* just-requested branch does not exist at launch and would otherwise only
|
|
1865
|
+
* surface on the next 30s poll. This watches the project's branch list
|
|
1866
|
+
* every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
|
|
1867
|
+
* or in the list) - then does one full refresh and stops - or until a
|
|
1868
|
+
* bounded number of attempts elapses. The panel is never updated before
|
|
1869
|
+
* the branch genuinely exists. */
|
|
1870
|
+
_watchForBranch(encodedPath, forkId) {
|
|
1871
|
+
let attempts = 0;
|
|
1872
|
+
const tick = async () => {
|
|
1873
|
+
if (this.isDisposed) {
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
attempts += 1;
|
|
1877
|
+
try {
|
|
1878
|
+
const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`, this._serverSettings, { cache: 'no-store' });
|
|
1879
|
+
const appeared = data.current === forkId ||
|
|
1880
|
+
data.branches.some(b => b.session_id === forkId);
|
|
1881
|
+
if (appeared) {
|
|
1882
|
+
await this._fetch();
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
catch (_a) {
|
|
1887
|
+
// Transient failure - keep watching until the attempt budget runs out.
|
|
1888
|
+
}
|
|
1889
|
+
if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
|
|
1890
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
1894
|
+
}
|
|
1712
1895
|
// --------------------------------------------------------------- polling
|
|
1713
1896
|
_startPolling() {
|
|
1714
1897
|
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.33",
|
|
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",
|
|
@@ -220,6 +220,17 @@ describe('launch spinner dismiss contract', () => {
|
|
|
220
220
|
expect(checkboxIdx).toBeGreaterThan(currentIdx);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it('current row exposes aria-current and a plain lowercase "current" label', () => {
|
|
224
|
+
const popup = (widgetSrc.match(
|
|
225
|
+
/private _showBranchPopup[\s\S]*?\n \}/
|
|
226
|
+
) ?? [''])[0];
|
|
227
|
+
// Programmatic active state for assistive tech (visual cues are the
|
|
228
|
+
// brand left-bar, tint and the plain word) - UX review finding.
|
|
229
|
+
expect(popup).toMatch(/setAttribute\('aria-current', 'true'\)/);
|
|
230
|
+
// The marker is plain text "current" - the boxed uppercase chip is gone.
|
|
231
|
+
expect(popup).toMatch(/badge\.textContent = 'current'/);
|
|
232
|
+
});
|
|
233
|
+
|
|
223
234
|
it('checkbox is its own click zone and selection gates row switch', () => {
|
|
224
235
|
const popup = (widgetSrc.match(
|
|
225
236
|
/private _showBranchPopup[\s\S]*?\n \}/
|
|
@@ -506,6 +517,148 @@ describe('launch spinner dismiss contract', () => {
|
|
|
506
517
|
});
|
|
507
518
|
});
|
|
508
519
|
|
|
520
|
+
/**
|
|
521
|
+
* Contract for conversation-aware terminal reuse and the Open Branched
|
|
522
|
+
* Conversation feature. Reuse must refuse to focus a terminal running a
|
|
523
|
+
* DIFFERENT or UNKNOWN conversation (the switch-then-click bug, DEF-4): a
|
|
524
|
+
* terminal is reused only on a POSITIVE conversation-id match. Every
|
|
525
|
+
* extension launch carries an explicit id so its terminal is identifiable
|
|
526
|
+
* (resume -> --resume, new session / fork -> --session-id).
|
|
527
|
+
*/
|
|
528
|
+
describe('conversation-aware reuse + open-branch contract', () => {
|
|
529
|
+
const findTerm = (widgetSrc.match(
|
|
530
|
+
/private async _findTerminalForCwd[\s\S]*?\n \}/
|
|
531
|
+
) ?? [''])[0];
|
|
532
|
+
const doResume = (widgetSrc.match(
|
|
533
|
+
/private async _doResumeInTerminal[\s\S]*?\n \}/
|
|
534
|
+
) ?? [''])[0];
|
|
535
|
+
const resume = (widgetSrc.match(
|
|
536
|
+
/private async _resumeInTerminal[\s\S]*?\n \}/
|
|
537
|
+
) ?? [''])[0];
|
|
538
|
+
const openBranch = (widgetSrc.match(
|
|
539
|
+
/private async _openBranch[\s\S]*?\n \}/
|
|
540
|
+
) ?? [''])[0];
|
|
541
|
+
const newSession = (widgetSrc.match(
|
|
542
|
+
/private async _newSession[\s\S]*?\n \}/
|
|
543
|
+
) ?? [''])[0];
|
|
544
|
+
const watch = (widgetSrc.match(/private _watchForBranch[\s\S]*?\n \}/) ?? [
|
|
545
|
+
''
|
|
546
|
+
])[0];
|
|
547
|
+
|
|
548
|
+
it('terminal-cwd response carries the running conversation id', () => {
|
|
549
|
+
const iface = (widgetSrc.match(
|
|
550
|
+
/interface ITerminalCwdResponse \{[\s\S]*?\}/
|
|
551
|
+
) ?? [''])[0];
|
|
552
|
+
expect(iface).toMatch(/session_id\?: string \| null/);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('_findTerminalForCwd takes the wanted id with no strict flag', () => {
|
|
556
|
+
expect(findTerm).toMatch(
|
|
557
|
+
/_findTerminalForCwd\(\s*projectPath: string,\s*wantedSessionId: string \| undefined\s*\)/
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('reuse requires a positive conversation-id match (no lenient unknown reuse)', () => {
|
|
562
|
+
expect(findTerm).toMatch(/const runningId = data\.session_id \?\? null/);
|
|
563
|
+
// The ONLY reuse condition: a truthy wanted id equal to the observed
|
|
564
|
+
// id. A missing wanted id or an unknown (null) running id never matches,
|
|
565
|
+
// so a -c/--continue or bare-claude terminal is never reused (DEF-4).
|
|
566
|
+
expect(findTerm).toMatch(
|
|
567
|
+
/if \(!wantedSessionId \|\| runningId !== wantedSessionId\) \{\s*continue;/
|
|
568
|
+
);
|
|
569
|
+
// The old lenient/strict machinery is gone.
|
|
570
|
+
expect(findTerm).not.toMatch(/unknownConversation/);
|
|
571
|
+
expect(findTerm).not.toMatch(/strict/);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('microcache reuse is gated purely on the conversation id', () => {
|
|
575
|
+
expect(doResume).toMatch(/cached\.sessionId === session\.session_id/);
|
|
576
|
+
expect(doResume).not.toMatch(/unknownConversation/);
|
|
577
|
+
expect(doResume).not.toMatch(/strict/);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('microcache is tagged with the OBSERVED running id', () => {
|
|
581
|
+
expect(doResume).toMatch(
|
|
582
|
+
/widget: found\.widget,\s*sessionId: found\.runningId \?\? undefined/
|
|
583
|
+
);
|
|
584
|
+
expect(findTerm).toMatch(/return \{ widget, runningId \}/);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('in-flight launches are keyed per conversation, not per project', () => {
|
|
588
|
+
expect(resume).toMatch(
|
|
589
|
+
/const key = `\$\{session\.project_path\}\\n\$\{session\.session_id \?\? ''\}`/
|
|
590
|
+
);
|
|
591
|
+
expect(resume).toMatch(/this\._pendingByPath\.(get|set)\(key/);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it('a new session launches with an explicit --session-id so it is identifiable', () => {
|
|
595
|
+
// DEF-4: a bare `claude` reports no id and would be wrongly reused;
|
|
596
|
+
// launching with a frontend id makes the terminal positively matchable.
|
|
597
|
+
expect(newSession).toMatch(/const newId = UUID\.uuid4\(\)/);
|
|
598
|
+
expect(newSession).toMatch(/new_session_id: newId/);
|
|
599
|
+
expect(newSession).toMatch(/sessionId: newId/);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('_openBranch reuses only a terminal running that conversation', () => {
|
|
603
|
+
expect(openBranch).toMatch(
|
|
604
|
+
/this\._resumeInTerminal\(\{ \.\.\.active, session_id: sessionId \}\)/
|
|
605
|
+
);
|
|
606
|
+
expect(openBranch).not.toMatch(/strict/);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('open-branch command and submenu are wired up', () => {
|
|
610
|
+
expect(widgetSrc).toMatch(/'claude-code-sessions:open-branch'/);
|
|
611
|
+
expect(widgetSrc).toMatch(
|
|
612
|
+
/this\._openBranchSubmenu\.title\.label = 'Open Branched Conversation'/
|
|
613
|
+
);
|
|
614
|
+
// Top 5 branches populate the open submenu, each via the open command.
|
|
615
|
+
const populate = (widgetSrc.match(
|
|
616
|
+
/this\._openBranchSubmenu\.clearItems[\s\S]*?switch-branch-more'\s*\}\);/
|
|
617
|
+
) ?? [''])[0];
|
|
618
|
+
expect(populate).toMatch(/data\.branches\.slice\(0, 5\)/);
|
|
619
|
+
expect(populate).toMatch(/command: 'claude-code-sessions:open-branch'/);
|
|
620
|
+
// The context menu shows the open submenu when the row has branches.
|
|
621
|
+
const rebuild = (widgetSrc.match(
|
|
622
|
+
/private _rebuildContextMenu[\s\S]*?\n \}/
|
|
623
|
+
) ?? [''])[0];
|
|
624
|
+
expect(rebuild).toMatch(/submenu: this\._openBranchSubmenu/);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('popup rows carry an Open button that launches the branch', () => {
|
|
628
|
+
const popup = (widgetSrc.match(
|
|
629
|
+
/private _showBranchPopup[\s\S]*?\n \}\n/
|
|
630
|
+
) ?? [''])[0];
|
|
631
|
+
expect(popup).toMatch(/jp-ClaudeSessionsPanel-branchOpen/);
|
|
632
|
+
expect(popup).toMatch(/void this\._openBranch\(sessionId\)/);
|
|
633
|
+
// Open appears on both the current row and each branch row.
|
|
634
|
+
expect(popup).toMatch(/openButton\(current\)/);
|
|
635
|
+
expect(popup).toMatch(/openButton\(b\.session_id\)/);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it('a new branch is watched for and surfaces fast, not on the slow poll', () => {
|
|
639
|
+
expect(watch).toMatch(/data\.current === forkId/);
|
|
640
|
+
expect(watch).toMatch(/b\.session_id === forkId/);
|
|
641
|
+
expect(watch).toMatch(/await this\._fetch\(\)/);
|
|
642
|
+
expect(watch).toMatch(/BRANCH_WATCH_MAX_ATTEMPTS/);
|
|
643
|
+
// Fork launch arms the watcher.
|
|
644
|
+
const fork = (widgetSrc.match(
|
|
645
|
+
/private async _branchSession[\s\S]*?\n \}/
|
|
646
|
+
) ?? [''])[0];
|
|
647
|
+
expect(fork).toMatch(
|
|
648
|
+
/this\._watchForBranch\(session\.encoded_path, forkId\)/
|
|
649
|
+
);
|
|
650
|
+
expect(widgetSrc).toMatch(/BRANCH_WATCH_INTERVAL_MS = 2_000/);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it('the Open button is styled in base.css', () => {
|
|
654
|
+
const css: string = fs.readFileSync(
|
|
655
|
+
path.join(__dirname, '..', '..', 'style', 'base.css'),
|
|
656
|
+
'utf-8'
|
|
657
|
+
);
|
|
658
|
+
expect(css).toMatch(/\.jp-ClaudeSessionsPanel-branchOpen \{/);
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
|
|
509
662
|
it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
|
|
510
663
|
expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
|
|
511
664
|
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
|
|
@@ -546,51 +553,82 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
546
553
|
session: ISession,
|
|
547
554
|
forceDangerous: boolean = false
|
|
548
555
|
): Promise<void> {
|
|
549
|
-
// Coalesce concurrent clicks on the
|
|
550
|
-
// to the in-flight promise instead of creating their own terminal.
|
|
551
|
-
|
|
556
|
+
// Coalesce concurrent clicks on the SAME conversation - subsequent clicks
|
|
557
|
+
// attach to the in-flight promise instead of creating their own terminal.
|
|
558
|
+
// The key is per-conversation, so opening a different branch of the same
|
|
559
|
+
// project launches independently rather than coalescing onto this one.
|
|
560
|
+
const key = `${session.project_path}\n${session.session_id ?? ''}`;
|
|
561
|
+
const inFlight = this._pendingByPath.get(key);
|
|
552
562
|
if (inFlight) {
|
|
553
563
|
return inFlight;
|
|
554
564
|
}
|
|
555
565
|
const promise = this._doResumeInTerminal(session, forceDangerous).finally(
|
|
556
566
|
() => {
|
|
557
|
-
this._pendingByPath.delete(
|
|
567
|
+
this._pendingByPath.delete(key);
|
|
558
568
|
}
|
|
559
569
|
);
|
|
560
|
-
this._pendingByPath.set(
|
|
570
|
+
this._pendingByPath.set(key, promise);
|
|
561
571
|
return promise;
|
|
562
572
|
}
|
|
563
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Reuse an open terminal only when it is POSITIVELY running the
|
|
576
|
+
* conversation the caller wants (its claude argv carries the same
|
|
577
|
+
* ``--resume``/``--session-id``); otherwise launch a fresh ``claude
|
|
578
|
+
* --resume <id>``.
|
|
579
|
+
*
|
|
580
|
+
* A cwd-matching terminal whose conversation is UNKNOWN (claude started
|
|
581
|
+
* with ``-c``/``--continue`` or a bare ``claude``, so no id is in its argv)
|
|
582
|
+
* is never reused - it may be running a different conversation of this
|
|
583
|
+
* project, which is the switch-then-click bug. Every terminal the extension
|
|
584
|
+
* launches carries an explicit id (``--resume`` for a resume,
|
|
585
|
+
* ``--session-id`` for a new session or a fork), so an unknown terminal is
|
|
586
|
+
* necessarily one the extension did not start.
|
|
587
|
+
*/
|
|
564
588
|
private async _doResumeInTerminal(
|
|
565
589
|
session: ISession,
|
|
566
590
|
forceDangerous: boolean
|
|
567
591
|
): Promise<void> {
|
|
568
592
|
try {
|
|
569
|
-
// Always prefer reusing an open terminal for this
|
|
593
|
+
// Always prefer reusing an open terminal for this conversation. The
|
|
570
594
|
// skip-permissions flag can only be applied to a fresh pty, never
|
|
571
595
|
// retroactively. So if the user wants dangerous mode but an open
|
|
572
596
|
// terminal already exists, show a modal asking them to close it
|
|
573
597
|
// first - we won't auto-close, won't silently reuse the wrong mode.
|
|
574
598
|
|
|
575
|
-
// 1. In-memory microcache.
|
|
599
|
+
// 1. In-memory microcache (most-recent terminal for this project).
|
|
600
|
+
// Reuse it only when it is running the wanted conversation.
|
|
576
601
|
const cached = this._terminalsByPath.get(session.project_path);
|
|
577
|
-
if (
|
|
602
|
+
if (
|
|
603
|
+
cached &&
|
|
604
|
+
!cached.widget.isDisposed &&
|
|
605
|
+
cached.sessionId === session.session_id
|
|
606
|
+
) {
|
|
578
607
|
if (forceDangerous) {
|
|
579
608
|
await this._showCloseExistingDialog();
|
|
580
609
|
}
|
|
581
|
-
this._focusTerminal(cached);
|
|
610
|
+
this._focusTerminal(cached.widget);
|
|
582
611
|
return;
|
|
583
612
|
}
|
|
584
613
|
|
|
585
614
|
// 2. Walk every live terminal widget JL knows about.
|
|
586
|
-
const found = await this._findTerminalForCwd(
|
|
615
|
+
const found = await this._findTerminalForCwd(
|
|
616
|
+
session.project_path,
|
|
617
|
+
session.session_id
|
|
618
|
+
);
|
|
587
619
|
if (found) {
|
|
588
|
-
|
|
589
|
-
|
|
620
|
+
// Tag the cache with the OBSERVED conversation - here it equals the
|
|
621
|
+
// wanted id (the gate in _findTerminalForCwd), so a later reuse trusts
|
|
622
|
+
// a confirmed conversation rather than a wish.
|
|
623
|
+
this._terminalsByPath.set(session.project_path, {
|
|
624
|
+
widget: found.widget,
|
|
625
|
+
sessionId: found.runningId ?? undefined
|
|
626
|
+
});
|
|
627
|
+
this._wireTerminalDisposal(session.project_path, found.widget);
|
|
590
628
|
if (forceDangerous) {
|
|
591
629
|
await this._showCloseExistingDialog();
|
|
592
630
|
}
|
|
593
|
-
this._focusTerminal(found);
|
|
631
|
+
this._focusTerminal(found.widget);
|
|
594
632
|
return;
|
|
595
633
|
}
|
|
596
634
|
|
|
@@ -620,7 +658,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
620
658
|
name: launched.terminal_name
|
|
621
659
|
});
|
|
622
660
|
if (widget?.id) {
|
|
623
|
-
this._terminalsByPath.set(session.project_path,
|
|
661
|
+
this._terminalsByPath.set(session.project_path, {
|
|
662
|
+
widget,
|
|
663
|
+
sessionId: session.session_id
|
|
664
|
+
});
|
|
624
665
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
625
666
|
this._focusTerminal(widget);
|
|
626
667
|
}
|
|
@@ -655,6 +696,11 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
655
696
|
if (!projectPath) {
|
|
656
697
|
return;
|
|
657
698
|
}
|
|
699
|
+
// Launch with a frontend-chosen session id (claude --session-id <uuid>
|
|
700
|
+
// starts a fresh session with that id) so the terminal's claude is
|
|
701
|
+
// identifiable from its argv. Reuse can then refocus this exact
|
|
702
|
+
// conversation later instead of guessing from an unknown terminal.
|
|
703
|
+
const newId = UUID.uuid4();
|
|
658
704
|
const spinner = this._showLaunchSpinner();
|
|
659
705
|
try {
|
|
660
706
|
const launched = await requestAPI<ILaunchTerminalResponse>(
|
|
@@ -664,6 +710,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
664
710
|
method: 'POST',
|
|
665
711
|
body: JSON.stringify({
|
|
666
712
|
project_path: projectPath,
|
|
713
|
+
new_session_id: newId,
|
|
667
714
|
dangerously_skip_permissions:
|
|
668
715
|
forceDangerous || this._dangerouslySkip
|
|
669
716
|
})
|
|
@@ -673,7 +720,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
673
720
|
name: launched.terminal_name
|
|
674
721
|
});
|
|
675
722
|
if (widget?.id) {
|
|
676
|
-
this._terminalsByPath.set(projectPath,
|
|
723
|
+
this._terminalsByPath.set(projectPath, {
|
|
724
|
+
widget,
|
|
725
|
+
sessionId: newId
|
|
726
|
+
});
|
|
677
727
|
this._wireTerminalDisposal(projectPath, widget);
|
|
678
728
|
this._focusTerminal(widget);
|
|
679
729
|
}
|
|
@@ -711,7 +761,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
711
761
|
});
|
|
712
762
|
}
|
|
713
763
|
|
|
714
|
-
private async _findTerminalForCwd(
|
|
764
|
+
private async _findTerminalForCwd(
|
|
765
|
+
projectPath: string,
|
|
766
|
+
wantedSessionId: string | undefined
|
|
767
|
+
): Promise<{ widget: any; runningId: string | null } | null> {
|
|
715
768
|
if (!this._terminalTracker) {
|
|
716
769
|
return null;
|
|
717
770
|
}
|
|
@@ -738,10 +791,26 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
738
791
|
if (!data?.has_claude) {
|
|
739
792
|
continue;
|
|
740
793
|
}
|
|
794
|
+
// Conversation gate: reuse ONLY a terminal we can positively confirm
|
|
795
|
+
// is running the wanted conversation. A terminal whose conversation
|
|
796
|
+
// is unknown (claude started with -c/--continue or a bare claude, so
|
|
797
|
+
// its argv carries no id) is never reused - it may be running a
|
|
798
|
+
// different conversation of this project (the switch-then-click bug).
|
|
799
|
+
// Extension launches always carry an explicit id, so an unknown
|
|
800
|
+
// terminal is necessarily foreign.
|
|
801
|
+
const runningId = data.session_id ?? null;
|
|
802
|
+
// A missing wanted id (undefined) or an unknown running id (null) can
|
|
803
|
+
// never be a positive match - guard so neither side's "absent" value
|
|
804
|
+
// is mistaken for equality and an unknown terminal slips through.
|
|
805
|
+
if (!wantedSessionId || runningId !== wantedSessionId) {
|
|
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
|
+
// runningId equals the wanted id here (gated above), so the caller
|
|
812
|
+
// tags its cache with a confirmed conversation id.
|
|
813
|
+
return { widget, runningId };
|
|
745
814
|
}
|
|
746
815
|
}
|
|
747
816
|
} catch (_err) {
|
|
@@ -792,7 +861,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
792
861
|
return;
|
|
793
862
|
}
|
|
794
863
|
widget.disposed.connect(() => {
|
|
795
|
-
if (this._terminalsByPath.get(projectPath) === widget) {
|
|
864
|
+
if (this._terminalsByPath.get(projectPath)?.widget === widget) {
|
|
796
865
|
this._terminalsByPath.delete(projectPath);
|
|
797
866
|
}
|
|
798
867
|
});
|
|
@@ -1311,6 +1380,17 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1311
1380
|
}
|
|
1312
1381
|
});
|
|
1313
1382
|
|
|
1383
|
+
this._commands.addCommand('claude-code-sessions:open-branch', {
|
|
1384
|
+
label: args => String(args.label ?? ''),
|
|
1385
|
+
icon: terminalIcon,
|
|
1386
|
+
execute: args => {
|
|
1387
|
+
const sessionId = String(args.session_id ?? '');
|
|
1388
|
+
if (sessionId) {
|
|
1389
|
+
void this._openBranch(sessionId);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1314
1394
|
this._commands.addCommand('claude-code-sessions:branch-session', {
|
|
1315
1395
|
label: 'Normal',
|
|
1316
1396
|
execute: () => void this._branchSession(false)
|
|
@@ -1362,6 +1442,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1362
1442
|
this._branchSubmenu.title.label = 'Switch and Manage Sessions';
|
|
1363
1443
|
this._branchSubmenu.title.icon = switchIcon;
|
|
1364
1444
|
|
|
1445
|
+
// Submenu that OPENS a conversation directly in its own terminal (vs the
|
|
1446
|
+
// switch submenu, which only changes which branch the row points at).
|
|
1447
|
+
// Several branches can be open at once, independently.
|
|
1448
|
+
this._openBranchSubmenu = new Menu({ commands: this._commands });
|
|
1449
|
+
this._openBranchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1450
|
+
this._openBranchSubmenu.title.label = 'Open Branched Conversation';
|
|
1451
|
+
this._openBranchSubmenu.title.icon = terminalIcon;
|
|
1452
|
+
|
|
1365
1453
|
// Submenu grouping the two branch-session launch modes.
|
|
1366
1454
|
this._branchSessionMenu = new Menu({ commands: this._commands });
|
|
1367
1455
|
this._branchSessionMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
@@ -1411,6 +1499,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1411
1499
|
});
|
|
1412
1500
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1413
1501
|
if (withBranches) {
|
|
1502
|
+
this._contextMenu.addItem({
|
|
1503
|
+
type: 'submenu',
|
|
1504
|
+
submenu: this._openBranchSubmenu
|
|
1505
|
+
});
|
|
1414
1506
|
this._contextMenu.addItem({
|
|
1415
1507
|
type: 'submenu',
|
|
1416
1508
|
submenu: this._branchSubmenu
|
|
@@ -1462,6 +1554,25 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1462
1554
|
this._branchSubmenu.addItem({
|
|
1463
1555
|
command: 'claude-code-sessions:switch-branch-more'
|
|
1464
1556
|
});
|
|
1557
|
+
|
|
1558
|
+
// Open submenu: same top-5 branches, but each launches its own
|
|
1559
|
+
// terminal directly. Falls through to the Manage Sessions popup for
|
|
1560
|
+
// the full list (from which any conversation can also be opened).
|
|
1561
|
+
this._openBranchSubmenu.clearItems();
|
|
1562
|
+
this._openBranchSubmenu.title.label = `Open Branched Conversation (${data.branches.length})`;
|
|
1563
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1564
|
+
this._openBranchSubmenu.addItem({
|
|
1565
|
+
command: 'claude-code-sessions:open-branch',
|
|
1566
|
+
args: {
|
|
1567
|
+
session_id: b.session_id,
|
|
1568
|
+
label: `${this._branchDisplayName(b)} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
this._openBranchSubmenu.addItem({ type: 'separator' });
|
|
1573
|
+
this._openBranchSubmenu.addItem({
|
|
1574
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1575
|
+
});
|
|
1465
1576
|
hasBranches = data.branches.length > 0;
|
|
1466
1577
|
} catch {
|
|
1467
1578
|
hasBranches = false;
|
|
@@ -1558,6 +1669,24 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1558
1669
|
buttons: [Dialog.cancelButton()]
|
|
1559
1670
|
});
|
|
1560
1671
|
|
|
1672
|
+
// Per-row "Open" launches that conversation in its own terminal (via
|
|
1673
|
+
// _openBranch, reusing only a terminal already running it) and closes the
|
|
1674
|
+
// popup. stopPropagation keeps the click from toggling selection or
|
|
1675
|
+
// switching the row.
|
|
1676
|
+
const openButton = (sessionId: string): HTMLButtonElement => {
|
|
1677
|
+
const btn = document.createElement('button');
|
|
1678
|
+
btn.type = 'button';
|
|
1679
|
+
btn.className = 'jp-ClaudeSessionsPanel-branchOpen';
|
|
1680
|
+
btn.textContent = 'Open';
|
|
1681
|
+
btn.title = 'Open this conversation in its own terminal';
|
|
1682
|
+
btn.addEventListener('click', e => {
|
|
1683
|
+
e.stopPropagation();
|
|
1684
|
+
dialog.dispose();
|
|
1685
|
+
void this._openBranch(sessionId);
|
|
1686
|
+
});
|
|
1687
|
+
return btn;
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1561
1690
|
const visibleMatches = (): IBranch[] => {
|
|
1562
1691
|
const needle = search.value.trim().toLowerCase();
|
|
1563
1692
|
return items.filter(
|
|
@@ -1598,6 +1727,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1598
1727
|
const currentRow = document.createElement('div');
|
|
1599
1728
|
currentRow.className = 'jp-ClaudeSessionsPanel-branchRow jp-mod-current';
|
|
1600
1729
|
currentRow.title = `Session id: ${current}`;
|
|
1730
|
+
// Expose the active state to assistive tech - the brand left-bar, tint
|
|
1731
|
+
// and the plain "current" text are visual-only cues.
|
|
1732
|
+
currentRow.setAttribute('aria-current', 'true');
|
|
1601
1733
|
// Empty select cell keeps the name column aligned with branch rows.
|
|
1602
1734
|
const currentSelect = document.createElement('span');
|
|
1603
1735
|
currentSelect.className = 'jp-ClaudeSessionsPanel-branchSelectCell';
|
|
@@ -1613,6 +1745,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1613
1745
|
badge.className = 'jp-ClaudeSessionsPanel-branchCurrentBadge';
|
|
1614
1746
|
badge.textContent = 'current';
|
|
1615
1747
|
currentRow.appendChild(badge);
|
|
1748
|
+
currentRow.appendChild(openButton(current));
|
|
1616
1749
|
currentRow.appendChild(this._branchCopyButton(current));
|
|
1617
1750
|
list.appendChild(currentRow);
|
|
1618
1751
|
|
|
@@ -1677,6 +1810,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1677
1810
|
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1678
1811
|
row.appendChild(time);
|
|
1679
1812
|
|
|
1813
|
+
row.appendChild(openButton(b.session_id));
|
|
1680
1814
|
row.appendChild(this._branchCopyButton(b.session_id));
|
|
1681
1815
|
|
|
1682
1816
|
row.addEventListener('click', () => {
|
|
@@ -1902,7 +2036,12 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1902
2036
|
name: launched.terminal_name
|
|
1903
2037
|
});
|
|
1904
2038
|
if (widget?.id) {
|
|
1905
|
-
|
|
2039
|
+
// The terminal runs the FORK (forkId), so tag it with that id - a
|
|
2040
|
+
// later click on the now-current forked row reuses this terminal.
|
|
2041
|
+
this._terminalsByPath.set(session.project_path, {
|
|
2042
|
+
widget,
|
|
2043
|
+
sessionId: forkId
|
|
2044
|
+
});
|
|
1906
2045
|
this._wireTerminalDisposal(session.project_path, widget);
|
|
1907
2046
|
this._focusTerminal(widget);
|
|
1908
2047
|
}
|
|
@@ -1913,8 +2052,10 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1913
2052
|
spinner.dispose();
|
|
1914
2053
|
}
|
|
1915
2054
|
// No post-hoc title write: claude owns the name via ``-n`` and stamps it
|
|
1916
|
-
// on the fork's first turn.
|
|
1917
|
-
//
|
|
2055
|
+
// on the fork's first turn. claude materialises the fork's JSONL lazily,
|
|
2056
|
+
// so watch for it at a fast cadence and refresh the moment it appears -
|
|
2057
|
+
// the branch surfaces in seconds instead of on the next slow poll.
|
|
2058
|
+
this._watchForBranch(session.encoded_path, forkId);
|
|
1918
2059
|
}
|
|
1919
2060
|
|
|
1920
2061
|
/** Switch the active row's project to another conversation branch.
|
|
@@ -1960,6 +2101,61 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1960
2101
|
}
|
|
1961
2102
|
}
|
|
1962
2103
|
|
|
2104
|
+
/** Open a specific conversation branch in its own terminal.
|
|
2105
|
+
*
|
|
2106
|
+
* Reuse only a terminal already running THIS conversation; otherwise launch
|
|
2107
|
+
* a fresh ``claude --resume <id>``. So several branches of one project can
|
|
2108
|
+
* be open independently and side by side - opening branch B never disturbs
|
|
2109
|
+
* branch A's terminal, and never refocuses a terminal running a different
|
|
2110
|
+
* conversation. Honours the global skip-permissions toggle like a normal
|
|
2111
|
+
* resume. */
|
|
2112
|
+
private async _openBranch(sessionId: string): Promise<void> {
|
|
2113
|
+
const active = this._activeSession;
|
|
2114
|
+
if (!active || !sessionId) {
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
await this._resumeInTerminal({ ...active, session_id: sessionId });
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
/** Poll fast for a freshly forked branch to materialise, then refresh.
|
|
2121
|
+
*
|
|
2122
|
+
* claude writes a forked session's JSONL lazily (on its first turn), so a
|
|
2123
|
+
* just-requested branch does not exist at launch and would otherwise only
|
|
2124
|
+
* surface on the next 30s poll. This watches the project's branch list
|
|
2125
|
+
* every ``BRANCH_WATCH_INTERVAL_MS`` until ``forkId`` appears (as current
|
|
2126
|
+
* or in the list) - then does one full refresh and stops - or until a
|
|
2127
|
+
* bounded number of attempts elapses. The panel is never updated before
|
|
2128
|
+
* the branch genuinely exists. */
|
|
2129
|
+
private _watchForBranch(encodedPath: string, forkId: string): void {
|
|
2130
|
+
let attempts = 0;
|
|
2131
|
+
const tick = async (): Promise<void> => {
|
|
2132
|
+
if (this.isDisposed) {
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
attempts += 1;
|
|
2136
|
+
try {
|
|
2137
|
+
const data = await requestAPI<IBranchesResponse>(
|
|
2138
|
+
`sessions/branches?encoded_path=${encodeURIComponent(encodedPath)}`,
|
|
2139
|
+
this._serverSettings,
|
|
2140
|
+
{ cache: 'no-store' }
|
|
2141
|
+
);
|
|
2142
|
+
const appeared =
|
|
2143
|
+
data.current === forkId ||
|
|
2144
|
+
data.branches.some(b => b.session_id === forkId);
|
|
2145
|
+
if (appeared) {
|
|
2146
|
+
await this._fetch();
|
|
2147
|
+
return;
|
|
2148
|
+
}
|
|
2149
|
+
} catch {
|
|
2150
|
+
// Transient failure - keep watching until the attempt budget runs out.
|
|
2151
|
+
}
|
|
2152
|
+
if (attempts < BRANCH_WATCH_MAX_ATTEMPTS) {
|
|
2153
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
2154
|
+
}
|
|
2155
|
+
};
|
|
2156
|
+
window.setTimeout(() => void tick(), BRANCH_WATCH_INTERVAL_MS);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
1963
2159
|
// --------------------------------------------------------------- polling
|
|
1964
2160
|
|
|
1965
2161
|
private _startPolling(): void {
|
|
@@ -1994,6 +2190,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1994
2190
|
private _commands!: CommandRegistry;
|
|
1995
2191
|
private _contextMenu!: Menu;
|
|
1996
2192
|
private _branchSubmenu!: Menu;
|
|
2193
|
+
private _openBranchSubmenu!: Menu;
|
|
1997
2194
|
private _branchSessionMenu!: Menu;
|
|
1998
2195
|
private _lastBranches: IBranch[] = [];
|
|
1999
2196
|
private _lastBranchesCurrent = '';
|
|
@@ -2004,7 +2201,14 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
2004
2201
|
private readonly _removingPaths: Set<string> = new Set();
|
|
2005
2202
|
private readonly _terminalTracker: ITerminalTracker | null;
|
|
2006
2203
|
private readonly _fileBrowser: IDefaultFileBrowser | null;
|
|
2007
|
-
|
|
2204
|
+
// Microcache of the most-recent terminal per project, tagged with the
|
|
2205
|
+
// conversation it is running so reuse can tell a project's branches apart.
|
|
2206
|
+
private readonly _terminalsByPath: Map<
|
|
2207
|
+
string,
|
|
2208
|
+
{ widget: any; sessionId?: string }
|
|
2209
|
+
> = new Map();
|
|
2210
|
+
// In-flight launches, keyed per CONVERSATION (path + session id) so two
|
|
2211
|
+
// different branches of one project can open independently and concurrently.
|
|
2008
2212
|
private readonly _pendingByPath: Map<string, Promise<void>> = new Map();
|
|
2009
2213
|
private readonly _rootDir: string;
|
|
2010
2214
|
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;
|
|
@@ -503,14 +522,9 @@
|
|
|
503
522
|
|
|
504
523
|
.jp-ClaudeSessionsPanel-branchCurrentBadge {
|
|
505
524
|
flex: none;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
border-radius: 8px;
|
|
509
|
-
color: var(--jp-brand-color1);
|
|
525
|
+
white-space: nowrap;
|
|
526
|
+
color: var(--jp-ui-font-color2);
|
|
510
527
|
font-size: var(--jp-ui-font-size0);
|
|
511
|
-
font-weight: 600;
|
|
512
|
-
text-transform: uppercase;
|
|
513
|
-
letter-spacing: 0.5px;
|
|
514
528
|
}
|
|
515
529
|
|
|
516
530
|
.jp-ClaudeSessionsPanel-branchSelectAll {
|