claude-code-kanban 3.5.3 → 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/lib/parsers.js +21 -0
- package/package.json +2 -1
- package/public/app.js +97 -16
- package/public/style.css +69 -4
- package/server.js +24 -6
package/lib/parsers.js
CHANGED
|
@@ -714,6 +714,27 @@ function buildAgentProgressMap(jsonlPath) {
|
|
|
714
714
|
|
|
715
715
|
function readCompactSummaries(jsonlPath) {
|
|
716
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.
|
|
717
738
|
try {
|
|
718
739
|
const subagentsDir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, '.jsonl'), 'subagents');
|
|
719
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"
|
package/public/app.js
CHANGED
|
@@ -1623,9 +1623,9 @@ function getToolDetail(tool, params, detail) {
|
|
|
1623
1623
|
const parts = [];
|
|
1624
1624
|
if (params.offset) parts.push(`L${params.offset}`);
|
|
1625
1625
|
if (params.limit) parts.push(`+${params.limit}`);
|
|
1626
|
-
if (parts.length) extra = ` <span style="color:var(--text-muted)
|
|
1626
|
+
if (parts.length) extra = ` <span style="color:var(--text-muted)">${parts.join(' ')}</span>`;
|
|
1627
1627
|
}
|
|
1628
|
-
return ` <span style="color:var(--text-
|
|
1628
|
+
return ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>${extra}`;
|
|
1629
1629
|
}
|
|
1630
1630
|
function renderTaskResult(toolResult) {
|
|
1631
1631
|
if (!toolResult) return '';
|
|
@@ -2277,7 +2277,10 @@ function renderSessions() {
|
|
|
2277
2277
|
const hasInProgress = session.inProgress > 0;
|
|
2278
2278
|
const isLive =
|
|
2279
2279
|
hasInProgress || (session.modifiedAt && Date.now() - new Date(session.modifiedAt).getTime() <= LIVE_INDICATOR_MS);
|
|
2280
|
-
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;
|
|
2281
2284
|
const useGrouped = sessionFilter === 'active' && session.project;
|
|
2282
2285
|
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
2283
2286
|
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
@@ -2299,8 +2302,9 @@ function renderSessions() {
|
|
|
2299
2302
|
const showCtx = !!session.contextStatus;
|
|
2300
2303
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
2301
2304
|
const bookmarksCount = loadPins(session.id).length;
|
|
2305
|
+
const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
|
|
2302
2306
|
return `
|
|
2303
|
-
<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}">
|
|
2304
2308
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${escapeHtml(session.id)}')" title="${pinTitle} session">${SESSION_PIN_SVG}</span>
|
|
2305
2309
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
2306
2310
|
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
@@ -3957,6 +3961,19 @@ function matchKey(e, ...keys) {
|
|
|
3957
3961
|
return keys.some((k) => e.key === k || e.code === k);
|
|
3958
3962
|
}
|
|
3959
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
|
+
|
|
3960
3977
|
document.addEventListener('keydown', (e) => {
|
|
3961
3978
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') {
|
|
3962
3979
|
return;
|
|
@@ -3969,9 +3986,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
3969
3986
|
closeScratchpad();
|
|
3970
3987
|
return;
|
|
3971
3988
|
}
|
|
3972
|
-
//
|
|
3973
|
-
document.querySelectorAll('.modal-overlay.visible')
|
|
3974
|
-
|
|
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();
|
|
3975
3996
|
} else if (
|
|
3976
3997
|
e.code === 'KeyM' &&
|
|
3977
3998
|
e.shiftKey &&
|
|
@@ -4241,8 +4262,24 @@ function openPreviewModal(filePath, content) {
|
|
|
4241
4262
|
currentPreviewPath = filePath;
|
|
4242
4263
|
document.getElementById('preview-modal-title').textContent = filePath.split(/[\\/]/).pop();
|
|
4243
4264
|
const { fm, body } = /\.(md|markdown)$/i.test(filePath) ? splitFrontmatter(content) : { fm: null, body: content };
|
|
4244
|
-
document.getElementById('preview-modal-body')
|
|
4245
|
-
|
|
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
|
+
}
|
|
4246
4283
|
document.getElementById('preview-modal-meta').textContent = filePath;
|
|
4247
4284
|
document.getElementById('preview-modal').classList.add('visible');
|
|
4248
4285
|
updatePreviewLinkBtn();
|
|
@@ -4313,7 +4350,6 @@ function refreshInfoModalLinkedDocs() {
|
|
|
4313
4350
|
bindLinkedDocsHandlers(node, _infoModalSessionId);
|
|
4314
4351
|
}
|
|
4315
4352
|
|
|
4316
|
-
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
4317
4353
|
function closePreviewModal() {
|
|
4318
4354
|
resetModalFullscreen('preview-modal');
|
|
4319
4355
|
currentPreviewPath = null;
|
|
@@ -4325,10 +4361,12 @@ function openPreviewInEditor() {
|
|
|
4325
4361
|
postAndToast('/api/open-in-editor', { file: currentPreviewPath }, 'in editor');
|
|
4326
4362
|
}
|
|
4327
4363
|
|
|
4328
|
-
async function openPreviewByPath(filePath) {
|
|
4364
|
+
async function openPreviewByPath(filePath, base) {
|
|
4329
4365
|
if (!filePath) return;
|
|
4330
4366
|
try {
|
|
4331
|
-
const
|
|
4367
|
+
const qs = new URLSearchParams({ path: filePath });
|
|
4368
|
+
if (base) qs.set('base', base);
|
|
4369
|
+
const r = await fetch(`/api/preview?${qs}`);
|
|
4332
4370
|
if (!r.ok) {
|
|
4333
4371
|
showToast('Preview file unavailable');
|
|
4334
4372
|
return;
|
|
@@ -4372,22 +4410,33 @@ async function handlePreviewOpenEvent(data) {
|
|
|
4372
4410
|
openPreviewModal(filePath, content);
|
|
4373
4411
|
}
|
|
4374
4412
|
|
|
4413
|
+
function getSessionBaseDir(sessionId) {
|
|
4414
|
+
const s = sessions.find((x) => x.id === sessionId);
|
|
4415
|
+
return s?.cwd || s?.project || '';
|
|
4416
|
+
}
|
|
4417
|
+
|
|
4375
4418
|
function renderLinkedDocsHtml(sessionId) {
|
|
4376
4419
|
const paths = getSessionPreviewPaths(sessionId);
|
|
4377
4420
|
if (!paths.length) return '';
|
|
4421
|
+
const baseDir = getSessionBaseDir(sessionId);
|
|
4378
4422
|
const items = paths
|
|
4379
4423
|
.map((p, i) => {
|
|
4380
4424
|
const name = p.split(/[\\/]/).pop();
|
|
4381
|
-
|
|
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>`;
|
|
4382
4431
|
})
|
|
4383
|
-
.join('
|
|
4432
|
+
.join('');
|
|
4384
4433
|
return `<div class="linked-docs-section" style="margin-bottom:16px;font-size:12px;">
|
|
4385
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;">
|
|
4386
4435
|
${linkSvg(12)}
|
|
4387
4436
|
<span>Linked documents</span>
|
|
4388
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>
|
|
4389
4438
|
</div>
|
|
4390
|
-
<
|
|
4439
|
+
<ul class="linked-doc-list">${items}</ul>
|
|
4391
4440
|
</div>`;
|
|
4392
4441
|
}
|
|
4393
4442
|
|
|
@@ -4396,10 +4445,11 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
4396
4445
|
const links = container.querySelectorAll('.linked-doc-link');
|
|
4397
4446
|
if (!links.length) return;
|
|
4398
4447
|
const paths = getSessionPreviewPaths(sessionId);
|
|
4448
|
+
const base = getSessionBaseDir(sessionId);
|
|
4399
4449
|
for (const link of links) {
|
|
4400
4450
|
link.addEventListener('click', (e) => {
|
|
4401
4451
|
e.preventDefault();
|
|
4402
|
-
openPreviewByPath(paths[+link.dataset.idx]);
|
|
4452
|
+
openPreviewByPath(paths[+link.dataset.idx], base);
|
|
4403
4453
|
});
|
|
4404
4454
|
}
|
|
4405
4455
|
}
|
|
@@ -4748,6 +4798,18 @@ function escapeHtml(text) {
|
|
|
4748
4798
|
return div.innerHTML;
|
|
4749
4799
|
}
|
|
4750
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
|
+
|
|
4751
4813
|
function renderMarkdown(text) {
|
|
4752
4814
|
if (typeof DOMPurify !== 'undefined' && typeof marked !== 'undefined') {
|
|
4753
4815
|
return DOMPurify.sanitize(marked.parse(text));
|
|
@@ -5867,6 +5929,25 @@ document.addEventListener('keydown', (e) => {
|
|
|
5867
5929
|
}
|
|
5868
5930
|
});
|
|
5869
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
|
+
|
|
5870
5951
|
window.hubNavigate = function hubNavigate(app, url) {
|
|
5871
5952
|
if (!window.__HUB__?.enabled) return;
|
|
5872
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 });
|
|
@@ -1548,7 +1566,7 @@ app.post('/api/session/pin', async (req, res) => {
|
|
|
1548
1566
|
|
|
1549
1567
|
app.get('/api/preview', async (req, res) => {
|
|
1550
1568
|
try {
|
|
1551
|
-
const abs = resolvePreviewPath(req.query.path);
|
|
1569
|
+
const abs = resolvePreviewPath(req.query.path, req.query.base);
|
|
1552
1570
|
if (!abs) return res.status(400).json({ error: 'path is required' });
|
|
1553
1571
|
const content = await readMarkdownFile(abs);
|
|
1554
1572
|
res.json({ path: abs, content });
|