claude-code-kanban 3.7.0 → 3.9.0
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/cli.js +95 -15
- package/lib/parsers.js +32 -2
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +10 -2
- package/public/app.js +192 -68
- package/public/index.html +12 -0
- package/public/style.css +15 -1
- package/server.js +52 -3
package/cli.js
CHANGED
|
@@ -14,13 +14,14 @@ const COMMANDS = {
|
|
|
14
14
|
summary: 'List or open Claude Code sessions',
|
|
15
15
|
verbs: {
|
|
16
16
|
list: {
|
|
17
|
-
summary: 'List sessions',
|
|
18
|
-
usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--json]',
|
|
17
|
+
summary: 'List sessions (pinned/sticky always included)',
|
|
18
|
+
usage: 'claude-code-kanban session list [--active] [--days <n>] [--project <name>] [--limit <n|all>] [--no-pins] [--json]',
|
|
19
19
|
flags: {
|
|
20
20
|
'--active': 'Only sessions with recent activity (sidebar-style filter)',
|
|
21
21
|
'--days <n>': 'Only sessions modified within the last N days (fractional ok, e.g. 0.5)',
|
|
22
22
|
'--project <name>': 'Filter by project name (substring match)',
|
|
23
23
|
'--limit <n|all>': 'Max rows to display (default: 10). Use "all" for no cap.',
|
|
24
|
+
'--no-pins': 'Disable always-include and sticky-first ordering for pinned sessions',
|
|
24
25
|
'--json': 'Output JSON instead of a table',
|
|
25
26
|
},
|
|
26
27
|
run: runSessionListCli,
|
|
@@ -52,6 +53,15 @@ const COMMANDS = {
|
|
|
52
53
|
},
|
|
53
54
|
run: runSessionPinCli,
|
|
54
55
|
},
|
|
56
|
+
pins: {
|
|
57
|
+
summary: 'List sessions pinned/stickied via the dashboard or CLI',
|
|
58
|
+
usage: 'claude-code-kanban session pins [--sticky] [--json]',
|
|
59
|
+
flags: {
|
|
60
|
+
'--sticky': 'Only sessions in sticky state',
|
|
61
|
+
'--json': 'Output JSON instead of a table',
|
|
62
|
+
},
|
|
63
|
+
run: runSessionPinsCli,
|
|
64
|
+
},
|
|
55
65
|
peek: {
|
|
56
66
|
summary: 'Show the last N messages from a session',
|
|
57
67
|
usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
|
|
@@ -239,13 +249,23 @@ function parseLimit(args, { fallback, allowAll = false }) {
|
|
|
239
249
|
return { ok: true, limit: n };
|
|
240
250
|
}
|
|
241
251
|
|
|
242
|
-
async function fetchSessionsList(limit) {
|
|
252
|
+
async function fetchSessionsList(limit, pinnedIds = []) {
|
|
243
253
|
const q = limit === null ? 'all' : String(limit);
|
|
244
|
-
const
|
|
254
|
+
const pinnedQ = pinnedIds.length ? `&pinned=${pinnedIds.join(',')}` : '';
|
|
255
|
+
const res = await cliFetch(`/api/sessions?limit=${q}${pinnedQ}`);
|
|
245
256
|
if (!res.ok) throw new Error(`Failed to fetch sessions (${res.status})`);
|
|
246
257
|
return res.json();
|
|
247
258
|
}
|
|
248
259
|
|
|
260
|
+
async function fetchPinsMap() {
|
|
261
|
+
try {
|
|
262
|
+
const res = await cliFetch('/api/session/pins');
|
|
263
|
+
if (!res.ok) return {};
|
|
264
|
+
const { pins = {} } = await res.json();
|
|
265
|
+
return pins;
|
|
266
|
+
} catch { return {}; }
|
|
267
|
+
}
|
|
268
|
+
|
|
249
269
|
async function resolveSessionByIdOrPrefix(idArg) {
|
|
250
270
|
let res;
|
|
251
271
|
try {
|
|
@@ -273,6 +293,7 @@ async function resolveSessionByIdOrPrefix(idArg) {
|
|
|
273
293
|
|
|
274
294
|
async function runSessionListCli(args) {
|
|
275
295
|
const activeOnly = args.includes('--active');
|
|
296
|
+
const noPins = args.includes('--no-pins');
|
|
276
297
|
const projectFilter = getArgValue(args, 'project');
|
|
277
298
|
const daysArg = getArgValue(args, 'days');
|
|
278
299
|
const days = daysArg !== null ? parseFloat(daysArg) : null;
|
|
@@ -284,27 +305,40 @@ async function runSessionListCli(args) {
|
|
|
284
305
|
if (!parsed.ok) { console.error(parsed.error); return 1; }
|
|
285
306
|
const limit = parsed.limit;
|
|
286
307
|
const asJson = args.includes('--json');
|
|
308
|
+
const pinsMap = noPins ? {} : await fetchPinsMap();
|
|
309
|
+
const pinnedIds = Object.keys(pinsMap);
|
|
287
310
|
const hasClientFilter = activeOnly || days !== null || projectFilter;
|
|
288
311
|
let list;
|
|
289
312
|
try {
|
|
290
|
-
list = await fetchSessionsList(hasClientFilter ? null : limit);
|
|
313
|
+
list = await fetchSessionsList(hasClientFilter ? null : limit, pinnedIds);
|
|
291
314
|
} catch (e) {
|
|
292
315
|
reportCliError(e);
|
|
293
316
|
return 1;
|
|
294
317
|
}
|
|
295
|
-
|
|
318
|
+
const pinOf = id => pinsMap[id] || null;
|
|
319
|
+
if (activeOnly) list = list.filter(s => pinOf(s.id) || isSessionActive(s));
|
|
296
320
|
if (days !== null) {
|
|
297
321
|
const cutoff = Date.now() - days * 86_400_000;
|
|
298
|
-
list = list.filter(s => s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff);
|
|
322
|
+
list = list.filter(s => pinOf(s.id) || (s.modifiedAt && new Date(s.modifiedAt).getTime() >= cutoff));
|
|
299
323
|
}
|
|
300
324
|
if (projectFilter) {
|
|
301
325
|
const needle = projectFilter.toLowerCase();
|
|
302
326
|
list = list.filter(s => (s.project || '').toLowerCase().includes(needle));
|
|
303
327
|
}
|
|
304
|
-
const
|
|
305
|
-
|
|
328
|
+
const pinRank = id => pinOf(id) === 'sticky' ? 0 : pinOf(id) === 'pinned' ? 1 : 2;
|
|
329
|
+
list.sort((a, b) => {
|
|
330
|
+
const r = pinRank(a.id) - pinRank(b.id);
|
|
331
|
+
if (r !== 0) return r;
|
|
332
|
+
return new Date(b.modifiedAt || 0) - new Date(a.modifiedAt || 0);
|
|
333
|
+
});
|
|
334
|
+
if (limit !== null && list.length > limit) {
|
|
335
|
+
const top = list.slice(0, limit);
|
|
336
|
+
const topIds = new Set(top.map(s => s.id));
|
|
337
|
+
const extraPinned = list.filter(s => pinOf(s.id) && !topIds.has(s.id));
|
|
338
|
+
list = [...top, ...extraPinned];
|
|
339
|
+
}
|
|
306
340
|
if (asJson) {
|
|
307
|
-
console.log(JSON.stringify(list, null, 2));
|
|
341
|
+
console.log(JSON.stringify(list.map(s => ({ ...s, pinState: pinOf(s.id) })), null, 2));
|
|
308
342
|
return 0;
|
|
309
343
|
}
|
|
310
344
|
if (!list.length) {
|
|
@@ -313,6 +347,7 @@ async function runSessionListCli(args) {
|
|
|
313
347
|
}
|
|
314
348
|
const rows = list.map(s => ({
|
|
315
349
|
id: s.id.slice(0, 8),
|
|
350
|
+
pin: pinOf(s.id) || '',
|
|
316
351
|
status: sessionStatus(s),
|
|
317
352
|
age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
|
|
318
353
|
tasks: `${s.completed}/${s.taskCount}`,
|
|
@@ -321,17 +356,15 @@ async function runSessionListCli(args) {
|
|
|
321
356
|
}));
|
|
322
357
|
const w = {
|
|
323
358
|
id: 8,
|
|
359
|
+
pin: Math.max(3, ...rows.map(r => r.pin.length)),
|
|
324
360
|
status: Math.max(6, ...rows.map(r => r.status.length)),
|
|
325
361
|
age: Math.max(3, ...rows.map(r => r.age.length)),
|
|
326
362
|
tasks: Math.max(5, ...rows.map(r => r.tasks.length)),
|
|
327
363
|
project: Math.max(7, ...rows.map(r => r.project.length)),
|
|
328
364
|
};
|
|
329
|
-
console.log(`${'ID'.padEnd(w.id)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
|
|
365
|
+
console.log(`${'ID'.padEnd(w.id)} ${'PIN'.padEnd(w.pin)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'TASKS'.padEnd(w.tasks)} ${'PROJECT'.padEnd(w.project)} TITLE`);
|
|
330
366
|
for (const r of rows) {
|
|
331
|
-
console.log(`${r.id.padEnd(w.id)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
|
|
332
|
-
}
|
|
333
|
-
if (limit !== null && totalMatched > limit) {
|
|
334
|
-
console.log(`\n... ${totalMatched - limit} more. Use --limit <n> or --limit all to see them.`);
|
|
367
|
+
console.log(`${r.id.padEnd(w.id)} ${r.pin.padEnd(w.pin)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.tasks.padEnd(w.tasks)} ${r.project.padEnd(w.project)} ${r.title}`);
|
|
335
368
|
}
|
|
336
369
|
return 0;
|
|
337
370
|
}
|
|
@@ -396,6 +429,53 @@ async function runSessionPinCli(args) {
|
|
|
396
429
|
} catch (e) { reportCliError(e); return 1; }
|
|
397
430
|
}
|
|
398
431
|
|
|
432
|
+
async function runSessionPinsCli(args) {
|
|
433
|
+
const stickyOnly = args.includes('--sticky');
|
|
434
|
+
const asJson = args.includes('--json');
|
|
435
|
+
const pinsMap = await fetchPinsMap();
|
|
436
|
+
const items = Object.entries(pinsMap)
|
|
437
|
+
.filter(([, state]) => !stickyOnly || state === 'sticky')
|
|
438
|
+
.map(([id, state]) => ({ id, state }));
|
|
439
|
+
if (!items.length) {
|
|
440
|
+
if (asJson) console.log('[]'); else console.log('No pinned sessions.');
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
let sessions;
|
|
444
|
+
try {
|
|
445
|
+
sessions = await fetchSessionsList(items.length, items.map(p => p.id));
|
|
446
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
447
|
+
const byId = new Map(sessions.map(s => [s.id, s]));
|
|
448
|
+
let rows = items
|
|
449
|
+
.map(p => {
|
|
450
|
+
const s = byId.get(p.id) || {};
|
|
451
|
+
return {
|
|
452
|
+
id: p.id,
|
|
453
|
+
state: p.state,
|
|
454
|
+
status: s.id ? sessionStatus(s) : '-',
|
|
455
|
+
age: s.modifiedAt ? formatAge(Date.now() - new Date(s.modifiedAt).getTime()) : '-',
|
|
456
|
+
project: path.basename(s.project || ''),
|
|
457
|
+
title: s.customTitle || s.name || s.slug || '',
|
|
458
|
+
};
|
|
459
|
+
})
|
|
460
|
+
.sort((a, b) => (a.state === b.state ? 0 : a.state === 'sticky' ? -1 : 1));
|
|
461
|
+
if (asJson) {
|
|
462
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
const w = {
|
|
466
|
+
id: 8,
|
|
467
|
+
state: Math.max(5, ...rows.map(r => r.state.length)),
|
|
468
|
+
status: Math.max(6, ...rows.map(r => r.status.length)),
|
|
469
|
+
age: Math.max(3, ...rows.map(r => r.age.length)),
|
|
470
|
+
project: Math.max(7, ...rows.map(r => r.project.length)),
|
|
471
|
+
};
|
|
472
|
+
console.log(`${'ID'.padEnd(w.id)} ${'STATE'.padEnd(w.state)} ${'STATUS'.padEnd(w.status)} ${'AGE'.padEnd(w.age)} ${'PROJECT'.padEnd(w.project)} TITLE`);
|
|
473
|
+
for (const r of rows) {
|
|
474
|
+
console.log(`${r.id.slice(0, 8).padEnd(w.id)} ${r.state.padEnd(w.state)} ${r.status.padEnd(w.status)} ${r.age.padEnd(w.age)} ${r.project.padEnd(w.project)} ${r.title}`);
|
|
475
|
+
}
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
|
|
399
479
|
async function runSessionViewCli(args) {
|
|
400
480
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
401
481
|
if (!idArg) {
|
package/lib/parsers.js
CHANGED
|
@@ -552,14 +552,15 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
552
552
|
readSize *= 4;
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
-
// Attach tool results to their corresponding tool_use messages
|
|
555
|
+
// Attach tool results to their corresponding tool_use messages.
|
|
556
|
+
// For perf, we never ship the full text in the messages payload — when
|
|
557
|
+
// truncated, the client lazy-fetches via /api/sessions/:id/tool-result/:toolUseId.
|
|
556
558
|
for (const msg of messages) {
|
|
557
559
|
if (msg.type === 'tool_use' && msg.toolUseId && toolResults.has(msg.toolUseId)) {
|
|
558
560
|
const full = toolResults.get(msg.toolUseId);
|
|
559
561
|
const truncated = full.length > TOOL_RESULT_MAX;
|
|
560
562
|
msg.toolResult = truncated ? full.slice(0, TOOL_RESULT_MAX) + '\n... (truncated)' : full;
|
|
561
563
|
msg.toolResultTruncated = truncated;
|
|
562
|
-
if (truncated) msg.toolResultFull = full;
|
|
563
564
|
}
|
|
564
565
|
}
|
|
565
566
|
|
|
@@ -578,6 +579,34 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
578
579
|
}
|
|
579
580
|
}
|
|
580
581
|
|
|
582
|
+
function readFullToolResult(jsonlPath, toolUseId) {
|
|
583
|
+
if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
|
|
584
|
+
try {
|
|
585
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
586
|
+
const lines = content.split('\n');
|
|
587
|
+
for (const line of lines) {
|
|
588
|
+
if (!line || line.indexOf(toolUseId) === -1) continue;
|
|
589
|
+
try {
|
|
590
|
+
const obj = JSON.parse(line);
|
|
591
|
+
if (obj?.message?.content && Array.isArray(obj.message.content)) {
|
|
592
|
+
for (const block of obj.message.content) {
|
|
593
|
+
if (block.type === 'tool_result' && block.tool_use_id === toolUseId) {
|
|
594
|
+
if (typeof block.content === 'string') return block.content;
|
|
595
|
+
if (Array.isArray(block.content)) {
|
|
596
|
+
return block.content
|
|
597
|
+
.filter((c) => c.type === 'text' && c.text)
|
|
598
|
+
.map((c) => c.text)
|
|
599
|
+
.join('\n');
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} catch (_) {}
|
|
605
|
+
}
|
|
606
|
+
} catch (_) {}
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
|
|
581
610
|
function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
|
|
582
611
|
const fetchLimit = limit + 1;
|
|
583
612
|
const applyFilter = beforeTimestamp
|
|
@@ -850,6 +879,7 @@ module.exports = {
|
|
|
850
879
|
readSessionInfoFromJsonl,
|
|
851
880
|
readRecentMessages,
|
|
852
881
|
readMessagesPage,
|
|
882
|
+
readFullToolResult,
|
|
853
883
|
buildAgentProgressMap,
|
|
854
884
|
buildSessionDigest,
|
|
855
885
|
readCompactSummaries,
|
package/package.json
CHANGED
|
@@ -32,10 +32,18 @@ claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
|
|
|
32
32
|
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
State applies to every connected browser tab (broadcast via SSE) and persists in each tab's localStorage. With no tabs open the command is a no-op.
|
|
36
|
-
|
|
37
35
|
Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
|
|
38
36
|
|
|
37
|
+
## List pinned sessions
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
claude-code-kanban session pins # all pinned/sticky sessions
|
|
41
|
+
claude-code-kanban session pins --sticky # sticky only
|
|
42
|
+
claude-code-kanban session pins --json # JSON output
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Trigger phrases: "show pinned sessions", "what's pinned", "list pins".
|
|
46
|
+
|
|
39
47
|
## Preview a file in kanban
|
|
40
48
|
|
|
41
49
|
Opens a markdown file in the preview modal:
|
package/public/app.js
CHANGED
|
@@ -496,6 +496,7 @@ async function fetchTasks(sessionId) {
|
|
|
496
496
|
if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
|
|
497
497
|
revealedStorageSessionId = null;
|
|
498
498
|
}
|
|
499
|
+
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
499
500
|
currentSessionId = sessionId;
|
|
500
501
|
currentPins = loadPins(sessionId);
|
|
501
502
|
ownerFilter = '';
|
|
@@ -1206,6 +1207,9 @@ function togglePin(msgIndex) {
|
|
|
1206
1207
|
text: m.text || null,
|
|
1207
1208
|
fullText: m.fullText || null,
|
|
1208
1209
|
tool: m.tool || null,
|
|
1210
|
+
toolUseId: m.toolUseId || null,
|
|
1211
|
+
toolResult: m.toolResult || null,
|
|
1212
|
+
toolResultTruncated: m.toolResultTruncated || false,
|
|
1209
1213
|
detail: m.detail || null,
|
|
1210
1214
|
fullDetail: m.fullDetail || null,
|
|
1211
1215
|
description: m.description || null,
|
|
@@ -1294,6 +1298,8 @@ function togglePinnedCollapse() {
|
|
|
1294
1298
|
//#region PINNING
|
|
1295
1299
|
let pinnedSessionIds = new Set();
|
|
1296
1300
|
let stickySessionIds = new Set();
|
|
1301
|
+
// Pinning the currently-selected session keeps it in place until deselected (less UI movement).
|
|
1302
|
+
const deferredPinPlacement = new Set();
|
|
1297
1303
|
|
|
1298
1304
|
function loadPinnedSessions() {
|
|
1299
1305
|
try {
|
|
@@ -1316,35 +1322,57 @@ function savePinnedSessions() {
|
|
|
1316
1322
|
localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
|
|
1317
1323
|
}
|
|
1318
1324
|
|
|
1319
|
-
//
|
|
1325
|
+
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
1326
|
+
function offloadSessionPin(sessionId) {
|
|
1327
|
+
const state = getSessionPinState(sessionId);
|
|
1328
|
+
fetch('/api/session/pin', {
|
|
1329
|
+
method: 'POST',
|
|
1330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1331
|
+
body: JSON.stringify({ id: sessionId, state }),
|
|
1332
|
+
}).catch(() => {});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1320
1335
|
function toggleSessionPin(sessionId) {
|
|
1321
1336
|
if (pinnedSessionIds.has(sessionId)) {
|
|
1322
1337
|
pinnedSessionIds.delete(sessionId);
|
|
1323
1338
|
stickySessionIds.delete(sessionId);
|
|
1339
|
+
deferredPinPlacement.delete(sessionId);
|
|
1324
1340
|
} else {
|
|
1325
1341
|
pinnedSessionIds.add(sessionId);
|
|
1342
|
+
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1326
1343
|
}
|
|
1327
1344
|
savePinnedSessions();
|
|
1345
|
+
offloadSessionPin(sessionId);
|
|
1328
1346
|
renderSessions();
|
|
1329
1347
|
}
|
|
1330
1348
|
|
|
1331
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
1332
1349
|
function toggleSessionSticky(sessionId) {
|
|
1333
1350
|
if (stickySessionIds.has(sessionId)) {
|
|
1334
1351
|
stickySessionIds.delete(sessionId);
|
|
1335
1352
|
pinnedSessionIds.delete(sessionId);
|
|
1353
|
+
deferredPinPlacement.delete(sessionId);
|
|
1336
1354
|
} else {
|
|
1337
1355
|
pinnedSessionIds.add(sessionId);
|
|
1338
1356
|
stickySessionIds.add(sessionId);
|
|
1357
|
+
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1339
1358
|
}
|
|
1340
1359
|
savePinnedSessions();
|
|
1360
|
+
offloadSessionPin(sessionId);
|
|
1341
1361
|
renderSessions();
|
|
1342
1362
|
}
|
|
1343
1363
|
|
|
1364
|
+
function isPlacedPinned(id) {
|
|
1365
|
+
return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
1366
|
+
}
|
|
1367
|
+
function isPlacedSticky(id) {
|
|
1368
|
+
return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1344
1371
|
function handleSessionPinEvent({ id, state }) {
|
|
1345
1372
|
if (!id) return;
|
|
1346
1373
|
pinnedSessionIds.delete(id);
|
|
1347
1374
|
stickySessionIds.delete(id);
|
|
1375
|
+
deferredPinPlacement.delete(id);
|
|
1348
1376
|
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
1349
1377
|
if (state === 'sticky') {
|
|
1350
1378
|
pinnedSessionIds.add(id);
|
|
@@ -1372,11 +1400,16 @@ function _renderPinToDetail(pin) {
|
|
|
1372
1400
|
document.getElementById('msg-detail-title').textContent = pin.tool || 'Tool';
|
|
1373
1401
|
const fullText = pin.fullDetail || pin.detail || '';
|
|
1374
1402
|
const pinParamsHtml = renderToolParamsHtml(pin.params);
|
|
1375
|
-
const pinResultHtml = renderToolResultHtml(
|
|
1403
|
+
const pinResultHtml = renderToolResultHtml(
|
|
1404
|
+
pin.toolResult,
|
|
1405
|
+
pin.toolResultTruncated,
|
|
1406
|
+
pin.toolResultFull,
|
|
1407
|
+
pin.toolUseId,
|
|
1408
|
+
);
|
|
1376
1409
|
const pinDetailEscaped = escapeHtml(fullText);
|
|
1377
1410
|
const pinDetailRendered = pin.tool === 'Bash' ? highlightBash(pinDetailEscaped) : pinDetailEscaped;
|
|
1378
1411
|
body.innerHTML =
|
|
1379
|
-
(fullText ? `<pre class="
|
|
1412
|
+
(fullText ? `<pre class="${TINTED_PRE_CLASS}">${pinDetailRendered}</pre>` : '<em>No details</em>') +
|
|
1380
1413
|
pinParamsHtml +
|
|
1381
1414
|
pinResultHtml;
|
|
1382
1415
|
} else if (pin.type === 'agent') {
|
|
@@ -1437,7 +1470,7 @@ function showMsgDetail(idx) {
|
|
|
1437
1470
|
const taskResultHtml = TASK_TOOLS.has(m.tool) ? renderTaskResult(m.toolResult) : '';
|
|
1438
1471
|
const toolResultHtml = hideResult
|
|
1439
1472
|
? ''
|
|
1440
|
-
: renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull);
|
|
1473
|
+
: renderToolResultHtml(m.toolResult, m.toolResultTruncated, m.toolResultFull, m.toolUseId);
|
|
1441
1474
|
const hasAgentTabs = m.tool === 'Agent' && m.agentId && (m.agentLastMessage || m.agentPrompt);
|
|
1442
1475
|
let mainHtml;
|
|
1443
1476
|
if (sendProto) {
|
|
@@ -1451,7 +1484,7 @@ function showMsgDetail(idx) {
|
|
|
1451
1484
|
} else if (fullText) {
|
|
1452
1485
|
const detailEscaped = escapeHtml(fullText);
|
|
1453
1486
|
const detailRendered = m.tool === 'Bash' ? highlightBash(detailEscaped) : detailEscaped;
|
|
1454
|
-
mainHtml = `${descHtml}<pre class="
|
|
1487
|
+
mainHtml = `${descHtml}<pre class="${TINTED_PRE_CLASS}">${detailRendered}</pre>`;
|
|
1455
1488
|
} else {
|
|
1456
1489
|
mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
|
|
1457
1490
|
}
|
|
@@ -1698,11 +1731,11 @@ function renderToolParamsHtml(params) {
|
|
|
1698
1731
|
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
|
|
1699
1732
|
if (params.old_string) {
|
|
1700
1733
|
html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
|
|
1701
|
-
<pre class="
|
|
1734
|
+
<pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
|
|
1702
1735
|
}
|
|
1703
1736
|
if (params.new_string) {
|
|
1704
1737
|
html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
|
|
1705
|
-
<pre class="
|
|
1738
|
+
<pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
|
|
1706
1739
|
}
|
|
1707
1740
|
html += `</div>`;
|
|
1708
1741
|
}
|
|
@@ -1717,13 +1750,14 @@ function renderToolParamsHtml(params) {
|
|
|
1717
1750
|
const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
|
|
1718
1751
|
fontSize: '0.75rem',
|
|
1719
1752
|
maxHeight: '500px',
|
|
1753
|
+
tinted: true,
|
|
1720
1754
|
});
|
|
1721
1755
|
writeMoreBtn = ` ${toggle.btn}`;
|
|
1722
1756
|
fullBlock = toggle.full;
|
|
1723
1757
|
}
|
|
1724
1758
|
html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
|
|
1725
1759
|
<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
|
|
1726
|
-
<pre class="
|
|
1760
|
+
<pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
|
|
1727
1761
|
${fullBlock}
|
|
1728
1762
|
</div>`;
|
|
1729
1763
|
}
|
|
@@ -1757,26 +1791,31 @@ function highlightBash(escaped) {
|
|
|
1757
1791
|
.replace(/((?:^|\s)(?:&&|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
|
|
1758
1792
|
}
|
|
1759
1793
|
|
|
1794
|
+
const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
|
|
1760
1795
|
let _expandIdCounter = 0;
|
|
1761
|
-
function
|
|
1762
|
-
const
|
|
1763
|
-
const
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
t.style.display = expand ? 'none' : 'block';
|
|
1796
|
+
function _applyExpandToggle(btn, fullEl) {
|
|
1797
|
+
const truncEl = btn.parentElement.nextElementSibling;
|
|
1798
|
+
const expand = fullEl.style.display === 'none';
|
|
1799
|
+
fullEl.style.display = expand ? 'block' : 'none';
|
|
1800
|
+
if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
|
|
1767
1801
|
btn.textContent = expand ? 'Show less' : 'Show more';
|
|
1768
1802
|
const panel = btn.closest('.message-panel');
|
|
1769
1803
|
if (panel) panel.classList.toggle('msg-expanded-wide', expand);
|
|
1770
1804
|
const modal = btn.closest('.modal');
|
|
1771
1805
|
if (modal) _setModalWidth(modal, 'Expand', expand, '60vw', '60vw');
|
|
1772
1806
|
}
|
|
1807
|
+
function _toggleExpand(btn) {
|
|
1808
|
+
const f = document.getElementById(btn.dataset.expandId);
|
|
1809
|
+
if (f) _applyExpandToggle(btn, f);
|
|
1810
|
+
}
|
|
1773
1811
|
function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
|
|
1774
1812
|
const id = `expand-${++_expandIdCounter}`;
|
|
1775
1813
|
const fontSize = opts.fontSize || '0.8rem';
|
|
1776
1814
|
const maxHeight = opts.maxHeight || '';
|
|
1777
|
-
const
|
|
1815
|
+
const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
|
|
1816
|
+
const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
|
|
1778
1817
|
const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
|
|
1779
|
-
const full = `<pre id="${id}" class="
|
|
1818
|
+
const full = `<pre id="${id}" class="${cls}" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
|
|
1780
1819
|
return { btn, full };
|
|
1781
1820
|
}
|
|
1782
1821
|
|
|
@@ -1796,7 +1835,7 @@ function autoSizeModal(modal, body) {
|
|
|
1796
1835
|
if (desired > current) modal.style.maxWidth = `${desired}px`;
|
|
1797
1836
|
}
|
|
1798
1837
|
|
|
1799
|
-
function renderToolResultHtml(toolResult, isTruncated, fullResult) {
|
|
1838
|
+
function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
|
|
1800
1839
|
if (!toolResult) return '';
|
|
1801
1840
|
const stripped = stripLineNumbers(toolResult);
|
|
1802
1841
|
const escaped = escapeHtml(stripped);
|
|
@@ -1806,6 +1845,10 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
|
|
|
1806
1845
|
const toggle = makeExpandToggle(escaped, escapeHtml(stripLineNumbers(fullResult)));
|
|
1807
1846
|
truncLabel = toggle.btn;
|
|
1808
1847
|
fullBlock = toggle.full;
|
|
1848
|
+
} else if (isTruncated && toolUseId) {
|
|
1849
|
+
const id = `expand-${++_expandIdCounter}`;
|
|
1850
|
+
truncLabel = `<button data-expand-id="${id}" data-tool-use-id="${escapeHtml(toolUseId)}" onclick="_toggleToolResultExpand(this)" class="expand-toggle-btn" style="font-size:0.8rem">Show more</button>`;
|
|
1851
|
+
fullBlock = `<pre id="${id}" class="msg-detail-pre" style="overflow:auto;display:none"></pre>`;
|
|
1809
1852
|
} else if (isTruncated) {
|
|
1810
1853
|
truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
|
|
1811
1854
|
}
|
|
@@ -1816,12 +1859,42 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult) {
|
|
|
1816
1859
|
</div>`;
|
|
1817
1860
|
}
|
|
1818
1861
|
|
|
1862
|
+
async function _toggleToolResultExpand(btn) {
|
|
1863
|
+
const f = document.getElementById(btn.dataset.expandId);
|
|
1864
|
+
if (!f) return;
|
|
1865
|
+
if (!btn.dataset.loaded) {
|
|
1866
|
+
if (!currentSessionId || !btn.dataset.toolUseId) return;
|
|
1867
|
+
btn.disabled = true;
|
|
1868
|
+
btn.textContent = 'Loading…';
|
|
1869
|
+
try {
|
|
1870
|
+
const r = await fetch(
|
|
1871
|
+
`/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
|
|
1872
|
+
);
|
|
1873
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
1874
|
+
const { content } = await r.json();
|
|
1875
|
+
f.textContent = stripLineNumbers(content);
|
|
1876
|
+
btn.dataset.loaded = '1';
|
|
1877
|
+
} catch (_e) {
|
|
1878
|
+
btn.textContent = 'Show more';
|
|
1879
|
+
btn.disabled = false;
|
|
1880
|
+
showToast('Failed to load full output');
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
btn.disabled = false;
|
|
1884
|
+
}
|
|
1885
|
+
_applyExpandToggle(btn, f);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1819
1888
|
function buildToolContent(m) {
|
|
1820
1889
|
let content = m.fullDetail || m.detail || '';
|
|
1821
1890
|
if (m.toolResult) content += `\n\n--- Output ---\n\n${m.toolResultFull || m.toolResult}`;
|
|
1822
1891
|
return content;
|
|
1823
1892
|
}
|
|
1824
1893
|
|
|
1894
|
+
function getMessageDisplayContent(m) {
|
|
1895
|
+
return m.type === 'tool_use' ? buildToolContent(m) : m.compactSummary || stripAnsi(m.fullText || m.text);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1825
1898
|
function getDetailMsg() {
|
|
1826
1899
|
if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx];
|
|
1827
1900
|
if (currentPinDetailId) return currentPins.find((p) => p.id === currentPinDetailId);
|
|
@@ -1832,8 +1905,7 @@ function getDetailMsg() {
|
|
|
1832
1905
|
async function copyMsgToClipboard(btn) {
|
|
1833
1906
|
const m = getDetailMsg();
|
|
1834
1907
|
if (!m) return;
|
|
1835
|
-
|
|
1836
|
-
copyWithFeedback(content, btn);
|
|
1908
|
+
copyWithFeedback(getMessageDisplayContent(m), btn);
|
|
1837
1909
|
}
|
|
1838
1910
|
|
|
1839
1911
|
async function postAndToast(url, body, label) {
|
|
@@ -1853,9 +1925,8 @@ async function postAndToast(url, body, label) {
|
|
|
1853
1925
|
async function openMsgInEditor() {
|
|
1854
1926
|
const m = getDetailMsg();
|
|
1855
1927
|
if (!m) return;
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
postAndToast('/api/open-in-editor', { content, title }, 'in editor');
|
|
1928
|
+
const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
|
|
1929
|
+
postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
|
|
1859
1930
|
}
|
|
1860
1931
|
|
|
1861
1932
|
function formatDuration(ms) {
|
|
@@ -2332,7 +2403,8 @@ function renderSessions() {
|
|
|
2332
2403
|
|
|
2333
2404
|
const pinState = getSessionPinState(session.id);
|
|
2334
2405
|
const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
|
|
2335
|
-
const pinTitle =
|
|
2406
|
+
const pinTitle =
|
|
2407
|
+
pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
|
|
2336
2408
|
const showCtx = !!session.contextStatus;
|
|
2337
2409
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
2338
2410
|
const bookmarksCount = loadPins(session.id).length;
|
|
@@ -2383,12 +2455,10 @@ function renderSessions() {
|
|
|
2383
2455
|
const groupPinned = localStorage.getItem('groupPinnedSessions') !== 'false';
|
|
2384
2456
|
const renderGroupSessions = (sessions, pinKey) => {
|
|
2385
2457
|
if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
|
|
2386
|
-
const gPinned = sessions.filter((s) =>
|
|
2458
|
+
const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
|
|
2387
2459
|
if (gPinned.length === 0) return sessions.map(renderSessionCard).join('');
|
|
2388
2460
|
const gIdlePinned = gPinned.filter((s) => !isSessionActive(s));
|
|
2389
|
-
const gUnpinned = sessions.filter(
|
|
2390
|
-
(s) => !pinnedSessionIds.has(s.id) || isSessionActive(s) || stickySessionIds.has(s.id),
|
|
2391
|
-
);
|
|
2461
|
+
const gUnpinned = sessions.filter((s) => !isPlacedPinned(s.id) || isSessionActive(s) || isPlacedSticky(s.id));
|
|
2392
2462
|
const pinCollapsed = collapsedProjectGroups.has(pinKey);
|
|
2393
2463
|
if (gIdlePinned.length === 0 && !pinCollapsed) return gUnpinned.map(renderSessionCard).join('');
|
|
2394
2464
|
return (
|
|
@@ -2415,8 +2485,7 @@ function renderSessions() {
|
|
|
2415
2485
|
);
|
|
2416
2486
|
};
|
|
2417
2487
|
if (!groupPinned && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2418
|
-
const pinWeight = (s) =>
|
|
2419
|
-
stickySessionIds.has(s.id) ? 2 : pinnedSessionIds.has(s.id) && !isSessionActive(s) ? 1 : 0;
|
|
2488
|
+
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
2420
2489
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
2421
2490
|
for (const [, arr] of groups) arr.sort(pinSort);
|
|
2422
2491
|
ungrouped.sort(pinSort);
|
|
@@ -2492,12 +2561,10 @@ function renderSessions() {
|
|
|
2492
2561
|
|
|
2493
2562
|
sessionsList.innerHTML = html;
|
|
2494
2563
|
} else {
|
|
2495
|
-
const sticky = filteredSessions.filter((s) =>
|
|
2496
|
-
const idlePinned = filteredSessions.filter((s) =>
|
|
2564
|
+
const sticky = filteredSessions.filter((s) => isPlacedSticky(s.id));
|
|
2565
|
+
const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
|
|
2497
2566
|
const rest = filteredSessions.filter(
|
|
2498
|
-
(s) =>
|
|
2499
|
-
(!pinnedSessionIds.has(s.id) && !stickySessionIds.has(s.id)) ||
|
|
2500
|
-
(pinnedSessionIds.has(s.id) && isSessionActive(s)),
|
|
2567
|
+
(s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
|
|
2501
2568
|
);
|
|
2502
2569
|
let html = '';
|
|
2503
2570
|
if (sticky.length > 0) {
|
|
@@ -2860,17 +2927,26 @@ function getGroupSessionsContainer(header) {
|
|
|
2860
2927
|
|
|
2861
2928
|
function getNavigableItems() {
|
|
2862
2929
|
const items = [];
|
|
2930
|
+
const walkGroupContainer = (container) => {
|
|
2931
|
+
if (!container) return;
|
|
2932
|
+
for (const child of container.children) {
|
|
2933
|
+
if (child.classList.contains('pinned-sub-section')) {
|
|
2934
|
+
const subHeader = child.querySelector('.pinned-sub-header');
|
|
2935
|
+
if (subHeader) items.push(subHeader);
|
|
2936
|
+
const subItems = child.querySelector('.pinned-sub-items');
|
|
2937
|
+
if (subItems && !subItems.classList.contains('collapsed')) {
|
|
2938
|
+
for (const s of subItems.querySelectorAll(':scope > .session-item')) items.push(s);
|
|
2939
|
+
}
|
|
2940
|
+
} else if (child.classList.contains('session-item')) {
|
|
2941
|
+
items.push(child);
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
};
|
|
2863
2945
|
for (const el of sessionsList.children) {
|
|
2864
2946
|
if (el.classList.contains('project-group-header')) {
|
|
2865
2947
|
items.push(el);
|
|
2866
2948
|
if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
|
|
2867
|
-
|
|
2868
|
-
if (container) {
|
|
2869
|
-
for (const s of container.querySelectorAll('.session-item')) {
|
|
2870
|
-
if (s.closest('.pinned-sub-items.collapsed')) continue;
|
|
2871
|
-
items.push(s);
|
|
2872
|
-
}
|
|
2873
|
-
}
|
|
2949
|
+
walkGroupContainer(getGroupSessionsContainer(el));
|
|
2874
2950
|
}
|
|
2875
2951
|
} else if (el.classList.contains('session-item')) {
|
|
2876
2952
|
items.push(el);
|
|
@@ -2931,49 +3007,57 @@ function setGroupCollapsed(header, collapsed) {
|
|
|
2931
3007
|
} catch (_) {}
|
|
2932
3008
|
}
|
|
2933
3009
|
|
|
3010
|
+
function isGroupHeader(el) {
|
|
3011
|
+
return el.classList.contains('project-group-header') || el.classList.contains('pinned-sub-header');
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
function findParentHeader(el) {
|
|
3015
|
+
const subContainer = el.closest('.pinned-sub-items');
|
|
3016
|
+
if (subContainer?.previousElementSibling?.classList.contains('pinned-sub-header')) {
|
|
3017
|
+
return subContainer.previousElementSibling;
|
|
3018
|
+
}
|
|
3019
|
+
const container = el.closest('.project-group-sessions');
|
|
3020
|
+
if (!container) return null;
|
|
3021
|
+
let header = container.previousElementSibling;
|
|
3022
|
+
while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
|
|
3023
|
+
return header;
|
|
3024
|
+
}
|
|
3025
|
+
|
|
2934
3026
|
function handleSidebarHorizontal(direction) {
|
|
2935
3027
|
const items = getNavigableItems();
|
|
2936
3028
|
if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
|
|
2937
3029
|
const el = items[selectedSessionIdx];
|
|
2938
|
-
const isHeader = el.classList.contains('project-group-header');
|
|
2939
3030
|
const collapse = direction < 0;
|
|
2940
3031
|
|
|
2941
|
-
if (
|
|
2942
|
-
const
|
|
2943
|
-
const isCollapsed = collapsedProjectGroups.has(groupPath);
|
|
3032
|
+
if (isGroupHeader(el)) {
|
|
3033
|
+
const isCollapsed = collapsedProjectGroups.has(el.dataset.groupPath);
|
|
2944
3034
|
if (collapse) {
|
|
2945
3035
|
if (!isCollapsed) setGroupCollapsed(el, true);
|
|
3036
|
+
} else if (isCollapsed) {
|
|
3037
|
+
setGroupCollapsed(el, false);
|
|
2946
3038
|
} else {
|
|
2947
|
-
|
|
2948
|
-
setGroupCollapsed(el, false);
|
|
2949
|
-
} else {
|
|
2950
|
-
navigateSession(1);
|
|
2951
|
-
}
|
|
2952
|
-
}
|
|
2953
|
-
} else {
|
|
2954
|
-
if (collapse) {
|
|
2955
|
-
const container = el.closest('.project-group-sessions');
|
|
2956
|
-
if (container) {
|
|
2957
|
-
let header = container.previousElementSibling;
|
|
2958
|
-
while (header && !header.classList.contains('project-group-header')) header = header.previousElementSibling;
|
|
2959
|
-
if (header) {
|
|
2960
|
-
const headerIdx = items.indexOf(header);
|
|
2961
|
-
if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
} else {
|
|
2965
|
-
activateSelectedSession(items);
|
|
3039
|
+
navigateSession(1);
|
|
2966
3040
|
}
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
if (!collapse) {
|
|
3045
|
+
activateSelectedSession(items);
|
|
3046
|
+
return;
|
|
2967
3047
|
}
|
|
3048
|
+
|
|
3049
|
+
const header = findParentHeader(el);
|
|
3050
|
+
if (!header) return;
|
|
3051
|
+
const headerIdx = items.indexOf(header);
|
|
3052
|
+
if (headerIdx >= 0) selectSessionByIndex(headerIdx, items);
|
|
2968
3053
|
}
|
|
2969
3054
|
|
|
2970
3055
|
function activateSelectedSession(items) {
|
|
2971
3056
|
items = items || getNavigableItems();
|
|
2972
3057
|
if (selectedSessionIdx < 0 || selectedSessionIdx >= items.length) return;
|
|
2973
3058
|
const el = items[selectedSessionIdx];
|
|
2974
|
-
if (el
|
|
2975
|
-
|
|
2976
|
-
setGroupCollapsed(el, !collapsedProjectGroups.has(groupPath));
|
|
3059
|
+
if (isGroupHeader(el)) {
|
|
3060
|
+
setGroupCollapsed(el, !collapsedProjectGroups.has(el.dataset.groupPath));
|
|
2977
3061
|
} else {
|
|
2978
3062
|
el.click();
|
|
2979
3063
|
}
|
|
@@ -4082,6 +4166,14 @@ document.addEventListener('keydown', (e) => {
|
|
|
4082
4166
|
showStorageManager();
|
|
4083
4167
|
return;
|
|
4084
4168
|
}
|
|
4169
|
+
if (e.key === '.' || e.key === '>') {
|
|
4170
|
+
const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
|
|
4171
|
+
if (sid) {
|
|
4172
|
+
e.preventDefault();
|
|
4173
|
+
(e.shiftKey ? toggleSessionSticky : toggleSessionPin)(sid);
|
|
4174
|
+
return;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4085
4177
|
|
|
4086
4178
|
// Tab toggles focus zone
|
|
4087
4179
|
if (e.key === 'Tab') {
|
|
@@ -4213,6 +4305,18 @@ document.addEventListener('keydown', (e) => {
|
|
|
4213
4305
|
hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
|
|
4214
4306
|
return;
|
|
4215
4307
|
}
|
|
4308
|
+
if (e.code === 'KeyC' && e.shiftKey) {
|
|
4309
|
+
e.preventDefault();
|
|
4310
|
+
if (!contextSid) {
|
|
4311
|
+
showToast('No session selected');
|
|
4312
|
+
return;
|
|
4313
|
+
}
|
|
4314
|
+
navigator.clipboard
|
|
4315
|
+
.writeText(contextSid)
|
|
4316
|
+
.then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
|
|
4317
|
+
.catch(() => showToast('Failed to copy session id'));
|
|
4318
|
+
return;
|
|
4319
|
+
}
|
|
4216
4320
|
if (matchKey(e, 'KeyR')) {
|
|
4217
4321
|
e.preventDefault();
|
|
4218
4322
|
if (_manualRefreshing) return;
|
|
@@ -5986,4 +6090,24 @@ window.hubNavigate = function hubNavigate(app, url) {
|
|
|
5986
6090
|
if (!window.__HUB__?.enabled) return;
|
|
5987
6091
|
window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
|
|
5988
6092
|
};
|
|
6093
|
+
|
|
6094
|
+
(function initHubTheme() {
|
|
6095
|
+
const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark');
|
|
6096
|
+
const hubOrigin = () => (window.__HUB__?.url ? new URL(window.__HUB__.url).origin : null);
|
|
6097
|
+
let lastTheme = getTheme();
|
|
6098
|
+
window.addEventListener('message', (e) => {
|
|
6099
|
+
if (e.source !== window.parent || e.origin !== hubOrigin()) return;
|
|
6100
|
+
if (e.data?.type !== 'hub:theme') return;
|
|
6101
|
+
if (getTheme() === e.data.theme) return;
|
|
6102
|
+
window.toggleTheme();
|
|
6103
|
+
lastTheme = getTheme();
|
|
6104
|
+
});
|
|
6105
|
+
new MutationObserver(() => {
|
|
6106
|
+
const t = getTheme();
|
|
6107
|
+
if (t === lastTheme) return;
|
|
6108
|
+
lastTheme = t;
|
|
6109
|
+
const origin = hubOrigin();
|
|
6110
|
+
if (origin) window.parent.postMessage({ type: 'hub:theme', theme: t }, origin);
|
|
6111
|
+
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
|
6112
|
+
})();
|
|
5989
6113
|
// #endregion HUB_INTEGRATION
|
package/public/index.html
CHANGED
|
@@ -421,6 +421,14 @@
|
|
|
421
421
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">N</kbd></td>
|
|
422
422
|
<td style="padding: 4px 0; color: var(--text-primary);">Toggle scratchpad</td>
|
|
423
423
|
</tr>
|
|
424
|
+
<tr>
|
|
425
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">.</kbd></td>
|
|
426
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Pin/unpin selected session</td>
|
|
427
|
+
</tr>
|
|
428
|
+
<tr>
|
|
429
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">></kbd></td>
|
|
430
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Toggle sticky on selected session</td>
|
|
431
|
+
</tr>
|
|
424
432
|
<tr>
|
|
425
433
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">T</kbd></td>
|
|
426
434
|
<td style="padding: 4px 0; color: var(--text-primary);">Toggle theme</td>
|
|
@@ -445,6 +453,10 @@
|
|
|
445
453
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
|
|
446
454
|
<td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
|
|
447
455
|
</tr>
|
|
456
|
+
<tr>
|
|
457
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+C</kbd></td>
|
|
458
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Copy session id</td>
|
|
459
|
+
</tr>
|
|
448
460
|
<tr>
|
|
449
461
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J/K</kbd></td>
|
|
450
462
|
<td style="padding: 4px 0; color: var(--text-primary);">Navigate messages in detail modal</td>
|
package/public/style.css
CHANGED
|
@@ -2236,6 +2236,19 @@ body::before {
|
|
|
2236
2236
|
font-family: var(--font-mono);
|
|
2237
2237
|
font-size: 0.85rem;
|
|
2238
2238
|
}
|
|
2239
|
+
.msg-detail-pre-tinted {
|
|
2240
|
+
background: rgba(127, 127, 127, 0.15);
|
|
2241
|
+
border-radius: 4px;
|
|
2242
|
+
padding: 8px 10px;
|
|
2243
|
+
}
|
|
2244
|
+
.expand-toggle-btn {
|
|
2245
|
+
background: none;
|
|
2246
|
+
border: none;
|
|
2247
|
+
color: var(--accent);
|
|
2248
|
+
cursor: pointer;
|
|
2249
|
+
text-decoration: underline;
|
|
2250
|
+
margin-left: 6px;
|
|
2251
|
+
}
|
|
2239
2252
|
.msg-cmd .msg-text code {
|
|
2240
2253
|
background: var(--bg-hover);
|
|
2241
2254
|
padding: 2px 6px;
|
|
@@ -3488,7 +3501,8 @@ pre.mermaid svg {
|
|
|
3488
3501
|
color: var(--text-primary);
|
|
3489
3502
|
}
|
|
3490
3503
|
|
|
3491
|
-
.project-group-header.kb-selected
|
|
3504
|
+
.project-group-header.kb-selected,
|
|
3505
|
+
.pinned-sub-header.kb-selected {
|
|
3492
3506
|
color: var(--text-primary);
|
|
3493
3507
|
background: var(--bg-hover);
|
|
3494
3508
|
border-radius: 4px;
|
package/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const express = require('express');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs').promises;
|
|
6
|
-
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync } = require('fs');
|
|
6
|
+
const { existsSync, readdirSync, readFileSync, writeFileSync, statSync, createReadStream, unlinkSync, mkdirSync, renameSync } = require('fs');
|
|
7
7
|
const readline = require('readline');
|
|
8
8
|
const chokidar = require('chokidar');
|
|
9
9
|
const os = require('os');
|
|
@@ -19,7 +19,8 @@ const {
|
|
|
19
19
|
readCompactSummaries,
|
|
20
20
|
findTerminatedTeammates,
|
|
21
21
|
extractPromptFromTranscript,
|
|
22
|
-
extractModelFromTranscript
|
|
22
|
+
extractModelFromTranscript,
|
|
23
|
+
readFullToolResult
|
|
23
24
|
} = require('./lib/parsers');
|
|
24
25
|
|
|
25
26
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
@@ -72,6 +73,27 @@ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
|
|
|
72
73
|
const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
73
74
|
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
74
75
|
const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
|
|
76
|
+
const PINS_FILE = path.join(CCK_DIR, 'pins.json');
|
|
77
|
+
|
|
78
|
+
// Server-side pin mirror (UI authoritative, server stores latest pushed state for CLI queries).
|
|
79
|
+
function readPins() {
|
|
80
|
+
try {
|
|
81
|
+
const obj = JSON.parse(readFileSync(PINS_FILE, 'utf8'));
|
|
82
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) return obj;
|
|
83
|
+
} catch (_) {}
|
|
84
|
+
return {};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writePins(pins) {
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(CCK_DIR, { recursive: true });
|
|
90
|
+
const tmp = `${PINS_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
91
|
+
writeFileSync(tmp, JSON.stringify(pins, null, 2), 'utf8');
|
|
92
|
+
renameSync(tmp, PINS_FILE);
|
|
93
|
+
} catch (e) {
|
|
94
|
+
console.error('Failed to write pins.json:', e.message);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
75
97
|
|
|
76
98
|
const PERMISSION_TTL_MS = 1800000;
|
|
77
99
|
const AGENT_TTL_MS = 3600000;
|
|
@@ -1293,12 +1315,23 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
|
1293
1315
|
}
|
|
1294
1316
|
}
|
|
1295
1317
|
for (const msg of messages) {
|
|
1296
|
-
|
|
1318
|
+
// Keep toolUseId on truncated tool results so the client can lazy-fetch the full text
|
|
1319
|
+
if (msg.toolUseId && !msg.toolResultTruncated) delete msg.toolUseId;
|
|
1297
1320
|
delete msg.promptId;
|
|
1298
1321
|
}
|
|
1299
1322
|
res.json({ messages, hasMore, sessionId: req.params.sessionId });
|
|
1300
1323
|
});
|
|
1301
1324
|
|
|
1325
|
+
app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
|
|
1326
|
+
const metadata = loadSessionMetadata();
|
|
1327
|
+
const meta = metadata[req.params.sessionId];
|
|
1328
|
+
const jsonlPath = meta?.jsonlPath;
|
|
1329
|
+
if (!jsonlPath) return res.status(404).json({ error: 'session not found' });
|
|
1330
|
+
const content = readFullToolResult(jsonlPath, req.params.toolUseId);
|
|
1331
|
+
if (content == null) return res.status(404).json({ error: 'tool result not found' });
|
|
1332
|
+
res.json({ toolUseId: req.params.toolUseId, content });
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1302
1335
|
app.get('/api/version', (req, res) => {
|
|
1303
1336
|
const pkg = require('./package.json');
|
|
1304
1337
|
res.json({ version: pkg.version });
|
|
@@ -1549,6 +1582,10 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
1549
1582
|
if (!['none', 'pinned', 'sticky'].includes(state)) {
|
|
1550
1583
|
return res.status(400).json({ error: 'state must be none|pinned|sticky' });
|
|
1551
1584
|
}
|
|
1585
|
+
const pins = readPins();
|
|
1586
|
+
if (state === 'none') delete pins[id];
|
|
1587
|
+
else pins[id] = state;
|
|
1588
|
+
writePins(pins);
|
|
1552
1589
|
broadcast({ type: 'session:pin', id, state });
|
|
1553
1590
|
res.json({ success: true, id, state });
|
|
1554
1591
|
} catch (error) {
|
|
@@ -1557,6 +1594,18 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
1557
1594
|
}
|
|
1558
1595
|
});
|
|
1559
1596
|
|
|
1597
|
+
app.get('/api/session/pins', (req, res) => {
|
|
1598
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1599
|
+
try {
|
|
1600
|
+
const pins = readPins();
|
|
1601
|
+
const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
|
|
1602
|
+
res.json({ pins, items });
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
console.error('Error in GET /api/session/pins:', error);
|
|
1605
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1606
|
+
}
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1560
1609
|
app.get('/api/preview', async (req, res) => {
|
|
1561
1610
|
try {
|
|
1562
1611
|
const abs = resolvePreviewPath(req.query.path, req.query.base);
|