claude-code-kanban 3.8.0 → 3.10.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 +44 -11
- 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 +136 -68
- package/public/index.html +5 -25
- package/public/style.css +104 -141
- package/server.js +88 -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
|
@@ -115,6 +115,20 @@ function parseJsonlLine(line) {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
const TOOL_RESULT_MAX = 1500;
|
|
118
|
+
const USER_TEXT_MAX = 500;
|
|
119
|
+
const INTERRUPT_MARKER = '[Request interrupted by user]';
|
|
120
|
+
|
|
121
|
+
function pushUserMessage(messages, text, timestamp, sysLabel) {
|
|
122
|
+
if (sysLabel === '__skip__') return;
|
|
123
|
+
const truncated = text.length > USER_TEXT_MAX;
|
|
124
|
+
messages.push({
|
|
125
|
+
type: 'user',
|
|
126
|
+
text: truncated ? text.slice(0, USER_TEXT_MAX) + '...' : text,
|
|
127
|
+
fullText: truncated ? text : null,
|
|
128
|
+
timestamp,
|
|
129
|
+
...(sysLabel && { systemLabel: sysLabel })
|
|
130
|
+
});
|
|
131
|
+
}
|
|
118
132
|
|
|
119
133
|
// Cache: jsonlPath -> { scannedUpTo, customTitle }
|
|
120
134
|
// Only re-scan the new bytes appended since last scan
|
|
@@ -516,17 +530,16 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
516
530
|
});
|
|
517
531
|
continue;
|
|
518
532
|
}
|
|
519
|
-
|
|
520
|
-
if (sysLabel === '__skip__') continue;
|
|
521
|
-
const uTruncated = t.length > 500;
|
|
522
|
-
messages.push({
|
|
523
|
-
type: 'user',
|
|
524
|
-
text: uTruncated ? t.slice(0, 500) + '...' : t,
|
|
525
|
-
fullText: uTruncated ? t : null,
|
|
526
|
-
timestamp: obj.timestamp,
|
|
527
|
-
...(sysLabel && { systemLabel: sysLabel })
|
|
528
|
-
});
|
|
533
|
+
pushUserMessage(messages, t, obj.timestamp, getSystemMessageLabel(t));
|
|
529
534
|
} else if (Array.isArray(obj.message.content)) {
|
|
535
|
+
const joined = obj.message.content
|
|
536
|
+
.filter(b => b.type === 'text' && typeof b.text === 'string' && b.text)
|
|
537
|
+
.map(b => b.text)
|
|
538
|
+
.join('\n')
|
|
539
|
+
.trim();
|
|
540
|
+
if (joined && joined !== INTERRUPT_MARKER) {
|
|
541
|
+
pushUserMessage(messages, joined, obj.timestamp, getSystemMessageLabel(joined));
|
|
542
|
+
}
|
|
530
543
|
for (const block of obj.message.content) {
|
|
531
544
|
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
532
545
|
let resultText = '';
|
|
@@ -633,6 +646,9 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
|
|
|
633
646
|
function buildSessionDigest(jsonlPath) {
|
|
634
647
|
const map = {};
|
|
635
648
|
const terminated = new Map();
|
|
649
|
+
const rejectedToolUseIds = new Set();
|
|
650
|
+
const promptByToolUseId = {};
|
|
651
|
+
const killedAgentIds = new Set();
|
|
636
652
|
try {
|
|
637
653
|
const content = readFileSync(jsonlPath, 'utf8');
|
|
638
654
|
const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
|
|
@@ -642,6 +658,7 @@ function buildSessionDigest(jsonlPath) {
|
|
|
642
658
|
const bgAgentIdRe = /agentId: ([a-zA-Z0-9_@-]+)/;
|
|
643
659
|
const tmToolIdRe = /"tool_use_id":"([^"]+)"/;
|
|
644
660
|
const tmAgentIdRe = /agent_id: ([a-zA-Z0-9_@-]+)/;
|
|
661
|
+
const taskIdRe = /<task-id>([a-zA-Z0-9_-]+)<\/task-id>/;
|
|
645
662
|
const nameByToolUseId = {};
|
|
646
663
|
const descByToolUseId = {};
|
|
647
664
|
for (const line of content.split('\n')) {
|
|
@@ -708,10 +725,18 @@ function buildSessionDigest(jsonlPath) {
|
|
|
708
725
|
if (b.type === 'tool_use' && b.name === 'Agent' && b.id) {
|
|
709
726
|
if (b.input?.name) nameByToolUseId[b.id] = b.input.name;
|
|
710
727
|
if (b.input?.description) descByToolUseId[b.id] = b.input.description;
|
|
728
|
+
if (b.input?.prompt) promptByToolUseId[b.id] = b.input.prompt;
|
|
711
729
|
}
|
|
712
730
|
}
|
|
713
731
|
}
|
|
714
732
|
} catch (_) {}
|
|
733
|
+
} else if (line.includes('User rejected tool use') && line.includes('"tool_use_id"')) {
|
|
734
|
+
const m = tmToolIdRe.exec(line);
|
|
735
|
+
if (m) rejectedToolUseIds.add(m[1]);
|
|
736
|
+
} else if (line.includes('<task-notification>') &&
|
|
737
|
+
(line.includes('<status>killed</status>') || line.includes('<status>error</status>'))) {
|
|
738
|
+
const idMatch = taskIdRe.exec(line);
|
|
739
|
+
if (idMatch) killedAgentIds.add(idMatch[1]);
|
|
715
740
|
} else if (line.includes('"toolUseResult"') && line.includes('"agentId"') && line.includes('"tool_result"')) {
|
|
716
741
|
try {
|
|
717
742
|
const obj = JSON.parse(line);
|
|
@@ -734,7 +759,15 @@ function buildSessionDigest(jsonlPath) {
|
|
|
734
759
|
if (descByToolUseId[key]) entry.description = descByToolUseId[key];
|
|
735
760
|
}
|
|
736
761
|
} catch (_) {}
|
|
737
|
-
|
|
762
|
+
const rejectedAgentIds = new Set();
|
|
763
|
+
const rejectedPrompts = new Set();
|
|
764
|
+
for (const toolUseId of rejectedToolUseIds) {
|
|
765
|
+
const entry = map[toolUseId];
|
|
766
|
+
if (entry?.agentId) rejectedAgentIds.add(entry.agentId);
|
|
767
|
+
const prompt = entry?.prompt || promptByToolUseId[toolUseId];
|
|
768
|
+
if (prompt) rejectedPrompts.add(prompt);
|
|
769
|
+
}
|
|
770
|
+
return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
|
|
738
771
|
}
|
|
739
772
|
|
|
740
773
|
function buildAgentProgressMap(jsonlPath) {
|
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
|
@@ -4,6 +4,8 @@ let currentSessionId = null;
|
|
|
4
4
|
let currentTasks = [];
|
|
5
5
|
let viewMode = 'session';
|
|
6
6
|
let sessionFilter = 'active';
|
|
7
|
+
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
8
|
+
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
7
9
|
let sessionLimit = '20';
|
|
8
10
|
let filterProject = '__recent__'; // null = all, '__recent__' = last 24h, or project path
|
|
9
11
|
let recentProjects = new Set();
|
|
@@ -144,7 +146,6 @@ const inProgressCount = document.getElementById('in-progress-count');
|
|
|
144
146
|
const completedCount = document.getElementById('completed-count');
|
|
145
147
|
const detailPanel = document.getElementById('detail-panel');
|
|
146
148
|
const detailContent = document.getElementById('detail-content');
|
|
147
|
-
const connectionStatus = document.getElementById('connection-status');
|
|
148
149
|
const CONTENT_TRUNCATE_MAX = 1500;
|
|
149
150
|
const COLUMNS = [{ el: pendingTasks }, { el: inProgressTasks }, { el: completedTasks }];
|
|
150
151
|
|
|
@@ -186,7 +187,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
186
187
|
|
|
187
188
|
sessions = newSessions;
|
|
188
189
|
renderSessions();
|
|
189
|
-
|
|
190
|
+
renderActivityChip();
|
|
190
191
|
} catch (error) {
|
|
191
192
|
console.error('Failed to fetch sessions:', error);
|
|
192
193
|
}
|
|
@@ -412,15 +413,7 @@ function fuzzyMatch(text, query) {
|
|
|
412
413
|
|
|
413
414
|
//#endregion
|
|
414
415
|
|
|
415
|
-
|
|
416
|
-
function renderLiveUpdatesFromCache() {
|
|
417
|
-
let activeTasks = allTasksCache.filter((t) => t.status === 'in_progress' && !isInternalTask(t));
|
|
418
|
-
if (filterProject) {
|
|
419
|
-
activeTasks = activeTasks.filter((t) => matchesProjectFilter(t.project));
|
|
420
|
-
}
|
|
421
|
-
renderLiveUpdates(activeTasks);
|
|
422
|
-
}
|
|
423
|
-
|
|
416
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
424
417
|
function toggleSection(containerId, chevronId) {
|
|
425
418
|
const container = document.getElementById(containerId);
|
|
426
419
|
const chevron = document.getElementById(chevronId);
|
|
@@ -429,38 +422,90 @@ function toggleSection(containerId, chevronId) {
|
|
|
429
422
|
localStorage.setItem(`${containerId}Collapsed`, collapsed);
|
|
430
423
|
}
|
|
431
424
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
425
|
+
function isWaitingSession(s) {
|
|
426
|
+
return !!s.hasWaitingForUser;
|
|
427
|
+
}
|
|
428
|
+
function isActiveSession(s) {
|
|
429
|
+
return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
|
|
435
430
|
}
|
|
436
431
|
|
|
437
|
-
|
|
438
|
-
|
|
432
|
+
const ACTIVITY_PREDICATES = {
|
|
433
|
+
waiting: isWaitingSession,
|
|
434
|
+
active: isActiveSession,
|
|
435
|
+
};
|
|
439
436
|
|
|
440
|
-
|
|
441
|
-
container.innerHTML = '<div class="live-empty">No active tasks</div>';
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
437
|
+
let lastChipKey = '';
|
|
444
438
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
439
|
+
function renderActivityChip() {
|
|
440
|
+
const container = document.getElementById('activity-chips');
|
|
441
|
+
if (!container) return;
|
|
442
|
+
|
|
443
|
+
let waiting = 0;
|
|
444
|
+
let active = 0;
|
|
445
|
+
for (const s of sessions) {
|
|
446
|
+
if (s.hasWaitingForUser) waiting++;
|
|
447
|
+
else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const key = `${waiting}|${active}|${[...activityFilter].sort().join(',')}`;
|
|
451
|
+
if (key === lastChipKey) return;
|
|
452
|
+
lastChipKey = key;
|
|
453
|
+
|
|
454
|
+
const chips = [
|
|
455
|
+
{
|
|
456
|
+
kind: 'waiting',
|
|
457
|
+
count: waiting,
|
|
458
|
+
label: `${waiting} waiting`,
|
|
459
|
+
title: `${waiting} session${waiting === 1 ? '' : 's'} waiting for input`,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
kind: 'active',
|
|
463
|
+
count: active,
|
|
464
|
+
label: `${active} active`,
|
|
465
|
+
title: `${active} session${active === 1 ? '' : 's'} with running work or recent activity`,
|
|
466
|
+
},
|
|
467
|
+
];
|
|
468
|
+
|
|
469
|
+
container.innerHTML = chips
|
|
470
|
+
.map((c) => {
|
|
471
|
+
const isOn = activityFilter.has(c.kind);
|
|
472
|
+
const classes = [
|
|
473
|
+
'activity-chip',
|
|
474
|
+
`activity-${c.kind}`,
|
|
475
|
+
c.count === 0 ? 'activity-zero' : '',
|
|
476
|
+
isOn ? 'activity-filter-on' : '',
|
|
477
|
+
]
|
|
478
|
+
.filter(Boolean)
|
|
479
|
+
.join(' ');
|
|
480
|
+
const hint = isOn ? ' — click to clear filter' : ` — click to filter to ${c.kind}`;
|
|
481
|
+
return `
|
|
482
|
+
<button type="button"
|
|
483
|
+
class="${classes}"
|
|
484
|
+
onclick="setActivityFilter('${c.kind}')"
|
|
485
|
+
aria-pressed="${isOn ? 'true' : 'false'}"
|
|
486
|
+
title="${escapeHtml(c.title + hint)}">
|
|
487
|
+
<span class="activity-dot"></span>
|
|
488
|
+
<span class="activity-label">${escapeHtml(c.label)}</span>
|
|
489
|
+
</button>
|
|
490
|
+
`;
|
|
491
|
+
})
|
|
457
492
|
.join('');
|
|
458
493
|
}
|
|
459
494
|
|
|
460
495
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
496
|
+
function setActivityFilter(kind) {
|
|
497
|
+
if (activityFilter.has(kind)) activityFilter.delete(kind);
|
|
498
|
+
else activityFilter.add(kind);
|
|
499
|
+
// active/waiting only make sense with the active session filter on
|
|
500
|
+
const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
|
|
501
|
+
if (targetFilter !== sessionFilter) {
|
|
502
|
+
sessionFilter = targetFilter;
|
|
503
|
+
const dropdown = document.getElementById('session-filter');
|
|
504
|
+
if (dropdown) dropdown.value = targetFilter;
|
|
505
|
+
updateUrl();
|
|
506
|
+
}
|
|
507
|
+
renderSessions();
|
|
508
|
+
renderActivityChip();
|
|
464
509
|
}
|
|
465
510
|
|
|
466
511
|
let lastCurrentTasksHash = '';
|
|
@@ -1322,6 +1367,16 @@ function savePinnedSessions() {
|
|
|
1322
1367
|
localStorage.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
|
|
1323
1368
|
}
|
|
1324
1369
|
|
|
1370
|
+
// Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
|
|
1371
|
+
function offloadSessionPin(sessionId) {
|
|
1372
|
+
const state = getSessionPinState(sessionId);
|
|
1373
|
+
fetch('/api/session/pin', {
|
|
1374
|
+
method: 'POST',
|
|
1375
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1376
|
+
body: JSON.stringify({ id: sessionId, state }),
|
|
1377
|
+
}).catch(() => {});
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1325
1380
|
function toggleSessionPin(sessionId) {
|
|
1326
1381
|
if (pinnedSessionIds.has(sessionId)) {
|
|
1327
1382
|
pinnedSessionIds.delete(sessionId);
|
|
@@ -1332,6 +1387,7 @@ function toggleSessionPin(sessionId) {
|
|
|
1332
1387
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1333
1388
|
}
|
|
1334
1389
|
savePinnedSessions();
|
|
1390
|
+
offloadSessionPin(sessionId);
|
|
1335
1391
|
renderSessions();
|
|
1336
1392
|
}
|
|
1337
1393
|
|
|
@@ -1346,6 +1402,7 @@ function toggleSessionSticky(sessionId) {
|
|
|
1346
1402
|
if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
|
|
1347
1403
|
}
|
|
1348
1404
|
savePinnedSessions();
|
|
1405
|
+
offloadSessionPin(sessionId);
|
|
1349
1406
|
renderSessions();
|
|
1350
1407
|
}
|
|
1351
1408
|
|
|
@@ -2227,7 +2284,7 @@ async function showAllTasks() {
|
|
|
2227
2284
|
updateUrl();
|
|
2228
2285
|
renderAllTasks();
|
|
2229
2286
|
renderSessions();
|
|
2230
|
-
|
|
2287
|
+
renderActivityChip();
|
|
2231
2288
|
} catch (error) {
|
|
2232
2289
|
console.error('Failed to fetch all tasks:', error);
|
|
2233
2290
|
}
|
|
@@ -2301,7 +2358,11 @@ function renderSessions() {
|
|
|
2301
2358
|
filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project));
|
|
2302
2359
|
}
|
|
2303
2360
|
|
|
2304
|
-
|
|
2361
|
+
if (activityFilter.size > 0) {
|
|
2362
|
+
const preds = [...activityFilter].map((k) => ACTIVITY_PREDICATES[k]).filter(Boolean);
|
|
2363
|
+
if (preds.length) filteredSessions = filteredSessions.filter((s) => preds.some((p) => p(s)));
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2305
2366
|
if (searchQuery) {
|
|
2306
2367
|
const taskMatchIds = new Set();
|
|
2307
2368
|
for (const t of allTasksCache) {
|
|
@@ -2322,7 +2383,7 @@ function renderSessions() {
|
|
|
2322
2383
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
2323
2384
|
|
|
2324
2385
|
// Re-add pinned/sticky sessions that match the query but were excluded by active filter
|
|
2325
|
-
if (pinnedSessionIds.size > 0 || stickySessionIds.size > 0) {
|
|
2386
|
+
if (activityFilter.size === 0 && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2326
2387
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2327
2388
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
|
|
2328
2389
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
@@ -2330,7 +2391,8 @@ function renderSessions() {
|
|
|
2330
2391
|
}
|
|
2331
2392
|
|
|
2332
2393
|
// Include pinned/sticky sessions even if they don't match active/recent filter
|
|
2333
|
-
|
|
2394
|
+
// (skipped when an activity chip filter is on — user explicitly asked for a slice)
|
|
2395
|
+
if (activityFilter.size === 0 && !searchQuery && (pinnedSessionIds.size > 0 || stickySessionIds.size > 0)) {
|
|
2334
2396
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2335
2397
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
|
|
2336
2398
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
@@ -3534,7 +3596,6 @@ async function refreshCurrentView() {
|
|
|
3534
3596
|
await showAllTasks();
|
|
3535
3597
|
} else if (currentSessionId) {
|
|
3536
3598
|
await fetchTasks(currentSessionId);
|
|
3537
|
-
renderLiveUpdatesFromCache();
|
|
3538
3599
|
} else {
|
|
3539
3600
|
await fetchSessions();
|
|
3540
3601
|
}
|
|
@@ -3985,7 +4046,6 @@ function _renderStorageLinkedDocs() {
|
|
|
3985
4046
|
}
|
|
3986
4047
|
|
|
3987
4048
|
function _storagePreviewLinkedDoc(path) {
|
|
3988
|
-
closeStorageManager();
|
|
3989
4049
|
openPreviewByPath(path);
|
|
3990
4050
|
}
|
|
3991
4051
|
|
|
@@ -4293,6 +4353,33 @@ document.addEventListener('keydown', (e) => {
|
|
|
4293
4353
|
hubNavigate('memory', mSession?.project ? `?project=${encodeURIComponent(mSession.project)}` : undefined);
|
|
4294
4354
|
return;
|
|
4295
4355
|
}
|
|
4356
|
+
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
4357
|
+
e.preventDefault();
|
|
4358
|
+
if (!contextSid || dismissedSessionIds.has(contextSid)) return;
|
|
4359
|
+
const prevIdx = selectedSessionIdx;
|
|
4360
|
+
dismissedSessionIds.add(contextSid);
|
|
4361
|
+
updateDismissBtnState();
|
|
4362
|
+
renderSessions();
|
|
4363
|
+
const newItems = getNavigableItems();
|
|
4364
|
+
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
4365
|
+
// If the dismissed session is currently open, navigate to the previous one
|
|
4366
|
+
if (currentSessionId === contextSid || selectedSessionId === contextSid) {
|
|
4367
|
+
selectedSessionId = null;
|
|
4368
|
+
if (targetIdx >= 0) {
|
|
4369
|
+
const targetSid = newItems[targetIdx]?.dataset?.sessionId;
|
|
4370
|
+
if (targetSid) {
|
|
4371
|
+
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
4372
|
+
} else {
|
|
4373
|
+
showAllTasks().then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
4374
|
+
}
|
|
4375
|
+
} else {
|
|
4376
|
+
showAllTasks();
|
|
4377
|
+
}
|
|
4378
|
+
} else if (targetIdx >= 0) {
|
|
4379
|
+
selectSessionByIndex(targetIdx, newItems);
|
|
4380
|
+
}
|
|
4381
|
+
return;
|
|
4382
|
+
}
|
|
4296
4383
|
if (e.code === 'KeyC' && e.shiftKey) {
|
|
4297
4384
|
e.preventDefault();
|
|
4298
4385
|
if (!contextSid) {
|
|
@@ -4612,20 +4699,12 @@ function setupEventSource() {
|
|
|
4612
4699
|
wasConnected = true;
|
|
4613
4700
|
retryDelay = 1000;
|
|
4614
4701
|
hideOffline();
|
|
4615
|
-
connectionStatus.innerHTML = `
|
|
4616
|
-
<span class="connection-dot live"></span>
|
|
4617
|
-
<span>Connected</span>
|
|
4618
|
-
`;
|
|
4619
4702
|
};
|
|
4620
4703
|
|
|
4621
4704
|
eventSource.onerror = () => {
|
|
4622
4705
|
eventSource.close();
|
|
4623
4706
|
failCount++;
|
|
4624
4707
|
console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
|
|
4625
|
-
connectionStatus.innerHTML = `
|
|
4626
|
-
<span class="connection-dot error"></span>
|
|
4627
|
-
<span>Reconnecting...</span>
|
|
4628
|
-
`;
|
|
4629
4708
|
if (failCount >= 2) showOffline();
|
|
4630
4709
|
setTimeout(connect, retryDelay);
|
|
4631
4710
|
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
@@ -4655,7 +4734,7 @@ function setupEventSource() {
|
|
|
4655
4734
|
if (viewMode === 'all') {
|
|
4656
4735
|
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
4657
4736
|
renderAllTasks();
|
|
4658
|
-
|
|
4737
|
+
renderActivityChip();
|
|
4659
4738
|
} else if (viewMode === 'project' && currentProjectPath) {
|
|
4660
4739
|
const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
|
|
4661
4740
|
if (hasUpdate) fetchProjectView(currentProjectPath);
|
|
@@ -5084,8 +5163,10 @@ function getOwnerColor(name) {
|
|
|
5084
5163
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5085
5164
|
function filterBySessions(value) {
|
|
5086
5165
|
sessionFilter = value;
|
|
5166
|
+
if (value !== 'active') activityFilter.clear();
|
|
5087
5167
|
updateUrl();
|
|
5088
5168
|
renderSessions();
|
|
5169
|
+
renderActivityChip();
|
|
5089
5170
|
}
|
|
5090
5171
|
|
|
5091
5172
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
@@ -5348,7 +5429,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
|
|
|
5348
5429
|
});
|
|
5349
5430
|
|
|
5350
5431
|
function onMove(e) {
|
|
5351
|
-
const w = Math.
|
|
5432
|
+
const w = Math.max(200, startWidth - (e.clientX - startX));
|
|
5352
5433
|
panel.style.setProperty(cssVar, `${w}px`);
|
|
5353
5434
|
}
|
|
5354
5435
|
|
|
@@ -5772,15 +5853,6 @@ function filterByOwner(value) {
|
|
|
5772
5853
|
|
|
5773
5854
|
//#endregion
|
|
5774
5855
|
|
|
5775
|
-
//#region LAYOUT_SYNC
|
|
5776
|
-
const sidebarHeader = document.querySelector('.sidebar-header');
|
|
5777
|
-
const viewHeader = document.querySelector('.view-header');
|
|
5778
|
-
new ResizeObserver(() => {
|
|
5779
|
-
sidebarHeader.style.height = `${viewHeader.offsetHeight}px`;
|
|
5780
|
-
}).observe(viewHeader);
|
|
5781
|
-
|
|
5782
|
-
//#endregion
|
|
5783
|
-
|
|
5784
5856
|
//#region PWA
|
|
5785
5857
|
if ('serviceWorker' in navigator) {
|
|
5786
5858
|
navigator.serviceWorker.register('/sw.js');
|
|
@@ -5790,14 +5862,10 @@ if ('serviceWorker' in navigator) {
|
|
|
5790
5862
|
|
|
5791
5863
|
//#region INIT
|
|
5792
5864
|
loadTheme();
|
|
5793
|
-
|
|
5794
|
-
|
|
5795
|
-
|
|
5796
|
-
|
|
5797
|
-
.getElementById(id === 'live-updates' ? 'live-updates-chevron' : 'sessions-chevron')
|
|
5798
|
-
.classList.add('rotated');
|
|
5799
|
-
}
|
|
5800
|
-
});
|
|
5865
|
+
if (localStorage.getItem('sessions-filtersCollapsed') === 'true') {
|
|
5866
|
+
document.getElementById('sessions-filters').classList.add('collapsed');
|
|
5867
|
+
document.getElementById('sessions-chevron').classList.add('rotated');
|
|
5868
|
+
}
|
|
5801
5869
|
|
|
5802
5870
|
document.addEventListener('DOMContentLoaded', () => {
|
|
5803
5871
|
if (typeof marked !== 'undefined' && typeof hljs !== 'undefined') {
|
package/public/index.html
CHANGED
|
@@ -44,18 +44,7 @@
|
|
|
44
44
|
<!-- Sidebar -->
|
|
45
45
|
<aside class="sidebar">
|
|
46
46
|
<header class="sidebar-header">
|
|
47
|
-
<div class="
|
|
48
|
-
<div class="logo-mark">
|
|
49
|
-
<svg viewBox="4 6 16 12" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
50
|
-
<path d="M5 13l4 4L19 7"/>
|
|
51
|
-
</svg>
|
|
52
|
-
</div>
|
|
53
|
-
<span class="logo-text">Dashboard</span>
|
|
54
|
-
</div>
|
|
55
|
-
<div id="connection-status" class="connection">
|
|
56
|
-
<span class="connection-dot"></span>
|
|
57
|
-
<span>Connecting</span>
|
|
58
|
-
</div>
|
|
47
|
+
<div id="activity-chips" class="activity-chips"></div>
|
|
59
48
|
<button id="sidebar-toggle" class="sidebar-toggle-btn" onclick="toggleSidebar()" title="Toggle sidebar" aria-label="Toggle sidebar">
|
|
60
49
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
61
50
|
<path d="M15 18l-6-6 6-6"/>
|
|
@@ -63,19 +52,6 @@
|
|
|
63
52
|
</button>
|
|
64
53
|
</header>
|
|
65
54
|
|
|
66
|
-
<!-- Live Updates -->
|
|
67
|
-
<div class="sidebar-section">
|
|
68
|
-
<div class="section-header" onclick="toggleLiveUpdates()" style="cursor: pointer;">
|
|
69
|
-
<span>Live Updates</span>
|
|
70
|
-
<svg id="live-updates-chevron" class="collapse-chevron" viewBox="0 0 24 24">
|
|
71
|
-
<path d="M6 9l6 6 6-6"/>
|
|
72
|
-
</svg>
|
|
73
|
-
</div>
|
|
74
|
-
<div id="live-updates" class="live-updates">
|
|
75
|
-
<div class="live-empty">No active tasks</div>
|
|
76
|
-
</div>
|
|
77
|
-
</div>
|
|
78
|
-
|
|
79
55
|
<!-- Tasks -->
|
|
80
56
|
<div class="sidebar-section flex-1">
|
|
81
57
|
<div class="section-header" onclick="toggleSection('sessions-filters', 'sessions-chevron')" style="cursor: pointer;">
|
|
@@ -413,6 +389,10 @@
|
|
|
413
389
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">I</kbd></td>
|
|
414
390
|
<td style="padding: 4px 0; color: var(--text-primary);">Open session info</td>
|
|
415
391
|
</tr>
|
|
392
|
+
<tr>
|
|
393
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+D</kbd></td>
|
|
394
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Dismiss selected session</td>
|
|
395
|
+
</tr>
|
|
416
396
|
<tr>
|
|
417
397
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
|
|
418
398
|
<td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
|
package/public/style.css
CHANGED
|
@@ -100,19 +100,118 @@ body::before {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
.sidebar-header {
|
|
103
|
-
padding:
|
|
103
|
+
padding: 6px 10px;
|
|
104
104
|
border-bottom: none;
|
|
105
105
|
background-image: linear-gradient(to right, transparent, var(--border), transparent);
|
|
106
106
|
background-size: 100% 1px;
|
|
107
107
|
background-repeat: no-repeat;
|
|
108
108
|
background-position: bottom;
|
|
109
109
|
position: relative;
|
|
110
|
+
display: flex;
|
|
111
|
+
align-items: center;
|
|
112
|
+
justify-content: space-between;
|
|
113
|
+
gap: 8px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.activity-chips {
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: 6px;
|
|
120
|
+
flex-wrap: wrap;
|
|
121
|
+
min-width: 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.activity-chip {
|
|
125
|
+
display: inline-flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
gap: 7px;
|
|
128
|
+
padding: 4px 10px 4px 9px;
|
|
129
|
+
font: inherit;
|
|
130
|
+
font-size: 11px;
|
|
131
|
+
font-weight: 500;
|
|
132
|
+
letter-spacing: 0.04em;
|
|
133
|
+
color: var(--text-secondary);
|
|
134
|
+
background: var(--bg-deep);
|
|
135
|
+
border: 1px solid var(--border);
|
|
136
|
+
border-radius: 999px;
|
|
137
|
+
white-space: nowrap;
|
|
138
|
+
cursor: pointer;
|
|
139
|
+
transition:
|
|
140
|
+
color 0.2s ease,
|
|
141
|
+
border-color 0.2s ease,
|
|
142
|
+
background 0.2s ease,
|
|
143
|
+
transform 0.1s ease;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.activity-chip:hover {
|
|
147
|
+
background: var(--bg-hover);
|
|
148
|
+
border-color: color-mix(in srgb, var(--text-secondary) 30%, var(--border));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.activity-chip:active {
|
|
152
|
+
transform: scale(0.97);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
.activity-chip.activity-filter-on {
|
|
156
|
+
background: color-mix(in srgb, var(--accent) 14%, var(--bg-deep));
|
|
157
|
+
border-color: var(--accent);
|
|
158
|
+
box-shadow: inset 0 0 0 1px var(--accent);
|
|
159
|
+
}
|
|
160
|
+
.activity-chip.activity-waiting.activity-filter-on {
|
|
161
|
+
border-color: var(--warning);
|
|
162
|
+
box-shadow: inset 0 0 0 1px var(--warning);
|
|
163
|
+
background: color-mix(in srgb, var(--warning) 14%, var(--bg-deep));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.activity-dot {
|
|
167
|
+
width: 6px;
|
|
168
|
+
height: 6px;
|
|
169
|
+
border-radius: 50%;
|
|
170
|
+
background: var(--text-muted);
|
|
171
|
+
flex-shrink: 0;
|
|
172
|
+
transition:
|
|
173
|
+
background 0.2s ease,
|
|
174
|
+
box-shadow 0.2s ease;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.activity-chip.activity-zero {
|
|
178
|
+
color: var(--text-tertiary);
|
|
179
|
+
border-color: var(--border);
|
|
180
|
+
opacity: 0.6;
|
|
181
|
+
}
|
|
182
|
+
.activity-chip.activity-zero .activity-dot {
|
|
183
|
+
background: var(--text-muted);
|
|
184
|
+
box-shadow: none;
|
|
185
|
+
animation: none;
|
|
186
|
+
}
|
|
187
|
+
.activity-chip.activity-zero:hover {
|
|
188
|
+
opacity: 1;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.activity-chip.activity-waiting {
|
|
192
|
+
color: var(--warning);
|
|
193
|
+
border-color: color-mix(in srgb, var(--warning) 40%, var(--border));
|
|
194
|
+
}
|
|
195
|
+
.activity-chip.activity-waiting .activity-dot {
|
|
196
|
+
background: var(--warning);
|
|
197
|
+
box-shadow: 0 0 8px color-mix(in srgb, var(--warning) 60%, transparent);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.activity-chip.activity-active {
|
|
201
|
+
color: color-mix(in srgb, var(--accent) 70%, var(--text-secondary));
|
|
202
|
+
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
|
203
|
+
}
|
|
204
|
+
.activity-chip.activity-active .activity-dot {
|
|
205
|
+
background: color-mix(in srgb, var(--accent) 75%, var(--text-secondary));
|
|
206
|
+
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 30%, transparent);
|
|
207
|
+
animation: pulse 2.5s ease-in-out infinite;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.sidebar.collapsed .activity-chips {
|
|
211
|
+
display: none;
|
|
110
212
|
}
|
|
111
213
|
|
|
112
214
|
.sidebar-toggle-btn {
|
|
113
|
-
position: absolute;
|
|
114
|
-
top: 20px;
|
|
115
|
-
right: 8px;
|
|
116
215
|
width: 28px;
|
|
117
216
|
height: 28px;
|
|
118
217
|
display: flex;
|
|
@@ -178,62 +277,6 @@ body::before {
|
|
|
178
277
|
background: var(--accent-dim);
|
|
179
278
|
}
|
|
180
279
|
|
|
181
|
-
.logo {
|
|
182
|
-
display: flex;
|
|
183
|
-
align-items: center;
|
|
184
|
-
gap: 10px;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
.logo-mark {
|
|
188
|
-
width: 24px;
|
|
189
|
-
height: 24px;
|
|
190
|
-
background: var(--accent);
|
|
191
|
-
border-radius: 6px;
|
|
192
|
-
display: flex;
|
|
193
|
-
align-items: center;
|
|
194
|
-
justify-content: center;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.logo-mark svg {
|
|
198
|
-
width: 14px;
|
|
199
|
-
height: 14px;
|
|
200
|
-
color: white;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
.logo-text {
|
|
204
|
-
font-family: var(--serif);
|
|
205
|
-
font-size: 17px;
|
|
206
|
-
font-weight: 500;
|
|
207
|
-
letter-spacing: -0.02em;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
.connection {
|
|
211
|
-
display: flex;
|
|
212
|
-
align-items: center;
|
|
213
|
-
gap: 5px;
|
|
214
|
-
margin-top: 10px;
|
|
215
|
-
font-size: 10px;
|
|
216
|
-
color: var(--text-tertiary);
|
|
217
|
-
text-transform: uppercase;
|
|
218
|
-
letter-spacing: 0.05em;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
.connection-dot {
|
|
222
|
-
width: 6px;
|
|
223
|
-
height: 6px;
|
|
224
|
-
border-radius: 50%;
|
|
225
|
-
background: var(--warning);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
.connection-dot.live {
|
|
229
|
-
background: var(--success);
|
|
230
|
-
box-shadow: 0 0 8px var(--success);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
.connection-dot.error {
|
|
234
|
-
background: #ef4444;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
280
|
.offline-overlay {
|
|
238
281
|
display: none;
|
|
239
282
|
position: fixed;
|
|
@@ -370,7 +413,7 @@ body::before {
|
|
|
370
413
|
|
|
371
414
|
/* #endregion */
|
|
372
415
|
|
|
373
|
-
/* #region
|
|
416
|
+
/* #region COLLAPSIBLE */
|
|
374
417
|
.collapse-chevron {
|
|
375
418
|
width: 14px;
|
|
376
419
|
height: 14px;
|
|
@@ -401,80 +444,6 @@ body::before {
|
|
|
401
444
|
overflow: hidden;
|
|
402
445
|
}
|
|
403
446
|
|
|
404
|
-
.live-updates {
|
|
405
|
-
padding: 0 16px 8px;
|
|
406
|
-
max-height: 140px;
|
|
407
|
-
overflow-y: auto;
|
|
408
|
-
transition:
|
|
409
|
-
max-height 0.2s ease,
|
|
410
|
-
padding 0.2s ease,
|
|
411
|
-
opacity 0.2s ease;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
.live-updates.collapsed {
|
|
415
|
-
max-height: 0;
|
|
416
|
-
padding: 0 16px;
|
|
417
|
-
overflow: hidden;
|
|
418
|
-
opacity: 0;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
.live-empty {
|
|
422
|
-
padding: 8px;
|
|
423
|
-
text-align: center;
|
|
424
|
-
font-size: 11px;
|
|
425
|
-
color: var(--text-muted);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
.live-item {
|
|
429
|
-
display: flex;
|
|
430
|
-
align-items: flex-start;
|
|
431
|
-
gap: 8px;
|
|
432
|
-
padding: 6px 10px;
|
|
433
|
-
background: var(--bg-deep);
|
|
434
|
-
border: 1px solid transparent;
|
|
435
|
-
border-radius: 6px;
|
|
436
|
-
margin-bottom: 3px;
|
|
437
|
-
cursor: pointer;
|
|
438
|
-
transition: all 0.15s ease;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
.live-item:hover {
|
|
442
|
-
background: var(--bg-hover);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
.live-item .pulse {
|
|
446
|
-
width: 6px;
|
|
447
|
-
height: 6px;
|
|
448
|
-
margin-top: 4px;
|
|
449
|
-
background: var(--accent);
|
|
450
|
-
border-radius: 50%;
|
|
451
|
-
flex-shrink: 0;
|
|
452
|
-
animation: pulse 2s ease-in-out infinite;
|
|
453
|
-
box-shadow: 0 0 8px var(--accent-glow);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
.live-item-content {
|
|
457
|
-
flex: 1;
|
|
458
|
-
min-width: 0;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.live-item-action {
|
|
462
|
-
font-size: 11px;
|
|
463
|
-
color: var(--text-primary);
|
|
464
|
-
white-space: nowrap;
|
|
465
|
-
overflow: hidden;
|
|
466
|
-
text-overflow: ellipsis;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
.live-item-session {
|
|
470
|
-
font-size: 10px;
|
|
471
|
-
color: var(--text-tertiary);
|
|
472
|
-
margin-top: 1px;
|
|
473
|
-
white-space: nowrap;
|
|
474
|
-
overflow: hidden;
|
|
475
|
-
text-overflow: ellipsis;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
447
|
/* #endregion */
|
|
479
448
|
|
|
480
449
|
/* #region SESSIONS */
|
|
@@ -762,8 +731,6 @@ body::before {
|
|
|
762
731
|
.sidebar.collapsed {
|
|
763
732
|
width: 48px;
|
|
764
733
|
}
|
|
765
|
-
.sidebar.collapsed .logo-text,
|
|
766
|
-
.sidebar.collapsed .connection,
|
|
767
734
|
.sidebar.collapsed .sidebar-section,
|
|
768
735
|
.sidebar.collapsed .sidebar-footer {
|
|
769
736
|
display: none;
|
|
@@ -3455,10 +3422,6 @@ pre.mermaid svg {
|
|
|
3455
3422
|
}
|
|
3456
3423
|
}
|
|
3457
3424
|
|
|
3458
|
-
.connection-dot.live {
|
|
3459
|
-
animation: breathe 3s ease-in-out infinite;
|
|
3460
|
-
}
|
|
3461
|
-
|
|
3462
3425
|
/* Progress bar shimmer */
|
|
3463
3426
|
@keyframes shimmer {
|
|
3464
3427
|
0% {
|
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');
|
|
@@ -73,6 +73,27 @@ const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
|
|
|
73
73
|
const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
|
|
74
74
|
const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
|
|
75
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
|
+
}
|
|
76
97
|
|
|
77
98
|
const PERMISSION_TTL_MS = 1800000;
|
|
78
99
|
const AGENT_TTL_MS = 3600000;
|
|
@@ -1067,6 +1088,29 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1067
1088
|
}
|
|
1068
1089
|
}
|
|
1069
1090
|
} catch (_) {}
|
|
1091
|
+
// Mark agents whose spawning Agent tool_use was rejected by the user as stopped:
|
|
1092
|
+
// the parent will never read their output, so they're orphans. Match by agentId
|
|
1093
|
+
// when the digest already correlated tool_use→agent, else fall back to prompt text
|
|
1094
|
+
// (the agent-spy hook doesn't record the spawning tool_use_id).
|
|
1095
|
+
try {
|
|
1096
|
+
const { rejectedAgentIds = new Set(), rejectedPrompts = new Set(), killedAgentIds = new Set() } =
|
|
1097
|
+
getSessionDigest(meta.jsonlPath);
|
|
1098
|
+
if (rejectedAgentIds.size || rejectedPrompts.size || killedAgentIds.size) {
|
|
1099
|
+
for (const agent of liveAgents) {
|
|
1100
|
+
if (agent.status !== 'active' && agent.status !== 'idle') continue;
|
|
1101
|
+
let reason = null;
|
|
1102
|
+
if (killedAgentIds.has(agent.agentId)) reason = 'killed-by-harness';
|
|
1103
|
+
else if (rejectedAgentIds.has(agent.agentId) || (agent.prompt && rejectedPrompts.has(agent.prompt))) {
|
|
1104
|
+
reason = 'orphaned-by-rejection';
|
|
1105
|
+
}
|
|
1106
|
+
if (!reason) continue;
|
|
1107
|
+
agent.status = 'stopped';
|
|
1108
|
+
agent.stoppedAt = agent.stoppedAt || new Date().toISOString();
|
|
1109
|
+
agent.stopReason = agent.stopReason || reason;
|
|
1110
|
+
persistAgent(agentDir, agent);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch (_) {}
|
|
1070
1114
|
}
|
|
1071
1115
|
|
|
1072
1116
|
const dirty = new Set();
|
|
@@ -1169,6 +1213,31 @@ function subagentJsonlPath(meta, agentId) {
|
|
|
1169
1213
|
);
|
|
1170
1214
|
}
|
|
1171
1215
|
|
|
1216
|
+
// Claude Code can scatter a session's records across multiple project dirs
|
|
1217
|
+
// (e.g. main repo + worktree), so the subagent JSONL may live under a
|
|
1218
|
+
// different project dir than meta.jsonlPath. Fall back to scanning when the
|
|
1219
|
+
// derived path is missing.
|
|
1220
|
+
const subagentPathCache = new Map();
|
|
1221
|
+
function resolveSubagentJsonl(meta, sessionId, agentId) {
|
|
1222
|
+
const primary = subagentJsonlPath(meta, agentId);
|
|
1223
|
+
if (existsSync(primary)) return primary;
|
|
1224
|
+
const key = sessionId + '/' + agentId;
|
|
1225
|
+
if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
|
|
1226
|
+
let found = null;
|
|
1227
|
+
try {
|
|
1228
|
+
for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
|
|
1229
|
+
if (!entry.isDirectory()) continue;
|
|
1230
|
+
const candidate = path.join(
|
|
1231
|
+
PROJECTS_DIR, entry.name, sessionId,
|
|
1232
|
+
'subagents', 'agent-' + agentId + '.jsonl'
|
|
1233
|
+
);
|
|
1234
|
+
if (existsSync(candidate)) { found = candidate; break; }
|
|
1235
|
+
}
|
|
1236
|
+
} catch (_) { /* projects dir missing */ }
|
|
1237
|
+
subagentPathCache.set(key, found);
|
|
1238
|
+
return found || primary;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1172
1241
|
app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
|
|
1173
1242
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
1174
1243
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
@@ -1176,7 +1245,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
|
|
|
1176
1245
|
const metadata = loadSessionMetadata();
|
|
1177
1246
|
const meta = metadata[sessionId];
|
|
1178
1247
|
if (!meta?.jsonlPath) return res.json({ messages: [], agentId });
|
|
1179
|
-
const subagentJsonl =
|
|
1248
|
+
const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
|
|
1180
1249
|
if (!existsSync(subagentJsonl)) return res.json({ messages: [], agentId });
|
|
1181
1250
|
const messages = readRecentMessages(subagentJsonl, limit);
|
|
1182
1251
|
res.json({ messages, agentId });
|
|
@@ -1191,7 +1260,7 @@ app.get('/api/sessions/:sessionId/agents/:agentId/messages/stream', (req, res) =
|
|
|
1191
1260
|
res.status(404).json({ error: 'Session not found' });
|
|
1192
1261
|
return;
|
|
1193
1262
|
}
|
|
1194
|
-
const subagentJsonl =
|
|
1263
|
+
const subagentJsonl = resolveSubagentJsonl(meta, sessionId, agentId);
|
|
1195
1264
|
|
|
1196
1265
|
res.writeHead(200, {
|
|
1197
1266
|
'Content-Type': 'text/event-stream',
|
|
@@ -1561,6 +1630,10 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
1561
1630
|
if (!['none', 'pinned', 'sticky'].includes(state)) {
|
|
1562
1631
|
return res.status(400).json({ error: 'state must be none|pinned|sticky' });
|
|
1563
1632
|
}
|
|
1633
|
+
const pins = readPins();
|
|
1634
|
+
if (state === 'none') delete pins[id];
|
|
1635
|
+
else pins[id] = state;
|
|
1636
|
+
writePins(pins);
|
|
1564
1637
|
broadcast({ type: 'session:pin', id, state });
|
|
1565
1638
|
res.json({ success: true, id, state });
|
|
1566
1639
|
} catch (error) {
|
|
@@ -1569,6 +1642,18 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
1569
1642
|
}
|
|
1570
1643
|
});
|
|
1571
1644
|
|
|
1645
|
+
app.get('/api/session/pins', (req, res) => {
|
|
1646
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1647
|
+
try {
|
|
1648
|
+
const pins = readPins();
|
|
1649
|
+
const items = Object.entries(pins).map(([id, state]) => ({ id, state }));
|
|
1650
|
+
res.json({ pins, items });
|
|
1651
|
+
} catch (error) {
|
|
1652
|
+
console.error('Error in GET /api/session/pins:', error);
|
|
1653
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1572
1657
|
app.get('/api/preview', async (req, res) => {
|
|
1573
1658
|
try {
|
|
1574
1659
|
const abs = resolvePreviewPath(req.query.path, req.query.base);
|