jupyterlab_claude_code_extension 1.0.16
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/LICENSE +29 -0
- package/README.md +51 -0
- package/lib/icons.d.ts +5 -0
- package/lib/icons.js +44 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +57 -0
- package/lib/request.d.ts +10 -0
- package/lib/request.js +35 -0
- package/lib/types.d.ts +36 -0
- package/lib/types.js +1 -0
- package/lib/widget.d.ts +60 -0
- package/lib/widget.js +643 -0
- package/package.json +217 -0
- package/src/__tests__/jupyterlab_claude_code_extension.spec.ts +62 -0
- package/src/icons.ts +52 -0
- package/src/index.ts +95 -0
- package/src/request.ts +51 -0
- package/src/types.ts +42 -0
- package/src/widget.ts +764 -0
- package/style/base.css +246 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
package/lib/widget.js
ADDED
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
import { CommandRegistry } from '@lumino/commands';
|
|
2
|
+
import { Menu, Widget } from '@lumino/widgets';
|
|
3
|
+
import { requestAPI } from './request';
|
|
4
|
+
import { claudeIcon, refreshIcon, removeIcon, starFilledIcon } from './icons';
|
|
5
|
+
const POLL_INTERVAL_MS = 10000;
|
|
6
|
+
const RECENT_LIMIT = 10;
|
|
7
|
+
const EXPANDED_STORAGE_KEY = 'jupyterlab_claude_code_extension:expanded';
|
|
8
|
+
const SECTION_LABELS = {
|
|
9
|
+
favourites: 'Favourites',
|
|
10
|
+
recent: 'Recent',
|
|
11
|
+
all: 'All'
|
|
12
|
+
};
|
|
13
|
+
const DEFAULT_EXPANDED = {
|
|
14
|
+
favourites: true,
|
|
15
|
+
recent: true,
|
|
16
|
+
all: true
|
|
17
|
+
};
|
|
18
|
+
function loadExpanded() {
|
|
19
|
+
try {
|
|
20
|
+
const raw = window.localStorage.getItem(EXPANDED_STORAGE_KEY);
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return { ...DEFAULT_EXPANDED };
|
|
23
|
+
}
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
return {
|
|
26
|
+
favourites: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.favourites) === 'boolean'
|
|
27
|
+
? parsed.favourites
|
|
28
|
+
: DEFAULT_EXPANDED.favourites,
|
|
29
|
+
recent: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.recent) === 'boolean'
|
|
30
|
+
? parsed.recent
|
|
31
|
+
: DEFAULT_EXPANDED.recent,
|
|
32
|
+
all: typeof (parsed === null || parsed === void 0 ? void 0 : parsed.all) === 'boolean' ? parsed.all : DEFAULT_EXPANDED.all
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch (_err) {
|
|
36
|
+
return { ...DEFAULT_EXPANDED };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function saveExpanded(state) {
|
|
40
|
+
try {
|
|
41
|
+
window.localStorage.setItem(EXPANDED_STORAGE_KEY, JSON.stringify(state));
|
|
42
|
+
}
|
|
43
|
+
catch (_err) {
|
|
44
|
+
// localStorage unavailable (private mode, quota) - ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Drop any pre-v0.6.18 localStorage entries from previous schemes - they
|
|
48
|
+
// were unreliable and we now interrogate JL's terminal manager directly.
|
|
49
|
+
try {
|
|
50
|
+
window.localStorage.removeItem('jupyterlab_claude_code_extension:terminals');
|
|
51
|
+
}
|
|
52
|
+
catch (_err) {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
export class ClaudeCodeSessionsWidget extends Widget {
|
|
56
|
+
constructor(app, rootDir, terminalTracker = null) {
|
|
57
|
+
super();
|
|
58
|
+
this._refreshBtn = null;
|
|
59
|
+
this._sessions = null;
|
|
60
|
+
this._expanded = loadExpanded();
|
|
61
|
+
this._activeSession = null;
|
|
62
|
+
this._activeRowEl = null;
|
|
63
|
+
this._pollHandle = null;
|
|
64
|
+
this._removingPaths = new Set();
|
|
65
|
+
this._terminalsByPath = new Map();
|
|
66
|
+
this._pendingByPath = new Map();
|
|
67
|
+
this._resolveNames = true;
|
|
68
|
+
this._displayNames = new Map();
|
|
69
|
+
this._app = app;
|
|
70
|
+
this._serverSettings = app.serviceManager.serverSettings;
|
|
71
|
+
this._rootDir = rootDir.replace(/\/+$/, '');
|
|
72
|
+
this._terminalTracker = terminalTracker;
|
|
73
|
+
this.id = 'jupyterlab-claude-code-extension';
|
|
74
|
+
this.title.icon = claudeIcon;
|
|
75
|
+
this.title.caption = 'Claude Code Sessions';
|
|
76
|
+
this.addClass('jp-ClaudeSessionsPanel');
|
|
77
|
+
this._buildShell();
|
|
78
|
+
this._setupContextMenu();
|
|
79
|
+
}
|
|
80
|
+
refresh() {
|
|
81
|
+
this._showLoading();
|
|
82
|
+
this._setRefreshSpinning(true);
|
|
83
|
+
this._fetch()
|
|
84
|
+
.catch(err => this._showError(err))
|
|
85
|
+
.finally(() => this._setRefreshSpinning(false));
|
|
86
|
+
}
|
|
87
|
+
/** Toggle whether explicit ``/rename`` names are honoured. */
|
|
88
|
+
setResolveSessionNames(on) {
|
|
89
|
+
if (this._resolveNames === on) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this._resolveNames = on;
|
|
93
|
+
this._render();
|
|
94
|
+
}
|
|
95
|
+
onAfterShow(_msg) {
|
|
96
|
+
this.refresh();
|
|
97
|
+
this._startPolling();
|
|
98
|
+
}
|
|
99
|
+
onBeforeHide(_msg) {
|
|
100
|
+
this._stopPolling();
|
|
101
|
+
}
|
|
102
|
+
onCloseRequest(msg) {
|
|
103
|
+
this._stopPolling();
|
|
104
|
+
super.onCloseRequest(msg);
|
|
105
|
+
}
|
|
106
|
+
// ------------------------------------------------------------------ shell
|
|
107
|
+
_buildShell() {
|
|
108
|
+
const root = this.node;
|
|
109
|
+
root.innerHTML = '';
|
|
110
|
+
const header = document.createElement('div');
|
|
111
|
+
header.className = 'jp-ClaudeSessionsPanel-header';
|
|
112
|
+
const title = document.createElement('span');
|
|
113
|
+
title.className = 'jp-ClaudeSessionsPanel-title';
|
|
114
|
+
title.textContent = 'Claude Code Sessions';
|
|
115
|
+
header.appendChild(title);
|
|
116
|
+
const refreshBtn = document.createElement('button');
|
|
117
|
+
refreshBtn.className = 'jp-ClaudeSessionsPanel-iconButton';
|
|
118
|
+
refreshBtn.title = 'Refresh';
|
|
119
|
+
refreshIcon.element({ container: refreshBtn });
|
|
120
|
+
refreshBtn.addEventListener('click', () => this.refresh());
|
|
121
|
+
header.appendChild(refreshBtn);
|
|
122
|
+
this._refreshBtn = refreshBtn;
|
|
123
|
+
const body = document.createElement('div');
|
|
124
|
+
body.className = 'jp-ClaudeSessionsPanel-body';
|
|
125
|
+
const status = document.createElement('div');
|
|
126
|
+
status.className = 'jp-ClaudeSessionsPanel-status';
|
|
127
|
+
root.appendChild(header);
|
|
128
|
+
root.appendChild(body);
|
|
129
|
+
root.appendChild(status);
|
|
130
|
+
this._bodyEl = body;
|
|
131
|
+
this._statusEl = status;
|
|
132
|
+
}
|
|
133
|
+
_showLoading() {
|
|
134
|
+
this._statusEl.textContent = this._sessions === null ? 'Loading...' : '';
|
|
135
|
+
}
|
|
136
|
+
_showError(err) {
|
|
137
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
138
|
+
this._statusEl.textContent = `Error: ${message}`;
|
|
139
|
+
}
|
|
140
|
+
// ------------------------------------------------------------------ data
|
|
141
|
+
async _fetch() {
|
|
142
|
+
var _a;
|
|
143
|
+
const data = await requestAPI('sessions', this._serverSettings);
|
|
144
|
+
this._sessions = (_a = data.sessions) !== null && _a !== void 0 ? _a : [];
|
|
145
|
+
this._statusEl.textContent = '';
|
|
146
|
+
this._render();
|
|
147
|
+
}
|
|
148
|
+
async _toggleFavourite(session) {
|
|
149
|
+
const next = !session.favourite;
|
|
150
|
+
// Optimistic update
|
|
151
|
+
session.favourite = next;
|
|
152
|
+
this._render();
|
|
153
|
+
try {
|
|
154
|
+
await requestAPI('sessions/favourite', this._serverSettings, {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
project_path: session.project_path,
|
|
158
|
+
favourite: next
|
|
159
|
+
})
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
// Roll back on failure
|
|
164
|
+
session.favourite = !next;
|
|
165
|
+
this._render();
|
|
166
|
+
this._showError(err);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async _remove(session) {
|
|
170
|
+
var _a;
|
|
171
|
+
this._removingPaths.add(session.encoded_path);
|
|
172
|
+
this._render();
|
|
173
|
+
try {
|
|
174
|
+
await requestAPI('sessions/remove', this._serverSettings, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
body: JSON.stringify({ encoded_path: session.encoded_path })
|
|
177
|
+
});
|
|
178
|
+
// Drop locally and re-render; a full refresh will follow on next poll
|
|
179
|
+
this._sessions = ((_a = this._sessions) !== null && _a !== void 0 ? _a : []).filter(s => s.encoded_path !== session.encoded_path);
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
this._showError(err);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
this._removingPaths.delete(session.encoded_path);
|
|
186
|
+
this._render();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// -------------------------------------------------------------- terminal
|
|
190
|
+
async _resumeInTerminal(session) {
|
|
191
|
+
// Coalesce concurrent clicks on the same row - subsequent clicks attach
|
|
192
|
+
// to the in-flight promise instead of creating their own terminal.
|
|
193
|
+
const inFlight = this._pendingByPath.get(session.project_path);
|
|
194
|
+
if (inFlight) {
|
|
195
|
+
return inFlight;
|
|
196
|
+
}
|
|
197
|
+
const promise = this._doResumeInTerminal(session).finally(() => {
|
|
198
|
+
this._pendingByPath.delete(session.project_path);
|
|
199
|
+
});
|
|
200
|
+
this._pendingByPath.set(session.project_path, promise);
|
|
201
|
+
return promise;
|
|
202
|
+
}
|
|
203
|
+
async _doResumeInTerminal(session) {
|
|
204
|
+
var _a, _b, _c;
|
|
205
|
+
try {
|
|
206
|
+
// 1. In-memory microcache - covers rapid-click and post-creation reuse
|
|
207
|
+
// before the new widget propagates fully through the tracker. Cleared
|
|
208
|
+
// on widget disposal. NOT persisted to localStorage.
|
|
209
|
+
const cached = this._terminalsByPath.get(session.project_path);
|
|
210
|
+
if (cached && !cached.isDisposed) {
|
|
211
|
+
this._app.shell.activateById(cached.id);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// 2. Walk every live terminal widget JL knows about and ask the
|
|
215
|
+
// server for the cwd of EVERY process in its pty's tree. Match if
|
|
216
|
+
// any one of them equals project_path.
|
|
217
|
+
const found = await this._findTerminalForCwd(session.project_path);
|
|
218
|
+
if (found) {
|
|
219
|
+
this._terminalsByPath.set(session.project_path, found);
|
|
220
|
+
this._wireTerminalDisposal(session.project_path, found);
|
|
221
|
+
this._app.shell.activateById(found.id);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// 3. No matching open terminal - create a new one rooted at this folder.
|
|
225
|
+
const widget = await this._app.commands.execute('terminal:create-new', { cwd: session.project_path });
|
|
226
|
+
if (widget === null || widget === void 0 ? void 0 : widget.id) {
|
|
227
|
+
this._terminalsByPath.set(session.project_path, widget);
|
|
228
|
+
this._wireTerminalDisposal(session.project_path, widget);
|
|
229
|
+
}
|
|
230
|
+
const term = (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.content) === null || _a === void 0 ? void 0 : _a.session) !== null && _b !== void 0 ? _b : widget === null || widget === void 0 ? void 0 : widget.session;
|
|
231
|
+
const command = `cd ${this._shellQuote(session.project_path)} && claude --resume ${session.session_id}\r`;
|
|
232
|
+
if (!term || typeof term.send !== 'function') {
|
|
233
|
+
this._statusEl.textContent = `Run in a terminal: cd ${session.project_path} && claude --resume ${session.session_id}`;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Wait for the first message from the terminal (shell prompt) before
|
|
237
|
+
// injecting input - sending too early arrives before the shell is ready.
|
|
238
|
+
let sent = false;
|
|
239
|
+
const send = () => {
|
|
240
|
+
if (sent) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
sent = true;
|
|
244
|
+
term.send({ type: 'stdin', content: [command] });
|
|
245
|
+
};
|
|
246
|
+
if ((_c = term.messageReceived) === null || _c === void 0 ? void 0 : _c.connect) {
|
|
247
|
+
const handler = () => {
|
|
248
|
+
term.messageReceived.disconnect(handler);
|
|
249
|
+
// Tiny additional delay so the prompt is rendered and ready for input
|
|
250
|
+
setTimeout(send, 150);
|
|
251
|
+
};
|
|
252
|
+
term.messageReceived.connect(handler);
|
|
253
|
+
// Hard fallback: if no message arrives within 2s, send anyway
|
|
254
|
+
setTimeout(send, 2000);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
setTimeout(send, 600);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
this._showError(err);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
_shellQuote(s) {
|
|
265
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
266
|
+
}
|
|
267
|
+
async _findTerminalForCwd(projectPath) {
|
|
268
|
+
var _a, _b;
|
|
269
|
+
if (!this._terminalTracker) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const candidates = [];
|
|
273
|
+
this._terminalTracker.forEach((widget) => {
|
|
274
|
+
if (widget && !widget.isDisposed) {
|
|
275
|
+
candidates.push(widget);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
const target = projectPath.replace(/\/+$/, '');
|
|
279
|
+
for (const widget of candidates) {
|
|
280
|
+
const sessName = (_b = (_a = widget === null || widget === void 0 ? void 0 : widget.content) === null || _a === void 0 ? void 0 : _a.session) === null || _b === void 0 ? void 0 : _b.name;
|
|
281
|
+
if (typeof sessName !== 'string' || !sessName) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const data = await requestAPI(`terminal-cwd/${encodeURIComponent(sessName)}`, this._serverSettings);
|
|
286
|
+
const cwds = Array.isArray(data === null || data === void 0 ? void 0 : data.cwds) ? data.cwds : [];
|
|
287
|
+
for (const cwd of cwds) {
|
|
288
|
+
if ((cwd || '').replace(/\/+$/, '') === target) {
|
|
289
|
+
return widget;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch (_err) {
|
|
294
|
+
// Backend may report 404 for terminals that disappeared between
|
|
295
|
+
// tracker enumeration and fetch - skip and continue.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
_wireTerminalDisposal(projectPath, widget) {
|
|
301
|
+
var _a;
|
|
302
|
+
if (!((_a = widget === null || widget === void 0 ? void 0 : widget.disposed) === null || _a === void 0 ? void 0 : _a.connect)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
widget.disposed.connect(() => {
|
|
306
|
+
if (this._terminalsByPath.get(projectPath) === widget) {
|
|
307
|
+
this._terminalsByPath.delete(projectPath);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// -------------------------------------------------------------- rendering
|
|
312
|
+
/** Apply the resolve-names setting + path-segment disambiguation. */
|
|
313
|
+
_displayName(s) {
|
|
314
|
+
if (!this._resolveNames) {
|
|
315
|
+
return this._basename(s.project_path) || s.encoded_path;
|
|
316
|
+
}
|
|
317
|
+
return s.name || this._basename(s.project_path) || s.encoded_path;
|
|
318
|
+
}
|
|
319
|
+
_basename(p) {
|
|
320
|
+
if (!p) {
|
|
321
|
+
return '';
|
|
322
|
+
}
|
|
323
|
+
const parts = p.split('/').filter(Boolean);
|
|
324
|
+
return parts[parts.length - 1] || '';
|
|
325
|
+
}
|
|
326
|
+
/** Walk path tails until every name in a colliding group is unique. */
|
|
327
|
+
_disambiguate(rows) {
|
|
328
|
+
var _a;
|
|
329
|
+
const out = new Map();
|
|
330
|
+
const groups = new Map();
|
|
331
|
+
for (const r of rows) {
|
|
332
|
+
const n = this._displayName(r);
|
|
333
|
+
groups.set(n, ((_a = groups.get(n)) !== null && _a !== void 0 ? _a : []).concat(r));
|
|
334
|
+
}
|
|
335
|
+
for (const [name, group] of groups.entries()) {
|
|
336
|
+
if (group.length === 1) {
|
|
337
|
+
out.set(group[0].project_path, name);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const segs = group.map(r => r.project_path.split('/').filter(Boolean));
|
|
341
|
+
const max = Math.max(...segs.map(s => s.length));
|
|
342
|
+
let depth = 1;
|
|
343
|
+
while (depth <= max) {
|
|
344
|
+
const tails = segs.map(s => s.slice(-depth).join('/'));
|
|
345
|
+
if (new Set(tails).size === tails.length) {
|
|
346
|
+
group.forEach((r, i) => out.set(r.project_path, tails[i]));
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
depth += 1;
|
|
350
|
+
}
|
|
351
|
+
if (!out.has(group[0].project_path)) {
|
|
352
|
+
// Fallback to absolute path
|
|
353
|
+
group.forEach(r => out.set(r.project_path, r.project_path));
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
_render() {
|
|
359
|
+
var _a;
|
|
360
|
+
const sessions = (_a = this._sessions) !== null && _a !== void 0 ? _a : [];
|
|
361
|
+
// Capture scrollTop per section so polling refreshes don't reset the
|
|
362
|
+
// user's place inside the All list.
|
|
363
|
+
const scrolls = new Map();
|
|
364
|
+
this._bodyEl
|
|
365
|
+
.querySelectorAll('.jp-ClaudeSessionsPanel-section')
|
|
366
|
+
.forEach(sect => {
|
|
367
|
+
const key = sect.dataset.section;
|
|
368
|
+
const list = sect.querySelector('.jp-ClaudeSessionsPanel-list');
|
|
369
|
+
if (key && list) {
|
|
370
|
+
scrolls.set(key, list.scrollTop);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
this._bodyEl.innerHTML = '';
|
|
374
|
+
if (sessions.length === 0) {
|
|
375
|
+
const empty = document.createElement('div');
|
|
376
|
+
empty.className = 'jp-ClaudeSessionsPanel-empty';
|
|
377
|
+
empty.textContent = 'No Claude Code sessions found.';
|
|
378
|
+
this._bodyEl.appendChild(empty);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Compute disambiguated display names once per render.
|
|
382
|
+
this._displayNames = this._disambiguate(sessions);
|
|
383
|
+
const favourites = sessions.filter(s => s.favourite);
|
|
384
|
+
const recent = [...sessions]
|
|
385
|
+
.sort((a, b) => b.file_mtime - a.file_mtime)
|
|
386
|
+
.slice(0, RECENT_LIMIT);
|
|
387
|
+
const all = [...sessions].sort((a, b) => this._lookupName(a).localeCompare(this._lookupName(b)));
|
|
388
|
+
if (favourites.length > 0) {
|
|
389
|
+
this._renderSection('favourites', favourites);
|
|
390
|
+
}
|
|
391
|
+
this._renderSection('recent', recent);
|
|
392
|
+
this._renderSection('all', all);
|
|
393
|
+
// Restore scroll positions
|
|
394
|
+
this._bodyEl
|
|
395
|
+
.querySelectorAll('.jp-ClaudeSessionsPanel-section')
|
|
396
|
+
.forEach(sect => {
|
|
397
|
+
const key = sect.dataset.section;
|
|
398
|
+
const list = sect.querySelector('.jp-ClaudeSessionsPanel-list');
|
|
399
|
+
const saved = key ? scrolls.get(key) : undefined;
|
|
400
|
+
if (list && saved !== undefined) {
|
|
401
|
+
list.scrollTop = saved;
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
_renderSection(key, items) {
|
|
406
|
+
const section = document.createElement('div');
|
|
407
|
+
section.className = 'jp-ClaudeSessionsPanel-section';
|
|
408
|
+
section.dataset.section = key;
|
|
409
|
+
const expanded = this._expanded[key];
|
|
410
|
+
const header = document.createElement('button');
|
|
411
|
+
header.className = 'jp-ClaudeSessionsPanel-sectionHeader';
|
|
412
|
+
header.setAttribute('aria-expanded', String(expanded));
|
|
413
|
+
const caret = document.createElement('span');
|
|
414
|
+
caret.className = 'jp-ClaudeSessionsPanel-caret';
|
|
415
|
+
caret.textContent = expanded ? '▾' : '▸';
|
|
416
|
+
header.appendChild(caret);
|
|
417
|
+
const label = document.createElement('span');
|
|
418
|
+
label.className = 'jp-ClaudeSessionsPanel-sectionLabel';
|
|
419
|
+
label.textContent = `${SECTION_LABELS[key]} (${items.length})`;
|
|
420
|
+
header.appendChild(label);
|
|
421
|
+
header.addEventListener('click', () => {
|
|
422
|
+
this._expanded[key] = !this._expanded[key];
|
|
423
|
+
saveExpanded(this._expanded);
|
|
424
|
+
this._render();
|
|
425
|
+
});
|
|
426
|
+
section.appendChild(header);
|
|
427
|
+
if (expanded) {
|
|
428
|
+
const list = document.createElement('div');
|
|
429
|
+
list.className = 'jp-ClaudeSessionsPanel-list';
|
|
430
|
+
if (items.length === 0) {
|
|
431
|
+
const empty = document.createElement('div');
|
|
432
|
+
empty.className = 'jp-ClaudeSessionsPanel-emptySection';
|
|
433
|
+
empty.textContent =
|
|
434
|
+
key === 'favourites' ? 'No favourites yet.' : 'Empty.';
|
|
435
|
+
list.appendChild(empty);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
for (const item of items) {
|
|
439
|
+
list.appendChild(this._renderRow(item));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
section.appendChild(list);
|
|
443
|
+
}
|
|
444
|
+
this._bodyEl.appendChild(section);
|
|
445
|
+
}
|
|
446
|
+
_renderRow(session) {
|
|
447
|
+
const row = document.createElement('div');
|
|
448
|
+
row.className = 'jp-ClaudeSessionsPanel-row';
|
|
449
|
+
row.title = this._buildRowTooltip(session);
|
|
450
|
+
const removing = this._removingPaths.has(session.encoded_path);
|
|
451
|
+
if (removing) {
|
|
452
|
+
row.classList.add('jp-mod-busy');
|
|
453
|
+
}
|
|
454
|
+
if (removing) {
|
|
455
|
+
const spinner = document.createElement('span');
|
|
456
|
+
spinner.className = 'jp-ClaudeSessionsPanel-spinner';
|
|
457
|
+
spinner.title = 'Removing...';
|
|
458
|
+
row.appendChild(spinner);
|
|
459
|
+
}
|
|
460
|
+
else if (session.remote_control) {
|
|
461
|
+
const dot = document.createElement('span');
|
|
462
|
+
dot.className = 'jp-ClaudeSessionsPanel-dot';
|
|
463
|
+
dot.title = 'Remote control session is active';
|
|
464
|
+
row.appendChild(dot);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
const dotPlaceholder = document.createElement('span');
|
|
468
|
+
dotPlaceholder.className = 'jp-ClaudeSessionsPanel-dotPlaceholder';
|
|
469
|
+
row.appendChild(dotPlaceholder);
|
|
470
|
+
}
|
|
471
|
+
const name = document.createElement('span');
|
|
472
|
+
name.className = 'jp-ClaudeSessionsPanel-name';
|
|
473
|
+
name.textContent = this._lookupName(session);
|
|
474
|
+
row.appendChild(name);
|
|
475
|
+
if (session.favourite) {
|
|
476
|
+
const star = document.createElement('span');
|
|
477
|
+
star.className = 'jp-ClaudeSessionsPanel-favStar';
|
|
478
|
+
star.title = 'Favourite';
|
|
479
|
+
starFilledIcon.element({ container: star });
|
|
480
|
+
row.appendChild(star);
|
|
481
|
+
}
|
|
482
|
+
row.addEventListener('click', () => {
|
|
483
|
+
if (removing) {
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
void this._resumeInTerminal(session);
|
|
487
|
+
});
|
|
488
|
+
row.addEventListener('contextmenu', e => {
|
|
489
|
+
e.preventDefault();
|
|
490
|
+
if (removing) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this._activeSession = session;
|
|
494
|
+
this._setActiveRow(row);
|
|
495
|
+
this._contextMenu.open(e.clientX, e.clientY);
|
|
496
|
+
});
|
|
497
|
+
return row;
|
|
498
|
+
}
|
|
499
|
+
_lookupName(s) {
|
|
500
|
+
var _a;
|
|
501
|
+
return (_a = this._displayNames.get(s.project_path)) !== null && _a !== void 0 ? _a : this._displayName(s);
|
|
502
|
+
}
|
|
503
|
+
_buildRowTooltip(s) {
|
|
504
|
+
const lines = [this._lookupName(s)];
|
|
505
|
+
lines.push(`Path: ${this._displayPath(s.project_path)}`);
|
|
506
|
+
if (s.file_mtime) {
|
|
507
|
+
lines.push(`Last activity: ${this._formatRelativeTime(s.file_mtime)}`);
|
|
508
|
+
}
|
|
509
|
+
if (s.message_count) {
|
|
510
|
+
lines.push(`Messages: ${s.message_count}`);
|
|
511
|
+
}
|
|
512
|
+
if (s.git_branch) {
|
|
513
|
+
lines.push(`Branch: ${s.git_branch}`);
|
|
514
|
+
}
|
|
515
|
+
if (s.remote_control) {
|
|
516
|
+
lines.push('Remote control: active');
|
|
517
|
+
}
|
|
518
|
+
if (s.first_prompt) {
|
|
519
|
+
const trimmed = s.first_prompt.length > 100
|
|
520
|
+
? `${s.first_prompt.slice(0, 100)}...`
|
|
521
|
+
: s.first_prompt;
|
|
522
|
+
lines.push(`First prompt: ${trimmed}`);
|
|
523
|
+
}
|
|
524
|
+
if (s.session_id) {
|
|
525
|
+
lines.push(`Session id: ${s.session_id}`);
|
|
526
|
+
}
|
|
527
|
+
return lines.join('\n');
|
|
528
|
+
}
|
|
529
|
+
_displayPath(absolute) {
|
|
530
|
+
if (!this._rootDir) {
|
|
531
|
+
return absolute;
|
|
532
|
+
}
|
|
533
|
+
if (absolute === this._rootDir) {
|
|
534
|
+
return '.';
|
|
535
|
+
}
|
|
536
|
+
if (absolute.startsWith(this._rootDir + '/')) {
|
|
537
|
+
return absolute.slice(this._rootDir.length + 1);
|
|
538
|
+
}
|
|
539
|
+
return absolute;
|
|
540
|
+
}
|
|
541
|
+
_formatRelativeTime(epochMs) {
|
|
542
|
+
if (!epochMs) {
|
|
543
|
+
return 'unknown';
|
|
544
|
+
}
|
|
545
|
+
const diff = Date.now() - epochMs;
|
|
546
|
+
if (diff < 60000) {
|
|
547
|
+
return 'just now';
|
|
548
|
+
}
|
|
549
|
+
if (diff < 3600000) {
|
|
550
|
+
return `${Math.floor(diff / 60000)}m ago`;
|
|
551
|
+
}
|
|
552
|
+
if (diff < 86400000) {
|
|
553
|
+
return `${Math.floor(diff / 3600000)}h ago`;
|
|
554
|
+
}
|
|
555
|
+
if (diff < 30 * 86400000) {
|
|
556
|
+
return `${Math.floor(diff / 86400000)}d ago`;
|
|
557
|
+
}
|
|
558
|
+
return new Date(epochMs).toLocaleDateString();
|
|
559
|
+
}
|
|
560
|
+
_setRefreshSpinning(on) {
|
|
561
|
+
if (!this._refreshBtn) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
this._refreshBtn.classList.toggle('jp-mod-spinning', on);
|
|
565
|
+
}
|
|
566
|
+
_setActiveRow(row) {
|
|
567
|
+
if (this._activeRowEl && this._activeRowEl !== row) {
|
|
568
|
+
this._activeRowEl.classList.remove('jp-mod-active');
|
|
569
|
+
}
|
|
570
|
+
this._activeRowEl = row;
|
|
571
|
+
if (row) {
|
|
572
|
+
row.classList.add('jp-mod-active');
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// -------------------------------------------------------------- ctx menu
|
|
576
|
+
_setupContextMenu() {
|
|
577
|
+
this._commands = new CommandRegistry();
|
|
578
|
+
this._commands.addCommand('claude-code-sessions:toggle-favourite', {
|
|
579
|
+
label: () => {
|
|
580
|
+
var _a;
|
|
581
|
+
return ((_a = this._activeSession) === null || _a === void 0 ? void 0 : _a.favourite)
|
|
582
|
+
? 'Remove from Favourites'
|
|
583
|
+
: 'Add to Favourites';
|
|
584
|
+
},
|
|
585
|
+
execute: () => {
|
|
586
|
+
if (this._activeSession) {
|
|
587
|
+
void this._toggleFavourite(this._activeSession);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
this._commands.addCommand('claude-code-sessions:resume', {
|
|
592
|
+
label: 'Resume in Terminal',
|
|
593
|
+
execute: () => {
|
|
594
|
+
if (this._activeSession) {
|
|
595
|
+
void this._resumeInTerminal(this._activeSession);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
this._commands.addCommand('claude-code-sessions:remove', {
|
|
600
|
+
label: 'Remove from Claude',
|
|
601
|
+
icon: removeIcon,
|
|
602
|
+
execute: () => {
|
|
603
|
+
if (this._activeSession) {
|
|
604
|
+
void this._remove(this._activeSession);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
this._contextMenu = new Menu({ commands: this._commands });
|
|
609
|
+
this._contextMenu.addClass('jp-ClaudeSessionsContextMenu');
|
|
610
|
+
this._contextMenu.addItem({ command: 'claude-code-sessions:resume' });
|
|
611
|
+
this._contextMenu.addItem({
|
|
612
|
+
command: 'claude-code-sessions:toggle-favourite'
|
|
613
|
+
});
|
|
614
|
+
this._contextMenu.addItem({ type: 'separator' });
|
|
615
|
+
this._contextMenu.addItem({ command: 'claude-code-sessions:remove' });
|
|
616
|
+
this._contextMenu.aboutToClose.connect(() => {
|
|
617
|
+
// Only clear the visual highlight - DO NOT null _activeSession.
|
|
618
|
+
// Lumino fires aboutToClose BEFORE the activated item's command runs,
|
|
619
|
+
// so the command callback still needs to read _activeSession. The
|
|
620
|
+
// field is overwritten on the next contextmenu open.
|
|
621
|
+
this._setActiveRow(null);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
// --------------------------------------------------------------- polling
|
|
625
|
+
_startPolling() {
|
|
626
|
+
if (this._pollHandle !== null) {
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this._pollHandle = window.setInterval(() => {
|
|
630
|
+
// Don't reshuffle rows while the user is interacting with the context menu
|
|
631
|
+
if (this._contextMenu.isAttached) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
this._fetch().catch(err => this._showError(err));
|
|
635
|
+
}, POLL_INTERVAL_MS);
|
|
636
|
+
}
|
|
637
|
+
_stopPolling() {
|
|
638
|
+
if (this._pollHandle !== null) {
|
|
639
|
+
window.clearInterval(this._pollHandle);
|
|
640
|
+
this._pollHandle = null;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|