claude-code-kanban 3.5.1 → 3.6.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 +35 -0
- package/lib/parsers.js +41 -1
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +15 -1
- package/public/app.js +120 -17
- package/public/style.css +69 -4
- package/server.js +40 -6
package/cli.js
CHANGED
|
@@ -42,6 +42,16 @@ const COMMANDS = {
|
|
|
42
42
|
},
|
|
43
43
|
run: runSessionViewCli,
|
|
44
44
|
},
|
|
45
|
+
pin: {
|
|
46
|
+
summary: 'Pin (or unpin) a session in the sidebar of connected browser tabs',
|
|
47
|
+
usage: 'claude-code-kanban session pin <id> [--sticky] [--unpin]',
|
|
48
|
+
flags: {
|
|
49
|
+
'<id>': 'Full session id, or a unique prefix',
|
|
50
|
+
'--sticky': 'Set sticky state (always shown, top of list)',
|
|
51
|
+
'--unpin': 'Clear pin/sticky state',
|
|
52
|
+
},
|
|
53
|
+
run: runSessionPinCli,
|
|
54
|
+
},
|
|
45
55
|
peek: {
|
|
46
56
|
summary: 'Show the last N messages from a session',
|
|
47
57
|
usage: 'claude-code-kanban session peek <id> [--limit <n>] [--json]',
|
|
@@ -361,6 +371,31 @@ async function runSessionOpenCli(args) {
|
|
|
361
371
|
} catch (e) { reportCliError(e); return 1; }
|
|
362
372
|
}
|
|
363
373
|
|
|
374
|
+
async function runSessionPinCli(args) {
|
|
375
|
+
const idArg = args.find(a => !a.startsWith('--'));
|
|
376
|
+
if (!idArg) {
|
|
377
|
+
printLeafHelp('session pin', COMMANDS.session.verbs.pin);
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
const state = args.includes('--unpin') ? 'none' : args.includes('--sticky') ? 'sticky' : 'pinned';
|
|
381
|
+
const resolved = await resolveSessionByIdOrPrefix(idArg);
|
|
382
|
+
if (!resolved) return 1;
|
|
383
|
+
try {
|
|
384
|
+
const res = await cliFetch('/api/session/pin', {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: { 'Content-Type': 'application/json' },
|
|
387
|
+
body: JSON.stringify({ id: resolved.id, state })
|
|
388
|
+
});
|
|
389
|
+
if (!res.ok) {
|
|
390
|
+
console.error(`Pin failed (${res.status}): ${await res.text()}`);
|
|
391
|
+
return 1;
|
|
392
|
+
}
|
|
393
|
+
const label = state === 'none' ? 'unpinned' : state;
|
|
394
|
+
console.log(`Session ${label}: ${resolved.id}${resolved.customTitle ? ` (${resolved.customTitle})` : ''}`);
|
|
395
|
+
return 0;
|
|
396
|
+
} catch (e) { reportCliError(e); return 1; }
|
|
397
|
+
}
|
|
398
|
+
|
|
364
399
|
async function runSessionViewCli(args) {
|
|
365
400
|
const idArg = args.find(a => !a.startsWith('--'));
|
|
366
401
|
if (!idArg) {
|
package/lib/parsers.js
CHANGED
|
@@ -182,10 +182,22 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
182
182
|
try { return JSON.parse(`"${m[1]}"`); } catch (e) { return null; }
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
const sessionInfoCache = new Map();
|
|
186
|
+
const SESSION_INFO_CACHE_MAX = 2000;
|
|
187
|
+
|
|
185
188
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
186
189
|
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
|
|
187
190
|
let stat;
|
|
188
191
|
let fd;
|
|
192
|
+
try {
|
|
193
|
+
stat = statSync(jsonlPath);
|
|
194
|
+
const cached = sessionInfoCache.get(jsonlPath);
|
|
195
|
+
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
196
|
+
return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
|
|
197
|
+
}
|
|
198
|
+
} catch (_) {
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
189
201
|
// State shared across head-chunk parse, leftover flush, and tail parse.
|
|
190
202
|
let lastCwdFromHead = null;
|
|
191
203
|
const applyLine = (line) => {
|
|
@@ -200,7 +212,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
200
212
|
} catch (e) {}
|
|
201
213
|
};
|
|
202
214
|
try {
|
|
203
|
-
stat = statSync(jsonlPath);
|
|
204
215
|
fd = fs.openSync(jsonlPath, 'r');
|
|
205
216
|
const CHUNK_SIZE = 16384;
|
|
206
217
|
const TAIL_SIZE = 16384;
|
|
@@ -264,6 +275,14 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
264
275
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
265
276
|
}
|
|
266
277
|
|
|
278
|
+
if (stat) {
|
|
279
|
+
const { customTitle: _ct, ...rest } = result;
|
|
280
|
+
sessionInfoCache.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, result: rest });
|
|
281
|
+
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
282
|
+
const firstKey = sessionInfoCache.keys().next().value;
|
|
283
|
+
sessionInfoCache.delete(firstKey);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
267
286
|
result.customTitle = readCustomTitle(jsonlPath, stat);
|
|
268
287
|
return result;
|
|
269
288
|
}
|
|
@@ -695,6 +714,27 @@ function buildAgentProgressMap(jsonlPath) {
|
|
|
695
714
|
|
|
696
715
|
function readCompactSummaries(jsonlPath) {
|
|
697
716
|
const results = [];
|
|
717
|
+
// Inline format: newer Claude Code stores the summary directly in the parent
|
|
718
|
+
// session JSONL as a user message with isCompactSummary: true (no subagent file).
|
|
719
|
+
try {
|
|
720
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
721
|
+
for (const line of content.split('\n')) {
|
|
722
|
+
if (!line.trim() || !line.includes('isCompactSummary')) continue;
|
|
723
|
+
try {
|
|
724
|
+
const obj = JSON.parse(line);
|
|
725
|
+
if (!obj.isCompactSummary) continue;
|
|
726
|
+
const c = obj.message?.content;
|
|
727
|
+
let text = typeof c === 'string'
|
|
728
|
+
? c
|
|
729
|
+
: Array.isArray(c) ? c.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n') : '';
|
|
730
|
+
if (!text) continue;
|
|
731
|
+
// Strip the "This session is being continued..." preamble if present.
|
|
732
|
+
text = text.replace(/^This session is being continued[^\n]*\n+(The summary below[^\n]*\n+)?/i, '').trim();
|
|
733
|
+
if (text) results.push({ timestamp: obj.timestamp, summary: text });
|
|
734
|
+
} catch (_) {}
|
|
735
|
+
}
|
|
736
|
+
} catch (_) {}
|
|
737
|
+
// Legacy format: summary lives in subagents/agent-acompact-*.jsonl.
|
|
698
738
|
try {
|
|
699
739
|
const subagentsDir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, '.jsonl'), 'subagents');
|
|
700
740
|
const files = readdirSync(subagentsDir).filter(f => f.startsWith('agent-acompact-') && f.endsWith('.jsonl'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
"public/**/*"
|
|
52
52
|
],
|
|
53
53
|
"devDependencies": {
|
|
54
|
+
"@biomejs/biome": "2.4.14",
|
|
54
55
|
"ajv": "^8.18.0",
|
|
55
56
|
"ajv-formats": "^3.0.1",
|
|
56
57
|
"husky": "^9.1.7"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: kanban
|
|
3
|
-
description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
|
|
3
|
+
description: Drive the claude-code-kanban browser dashboard from this Claude session. Use this skill when the user mentions "kanban" together with "session" — e.g. "open this session in kanban", "show kanban", "focus current session in kanban", "pin/unpin a session in kanban", "preview this file in kanban", or asks to peek/view a kanban session.
|
|
4
4
|
compatibility: Requires the `claude-code-kanban` CLI on PATH and the server running locally (default port 3456).
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -22,6 +22,20 @@ claude-code-kanban session open ${CLAUDE_SESSION_ID}
|
|
|
22
22
|
|
|
23
23
|
Trigger phrases: "show this session in kanban", "focus current session", "open in kanban".
|
|
24
24
|
|
|
25
|
+
## Pin the current session in kanban
|
|
26
|
+
|
|
27
|
+
Pins the active Claude session in the sidebar so it stays visible regardless of filters. Three states: `pinned` (default), `sticky` (always at the top), or cleared via `--unpin`.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} # pin
|
|
31
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --sticky # sticky at top
|
|
32
|
+
claude-code-kanban session pin ${CLAUDE_SESSION_ID} --unpin # clear
|
|
33
|
+
```
|
|
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
|
+
Trigger phrases: "pin this session", "pin in kanban", "make this session sticky", "unpin session".
|
|
38
|
+
|
|
25
39
|
## Preview a file in kanban
|
|
26
40
|
|
|
27
41
|
Opens a markdown file in the preview modal:
|
package/public/app.js
CHANGED
|
@@ -1335,6 +1335,19 @@ function toggleSessionSticky(sessionId) {
|
|
|
1335
1335
|
renderSessions();
|
|
1336
1336
|
}
|
|
1337
1337
|
|
|
1338
|
+
function handleSessionPinEvent({ id, state }) {
|
|
1339
|
+
if (!id) return;
|
|
1340
|
+
pinnedSessionIds.delete(id);
|
|
1341
|
+
stickySessionIds.delete(id);
|
|
1342
|
+
if (state === 'pinned') pinnedSessionIds.add(id);
|
|
1343
|
+
if (state === 'sticky') {
|
|
1344
|
+
pinnedSessionIds.add(id);
|
|
1345
|
+
stickySessionIds.add(id);
|
|
1346
|
+
}
|
|
1347
|
+
savePinnedSessions();
|
|
1348
|
+
renderSessions();
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1338
1351
|
function getSessionPinState(sessionId) {
|
|
1339
1352
|
if (stickySessionIds.has(sessionId)) return 'sticky';
|
|
1340
1353
|
if (pinnedSessionIds.has(sessionId)) return 'pinned';
|
|
@@ -1610,9 +1623,9 @@ function getToolDetail(tool, params, detail) {
|
|
|
1610
1623
|
const parts = [];
|
|
1611
1624
|
if (params.offset) parts.push(`L${params.offset}`);
|
|
1612
1625
|
if (params.limit) parts.push(`+${params.limit}`);
|
|
1613
|
-
if (parts.length) extra = ` <span style="color:var(--text-muted)
|
|
1626
|
+
if (parts.length) extra = ` <span style="color:var(--text-muted)">${parts.join(' ')}</span>`;
|
|
1614
1627
|
}
|
|
1615
|
-
return ` <span style="color:var(--text-
|
|
1628
|
+
return ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>${extra}`;
|
|
1616
1629
|
}
|
|
1617
1630
|
function renderTaskResult(toolResult) {
|
|
1618
1631
|
if (!toolResult) return '';
|
|
@@ -2264,7 +2277,10 @@ function renderSessions() {
|
|
|
2264
2277
|
const hasInProgress = session.inProgress > 0;
|
|
2265
2278
|
const isLive =
|
|
2266
2279
|
hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
|
|
2267
|
-
const
|
|
2280
|
+
const rawName = session.name || session.id;
|
|
2281
|
+
const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
|
|
2282
|
+
? rawName.slice(0, 8)
|
|
2283
|
+
: rawName;
|
|
2268
2284
|
const useGrouped = sessionFilter === 'active' && session.project;
|
|
2269
2285
|
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
2270
2286
|
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
@@ -2286,8 +2302,9 @@ function renderSessions() {
|
|
|
2286
2302
|
const showCtx = !!session.contextStatus;
|
|
2287
2303
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
2288
2304
|
const bookmarksCount = loadPins(session.id).length;
|
|
2305
|
+
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
2289
2306
|
return `
|
|
2290
|
-
<button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${
|
|
2307
|
+
<button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
|
|
2291
2308
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
|
|
2292
2309
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
2293
2310
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
@@ -3944,6 +3961,19 @@ function matchKey(e, ...keys) {
|
|
|
3944
3961
|
return keys.some((k) => e.key === k || e.code === k);
|
|
3945
3962
|
}
|
|
3946
3963
|
|
|
3964
|
+
const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
|
|
3965
|
+
const MODAL_CLOSERS = {
|
|
3966
|
+
'preview-modal': () => closePreviewModal(),
|
|
3967
|
+
'msg-detail-modal': () => {
|
|
3968
|
+
closeMsgDetailModal();
|
|
3969
|
+
msgDetailFollowLatest = false;
|
|
3970
|
+
},
|
|
3971
|
+
'plan-modal': () => closePlanModal(),
|
|
3972
|
+
'team-modal': () => closeTeamModal(),
|
|
3973
|
+
'agent-modal': () => closeAgentModal(),
|
|
3974
|
+
'help-modal': () => closeHelpModal(),
|
|
3975
|
+
};
|
|
3976
|
+
|
|
3947
3977
|
document.addEventListener('keydown', (e) => {
|
|
3948
3978
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
3949
3979
|
return;
|
|
@@ -3956,9 +3986,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
3956
3986
|
closeScratchpad();
|
|
3957
3987
|
return;
|
|
3958
3988
|
}
|
|
3959
|
-
//
|
|
3960
|
-
document.querySelectorAll('.modal-overlay.visible')
|
|
3961
|
-
|
|
3989
|
+
// Close only the topmost so a child Esc doesn't also dismiss its parent.
|
|
3990
|
+
const visible = [...document.querySelectorAll('.modal-overlay.visible')];
|
|
3991
|
+
const topId = MODAL_ESC_PRIORITY.find((id) => visible.some((m) => m.id === id)) || visible[visible.length - 1].id;
|
|
3992
|
+
const close = MODAL_CLOSERS[topId];
|
|
3993
|
+
if (close) close();
|
|
3994
|
+
else document.getElementById(topId).classList.remove('visible');
|
|
3995
|
+
e.stopImmediatePropagation();
|
|
3962
3996
|
} else if (
|
|
3963
3997
|
e.code === 'KeyM' &&
|
|
3964
3998
|
e.shiftKey &&
|
|
@@ -4153,6 +4187,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
4153
4187
|
lastTasksHash = '';
|
|
4154
4188
|
const refreshes = [fetchSessions()];
|
|
4155
4189
|
if (currentSessionId) refreshes.push(fetchTasks(currentSessionId));
|
|
4190
|
+
refreshRateLimits();
|
|
4156
4191
|
Promise.all(refreshes)
|
|
4157
4192
|
.then(() => showToast('Data refreshed', 'success'))
|
|
4158
4193
|
.finally(() => {
|
|
@@ -4227,8 +4262,24 @@ function openPreviewModal(filePath, content) {
|
|
|
4227
4262
|
currentPreviewPath = filePath;
|
|
4228
4263
|
document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
|
|
4229
4264
|
const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
|
|
4230
|
-
document.getElementById('preview-modal-body')
|
|
4231
|
-
|
|
4265
|
+
const bodyEl = document.getElementById('preview-modal-body');
|
|
4266
|
+
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
4267
|
+
if (!bodyEl.dataset.relLinkBound) {
|
|
4268
|
+
bodyEl.addEventListener('click', (e) => {
|
|
4269
|
+
const a = e.target.closest('a[href]');
|
|
4270
|
+
if (!a) return;
|
|
4271
|
+
const href = a.getAttribute('href');
|
|
4272
|
+
if (!href || href.startsWith('#')) return;
|
|
4273
|
+
const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
|
|
4274
|
+
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
4275
|
+
if (isAbsoluteUrl) return;
|
|
4276
|
+
if (!/\.(md|markdown)(#.*)?$/i.test(href)) return;
|
|
4277
|
+
e.preventDefault();
|
|
4278
|
+
const cleanHref = href.replace(/#.*$/, '');
|
|
4279
|
+
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
|
|
4280
|
+
});
|
|
4281
|
+
bodyEl.dataset.relLinkBound = '1';
|
|
4282
|
+
}
|
|
4232
4283
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
4233
4284
|
document.getElementById('preview-modal').classList.add('visible');
|
|
4234
4285
|
updatePreviewLinkBtn();
|
|
@@ -4299,7 +4350,6 @@ function refreshInfoModalLinkedDocs() {
|
|
|
4299
4350
|
bindLinkedDocsHandlers(node, _infoModalSessionId);
|
|
4300
4351
|
}
|
|
4301
4352
|
|
|
4302
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4303
4353
|
function closePreviewModal() {
|
|
4304
4354
|
resetModalFullscreen('preview-modal');
|
|
4305
4355
|
currentPreviewPath = null;
|
|
@@ -4311,10 +4361,12 @@ function openPreviewInEditor() {
|
|
|
4311
4361
|
postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
|
|
4312
4362
|
}
|
|
4313
4363
|
|
|
4314
|
-
async function openPreviewByPath(filePath) {
|
|
4364
|
+
async function openPreviewByPath(filePath, base) {
|
|
4315
4365
|
if (!filePath) return;
|
|
4316
4366
|
try {
|
|
4317
|
-
const
|
|
4367
|
+
const qs = new URLSearchParams({ path: filePath });
|
|
4368
|
+
if (base) qs.set('base', base);
|
|
4369
|
+
const r = await fetch(`/api/preview?${qs}`);
|
|
4318
4370
|
if (!r.ok) {
|
|
4319
4371
|
showToast('Preview file unavailable');
|
|
4320
4372
|
return;
|
|
@@ -4358,22 +4410,33 @@ async function handlePreviewOpenEvent(data) {
|
|
|
4358
4410
|
openPreviewModal(filePath, content);
|
|
4359
4411
|
}
|
|
4360
4412
|
|
|
4413
|
+
function getSessionBaseDir(sessionId) {
|
|
4414
|
+
const s = sessions.find((x) => x.id === sessionId);
|
|
4415
|
+
return s?.cwd || s?.project || '';
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4361
4418
|
function renderLinkedDocsHtml(sessionId) {
|
|
4362
4419
|
const paths = getSessionPreviewPaths(sessionId);
|
|
4363
4420
|
if (!paths.length) return '';
|
|
4421
|
+
const baseDir = getSessionBaseDir(sessionId);
|
|
4364
4422
|
const items = paths
|
|
4365
4423
|
.map((p, i) => {
|
|
4366
4424
|
const name = p.split(/[\\/]/).pop();
|
|
4367
|
-
|
|
4425
|
+
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
4426
|
+
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
4427
|
+
return `<li class="linked-doc-item">
|
|
4428
|
+
<a href="#" class="linked-doc-link" data-idx="${i}" title="${escapeHtml(p)}">${escapeHtml(name)}</a>
|
|
4429
|
+
${pathSpan}
|
|
4430
|
+
</li>`;
|
|
4368
4431
|
})
|
|
4369
|
-
.join('
|
|
4432
|
+
.join('');
|
|
4370
4433
|
return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
|
|
4371
4434
|
<div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
|
|
4372
4435
|
${linkSvg(12)}
|
|
4373
4436
|
<span>Linked documents</span>
|
|
4374
4437
|
<span style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</span>
|
|
4375
4438
|
</div>
|
|
4376
|
-
<
|
|
4439
|
+
<ul class="linked-doc-list">${items}</ul>
|
|
4377
4440
|
</div>`;
|
|
4378
4441
|
}
|
|
4379
4442
|
|
|
@@ -4382,10 +4445,11 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
4382
4445
|
const links = container.querySelectorAll('.linked-doc-link');
|
|
4383
4446
|
if (!links.length) return;
|
|
4384
4447
|
const paths = getSessionPreviewPaths(sessionId);
|
|
4448
|
+
const base = getSessionBaseDir(sessionId);
|
|
4385
4449
|
for (const link of links) {
|
|
4386
4450
|
link.addEventListener('click', (e) => {
|
|
4387
4451
|
e.preventDefault();
|
|
4388
|
-
openPreviewByPath(paths[+link.dataset.idx]);
|
|
4452
|
+
openPreviewByPath(paths[+link.dataset.idx], base);
|
|
4389
4453
|
});
|
|
4390
4454
|
}
|
|
4391
4455
|
}
|
|
@@ -4515,6 +4579,10 @@ function setupEventSource() {
|
|
|
4515
4579
|
handleSessionOpenEvent(data);
|
|
4516
4580
|
}
|
|
4517
4581
|
|
|
4582
|
+
if (data.type === 'session:pin') {
|
|
4583
|
+
handleSessionPinEvent(data);
|
|
4584
|
+
}
|
|
4585
|
+
|
|
4518
4586
|
if (data.type === 'team-update') {
|
|
4519
4587
|
const teamSession = sessions.find((s) => s.isTeam && s.teamName === data.teamName);
|
|
4520
4588
|
if (teamSession) {
|
|
@@ -4730,6 +4798,18 @@ function escapeHtml(text) {
|
|
|
4730
4798
|
return div.innerHTML;
|
|
4731
4799
|
}
|
|
4732
4800
|
|
|
4801
|
+
function toRelativeIfUnder(filePath, baseDir) {
|
|
4802
|
+
if (!filePath || !baseDir) return null;
|
|
4803
|
+
const fp = filePath.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
4804
|
+
const bd = baseDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
4805
|
+
const isWin = /^[a-zA-Z]:\//.test(fp) || /^[a-zA-Z]:\//.test(bd);
|
|
4806
|
+
const a = isWin ? fp.toLowerCase() : fp;
|
|
4807
|
+
const b = isWin ? bd.toLowerCase() : bd;
|
|
4808
|
+
if (a === b) return '.';
|
|
4809
|
+
if (!a.startsWith(`${b}/`)) return null;
|
|
4810
|
+
return fp.slice(bd.length + 1);
|
|
4811
|
+
}
|
|
4812
|
+
|
|
4733
4813
|
function renderMarkdown(text) {
|
|
4734
4814
|
if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
|
|
4735
4815
|
return DOMPurify.sanitize(marked.parse(text));
|
|
@@ -5725,7 +5805,11 @@ function refreshRateLimits() {
|
|
|
5725
5805
|
fetch('/api/context-status')
|
|
5726
5806
|
.then((r) => r.json())
|
|
5727
5807
|
.then((all) => {
|
|
5728
|
-
|
|
5808
|
+
let freshest = null;
|
|
5809
|
+
for (const e of Object.values(all || {})) {
|
|
5810
|
+
if (e?.rate_limits && (!freshest || (e._updatedAt || 0) > (freshest._updatedAt || 0))) freshest = e;
|
|
5811
|
+
}
|
|
5812
|
+
const rl = freshest?.rate_limits || null;
|
|
5729
5813
|
const fh = rl?.five_hour?.used_percentage ?? null;
|
|
5730
5814
|
const sd = rl?.seven_day?.used_percentage ?? null;
|
|
5731
5815
|
const key = `${fh}|${sd}`;
|
|
@@ -5845,6 +5929,25 @@ document.addEventListener('keydown', (e) => {
|
|
|
5845
5929
|
}
|
|
5846
5930
|
});
|
|
5847
5931
|
|
|
5932
|
+
document.addEventListener('click', (e) => {
|
|
5933
|
+
if (!window.__HUB__?.enabled) return;
|
|
5934
|
+
const a = e.target.closest?.('a[href]');
|
|
5935
|
+
if (!a) return;
|
|
5936
|
+
const href = a.getAttribute('href');
|
|
5937
|
+
if (!href) return;
|
|
5938
|
+
let url;
|
|
5939
|
+
try {
|
|
5940
|
+
url = new URL(href, window.location.href);
|
|
5941
|
+
} catch (_) {
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
if (url.origin === window.location.origin) return;
|
|
5945
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return;
|
|
5946
|
+
e.preventDefault();
|
|
5947
|
+
e.stopPropagation();
|
|
5948
|
+
window.parent?.postMessage({ type: 'hub:openExternal', url: url.href }, '*');
|
|
5949
|
+
});
|
|
5950
|
+
|
|
5848
5951
|
window.hubNavigate = function hubNavigate(app, url) {
|
|
5849
5952
|
if (!window.__HUB__?.enabled) return;
|
|
5850
5953
|
window.parent?.postMessage({ type: 'hub:navigate', app, url }, '*');
|
package/public/style.css
CHANGED
|
@@ -549,14 +549,13 @@ body::before {
|
|
|
549
549
|
|
|
550
550
|
.session-branch {
|
|
551
551
|
font-size: 10px;
|
|
552
|
-
color: var(--text-
|
|
552
|
+
color: var(--text-secondary);
|
|
553
553
|
margin-top: 2px;
|
|
554
554
|
display: block;
|
|
555
555
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
|
556
556
|
white-space: nowrap;
|
|
557
557
|
overflow: hidden;
|
|
558
558
|
text-overflow: ellipsis;
|
|
559
|
-
opacity: 0.7;
|
|
560
559
|
}
|
|
561
560
|
|
|
562
561
|
.session-plan {
|
|
@@ -1877,7 +1876,7 @@ body::before {
|
|
|
1877
1876
|
width: 16px;
|
|
1878
1877
|
height: 16px;
|
|
1879
1878
|
margin-top: 2px;
|
|
1880
|
-
opacity: 0.
|
|
1879
|
+
opacity: 0.9;
|
|
1881
1880
|
}
|
|
1882
1881
|
.msg-body {
|
|
1883
1882
|
flex: 1;
|
|
@@ -2575,6 +2574,40 @@ body::before {
|
|
|
2575
2574
|
color: var(--text-primary);
|
|
2576
2575
|
}
|
|
2577
2576
|
|
|
2577
|
+
.linked-doc-list {
|
|
2578
|
+
list-style: none;
|
|
2579
|
+
padding: 0;
|
|
2580
|
+
margin: 0;
|
|
2581
|
+
display: flex;
|
|
2582
|
+
flex-direction: column;
|
|
2583
|
+
gap: 4px;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
.linked-doc-item {
|
|
2587
|
+
display: flex;
|
|
2588
|
+
align-items: baseline;
|
|
2589
|
+
gap: 8px;
|
|
2590
|
+
min-width: 0;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
.linked-doc-link {
|
|
2594
|
+
color: var(--accent-text);
|
|
2595
|
+
text-decoration: underline;
|
|
2596
|
+
text-decoration-style: dotted;
|
|
2597
|
+
text-underline-offset: 3px;
|
|
2598
|
+
flex-shrink: 0;
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
.linked-doc-path {
|
|
2602
|
+
color: var(--text-muted);
|
|
2603
|
+
font-size: 11px;
|
|
2604
|
+
opacity: 0.7;
|
|
2605
|
+
overflow: hidden;
|
|
2606
|
+
text-overflow: ellipsis;
|
|
2607
|
+
white-space: nowrap;
|
|
2608
|
+
min-width: 0;
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2578
2611
|
/* #endregion */
|
|
2579
2612
|
|
|
2580
2613
|
/* #region PERMISSION_PENDING */
|
|
@@ -3678,7 +3711,39 @@ pre.mermaid svg {
|
|
|
3678
3711
|
.session-item.kb-selected::before {
|
|
3679
3712
|
width: 0;
|
|
3680
3713
|
}
|
|
3714
|
+
.session-item.stale .session-time,
|
|
3715
|
+
.session-item.warm .session-time {
|
|
3716
|
+
display: inline-flex;
|
|
3717
|
+
align-items: center;
|
|
3718
|
+
gap: 6px;
|
|
3719
|
+
}
|
|
3720
|
+
.session-item.stale .session-time::before,
|
|
3721
|
+
.session-item.warm .session-time::before {
|
|
3722
|
+
content: '';
|
|
3723
|
+
width: 6px;
|
|
3724
|
+
height: 6px;
|
|
3725
|
+
border-radius: 50%;
|
|
3726
|
+
flex-shrink: 0;
|
|
3727
|
+
}
|
|
3728
|
+
.session-item.warm .session-time {
|
|
3729
|
+
color: var(--success);
|
|
3730
|
+
}
|
|
3731
|
+
.session-item.warm .session-time::before {
|
|
3732
|
+
background: var(--success);
|
|
3733
|
+
}
|
|
3681
3734
|
.session-item.stale {
|
|
3682
|
-
opacity: 0.
|
|
3735
|
+
opacity: 0.85;
|
|
3736
|
+
}
|
|
3737
|
+
.session-item.stale:hover,
|
|
3738
|
+
.session-item.stale.active,
|
|
3739
|
+
.session-item.stale.kb-selected {
|
|
3740
|
+
opacity: 1;
|
|
3741
|
+
}
|
|
3742
|
+
.session-item.stale .session-time {
|
|
3743
|
+
color: var(--text-muted);
|
|
3744
|
+
}
|
|
3745
|
+
.session-item.stale .session-time::before {
|
|
3746
|
+
background: transparent;
|
|
3747
|
+
border: 1px solid var(--text-muted);
|
|
3683
3748
|
}
|
|
3684
3749
|
/* #endregion */
|
package/server.js
CHANGED
|
@@ -119,11 +119,18 @@ function isAgentFresh(agent) {
|
|
|
119
119
|
return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
const sessionLogStatCache = new Map();
|
|
122
123
|
function getSessionLogStat(meta) {
|
|
123
124
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
124
125
|
try {
|
|
125
126
|
const st = statSync(meta.jsonlPath);
|
|
126
|
-
|
|
127
|
+
const cached = sessionLogStatCache.get(meta.jsonlPath);
|
|
128
|
+
if (cached && cached.mtime === st.mtimeMs) return cached;
|
|
129
|
+
const content = readFileSync(meta.jsonlPath, 'utf8');
|
|
130
|
+
const hasMessages = content.includes('"type":"assistant"');
|
|
131
|
+
const data = { mtime: st.mtimeMs, hasMessages };
|
|
132
|
+
sessionLogStatCache.set(meta.jsonlPath, data);
|
|
133
|
+
return data;
|
|
127
134
|
} catch (e) { return { mtime: null, hasMessages: false }; }
|
|
128
135
|
}
|
|
129
136
|
|
|
@@ -1475,15 +1482,26 @@ async function readMarkdownFile(absPath) {
|
|
|
1475
1482
|
}
|
|
1476
1483
|
}
|
|
1477
1484
|
|
|
1478
|
-
function resolvePreviewPath(filePath) {
|
|
1485
|
+
function resolvePreviewPath(filePath, base) {
|
|
1479
1486
|
if (!filePath || typeof filePath !== 'string') return null;
|
|
1480
|
-
|
|
1487
|
+
if (path.isAbsolute(filePath)) return filePath;
|
|
1488
|
+
if (base && typeof base === 'string' && path.isAbsolute(base)) {
|
|
1489
|
+
let baseDir = base;
|
|
1490
|
+
try {
|
|
1491
|
+
if (statSync(base).isFile()) baseDir = path.dirname(base);
|
|
1492
|
+
} catch {
|
|
1493
|
+
// base doesn't exist — fall back to dirname if it looks like a file
|
|
1494
|
+
if (path.extname(base)) baseDir = path.dirname(base);
|
|
1495
|
+
}
|
|
1496
|
+
return path.resolve(baseDir, filePath);
|
|
1497
|
+
}
|
|
1498
|
+
return path.resolve(filePath);
|
|
1481
1499
|
}
|
|
1482
1500
|
|
|
1483
1501
|
app.post('/api/preview', async (req, res) => {
|
|
1484
1502
|
try {
|
|
1485
|
-
const { path: filePath, sessionId } = req.body || {};
|
|
1486
|
-
const abs = resolvePreviewPath(filePath);
|
|
1503
|
+
const { path: filePath, sessionId, base } = req.body || {};
|
|
1504
|
+
const abs = resolvePreviewPath(filePath, base);
|
|
1487
1505
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
1488
1506
|
const content = await readMarkdownFile(abs);
|
|
1489
1507
|
broadcast({ type: 'preview:open', path: abs, content, sessionId: sessionId || null });
|
|
@@ -1531,9 +1549,24 @@ app.post('/api/session/open', async (req, res) => {
|
|
|
1531
1549
|
}
|
|
1532
1550
|
});
|
|
1533
1551
|
|
|
1552
|
+
app.post('/api/session/pin', async (req, res) => {
|
|
1553
|
+
try {
|
|
1554
|
+
const { id, state } = req.body || {};
|
|
1555
|
+
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'id is required' });
|
|
1556
|
+
if (!['none', 'pinned', 'sticky'].includes(state)) {
|
|
1557
|
+
return res.status(400).json({ error: 'state must be none|pinned|sticky' });
|
|
1558
|
+
}
|
|
1559
|
+
broadcast({ type: 'session:pin', id, state });
|
|
1560
|
+
res.json({ success: true, id, state });
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
console.error('Error in /api/session/pin:', error);
|
|
1563
|
+
res.status(500).json({ error: error.message || 'Failed' });
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
|
|
1534
1567
|
app.get('/api/preview', async (req, res) => {
|
|
1535
1568
|
try {
|
|
1536
|
-
const abs = resolvePreviewPath(req.query.path);
|
|
1569
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
1537
1570
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
1538
1571
|
const content = await readMarkdownFile(abs);
|
|
1539
1572
|
res.json({ path: abs, content });
|
|
@@ -1746,6 +1779,7 @@ contextStatusWatcher.on('all', (event, filePath) => {
|
|
|
1746
1779
|
if (event === 'add' || event === 'change') {
|
|
1747
1780
|
try {
|
|
1748
1781
|
const data = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
1782
|
+
try { data._updatedAt = statSync(filePath).mtimeMs; } catch (_) { data._updatedAt = Date.now(); }
|
|
1749
1783
|
contextStatusCache.set(sessionId, data);
|
|
1750
1784
|
evictStaleCache(contextStatusCache);
|
|
1751
1785
|
} catch (e) { /* ignore malformed */ }
|