jupyterlab_claude_code_extension 1.1.31 → 1.2.4
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/types.d.ts +18 -0
- package/lib/widget.d.ts +17 -0
- package/lib/widget.js +185 -13
- package/package.json +1 -1
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +84 -0
- package/src/types.ts +22 -0
- package/src/widget.ts +223 -14
- package/style/base.css +53 -0
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Browse, resume, and manage your Claude Code sessions from a JupyterLab side pane
|
|
|
20
20
|
- **Favorites** - star projects you keep coming back to via the right-click menu
|
|
21
21
|
- **Remove** - drop a project's Claude history from the panel via the right-click menu; the history folder is moved to the trash (it honours JupyterLab's "move files to trash" setting), not deleted permanently
|
|
22
22
|
- **Clean up parallel sessions** - when a project has accumulated extra sessions beyond the main one, a right-click menu item (showing the count in brackets) removes them all, keeping only the main session; removed files honour the same trash setting
|
|
23
|
+
- **Switch conversation branch** - a right-click submenu lists a project's other conversations (5 most recent, with a searchable "More..." popup for longer lists) and switches the row's current one; rows with multiple conversations show the count in brackets after the name, e.g. `workspace (2)`
|
|
23
24
|
- **Search** - fuzzy filter toggled by the funnel button next to refresh
|
|
24
25
|
- **Presentation modes** - label rows by session name (so a `/rename` shows through), folder name, or path relative to the JupyterLab root
|
|
25
26
|
- **Hover tooltip** with project path, last activity, message count, branch, and session id
|
package/lib/types.d.ts
CHANGED
|
@@ -42,6 +42,24 @@ export interface ICleanupRequest {
|
|
|
42
42
|
export interface ICleanupResponse {
|
|
43
43
|
removed_count: number;
|
|
44
44
|
}
|
|
45
|
+
export interface IBranch {
|
|
46
|
+
session_id: string;
|
|
47
|
+
file_mtime: number;
|
|
48
|
+
label: string;
|
|
49
|
+
}
|
|
50
|
+
export interface IBranchesResponse {
|
|
51
|
+
current: string;
|
|
52
|
+
total: number;
|
|
53
|
+
branches: IBranch[];
|
|
54
|
+
}
|
|
55
|
+
export interface ISwitchRequest {
|
|
56
|
+
encoded_path: string;
|
|
57
|
+
session_id: string;
|
|
58
|
+
}
|
|
59
|
+
export interface ISwitchResponse {
|
|
60
|
+
requested: string;
|
|
61
|
+
current: string | null;
|
|
62
|
+
}
|
|
45
63
|
export interface ILaunchTerminalRequest {
|
|
46
64
|
project_path: string;
|
|
47
65
|
/** Omit to start a brand-new claude session instead of resuming one. */
|
package/lib/widget.d.ts
CHANGED
|
@@ -95,6 +95,21 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
95
95
|
private _setRefreshSpinning;
|
|
96
96
|
private _setActiveRow;
|
|
97
97
|
private _setupContextMenu;
|
|
98
|
+
/** Rebuild the context menu's items. Lumino submenu-type items have no
|
|
99
|
+
* ``isVisible`` hook, so the menu is rebuilt per open and the branch
|
|
100
|
+
* submenu inserted only when the row actually has branches. */
|
|
101
|
+
private _rebuildContextMenu;
|
|
102
|
+
/** Open the row context menu, populating the branch submenu first when
|
|
103
|
+
* the project has more than one conversation. On a fetch failure the
|
|
104
|
+
* menu opens without the submenu. */
|
|
105
|
+
private _openContextMenu;
|
|
106
|
+
/** Popup with the project's full branch list - browse and filter when
|
|
107
|
+
* the list is too large for the submenu. Clicking an entry switches. */
|
|
108
|
+
private _showBranchPopup;
|
|
109
|
+
/** Switch the active row's project to another conversation branch.
|
|
110
|
+
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
111
|
+
* the selected conversation as the row's current one. */
|
|
112
|
+
private _switchBranch;
|
|
98
113
|
private _startPolling;
|
|
99
114
|
private _stopPolling;
|
|
100
115
|
private readonly _app;
|
|
@@ -107,6 +122,8 @@ export declare class ClaudeCodeSessionsWidget extends Widget {
|
|
|
107
122
|
private _expanded;
|
|
108
123
|
private _commands;
|
|
109
124
|
private _contextMenu;
|
|
125
|
+
private _branchSubmenu;
|
|
126
|
+
private _lastBranches;
|
|
110
127
|
private _newSessionMenu;
|
|
111
128
|
private _activeSession;
|
|
112
129
|
private _activeRowEl;
|
package/lib/widget.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Clipboard, Dialog, Notification, showDialog } from '@jupyterlab/apputils';
|
|
2
|
+
import { ServerConnection } from '@jupyterlab/services';
|
|
2
3
|
import { folderIcon, terminalIcon } from '@jupyterlab/ui-components';
|
|
3
4
|
import { CommandRegistry } from '@lumino/commands';
|
|
4
5
|
import { Menu, Widget } from '@lumino/widgets';
|
|
@@ -63,6 +64,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
63
64
|
this._searchEl = null;
|
|
64
65
|
this._sessions = null;
|
|
65
66
|
this._expanded = loadExpanded();
|
|
67
|
+
this._lastBranches = [];
|
|
66
68
|
this._activeSession = null;
|
|
67
69
|
this._activeRowEl = null;
|
|
68
70
|
this._pollHandle = null;
|
|
@@ -804,8 +806,18 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
804
806
|
}
|
|
805
807
|
const name = document.createElement('span');
|
|
806
808
|
name.className = 'jp-ClaudeSessionsPanel-name';
|
|
807
|
-
|
|
809
|
+
// Conversation count in brackets - only when the project has branches.
|
|
810
|
+
name.textContent =
|
|
811
|
+
session.extra_sessions > 0
|
|
812
|
+
? `${this._lookupName(session)} (${session.extra_sessions + 1})`
|
|
813
|
+
: this._lookupName(session);
|
|
808
814
|
row.appendChild(name);
|
|
815
|
+
if (session.file_mtime) {
|
|
816
|
+
const time = document.createElement('span');
|
|
817
|
+
time.className = 'jp-ClaudeSessionsPanel-rowTime';
|
|
818
|
+
time.textContent = this._formatRelativeTime(session.file_mtime);
|
|
819
|
+
row.appendChild(time);
|
|
820
|
+
}
|
|
809
821
|
// No star in the Favorites section - every row there is a favorite
|
|
810
822
|
// by definition; stars are an indicator only useful in Recent/All.
|
|
811
823
|
if (session.favourite && sectionKey !== 'favourites') {
|
|
@@ -828,7 +840,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
828
840
|
}
|
|
829
841
|
this._activeSession = session;
|
|
830
842
|
this._setActiveRow(row);
|
|
831
|
-
this.
|
|
843
|
+
void this._openContextMenu(session, e.clientX, e.clientY);
|
|
832
844
|
});
|
|
833
845
|
return row;
|
|
834
846
|
}
|
|
@@ -845,6 +857,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
845
857
|
if (s.message_count) {
|
|
846
858
|
lines.push(`Messages: ${s.message_count}`);
|
|
847
859
|
}
|
|
860
|
+
if (s.extra_sessions > 0) {
|
|
861
|
+
lines.push(`Conversations: ${s.extra_sessions + 1}`);
|
|
862
|
+
}
|
|
848
863
|
if (s.git_branch) {
|
|
849
864
|
lines.push(`Branch: ${s.git_branch}`);
|
|
850
865
|
}
|
|
@@ -889,7 +904,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
889
904
|
}
|
|
890
905
|
const diff = Date.now() - epochMs;
|
|
891
906
|
if (diff < 60000) {
|
|
892
|
-
return '
|
|
907
|
+
return 'now';
|
|
893
908
|
}
|
|
894
909
|
if (diff < 3600000) {
|
|
895
910
|
return `${Math.floor(diff / 60000)}m ago`;
|
|
@@ -897,10 +912,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
897
912
|
if (diff < 86400000) {
|
|
898
913
|
return `${Math.floor(diff / 3600000)}h ago`;
|
|
899
914
|
}
|
|
900
|
-
|
|
901
|
-
return `${Math.floor(diff / 86400000)}d ago`;
|
|
902
|
-
}
|
|
903
|
-
return new Date(epochMs).toLocaleDateString();
|
|
915
|
+
return `${Math.floor(diff / 86400000)}d ago`;
|
|
904
916
|
}
|
|
905
917
|
_setRefreshSpinning(on) {
|
|
906
918
|
if (!this._refreshBtn) {
|
|
@@ -1012,6 +1024,22 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1012
1024
|
}
|
|
1013
1025
|
}
|
|
1014
1026
|
});
|
|
1027
|
+
this._commands.addCommand('claude-code-sessions:switch-branch', {
|
|
1028
|
+
label: args => { var _a; return String((_a = args.label) !== null && _a !== void 0 ? _a : ''); },
|
|
1029
|
+
execute: args => {
|
|
1030
|
+
var _a;
|
|
1031
|
+
const sessionId = String((_a = args.session_id) !== null && _a !== void 0 ? _a : '');
|
|
1032
|
+
if (sessionId) {
|
|
1033
|
+
void this._switchBranch(sessionId);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
this._commands.addCommand('claude-code-sessions:switch-branch-more', {
|
|
1038
|
+
label: () => `More... (${this._lastBranches.length} total)`,
|
|
1039
|
+
execute: () => {
|
|
1040
|
+
void this._showBranchPopup(this._lastBranches);
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1015
1043
|
this._commands.addCommand('claude-code-sessions:remove', {
|
|
1016
1044
|
label: 'Remove from Claude',
|
|
1017
1045
|
icon: removeIcon,
|
|
@@ -1040,8 +1068,28 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1040
1068
|
this._newSessionMenu.addItem({
|
|
1041
1069
|
command: 'claude-code-sessions:new-session-dangerous'
|
|
1042
1070
|
});
|
|
1071
|
+
// Submenu listing the project's other conversations ("branches") -
|
|
1072
|
+
// items are rebuilt on every context-menu open from a fresh
|
|
1073
|
+
// sessions/branches fetch.
|
|
1074
|
+
this._branchSubmenu = new Menu({ commands: this._commands });
|
|
1075
|
+
this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1076
|
+
this._branchSubmenu.title.label = 'Switch Conversation Branch';
|
|
1043
1077
|
this._contextMenu = new Menu({ commands: this._commands });
|
|
1044
1078
|
this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1079
|
+
this._rebuildContextMenu(false);
|
|
1080
|
+
this._contextMenu.aboutToClose.connect(() => {
|
|
1081
|
+
// Only clear the visual highlight - DO NOT null _activeSession.
|
|
1082
|
+
// Lumino fires aboutToClose BEFORE the activated item's command runs,
|
|
1083
|
+
// so the command callback still needs to read _activeSession. The
|
|
1084
|
+
// field is overwritten on the next contextmenu open.
|
|
1085
|
+
this._setActiveRow(null);
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
/** Rebuild the context menu's items. Lumino submenu-type items have no
|
|
1089
|
+
* ``isVisible`` hook, so the menu is rebuilt per open and the branch
|
|
1090
|
+
* submenu inserted only when the row actually has branches. */
|
|
1091
|
+
_rebuildContextMenu(withBranches) {
|
|
1092
|
+
this._contextMenu.clearItems();
|
|
1045
1093
|
this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
|
|
1046
1094
|
this._contextMenu.addItem({
|
|
1047
1095
|
command: 'claude-code-sessions:resume-dangerous'
|
|
@@ -1057,17 +1105,141 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1057
1105
|
});
|
|
1058
1106
|
this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
|
|
1059
1107
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1108
|
+
if (withBranches) {
|
|
1109
|
+
this._contextMenu.addItem({
|
|
1110
|
+
type: 'submenu',
|
|
1111
|
+
submenu: this._branchSubmenu
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1060
1114
|
this._contextMenu.addItem({
|
|
1061
1115
|
command: 'claude-code-sessions:cleanup-parallel'
|
|
1062
1116
|
});
|
|
1063
1117
|
this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1118
|
+
}
|
|
1119
|
+
/** Open the row context menu, populating the branch submenu first when
|
|
1120
|
+
* the project has more than one conversation. On a fetch failure the
|
|
1121
|
+
* menu opens without the submenu. */
|
|
1122
|
+
async _openContextMenu(session, x, y) {
|
|
1123
|
+
let hasBranches = false;
|
|
1124
|
+
if (session.extra_sessions > 0) {
|
|
1125
|
+
try {
|
|
1126
|
+
const data = await requestAPI(`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`, this._serverSettings, { cache: 'no-store' });
|
|
1127
|
+
this._lastBranches = data.branches;
|
|
1128
|
+
this._branchSubmenu.clearItems();
|
|
1129
|
+
// The submenu shows only the 5 most recent; the full list lives
|
|
1130
|
+
// behind "More..." in a searchable popup.
|
|
1131
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1132
|
+
this._branchSubmenu.addItem({
|
|
1133
|
+
command: 'claude-code-sessions:switch-branch',
|
|
1134
|
+
args: {
|
|
1135
|
+
session_id: b.session_id,
|
|
1136
|
+
label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
if (data.branches.length > 5) {
|
|
1141
|
+
this._branchSubmenu.addItem({ type: 'separator' });
|
|
1142
|
+
this._branchSubmenu.addItem({
|
|
1143
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
hasBranches = data.branches.length > 0;
|
|
1147
|
+
}
|
|
1148
|
+
catch (_a) {
|
|
1149
|
+
hasBranches = false;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
this._rebuildContextMenu(hasBranches);
|
|
1153
|
+
this._contextMenu.open(x, y);
|
|
1154
|
+
}
|
|
1155
|
+
/** Popup with the project's full branch list - browse and filter when
|
|
1156
|
+
* the list is too large for the submenu. Clicking an entry switches. */
|
|
1157
|
+
_showBranchPopup(branches) {
|
|
1158
|
+
const body = document.createElement('div');
|
|
1159
|
+
body.className = 'jp-ClaudeSessionsPanel-branchPopup';
|
|
1160
|
+
const search = document.createElement('input');
|
|
1161
|
+
search.type = 'search';
|
|
1162
|
+
search.placeholder = 'Filter branches...';
|
|
1163
|
+
search.className = 'jp-ClaudeSessionsPanel-branchSearch';
|
|
1164
|
+
body.appendChild(search);
|
|
1165
|
+
const list = document.createElement('div');
|
|
1166
|
+
list.className = 'jp-ClaudeSessionsPanel-branchList';
|
|
1167
|
+
body.appendChild(list);
|
|
1168
|
+
const bodyWidget = new Widget({ node: body });
|
|
1169
|
+
const dialog = new Dialog({
|
|
1170
|
+
title: 'Switch Conversation Branch',
|
|
1171
|
+
body: bodyWidget,
|
|
1172
|
+
buttons: [Dialog.cancelButton()]
|
|
1070
1173
|
});
|
|
1174
|
+
const render = () => {
|
|
1175
|
+
const needle = search.value.trim().toLowerCase();
|
|
1176
|
+
list.replaceChildren();
|
|
1177
|
+
const matches = branches.filter(b => !needle ||
|
|
1178
|
+
b.label.toLowerCase().includes(needle) ||
|
|
1179
|
+
b.session_id.toLowerCase().includes(needle));
|
|
1180
|
+
if (matches.length === 0) {
|
|
1181
|
+
const empty = document.createElement('div');
|
|
1182
|
+
empty.className = 'jp-ClaudeSessionsPanel-emptySection';
|
|
1183
|
+
empty.textContent = 'No matching branches.';
|
|
1184
|
+
list.appendChild(empty);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
for (const b of matches) {
|
|
1188
|
+
const row = document.createElement('div');
|
|
1189
|
+
row.className = 'jp-ClaudeSessionsPanel-branchRow';
|
|
1190
|
+
row.title = `Session id: ${b.session_id}`;
|
|
1191
|
+
const label = document.createElement('span');
|
|
1192
|
+
label.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1193
|
+
label.textContent = b.label;
|
|
1194
|
+
row.appendChild(label);
|
|
1195
|
+
const time = document.createElement('span');
|
|
1196
|
+
time.className = 'jp-ClaudeSessionsPanel-branchTime';
|
|
1197
|
+
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1198
|
+
row.appendChild(time);
|
|
1199
|
+
row.addEventListener('click', () => {
|
|
1200
|
+
dialog.dispose();
|
|
1201
|
+
void this._switchBranch(b.session_id);
|
|
1202
|
+
});
|
|
1203
|
+
list.appendChild(row);
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
search.addEventListener('input', render);
|
|
1207
|
+
render();
|
|
1208
|
+
void dialog.launch();
|
|
1209
|
+
search.focus();
|
|
1210
|
+
}
|
|
1211
|
+
/** Switch the active row's project to another conversation branch.
|
|
1212
|
+
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
1213
|
+
* the selected conversation as the row's current one. */
|
|
1214
|
+
async _switchBranch(sessionId) {
|
|
1215
|
+
const session = this._activeSession;
|
|
1216
|
+
if (!session) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
const result = await requestAPI('sessions/switch', this._serverSettings, {
|
|
1221
|
+
method: 'POST',
|
|
1222
|
+
body: JSON.stringify({
|
|
1223
|
+
encoded_path: session.encoded_path,
|
|
1224
|
+
session_id: sessionId
|
|
1225
|
+
})
|
|
1226
|
+
});
|
|
1227
|
+
if (result.current !== result.requested) {
|
|
1228
|
+
// The branch's recorded cwd is inconsistent with the project dir,
|
|
1229
|
+
// so the recency resolution cannot make it current.
|
|
1230
|
+
Notification.warning('Branch cannot become current - its recorded folder does not match the project.', { autoClose: 4000 });
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
catch (err) {
|
|
1234
|
+
const notFound = err instanceof ServerConnection.ResponseError &&
|
|
1235
|
+
err.response.status === 404;
|
|
1236
|
+
Notification.error(notFound
|
|
1237
|
+
? 'Branch no longer exists - the session list has been refreshed.'
|
|
1238
|
+
: `Branch switch failed: ${err}`, { autoClose: 4000 });
|
|
1239
|
+
}
|
|
1240
|
+
finally {
|
|
1241
|
+
await this._fetch();
|
|
1242
|
+
}
|
|
1071
1243
|
}
|
|
1072
1244
|
// --------------------------------------------------------------- polling
|
|
1073
1245
|
_startPolling() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jupyterlab_claude_code_extension",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.4",
|
|
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",
|
|
@@ -122,6 +122,90 @@ describe('launch spinner dismiss contract', () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Contract for branch switching: the context menu is rebuilt on every
|
|
127
|
+
* open (Lumino submenu items have no isVisible hook), the branch
|
|
128
|
+
* submenu is repopulated from a fresh sessions/branches fetch, the
|
|
129
|
+
* row name carries the conversation count only when branches exist,
|
|
130
|
+
* and _switchBranch always resyncs the list - including after a 404
|
|
131
|
+
* for a branch that vanished between menu display and click.
|
|
132
|
+
*/
|
|
133
|
+
describe('branch switching contract', () => {
|
|
134
|
+
const openMenu = (widgetSrc.match(
|
|
135
|
+
/private async _openContextMenu[\s\S]*?\n \}/
|
|
136
|
+
) ?? [''])[0];
|
|
137
|
+
const switchBranch = (widgetSrc.match(
|
|
138
|
+
/private async _switchBranch[\s\S]*?\n \}/
|
|
139
|
+
) ?? [''])[0];
|
|
140
|
+
|
|
141
|
+
it('shows the conversation count only when the project has branches', () => {
|
|
142
|
+
expect(widgetSrc).toMatch(
|
|
143
|
+
/session\.extra_sessions > 0\s*\?\s*`\$\{this\._lookupName\(session\)\} \(\$\{session\.extra_sessions \+ 1\}\)`/
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rebuilds submenu items from a fresh branches fetch on open', () => {
|
|
148
|
+
expect(openMenu).toMatch(/sessions\/branches\?encoded_path=/);
|
|
149
|
+
expect(openMenu).toMatch(/_branchSubmenu\.clearItems\(\)/);
|
|
150
|
+
expect(openMenu).toMatch(/_rebuildContextMenu\(hasBranches\)/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('caps the submenu at 5 and adds More... beyond that', () => {
|
|
154
|
+
expect(openMenu).toMatch(/\.slice\(0, 5\)/);
|
|
155
|
+
expect(openMenu).toMatch(
|
|
156
|
+
/branches\.length > 5[\s\S]*?switch-branch-more/
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('More... popup filters by label or session id and switches on click', () => {
|
|
161
|
+
const popup = (widgetSrc.match(
|
|
162
|
+
/private _showBranchPopup[\s\S]*?\n \}/
|
|
163
|
+
) ?? [''])[0];
|
|
164
|
+
expect(popup).toMatch(/createElement\('input'\)/);
|
|
165
|
+
expect(popup).toMatch(/label\.toLowerCase\(\)\.includes\(needle\)/);
|
|
166
|
+
expect(popup).toMatch(/session_id\.toLowerCase\(\)\.includes\(needle\)/);
|
|
167
|
+
expect(popup).toMatch(
|
|
168
|
+
/dialog\.dispose\(\);\s*void this\._switchBranch\(b\.session_id\)/
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('opens the menu without the submenu when the fetch fails', () => {
|
|
173
|
+
expect(openMenu).toMatch(/catch[\s\S]*?hasBranches = false/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('resyncs the session list after every switch attempt', () => {
|
|
177
|
+
expect(switchBranch).toMatch(/finally[\s\S]*?await this\._fetch\(\)/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('reports a removed branch distinctly via the 404 status', () => {
|
|
181
|
+
expect(switchBranch).toMatch(/status === 404/);
|
|
182
|
+
expect(switchBranch).toMatch(/Branch no longer exists/);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('shows last activity on session rows via the shared formatter', () => {
|
|
186
|
+
expect(widgetSrc).toMatch(
|
|
187
|
+
/jp-ClaudeSessionsPanel-rowTime[\s\S]*?_formatRelativeTime\(session\.file_mtime\)/
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('formats relative time as now / m / h / d ago, no date fallback', () => {
|
|
192
|
+
const fmt = (widgetSrc.match(
|
|
193
|
+
/private _formatRelativeTime[\s\S]*?\n \}/
|
|
194
|
+
) ?? [''])[0];
|
|
195
|
+
expect(fmt).toMatch(/return 'now'/);
|
|
196
|
+
expect(fmt).toMatch(/m ago/);
|
|
197
|
+
expect(fmt).toMatch(/h ago/);
|
|
198
|
+
expect(fmt).toMatch(/d ago/);
|
|
199
|
+
expect(fmt).not.toMatch(/toLocaleDateString/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('warns when the resolved current differs from the requested branch', () => {
|
|
203
|
+
expect(switchBranch).toMatch(
|
|
204
|
+
/result\.current !== result\.requested[\s\S]*?Notification\.warning/
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
125
209
|
it('_doResumeInTerminal dismisses spinner via dispose(), not resolve()', () => {
|
|
126
210
|
expect(widgetSrc).toMatch(/spinner\.dispose\(\)/);
|
|
127
211
|
expect(widgetSrc).not.toMatch(/spinner\.resolve\(\)/);
|
package/src/types.ts
CHANGED
|
@@ -51,6 +51,28 @@ export interface ICleanupResponse {
|
|
|
51
51
|
removed_count: number;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export interface IBranch {
|
|
55
|
+
session_id: string;
|
|
56
|
+
file_mtime: number;
|
|
57
|
+
label: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface IBranchesResponse {
|
|
61
|
+
current: string;
|
|
62
|
+
total: number;
|
|
63
|
+
branches: IBranch[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ISwitchRequest {
|
|
67
|
+
encoded_path: string;
|
|
68
|
+
session_id: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface ISwitchResponse {
|
|
72
|
+
requested: string;
|
|
73
|
+
current: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
54
76
|
export interface ILaunchTerminalRequest {
|
|
55
77
|
project_path: string;
|
|
56
78
|
/** Omit to start a brand-new claude session instead of resuming one. */
|
package/src/widget.ts
CHANGED
|
@@ -24,12 +24,15 @@ import {
|
|
|
24
24
|
starFilledIcon
|
|
25
25
|
} from './icons';
|
|
26
26
|
import {
|
|
27
|
+
IBranch,
|
|
28
|
+
IBranchesResponse,
|
|
27
29
|
IFavouriteResponse,
|
|
28
30
|
ILaunchTerminalResponse,
|
|
29
31
|
ICleanupResponse,
|
|
30
32
|
IRemoveResponse,
|
|
31
33
|
ISession,
|
|
32
|
-
ISessionsListResponse
|
|
34
|
+
ISessionsListResponse,
|
|
35
|
+
ISwitchResponse
|
|
33
36
|
} from './types';
|
|
34
37
|
|
|
35
38
|
const POLL_INTERVAL_MS = 30_000;
|
|
@@ -948,9 +951,20 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
948
951
|
|
|
949
952
|
const name = document.createElement('span');
|
|
950
953
|
name.className = 'jp-ClaudeSessionsPanel-name';
|
|
951
|
-
|
|
954
|
+
// Conversation count in brackets - only when the project has branches.
|
|
955
|
+
name.textContent =
|
|
956
|
+
session.extra_sessions > 0
|
|
957
|
+
? `${this._lookupName(session)} (${session.extra_sessions + 1})`
|
|
958
|
+
: this._lookupName(session);
|
|
952
959
|
row.appendChild(name);
|
|
953
960
|
|
|
961
|
+
if (session.file_mtime) {
|
|
962
|
+
const time = document.createElement('span');
|
|
963
|
+
time.className = 'jp-ClaudeSessionsPanel-rowTime';
|
|
964
|
+
time.textContent = this._formatRelativeTime(session.file_mtime);
|
|
965
|
+
row.appendChild(time);
|
|
966
|
+
}
|
|
967
|
+
|
|
954
968
|
// No star in the Favorites section - every row there is a favorite
|
|
955
969
|
// by definition; stars are an indicator only useful in Recent/All.
|
|
956
970
|
if (session.favourite && sectionKey !== 'favourites') {
|
|
@@ -974,7 +988,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
974
988
|
}
|
|
975
989
|
this._activeSession = session;
|
|
976
990
|
this._setActiveRow(row);
|
|
977
|
-
this.
|
|
991
|
+
void this._openContextMenu(session, e.clientX, e.clientY);
|
|
978
992
|
});
|
|
979
993
|
|
|
980
994
|
return row;
|
|
@@ -993,6 +1007,9 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
993
1007
|
if (s.message_count) {
|
|
994
1008
|
lines.push(`Messages: ${s.message_count}`);
|
|
995
1009
|
}
|
|
1010
|
+
if (s.extra_sessions > 0) {
|
|
1011
|
+
lines.push(`Conversations: ${s.extra_sessions + 1}`);
|
|
1012
|
+
}
|
|
996
1013
|
if (s.git_branch) {
|
|
997
1014
|
lines.push(`Branch: ${s.git_branch}`);
|
|
998
1015
|
}
|
|
@@ -1040,7 +1057,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1040
1057
|
}
|
|
1041
1058
|
const diff = Date.now() - epochMs;
|
|
1042
1059
|
if (diff < 60_000) {
|
|
1043
|
-
return '
|
|
1060
|
+
return 'now';
|
|
1044
1061
|
}
|
|
1045
1062
|
if (diff < 3_600_000) {
|
|
1046
1063
|
return `${Math.floor(diff / 60_000)}m ago`;
|
|
@@ -1048,10 +1065,7 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1048
1065
|
if (diff < 86_400_000) {
|
|
1049
1066
|
return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
1050
1067
|
}
|
|
1051
|
-
|
|
1052
|
-
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
1053
|
-
}
|
|
1054
|
-
return new Date(epochMs).toLocaleDateString();
|
|
1068
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
1055
1069
|
}
|
|
1056
1070
|
|
|
1057
1071
|
private _setRefreshSpinning(on: boolean): void {
|
|
@@ -1180,6 +1194,23 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1180
1194
|
}
|
|
1181
1195
|
});
|
|
1182
1196
|
|
|
1197
|
+
this._commands.addCommand('claude-code-sessions:switch-branch', {
|
|
1198
|
+
label: args => String(args.label ?? ''),
|
|
1199
|
+
execute: args => {
|
|
1200
|
+
const sessionId = String(args.session_id ?? '');
|
|
1201
|
+
if (sessionId) {
|
|
1202
|
+
void this._switchBranch(sessionId);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
this._commands.addCommand('claude-code-sessions:switch-branch-more', {
|
|
1208
|
+
label: () => `More... (${this._lastBranches.length} total)`,
|
|
1209
|
+
execute: () => {
|
|
1210
|
+
void this._showBranchPopup(this._lastBranches);
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1183
1214
|
this._commands.addCommand('claude-code-sessions:remove', {
|
|
1184
1215
|
label: 'Remove from Claude',
|
|
1185
1216
|
icon: removeIcon,
|
|
@@ -1212,8 +1243,31 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1212
1243
|
command: 'claude-code-sessions:new-session-dangerous'
|
|
1213
1244
|
});
|
|
1214
1245
|
|
|
1246
|
+
// Submenu listing the project's other conversations ("branches") -
|
|
1247
|
+
// items are rebuilt on every context-menu open from a fresh
|
|
1248
|
+
// sessions/branches fetch.
|
|
1249
|
+
this._branchSubmenu = new Menu({ commands: this._commands });
|
|
1250
|
+
this._branchSubmenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1251
|
+
this._branchSubmenu.title.label = 'Switch Conversation Branch';
|
|
1252
|
+
|
|
1215
1253
|
this._contextMenu = new Menu({ commands: this._commands });
|
|
1216
1254
|
this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
1255
|
+
this._rebuildContextMenu(false);
|
|
1256
|
+
|
|
1257
|
+
this._contextMenu.aboutToClose.connect(() => {
|
|
1258
|
+
// Only clear the visual highlight - DO NOT null _activeSession.
|
|
1259
|
+
// Lumino fires aboutToClose BEFORE the activated item's command runs,
|
|
1260
|
+
// so the command callback still needs to read _activeSession. The
|
|
1261
|
+
// field is overwritten on the next contextmenu open.
|
|
1262
|
+
this._setActiveRow(null);
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Rebuild the context menu's items. Lumino submenu-type items have no
|
|
1267
|
+
* ``isVisible`` hook, so the menu is rebuilt per open and the branch
|
|
1268
|
+
* submenu inserted only when the row actually has branches. */
|
|
1269
|
+
private _rebuildContextMenu(withBranches: boolean): void {
|
|
1270
|
+
this._contextMenu.clearItems();
|
|
1217
1271
|
this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
|
|
1218
1272
|
this._contextMenu.addItem({
|
|
1219
1273
|
command: 'claude-code-sessions:resume-dangerous'
|
|
@@ -1229,18 +1283,171 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1229
1283
|
});
|
|
1230
1284
|
this._contextMenu.addItem({ command: 'claude-code-sessions:copy-path' });
|
|
1231
1285
|
this._contextMenu.addItem({ type: 'separator' });
|
|
1286
|
+
if (withBranches) {
|
|
1287
|
+
this._contextMenu.addItem({
|
|
1288
|
+
type: 'submenu',
|
|
1289
|
+
submenu: this._branchSubmenu
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1232
1292
|
this._contextMenu.addItem({
|
|
1233
1293
|
command: 'claude-code-sessions:cleanup-parallel'
|
|
1234
1294
|
});
|
|
1235
1295
|
this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
|
|
1296
|
+
}
|
|
1236
1297
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1298
|
+
/** Open the row context menu, populating the branch submenu first when
|
|
1299
|
+
* the project has more than one conversation. On a fetch failure the
|
|
1300
|
+
* menu opens without the submenu. */
|
|
1301
|
+
private async _openContextMenu(
|
|
1302
|
+
session: ISession,
|
|
1303
|
+
x: number,
|
|
1304
|
+
y: number
|
|
1305
|
+
): Promise<void> {
|
|
1306
|
+
let hasBranches = false;
|
|
1307
|
+
if (session.extra_sessions > 0) {
|
|
1308
|
+
try {
|
|
1309
|
+
const data = await requestAPI<IBranchesResponse>(
|
|
1310
|
+
`sessions/branches?encoded_path=${encodeURIComponent(session.encoded_path)}`,
|
|
1311
|
+
this._serverSettings,
|
|
1312
|
+
{ cache: 'no-store' }
|
|
1313
|
+
);
|
|
1314
|
+
this._lastBranches = data.branches;
|
|
1315
|
+
this._branchSubmenu.clearItems();
|
|
1316
|
+
// The submenu shows only the 5 most recent; the full list lives
|
|
1317
|
+
// behind "More..." in a searchable popup.
|
|
1318
|
+
for (const b of data.branches.slice(0, 5)) {
|
|
1319
|
+
this._branchSubmenu.addItem({
|
|
1320
|
+
command: 'claude-code-sessions:switch-branch',
|
|
1321
|
+
args: {
|
|
1322
|
+
session_id: b.session_id,
|
|
1323
|
+
label: `${b.label} - ${this._formatRelativeTime(b.file_mtime)}`
|
|
1324
|
+
}
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
if (data.branches.length > 5) {
|
|
1328
|
+
this._branchSubmenu.addItem({ type: 'separator' });
|
|
1329
|
+
this._branchSubmenu.addItem({
|
|
1330
|
+
command: 'claude-code-sessions:switch-branch-more'
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
hasBranches = data.branches.length > 0;
|
|
1334
|
+
} catch {
|
|
1335
|
+
hasBranches = false;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
this._rebuildContextMenu(hasBranches);
|
|
1339
|
+
this._contextMenu.open(x, y);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** Popup with the project's full branch list - browse and filter when
|
|
1343
|
+
* the list is too large for the submenu. Clicking an entry switches. */
|
|
1344
|
+
private _showBranchPopup(branches: IBranch[]): void {
|
|
1345
|
+
const body = document.createElement('div');
|
|
1346
|
+
body.className = 'jp-ClaudeSessionsPanel-branchPopup';
|
|
1347
|
+
|
|
1348
|
+
const search = document.createElement('input');
|
|
1349
|
+
search.type = 'search';
|
|
1350
|
+
search.placeholder = 'Filter branches...';
|
|
1351
|
+
search.className = 'jp-ClaudeSessionsPanel-branchSearch';
|
|
1352
|
+
body.appendChild(search);
|
|
1353
|
+
|
|
1354
|
+
const list = document.createElement('div');
|
|
1355
|
+
list.className = 'jp-ClaudeSessionsPanel-branchList';
|
|
1356
|
+
body.appendChild(list);
|
|
1357
|
+
|
|
1358
|
+
const bodyWidget = new Widget({ node: body });
|
|
1359
|
+
const dialog = new Dialog({
|
|
1360
|
+
title: 'Switch Conversation Branch',
|
|
1361
|
+
body: bodyWidget,
|
|
1362
|
+
buttons: [Dialog.cancelButton()]
|
|
1243
1363
|
});
|
|
1364
|
+
|
|
1365
|
+
const render = () => {
|
|
1366
|
+
const needle = search.value.trim().toLowerCase();
|
|
1367
|
+
list.replaceChildren();
|
|
1368
|
+
const matches = branches.filter(
|
|
1369
|
+
b =>
|
|
1370
|
+
!needle ||
|
|
1371
|
+
b.label.toLowerCase().includes(needle) ||
|
|
1372
|
+
b.session_id.toLowerCase().includes(needle)
|
|
1373
|
+
);
|
|
1374
|
+
if (matches.length === 0) {
|
|
1375
|
+
const empty = document.createElement('div');
|
|
1376
|
+
empty.className = 'jp-ClaudeSessionsPanel-emptySection';
|
|
1377
|
+
empty.textContent = 'No matching branches.';
|
|
1378
|
+
list.appendChild(empty);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
for (const b of matches) {
|
|
1382
|
+
const row = document.createElement('div');
|
|
1383
|
+
row.className = 'jp-ClaudeSessionsPanel-branchRow';
|
|
1384
|
+
row.title = `Session id: ${b.session_id}`;
|
|
1385
|
+
|
|
1386
|
+
const label = document.createElement('span');
|
|
1387
|
+
label.className = 'jp-ClaudeSessionsPanel-branchLabel';
|
|
1388
|
+
label.textContent = b.label;
|
|
1389
|
+
row.appendChild(label);
|
|
1390
|
+
|
|
1391
|
+
const time = document.createElement('span');
|
|
1392
|
+
time.className = 'jp-ClaudeSessionsPanel-branchTime';
|
|
1393
|
+
time.textContent = this._formatRelativeTime(b.file_mtime);
|
|
1394
|
+
row.appendChild(time);
|
|
1395
|
+
|
|
1396
|
+
row.addEventListener('click', () => {
|
|
1397
|
+
dialog.dispose();
|
|
1398
|
+
void this._switchBranch(b.session_id);
|
|
1399
|
+
});
|
|
1400
|
+
list.appendChild(row);
|
|
1401
|
+
}
|
|
1402
|
+
};
|
|
1403
|
+
search.addEventListener('input', render);
|
|
1404
|
+
render();
|
|
1405
|
+
|
|
1406
|
+
void dialog.launch();
|
|
1407
|
+
search.focus();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/** Switch the active row's project to another conversation branch.
|
|
1411
|
+
* The backend touches the branch JSONL's mtime; a refresh then shows
|
|
1412
|
+
* the selected conversation as the row's current one. */
|
|
1413
|
+
private async _switchBranch(sessionId: string): Promise<void> {
|
|
1414
|
+
const session = this._activeSession;
|
|
1415
|
+
if (!session) {
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
try {
|
|
1419
|
+
const result = await requestAPI<ISwitchResponse>(
|
|
1420
|
+
'sessions/switch',
|
|
1421
|
+
this._serverSettings,
|
|
1422
|
+
{
|
|
1423
|
+
method: 'POST',
|
|
1424
|
+
body: JSON.stringify({
|
|
1425
|
+
encoded_path: session.encoded_path,
|
|
1426
|
+
session_id: sessionId
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
);
|
|
1430
|
+
if (result.current !== result.requested) {
|
|
1431
|
+
// The branch's recorded cwd is inconsistent with the project dir,
|
|
1432
|
+
// so the recency resolution cannot make it current.
|
|
1433
|
+
Notification.warning(
|
|
1434
|
+
'Branch cannot become current - its recorded folder does not match the project.',
|
|
1435
|
+
{ autoClose: 4000 }
|
|
1436
|
+
);
|
|
1437
|
+
}
|
|
1438
|
+
} catch (err) {
|
|
1439
|
+
const notFound =
|
|
1440
|
+
err instanceof ServerConnection.ResponseError &&
|
|
1441
|
+
err.response.status === 404;
|
|
1442
|
+
Notification.error(
|
|
1443
|
+
notFound
|
|
1444
|
+
? 'Branch no longer exists - the session list has been refreshed.'
|
|
1445
|
+
: `Branch switch failed: ${err}`,
|
|
1446
|
+
{ autoClose: 4000 }
|
|
1447
|
+
);
|
|
1448
|
+
} finally {
|
|
1449
|
+
await this._fetch();
|
|
1450
|
+
}
|
|
1244
1451
|
}
|
|
1245
1452
|
|
|
1246
1453
|
// --------------------------------------------------------------- polling
|
|
@@ -1275,6 +1482,8 @@ export class ClaudeCodeSessionsWidget extends Widget {
|
|
|
1275
1482
|
private _expanded: Record<SectionKey, boolean> = loadExpanded();
|
|
1276
1483
|
private _commands!: CommandRegistry;
|
|
1277
1484
|
private _contextMenu!: Menu;
|
|
1485
|
+
private _branchSubmenu!: Menu;
|
|
1486
|
+
private _lastBranches: IBranch[] = [];
|
|
1278
1487
|
private _newSessionMenu!: Menu;
|
|
1279
1488
|
private _activeSession: ISession | null = null;
|
|
1280
1489
|
private _activeRowEl: HTMLElement | null = null;
|
package/style/base.css
CHANGED
|
@@ -301,3 +301,56 @@
|
|
|
301
301
|
display: block;
|
|
302
302
|
margin: 0 auto;
|
|
303
303
|
}
|
|
304
|
+
|
|
305
|
+
/* Branch-switch popup: search box over a scrollable branch list. */
|
|
306
|
+
.jp-ClaudeSessionsPanel-branchPopup {
|
|
307
|
+
display: flex;
|
|
308
|
+
flex-direction: column;
|
|
309
|
+
gap: 8px;
|
|
310
|
+
min-width: 320px;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.jp-ClaudeSessionsPanel-branchSearch {
|
|
314
|
+
width: 100%;
|
|
315
|
+
box-sizing: border-box;
|
|
316
|
+
padding: 4px 6px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.jp-ClaudeSessionsPanel-branchList {
|
|
320
|
+
max-height: 280px;
|
|
321
|
+
overflow-y: auto;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.jp-ClaudeSessionsPanel-branchRow {
|
|
325
|
+
display: flex;
|
|
326
|
+
align-items: center;
|
|
327
|
+
justify-content: space-between;
|
|
328
|
+
gap: 12px;
|
|
329
|
+
padding: 4px 6px;
|
|
330
|
+
cursor: pointer;
|
|
331
|
+
border-radius: 2px;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.jp-ClaudeSessionsPanel-branchRow:hover {
|
|
335
|
+
background: var(--jp-layout-color2);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.jp-ClaudeSessionsPanel-branchLabel {
|
|
339
|
+
overflow: hidden;
|
|
340
|
+
text-overflow: ellipsis;
|
|
341
|
+
white-space: nowrap;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.jp-ClaudeSessionsPanel-branchTime {
|
|
345
|
+
flex: none;
|
|
346
|
+
color: var(--jp-ui-font-color2);
|
|
347
|
+
font-size: var(--jp-ui-font-size0);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/* Last-activity time on session rows - dim, right of the name. */
|
|
351
|
+
.jp-ClaudeSessionsPanel-rowTime {
|
|
352
|
+
flex: 0 0 auto;
|
|
353
|
+
margin-left: 6px;
|
|
354
|
+
color: var(--jp-ui-font-color2);
|
|
355
|
+
font-size: var(--jp-ui-font-size0);
|
|
356
|
+
}
|