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