claude-code-kanban 4.26.0 → 4.27.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/inline-assets.js +1 -1
- package/lib/parsers.js +113 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/public/app.js +496 -83
- package/public/index.html +5 -0
- package/public/style.css +214 -17
- package/server.js +407 -73
package/public/app.js
CHANGED
|
@@ -200,15 +200,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
200
200
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
201
201
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
202
202
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
203
|
-
// When server filters by activity, the focused session may not be active —
|
|
204
|
-
// include it in pinned so the server still returns it.
|
|
205
|
-
if ((sessionFilter === 'active' || zenMode) && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
206
203
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
204
|
+
// The focused session must come back whatever the filters say: renderSession and the
|
|
205
|
+
// info modal both look it up in `sessions` and bail when it is absent, so a session
|
|
206
|
+
// opened from outside the current filter would leave the view on the previous one.
|
|
207
|
+
const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
|
|
207
208
|
const projectParam =
|
|
208
209
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
209
210
|
const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
|
|
210
211
|
const sessionsPromise = fetch(
|
|
211
|
-
`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
|
|
212
|
+
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
212
213
|
).then((r) => r.json());
|
|
213
214
|
|
|
214
215
|
let newSessions, newTasks;
|
|
@@ -237,6 +238,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
237
238
|
}
|
|
238
239
|
}
|
|
239
240
|
|
|
241
|
+
// One session by id, ignoring every sidebar filter — that is what `include` means.
|
|
242
|
+
async function fetchSessionById(id) {
|
|
243
|
+
try {
|
|
244
|
+
const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(id)}`);
|
|
245
|
+
return r.ok ? (await r.json()).find((s) => s.id === id) || null : null;
|
|
246
|
+
} catch (_) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
240
251
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
241
252
|
function handleSearch(query) {
|
|
242
253
|
searchQuery = query.toLowerCase().trim();
|
|
@@ -416,14 +427,8 @@ async function fetchTasks(sessionId) {
|
|
|
416
427
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
417
428
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
418
429
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
newTasks = await res.json();
|
|
422
|
-
} else if (res.status === 404) {
|
|
423
|
-
newTasks = [];
|
|
424
|
-
} else {
|
|
425
|
-
throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
426
|
-
}
|
|
430
|
+
if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
431
|
+
const newTasks = await res.json();
|
|
427
432
|
|
|
428
433
|
const hash = JSON.stringify(newTasks);
|
|
429
434
|
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
|
|
@@ -435,6 +440,7 @@ async function fetchTasks(sessionId) {
|
|
|
435
440
|
if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
|
|
436
441
|
if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
|
|
437
442
|
closeScratchpad();
|
|
443
|
+
if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
438
444
|
if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
|
|
439
445
|
revealedPlanSessionId = null;
|
|
440
446
|
}
|
|
@@ -1154,11 +1160,22 @@ function renderMessageListBody(messages) {
|
|
|
1154
1160
|
|
|
1155
1161
|
const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
|
|
1156
1162
|
const pinBtn = renderMsgPinBtn(m, i);
|
|
1163
|
+
|
|
1157
1164
|
if (m.type === 'user') {
|
|
1158
1165
|
if (m.systemLabel) {
|
|
1166
|
+
// A message another agent sent into this session (subagent hand-back, peer
|
|
1167
|
+
// session) is agent output: the agent icon, a sender label, and a markdown
|
|
1168
|
+
// preview of the report — never the person icon.
|
|
1169
|
+
const preview = m.agentMessage ? buildAssistantPreview(m.fullText || m.text) : null;
|
|
1170
|
+
const bodyHtml = preview?.md
|
|
1171
|
+
? `<div class="msg-text msg-text-md">
|
|
1172
|
+
<div class="msg-md-content rendered-md${preview.remainder ? ' is-truncated' : ''}">${renderMarkdown(preview.md)}</div>${preview.remainder ? `<div class="msg-md-more">${escapeHtml(preview.remainder)}</div>` : ''}
|
|
1173
|
+
</div>`
|
|
1174
|
+
: '';
|
|
1175
|
+
const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
|
|
1159
1176
|
parts.push(`<div class="msg-item msg-system" ${clickable}>
|
|
1160
|
-
${MSG_ICON_SYSTEM}
|
|
1161
|
-
<div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div
|
|
1177
|
+
${m.agentMessage ? ICON_AGENT : MSG_ICON_SYSTEM}
|
|
1178
|
+
<div class="msg-body"><div class="msg-text"><code>${escapeHtml(m.systemLabel)}</code></div>${bodyHtml}<div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${pinBtn}
|
|
1162
1179
|
</div>`);
|
|
1163
1180
|
} else {
|
|
1164
1181
|
const cmd = parseCommandMessage(m.text);
|
|
@@ -1392,9 +1409,13 @@ function clearWaitingUi() {
|
|
|
1392
1409
|
waitingCustomDraft = {};
|
|
1393
1410
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1394
1411
|
// In follow mode stay in the modal and swap to the latest message —
|
|
1395
|
-
// closing it would silently drop the user out of following.
|
|
1396
|
-
|
|
1397
|
-
|
|
1412
|
+
// closing it would silently drop the user out of following. An ask that
|
|
1413
|
+
// opened its own window is the exception: there was no following to drop.
|
|
1414
|
+
if (msgDetailFollowLatest && !waitingDetailAutoOpened && currentMessages.length) {
|
|
1415
|
+
showMsgDetail(currentMessages.length - 1);
|
|
1416
|
+
} else {
|
|
1417
|
+
closeMsgDetailModal();
|
|
1418
|
+
}
|
|
1398
1419
|
}
|
|
1399
1420
|
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1400
1421
|
document.getElementById('plan-approval-footer')?.remove();
|
|
@@ -1985,7 +2006,7 @@ function showMsgDetail(idx) {
|
|
|
1985
2006
|
const rawText = stripAnsi(m.fullText || m.text || '');
|
|
1986
2007
|
const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
|
|
1987
2008
|
document.getElementById('msg-detail-title').textContent =
|
|
1988
|
-
m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
|
|
2009
|
+
m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
|
|
1989
2010
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1990
2011
|
const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
|
|
1991
2012
|
if (m.compactSummary) {
|
|
@@ -2023,6 +2044,7 @@ function showMsgDetail(idx) {
|
|
|
2023
2044
|
function closeMsgDetailModal() {
|
|
2024
2045
|
hideModalOverlay('msg-detail-modal');
|
|
2025
2046
|
msgDetailFollowLatest = false;
|
|
2047
|
+
waitingDetailAutoOpened = false;
|
|
2026
2048
|
// Drop the message highlight on close, mirroring task-card behavior.
|
|
2027
2049
|
msgHighlightDimmed = true;
|
|
2028
2050
|
const container = document.getElementById('message-panel-content');
|
|
@@ -3021,6 +3043,13 @@ async function revealSession(id) {
|
|
|
3021
3043
|
await fetchSessions();
|
|
3022
3044
|
session = sessions.find((s) => s.id === id);
|
|
3023
3045
|
}
|
|
3046
|
+
if (!session) {
|
|
3047
|
+
// The id is outside the list the filters asked for. renderSession bails on a session
|
|
3048
|
+
// missing from `sessions`, so pull that one row in before opening it; later polls
|
|
3049
|
+
// keep it, because fetchSessions pins currentSessionId.
|
|
3050
|
+
session = await fetchSessionById(id);
|
|
3051
|
+
if (session) sessions.push(session);
|
|
3052
|
+
}
|
|
3024
3053
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3025
3054
|
if (uncollapsed) persistCollapsedGroups();
|
|
3026
3055
|
expandSidebar();
|
|
@@ -3124,7 +3153,10 @@ function getFilteredSessions() {
|
|
|
3124
3153
|
}
|
|
3125
3154
|
}
|
|
3126
3155
|
if (filterProject) {
|
|
3127
|
-
|
|
3156
|
+
// The open session keeps its row even when it belongs to another project, so that
|
|
3157
|
+
// revealing one by id has something to scroll to. Same exemption the active branch
|
|
3158
|
+
// above already makes, for the same reason.
|
|
3159
|
+
filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
|
|
3128
3160
|
}
|
|
3129
3161
|
|
|
3130
3162
|
if (activityFilter.size > 0) {
|
|
@@ -3303,9 +3335,12 @@ function renderSessions() {
|
|
|
3303
3335
|
${renderScratchpadRow(zenSession)}
|
|
3304
3336
|
${renderLinkedDocsHtml(zenSession.id)}
|
|
3305
3337
|
${renderArtifactsHtml(zenSession.id)}
|
|
3306
|
-
</div
|
|
3338
|
+
</div>
|
|
3339
|
+
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
|
|
3307
3340
|
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
3308
|
-
|
|
3341
|
+
artifactsSection.load(zenSession.id);
|
|
3342
|
+
scratchFilesSection.load(zenSession.id);
|
|
3343
|
+
if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
|
|
3309
3344
|
return;
|
|
3310
3345
|
}
|
|
3311
3346
|
|
|
@@ -3581,6 +3616,11 @@ function renderSession() {
|
|
|
3581
3616
|
const session = sessions.find((s) => s.id === currentSessionId);
|
|
3582
3617
|
if (!session) return;
|
|
3583
3618
|
|
|
3619
|
+
// Opening the session, not opening a linked-docs surface: the card badge is the
|
|
3620
|
+
// first place a pad shows up, and it would otherwise stay at zero until the user
|
|
3621
|
+
// happened to open the info modal or zen panel.
|
|
3622
|
+
loadSessionPads(currentSessionId);
|
|
3623
|
+
|
|
3584
3624
|
const displayName =
|
|
3585
3625
|
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
3586
3626
|
|
|
@@ -5717,12 +5757,8 @@ function _renderStorageLinkedDocs() {
|
|
|
5717
5757
|
const entries = [];
|
|
5718
5758
|
for (const key of store.keys()) {
|
|
5719
5759
|
if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
if (Array.isArray(arr) && arr.length) {
|
|
5723
|
-
entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
|
|
5724
|
-
}
|
|
5725
|
-
} catch {}
|
|
5760
|
+
const paths = readStoredList(key);
|
|
5761
|
+
if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
|
|
5726
5762
|
}
|
|
5727
5763
|
if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
|
|
5728
5764
|
|
|
@@ -5783,6 +5819,8 @@ function _storageUnlinkDoc(sessionId, path) {
|
|
|
5783
5819
|
|
|
5784
5820
|
function _storageClearLinkedDocs(sessionId) {
|
|
5785
5821
|
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
5822
|
+
// The auto-link record deliberately survives: clearing is unlinking every row at
|
|
5823
|
+
// once, and an unlinked pad must not come back on the next read of the transcript.
|
|
5786
5824
|
afterLinkedDocsChanged(sessionId);
|
|
5787
5825
|
}
|
|
5788
5826
|
|
|
@@ -5799,6 +5837,8 @@ function _findOrphanedKeys() {
|
|
|
5799
5837
|
if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
|
|
5800
5838
|
} else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
|
|
5801
5839
|
if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
|
|
5840
|
+
} else if (key.startsWith(PAD_LINKED_PREFIX)) {
|
|
5841
|
+
if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
|
|
5802
5842
|
}
|
|
5803
5843
|
}
|
|
5804
5844
|
return orphaned;
|
|
@@ -6206,27 +6246,40 @@ document.addEventListener('keydown', (e) => {
|
|
|
6206
6246
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
6207
6247
|
let currentPreviewPath = null;
|
|
6208
6248
|
|
|
6209
|
-
function
|
|
6210
|
-
if (!sessionId) return [];
|
|
6249
|
+
function readStoredList(key) {
|
|
6211
6250
|
try {
|
|
6212
|
-
const
|
|
6213
|
-
const arr = raw ? JSON.parse(raw) : [];
|
|
6251
|
+
const arr = JSON.parse(store.getItem(key) || '[]');
|
|
6214
6252
|
return Array.isArray(arr) ? arr : [];
|
|
6215
6253
|
} catch {
|
|
6216
6254
|
return [];
|
|
6217
6255
|
}
|
|
6218
6256
|
}
|
|
6219
6257
|
|
|
6258
|
+
function getSessionPreviewPaths(sessionId) {
|
|
6259
|
+
return sessionId ? readStoredList(PREVIEW_STORAGE_PREFIX + sessionId) : [];
|
|
6260
|
+
}
|
|
6261
|
+
|
|
6262
|
+
// Windows reaches one file as both `C:\a\b` and `C:/a/b`, and the two sides of a link
|
|
6263
|
+
// disagree by construction: a pad path is built with `path.join`, a hand-typed one is
|
|
6264
|
+
// stored as it was typed. Rows are compared through here so the same file cannot be
|
|
6265
|
+
// listed twice — and lists written before this existed heal on the next link.
|
|
6266
|
+
function canonicalPath(filePath) {
|
|
6267
|
+
const slashed = String(filePath).replace(/\\/g, '/');
|
|
6268
|
+
return /^[A-Za-z]:/.test(slashed) ? slashed.toLowerCase() : slashed;
|
|
6269
|
+
}
|
|
6270
|
+
|
|
6220
6271
|
function addSessionPreviewPath(sessionId, filePath) {
|
|
6221
6272
|
if (!sessionId || !filePath) return;
|
|
6222
|
-
const
|
|
6273
|
+
const key = canonicalPath(filePath);
|
|
6274
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6223
6275
|
paths.unshift(filePath);
|
|
6224
6276
|
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
6225
6277
|
}
|
|
6226
6278
|
|
|
6227
6279
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
6228
6280
|
if (!sessionId) return;
|
|
6229
|
-
const
|
|
6281
|
+
const key = canonicalPath(filePath);
|
|
6282
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6230
6283
|
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
6231
6284
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6232
6285
|
}
|
|
@@ -6296,23 +6349,48 @@ function bindPreviewRelativeLinks(bodyEl) {
|
|
|
6296
6349
|
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
6297
6350
|
if (isAbsoluteUrl) return;
|
|
6298
6351
|
const cleanHref = href.replace(/#.*$/, '');
|
|
6299
|
-
if (!isPreviewablePath(cleanHref)) return;
|
|
6300
6352
|
e.preventDefault();
|
|
6301
|
-
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
|
|
6353
|
+
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
|
|
6302
6354
|
});
|
|
6303
6355
|
bodyEl.dataset.relLinkBound = '1';
|
|
6304
6356
|
}
|
|
6305
6357
|
|
|
6306
6358
|
// `kind` comes from the server, which is the only place that decides what is previewable.
|
|
6359
|
+
// hljs gets the extension as the language hint and `highlightAuto` is deliberately not
|
|
6360
|
+
// the fallback: on a config or data file it guesses wildly, and wrong colors read as a
|
|
6361
|
+
// broken file. Above the size cap it is skipped outright — it is O(size) with heavy
|
|
6362
|
+
// backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
|
|
6363
|
+
const HLJS_MAX_CHARS = 256 * 1024;
|
|
6364
|
+
|
|
6365
|
+
function renderSourcePreview(filePath, content) {
|
|
6366
|
+
const ext = (filePath.split('.').pop() || '').toLowerCase();
|
|
6367
|
+
if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
|
|
6368
|
+
try {
|
|
6369
|
+
const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
|
|
6370
|
+
return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
|
|
6371
|
+
} catch (_) {}
|
|
6372
|
+
}
|
|
6373
|
+
return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
|
|
6374
|
+
}
|
|
6375
|
+
|
|
6307
6376
|
function openPreviewModal(filePath, content, kind) {
|
|
6308
6377
|
currentPreviewPath = filePath;
|
|
6309
|
-
|
|
6378
|
+
const fileName = filePath.split(/[\\/]/).pop();
|
|
6379
|
+
document.getElementById('preview-modal-title').textContent = fileName;
|
|
6310
6380
|
const bodyEl = document.getElementById('preview-modal-body');
|
|
6311
6381
|
const isHtml = kind === 'html';
|
|
6312
6382
|
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
6313
6383
|
bindPreviewRelativeLinks(bodyEl);
|
|
6314
6384
|
if (isHtml) {
|
|
6315
6385
|
renderHtmlPreview(bodyEl, content);
|
|
6386
|
+
} else if (kind === 'text') {
|
|
6387
|
+
bodyEl.innerHTML = renderSourcePreview(filePath, content);
|
|
6388
|
+
} else if (kind === 'image') {
|
|
6389
|
+
const img = document.createElement('img');
|
|
6390
|
+
img.className = 'preview-image';
|
|
6391
|
+
img.src = `/api/preview/image?${new URLSearchParams({ path: filePath })}`;
|
|
6392
|
+
img.alt = fileName;
|
|
6393
|
+
bodyEl.replaceChildren(img);
|
|
6316
6394
|
} else {
|
|
6317
6395
|
const { fm, body } = splitFrontmatter(content);
|
|
6318
6396
|
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
@@ -6324,7 +6402,8 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
6324
6402
|
|
|
6325
6403
|
function isPreviewLinkedToCurrentSession() {
|
|
6326
6404
|
if (!currentPreviewPath || !currentSessionId) return false;
|
|
6327
|
-
|
|
6405
|
+
const key = canonicalPath(currentPreviewPath);
|
|
6406
|
+
return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
|
|
6328
6407
|
}
|
|
6329
6408
|
|
|
6330
6409
|
function updatePreviewLinkBtn() {
|
|
@@ -6375,13 +6454,20 @@ function closePreviewModal() {
|
|
|
6375
6454
|
currentPreviewPath = null;
|
|
6376
6455
|
}
|
|
6377
6456
|
|
|
6457
|
+
function openFileInEditor(filePath) {
|
|
6458
|
+
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
6459
|
+
}
|
|
6460
|
+
|
|
6378
6461
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6379
6462
|
function openPreviewInEditor() {
|
|
6380
6463
|
if (!currentPreviewPath) return;
|
|
6381
|
-
|
|
6464
|
+
openFileInEditor(currentPreviewPath);
|
|
6382
6465
|
}
|
|
6383
6466
|
|
|
6384
|
-
|
|
6467
|
+
// The server is the only place that knows what it can render, so a null `kind` is its
|
|
6468
|
+
// answer to "not previewable" rather than a failure: callers that have a fallback
|
|
6469
|
+
// destination pass `onUnsupported` instead of keeping a copy of the extension list here.
|
|
6470
|
+
async function openPreviewByPath(filePath, base, onUnsupported) {
|
|
6385
6471
|
if (!filePath) return;
|
|
6386
6472
|
try {
|
|
6387
6473
|
const qs = new URLSearchParams({ path: filePath });
|
|
@@ -6392,6 +6478,15 @@ async function openPreviewByPath(filePath, base) {
|
|
|
6392
6478
|
return;
|
|
6393
6479
|
}
|
|
6394
6480
|
const data = await r.json();
|
|
6481
|
+
if (data.exists === false) {
|
|
6482
|
+
showToast('File not found');
|
|
6483
|
+
return;
|
|
6484
|
+
}
|
|
6485
|
+
if (data.kind === null) {
|
|
6486
|
+
if (onUnsupported) onUnsupported(data.path);
|
|
6487
|
+
else showToast('Nothing to preview in this file');
|
|
6488
|
+
return;
|
|
6489
|
+
}
|
|
6395
6490
|
openPreviewModal(data.path, data.content, data.kind);
|
|
6396
6491
|
} catch {
|
|
6397
6492
|
showToast('Failed to load preview');
|
|
@@ -6445,10 +6540,69 @@ function getSessionBaseDir(sessionId) {
|
|
|
6445
6540
|
return s?.cwd || s?.project || '';
|
|
6446
6541
|
}
|
|
6447
6542
|
|
|
6448
|
-
// Linked paths are stored as bare strings, so
|
|
6449
|
-
//
|
|
6450
|
-
|
|
6451
|
-
|
|
6543
|
+
// Linked paths are stored as bare strings, so the row has to ask what each one is. The
|
|
6544
|
+
// server owns that answer — /api/file/resolve reports `kind` for any file, previewable
|
|
6545
|
+
// or not — and it is cached for the tab because a path's kind does not change under it.
|
|
6546
|
+
// A resolve that fails is cached as unpreviewable: the editor is the right destination
|
|
6547
|
+
// for a file the server cannot read either, and re-asking on every render of a panel the
|
|
6548
|
+
// SSE tick repaints would be a poll.
|
|
6549
|
+
const previewKindByPath = new Map();
|
|
6550
|
+
const kindResolveInFlight = new Set();
|
|
6551
|
+
|
|
6552
|
+
async function loadPreviewKinds(sessionId, paths) {
|
|
6553
|
+
const pending = paths.filter((p) => !previewKindByPath.has(p) && !kindResolveInFlight.has(p));
|
|
6554
|
+
if (!pending.length) return;
|
|
6555
|
+
for (const p of pending) kindResolveInFlight.add(p);
|
|
6556
|
+
try {
|
|
6557
|
+
await Promise.all(
|
|
6558
|
+
pending.map(async (p) => {
|
|
6559
|
+
let kind = null;
|
|
6560
|
+
try {
|
|
6561
|
+
const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
|
|
6562
|
+
if (r.ok) kind = (await r.json()).kind;
|
|
6563
|
+
} catch (_) {}
|
|
6564
|
+
previewKindByPath.set(p, kind);
|
|
6565
|
+
}),
|
|
6566
|
+
);
|
|
6567
|
+
} finally {
|
|
6568
|
+
for (const p of pending) kindResolveInFlight.delete(p);
|
|
6569
|
+
}
|
|
6570
|
+
afterLinkedDocsChanged(sessionId);
|
|
6571
|
+
}
|
|
6572
|
+
|
|
6573
|
+
// A pad the session made with `scratch new` becomes an ordinary linked document.
|
|
6574
|
+
// The server derives the pads on every read, so this record of the ones already
|
|
6575
|
+
// linked is what stops the next read undoing an unlink.
|
|
6576
|
+
const PAD_LINKED_PREFIX = 'autolinked-pads-';
|
|
6577
|
+
const padsFetched = new Set();
|
|
6578
|
+
|
|
6579
|
+
function getOfferedPadPaths(sessionId) {
|
|
6580
|
+
return readStoredList(PAD_LINKED_PREFIX + sessionId);
|
|
6581
|
+
}
|
|
6582
|
+
|
|
6583
|
+
function setOfferedPadPaths(sessionId, paths) {
|
|
6584
|
+
store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
|
|
6585
|
+
}
|
|
6586
|
+
|
|
6587
|
+
async function loadSessionPads(sessionId) {
|
|
6588
|
+
if (!sessionId || padsFetched.has(sessionId)) return;
|
|
6589
|
+
padsFetched.add(sessionId);
|
|
6590
|
+
let pads;
|
|
6591
|
+
try {
|
|
6592
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
|
|
6593
|
+
if (!r.ok) throw new Error(r.status);
|
|
6594
|
+
pads = (await r.json()).pads || [];
|
|
6595
|
+
} catch (_) {
|
|
6596
|
+
padsFetched.delete(sessionId);
|
|
6597
|
+
return;
|
|
6598
|
+
}
|
|
6599
|
+
const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
|
|
6600
|
+
const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
|
|
6601
|
+
if (!fresh.length) return;
|
|
6602
|
+
// Recorded before the link, so a render triggered by the link already sees a pad
|
|
6603
|
+
// that will not be offered again.
|
|
6604
|
+
setOfferedPadPaths(sessionId, [...offered, ...fresh]);
|
|
6605
|
+
for (const p of fresh) setSessionDocLink(sessionId, p, false);
|
|
6452
6606
|
}
|
|
6453
6607
|
|
|
6454
6608
|
// A scratchpad manifest opens in the external `scratch` viewer, not the editor —
|
|
@@ -6477,11 +6631,14 @@ const LINKED_DOC_OPENERS = {
|
|
|
6477
6631
|
},
|
|
6478
6632
|
};
|
|
6479
6633
|
|
|
6634
|
+
// The manifest is checked before previewability, not after: its `.json` is previewable
|
|
6635
|
+
// text, but the pad is what the user linked, so the viewer wins over the raw file.
|
|
6636
|
+
// Without the CLI there is nothing to launch, so it falls back to the editor.
|
|
6480
6637
|
function linkedDocOpener(p) {
|
|
6481
|
-
if (
|
|
6482
|
-
//
|
|
6483
|
-
|
|
6484
|
-
return 'editor';
|
|
6638
|
+
if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
|
|
6639
|
+
// A path whose kind has not arrived yet renders as previewable, which is what the
|
|
6640
|
+
// click does anyway — openPreviewByPath falls back to the editor on a null kind.
|
|
6641
|
+
return previewKindByPath.get(p) === null ? 'editor' : 'preview';
|
|
6485
6642
|
}
|
|
6486
6643
|
|
|
6487
6644
|
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
@@ -6494,7 +6651,7 @@ function linkedDocLabel(p) {
|
|
|
6494
6651
|
function openLinkedDoc(p, baseDir) {
|
|
6495
6652
|
const opener = linkedDocOpener(p);
|
|
6496
6653
|
if (opener === 'preview') {
|
|
6497
|
-
openPreviewByPath(p, baseDir);
|
|
6654
|
+
openPreviewByPath(p, baseDir, openFileInEditor);
|
|
6498
6655
|
return;
|
|
6499
6656
|
}
|
|
6500
6657
|
const { url, body, toast } = LINKED_DOC_OPENERS[opener];
|
|
@@ -6503,6 +6660,9 @@ function openLinkedDoc(p, baseDir) {
|
|
|
6503
6660
|
|
|
6504
6661
|
function renderLinkedDocsHtml(sessionId) {
|
|
6505
6662
|
const paths = getSessionPreviewPaths(sessionId);
|
|
6663
|
+
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
6664
|
+
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
6665
|
+
loadPreviewKinds(sessionId, paths);
|
|
6506
6666
|
const baseDir = getSessionBaseDir(sessionId);
|
|
6507
6667
|
const items = paths
|
|
6508
6668
|
.map((p) => {
|
|
@@ -6538,11 +6698,41 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6538
6698
|
</div>`;
|
|
6539
6699
|
}
|
|
6540
6700
|
|
|
6541
|
-
|
|
6542
|
-
|
|
6543
|
-
|
|
6544
|
-
|
|
6545
|
-
|
|
6701
|
+
function paintSectionSlots(attr, sessionId, html) {
|
|
6702
|
+
for (const slot of document.querySelectorAll(`[${attr}="${CSS.escape(sessionId)}"]`)) {
|
|
6703
|
+
slot.innerHTML = html;
|
|
6704
|
+
}
|
|
6705
|
+
}
|
|
6706
|
+
|
|
6707
|
+
// A panel section whose data is not part of the session object arrives after the panel
|
|
6708
|
+
// is already on screen, so every one of them needs the same lifecycle: one fetch per
|
|
6709
|
+
// session at a time, and a patch of the slot — always rendered, empty or not — once the
|
|
6710
|
+
// data lands. `ttlMs` is for the sections the server does not cache itself; at 0 the
|
|
6711
|
+
// section re-asks on every render, which is right when the server holds the answer.
|
|
6712
|
+
function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
|
|
6713
|
+
const store = new Map();
|
|
6714
|
+
const inFlight = new Set();
|
|
6715
|
+
return {
|
|
6716
|
+
get: (sessionId) => store.get(sessionId)?.data || [],
|
|
6717
|
+
repaint: (sessionId) => paintSectionSlots(attr, sessionId, innerHtml(sessionId)),
|
|
6718
|
+
async load(sessionId) {
|
|
6719
|
+
if (!sessionId || inFlight.has(sessionId)) return;
|
|
6720
|
+
const cached = store.get(sessionId);
|
|
6721
|
+
if (cached && Date.now() - cached.at < ttlMs) return;
|
|
6722
|
+
inFlight.add(sessionId);
|
|
6723
|
+
try {
|
|
6724
|
+
const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
|
|
6725
|
+
if (!res.ok) return;
|
|
6726
|
+
store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
|
|
6727
|
+
} catch (_) {
|
|
6728
|
+
return;
|
|
6729
|
+
} finally {
|
|
6730
|
+
inFlight.delete(sessionId);
|
|
6731
|
+
}
|
|
6732
|
+
paintSectionSlots(attr, sessionId, innerHtml(sessionId));
|
|
6733
|
+
},
|
|
6734
|
+
};
|
|
6735
|
+
}
|
|
6546
6736
|
|
|
6547
6737
|
function artifactLabel(a) {
|
|
6548
6738
|
if (a.title) return a.title;
|
|
@@ -6550,7 +6740,7 @@ function artifactLabel(a) {
|
|
|
6550
6740
|
}
|
|
6551
6741
|
|
|
6552
6742
|
function artifactsInnerHtml(sessionId) {
|
|
6553
|
-
const list =
|
|
6743
|
+
const list = artifactsSection.get(sessionId);
|
|
6554
6744
|
if (!list.length) return '';
|
|
6555
6745
|
const items = list
|
|
6556
6746
|
.map(
|
|
@@ -6574,27 +6764,196 @@ function renderArtifactsHtml(sessionId) {
|
|
|
6574
6764
|
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
6575
6765
|
}
|
|
6576
6766
|
|
|
6577
|
-
//
|
|
6578
|
-
//
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6767
|
+
// No TTL: the server holds the scan until the transcript grows, so a repeat costs one
|
|
6768
|
+
// stat, and an artifact published while the panel is open still appears.
|
|
6769
|
+
const artifactsSection = makeSectionLoader({
|
|
6770
|
+
attr: 'data-artifacts-for',
|
|
6771
|
+
endpoint: 'artifacts',
|
|
6772
|
+
pick: (data) => data.artifacts || [],
|
|
6773
|
+
innerHtml: (sessionId) => artifactsInnerHtml(sessionId),
|
|
6774
|
+
});
|
|
6775
|
+
|
|
6776
|
+
// The server decides liveness (a run has no terminal journal entry) and answers with
|
|
6777
|
+
// nothing once the workflow finishes, so the section empties itself and disappears.
|
|
6778
|
+
const workflowLiveSection = makeSectionLoader({
|
|
6779
|
+
attr: 'data-workflow-live-for',
|
|
6780
|
+
endpoint: 'workflow-live',
|
|
6781
|
+
pick: (data) => (data.workflow ? [data.workflow] : []),
|
|
6782
|
+
innerHtml: (sessionId) => workflowLiveInnerHtml(sessionId),
|
|
6783
|
+
// The agent hooks re-render the sidebar on every subagent start and stop, so the TTL is
|
|
6784
|
+
// what keeps that burst down to one ask per run step.
|
|
6785
|
+
ttlMs: 4000,
|
|
6786
|
+
});
|
|
6787
|
+
|
|
6788
|
+
function workflowLiveInnerHtml(sessionId) {
|
|
6789
|
+
const run = workflowLiveSection.get(sessionId)[0];
|
|
6790
|
+
if (!run) return '';
|
|
6791
|
+
const pct = run.startedCount ? Math.round((run.doneCount / run.startedCount) * 100) : 0;
|
|
6792
|
+
const phases = (run.phases || [])
|
|
6793
|
+
.map((p) => {
|
|
6794
|
+
const state = p.started === 0 ? 'pending' : p.done >= p.started ? 'done' : 'running';
|
|
6795
|
+
const count = p.started ? `${p.done}/${p.started}` : '';
|
|
6796
|
+
return `<span class="wf-live-phase ${escapeHtml(state)}">
|
|
6797
|
+
<span class="wf-live-phase-title">${escapeHtml(p.title)}</span>
|
|
6798
|
+
<span class="wf-live-phase-count">${count}</span>
|
|
6799
|
+
</span>`;
|
|
6800
|
+
})
|
|
6801
|
+
.join('');
|
|
6802
|
+
const running = run.running?.length
|
|
6803
|
+
? `<div class="wf-live-running" title="${escapeHtml(run.running.join(', '))}">${escapeHtml(run.running.join(' · '))}</div>`
|
|
6804
|
+
: '';
|
|
6805
|
+
return `<div class="panel-section-header">
|
|
6806
|
+
${WORKFLOW_GEAR_SVG}
|
|
6807
|
+
<span>Workflow</span>
|
|
6808
|
+
<span class="panel-section-count">${run.doneCount}/${run.startedCount} agents</span>
|
|
6809
|
+
</div>
|
|
6810
|
+
<div class="wf-live" onclick="showWorkflowModal('${escAttrJs(sessionId)}')" title="Open workflow run">
|
|
6811
|
+
<div class="wf-live-name"><span class="pulse"></span>${escapeHtml(run.name || run.id)}</div>
|
|
6812
|
+
<div class="progress-bar wf-live-bar"><div class="progress-fill" style="width: ${pct}%"></div></div>
|
|
6813
|
+
${phases ? `<div class="wf-live-phases">${phases}</div>` : ''}
|
|
6814
|
+
${running}
|
|
6815
|
+
</div>`;
|
|
6816
|
+
}
|
|
6817
|
+
|
|
6818
|
+
function renderWorkflowLiveHtml(sessionId) {
|
|
6819
|
+
return `<div class="workflow-live-section" data-workflow-live-for="${escapeHtml(sessionId)}">${workflowLiveInnerHtml(sessionId)}</div>`;
|
|
6820
|
+
}
|
|
6821
|
+
|
|
6822
|
+
// Every agent start and stop already re-renders the sidebar through the agent-activity
|
|
6823
|
+
// feed, which is what keeps the card current. The timer is the fallback for a config dir
|
|
6824
|
+
// with no cck hooks installed: there the last agent's result reaches nobody, and the card
|
|
6825
|
+
// would sit on a finished run until the next render. Slow on purpose — the hooked case
|
|
6826
|
+
// never waits for it. The generation guard drops a chain left mid-fetch by an earlier
|
|
6827
|
+
// render, so renders can't stack up chains.
|
|
6828
|
+
const WORKFLOW_LIVE_POLL_MS = 30000;
|
|
6829
|
+
let workflowLivePoll = null;
|
|
6830
|
+
let workflowLivePollFor = null;
|
|
6831
|
+
|
|
6832
|
+
function pollWorkflowLive(sessionId) {
|
|
6833
|
+
workflowLiveSection.load(sessionId);
|
|
6834
|
+
// A render must not restart the timer: renders arrive far faster than the interval, and
|
|
6835
|
+
// each restart would push the fallback tick back out of reach, leaving a finished run on
|
|
6836
|
+
// screen forever in a config dir with no hooks.
|
|
6837
|
+
if (workflowLivePoll && workflowLivePollFor === sessionId) return;
|
|
6838
|
+
clearTimeout(workflowLivePoll);
|
|
6839
|
+
workflowLivePollFor = sessionId;
|
|
6840
|
+
const tick = async () => {
|
|
6841
|
+
workflowLivePoll = null;
|
|
6842
|
+
if (workflowLivePollFor !== sessionId) return;
|
|
6843
|
+
if (!document.querySelector(`[data-workflow-live-for="${CSS.escape(sessionId)}"]`)) return;
|
|
6844
|
+
await workflowLiveSection.load(sessionId);
|
|
6845
|
+
if (workflowLivePollFor !== sessionId || !workflowLiveSection.get(sessionId).length) return;
|
|
6846
|
+
workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
|
|
6847
|
+
};
|
|
6848
|
+
workflowLivePoll = setTimeout(tick, WORKFLOW_LIVE_POLL_MS);
|
|
6849
|
+
}
|
|
6850
|
+
|
|
6851
|
+
const SCRATCH_FILES_COLLAPSED = 3;
|
|
6852
|
+
// Unlike the artifacts scan the server caches nothing here, so a TTL is what stops the
|
|
6853
|
+
// zen panel — re-rendered on every SSE tick — from turning a readdir plus a stat per
|
|
6854
|
+
// file into a 2 s poll.
|
|
6855
|
+
const scratchFilesSection = makeSectionLoader({
|
|
6856
|
+
attr: 'data-scratch-files-for',
|
|
6857
|
+
endpoint: 'scratchpad-files',
|
|
6858
|
+
pick: (data) => data.files || [],
|
|
6859
|
+
innerHtml: (sessionId) => scratchFilesInnerHtml(sessionId),
|
|
6860
|
+
ttlMs: 10000,
|
|
6861
|
+
});
|
|
6862
|
+
// Outlives the cache entries on purpose: a refetch on the TTL must not collapse a list
|
|
6863
|
+
// the user opened.
|
|
6864
|
+
const scratchFilesExpanded = new Set();
|
|
6865
|
+
// Open folders only, keyed by absolute path: path -> { status: 'loading' | 'ready' |
|
|
6866
|
+
// 'error', children }. Closing a folder drops its entry, so the cache is bounded by
|
|
6867
|
+
// what is on screen and a reopen shows fresh contents for one readdir.
|
|
6868
|
+
const scratchFolders = new Map();
|
|
6869
|
+
|
|
6870
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6871
|
+
function openScratchFile(filePath) {
|
|
6872
|
+
openPreviewByPath(filePath, undefined, openFileInEditor);
|
|
6873
|
+
}
|
|
6874
|
+
|
|
6875
|
+
function scratchRowHtml(f) {
|
|
6876
|
+
const escPath = escapeHtml(f.path);
|
|
6877
|
+
const isDir = f.kind === 'dir';
|
|
6878
|
+
const folder = isDir ? scratchFolders.get(f.path) : null;
|
|
6879
|
+
const name = escapeHtml(f.name);
|
|
6880
|
+
const link = isDir
|
|
6881
|
+
? `<button type="button" class="scratch-file-link scratch-folder-toggle${folder ? '' : ' collapsed'}" title="${escPath}" ${folder ? 'aria-expanded="true"' : 'aria-expanded="false"'}>${groupChevronSvg(10)}${name}/</button>`
|
|
6882
|
+
: `<button type="button" class="scratch-file-link" onclick="openScratchFile(this.closest('li').dataset.file)" title="${escPath}">${name}</button>`;
|
|
6883
|
+
let body = '';
|
|
6884
|
+
if (folder?.status === 'loading') body = '<div class="scratch-folder-empty">Loading…</div>';
|
|
6885
|
+
else if (folder?.status === 'error') body = '<div class="scratch-folder-empty">Failed to load</div>';
|
|
6886
|
+
else if (folder && !folder.children.length) body = '<div class="scratch-folder-empty">Empty</div>';
|
|
6887
|
+
else if (folder) body = scratchRowsHtml(folder.children);
|
|
6888
|
+
return `<li class="scratch-file-item${isDir ? ' scratch-folder-item' : ''}" data-file="${escPath}">
|
|
6889
|
+
${link}
|
|
6890
|
+
<span class="scratch-file-time">${formatDate(f.modifiedAt)}</span>
|
|
6891
|
+
<span class="row-actions scratch-file-actions">
|
|
6892
|
+
<button type="button" onclick="copyWithFeedback(this.closest('li').dataset.file, this)" title="Copy path" aria-label="Copy path">${ICON_COPY}</button>
|
|
6893
|
+
<button type="button" onclick="${isDir ? 'openFolderInEditor' : 'openFileInEditor'}(this.closest('li').dataset.file)" title="Open in editor" aria-label="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
6894
|
+
</span>
|
|
6895
|
+
${body}
|
|
6896
|
+
</li>`;
|
|
6897
|
+
}
|
|
6898
|
+
|
|
6899
|
+
function scratchRowsHtml(rows) {
|
|
6900
|
+
return `<ul class="scratch-file-list">${rows.map(scratchRowHtml).join('')}</ul>`;
|
|
6901
|
+
}
|
|
6902
|
+
|
|
6903
|
+
function scratchFilesInnerHtml(sessionId) {
|
|
6904
|
+
const list = scratchFilesSection.get(sessionId);
|
|
6905
|
+
if (!list.length) return '';
|
|
6906
|
+
const expanded = scratchFilesExpanded.has(sessionId);
|
|
6907
|
+
const shown = expanded ? list : list.slice(0, SCRATCH_FILES_COLLAPSED);
|
|
6908
|
+
const more =
|
|
6909
|
+
list.length > SCRATCH_FILES_COLLAPSED
|
|
6910
|
+
? `<button type="button" class="expand-toggle-btn scratch-files-more" onclick="toggleScratchFiles('${escAttrJs(sessionId)}')">${expanded ? 'Show less' : `Show all ${list.length}`}</button>`
|
|
6911
|
+
: '';
|
|
6912
|
+
return `${scratchRowsHtml(shown)}${more}`;
|
|
6913
|
+
}
|
|
6914
|
+
|
|
6915
|
+
// Delegated at the document because the whole zen panel is re-rendered on every SSE tick,
|
|
6916
|
+
// which would drop a listener bound on the section.
|
|
6917
|
+
document.addEventListener('click', (e) => {
|
|
6918
|
+
const toggle = e.target.closest('.scratch-folder-toggle');
|
|
6919
|
+
if (!toggle) return;
|
|
6920
|
+
const section = toggle.closest('[data-scratch-files-for]');
|
|
6921
|
+
if (section) toggleScratchFolder(section.dataset.scratchFilesFor, toggle.closest('li').dataset.file);
|
|
6922
|
+
});
|
|
6923
|
+
|
|
6924
|
+
async function toggleScratchFolder(sessionId, dirPath) {
|
|
6925
|
+
if (scratchFolders.has(dirPath)) {
|
|
6926
|
+
scratchFolders.delete(dirPath);
|
|
6927
|
+
scratchFilesSection.repaint(sessionId);
|
|
6928
|
+
return;
|
|
6929
|
+
}
|
|
6930
|
+
scratchFolders.set(dirPath, { status: 'loading', children: [] });
|
|
6931
|
+
scratchFilesSection.repaint(sessionId);
|
|
6932
|
+
let entry = { status: 'error', children: [] };
|
|
6582
6933
|
try {
|
|
6583
|
-
const res = await fetch(`/api/sessions/${sessionId}/
|
|
6584
|
-
if (
|
|
6585
|
-
const data = await res.json();
|
|
6586
|
-
artifactsBySession.set(sessionId, data.artifacts || []);
|
|
6934
|
+
const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
|
|
6935
|
+
if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
|
|
6587
6936
|
} catch (_) {
|
|
6588
|
-
|
|
6589
|
-
} finally {
|
|
6590
|
-
artifactsInFlight.delete(sessionId);
|
|
6937
|
+
// Reads "Failed to load"; closing and reopening the folder retries.
|
|
6591
6938
|
}
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6939
|
+
// The user may have closed the folder while the request was in flight.
|
|
6940
|
+
if (scratchFolders.has(dirPath)) {
|
|
6941
|
+
scratchFolders.set(dirPath, entry);
|
|
6942
|
+
scratchFilesSection.repaint(sessionId);
|
|
6595
6943
|
}
|
|
6596
6944
|
}
|
|
6597
6945
|
|
|
6946
|
+
function renderScratchFilesHtml(sessionId) {
|
|
6947
|
+
return `<div class="scratch-files-section" data-scratch-files-for="${escapeHtml(sessionId)}">${scratchFilesInnerHtml(sessionId)}</div>`;
|
|
6948
|
+
}
|
|
6949
|
+
|
|
6950
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6951
|
+
function toggleScratchFiles(sessionId) {
|
|
6952
|
+
if (scratchFilesExpanded.has(sessionId)) scratchFilesExpanded.delete(sessionId);
|
|
6953
|
+
else scratchFilesExpanded.add(sessionId);
|
|
6954
|
+
scratchFilesSection.repaint(sessionId);
|
|
6955
|
+
}
|
|
6956
|
+
|
|
6598
6957
|
// One delegated listener per section: the list is re-rendered on every change, so
|
|
6599
6958
|
// per-row handlers would only be rebound each time anyway.
|
|
6600
6959
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
@@ -6664,7 +7023,7 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
6664
7023
|
if (base) qs.set('base', base);
|
|
6665
7024
|
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
6666
7025
|
const data = await r.json().catch(() => ({}));
|
|
6667
|
-
if (!r.ok) {
|
|
7026
|
+
if (!r.ok || !data.exists) {
|
|
6668
7027
|
fail(data.error || 'File not found');
|
|
6669
7028
|
return;
|
|
6670
7029
|
}
|
|
@@ -6999,11 +7358,16 @@ function maybeFollowLatest() {
|
|
|
6999
7358
|
// on the card and the badge until they get to it. Each ask id is offered once,
|
|
7000
7359
|
// so closing the modal does not bring it back on the next poll.
|
|
7001
7360
|
let lastAutoOpenedWaitingId = null;
|
|
7361
|
+
// Set only on that self-opening path: answering an ask the user never asked to see
|
|
7362
|
+
// should put the screen back the way it was, where answering one they navigated to
|
|
7363
|
+
// keeps them following. Cleared with the modal, so a manual reopen is not "auto".
|
|
7364
|
+
let waitingDetailAutoOpened = false;
|
|
7002
7365
|
function maybeAutoOpenWaiting() {
|
|
7003
7366
|
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7004
7367
|
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7005
7368
|
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7006
7369
|
msgDetailFollowLatest = true;
|
|
7370
|
+
waitingDetailAutoOpened = true;
|
|
7007
7371
|
maybeFollowLatest();
|
|
7008
7372
|
}
|
|
7009
7373
|
|
|
@@ -8124,7 +8488,11 @@ async function showSessionInfoModal(sessionId) {
|
|
|
8124
8488
|
_planSessionId = sessionId;
|
|
8125
8489
|
const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
|
|
8126
8490
|
showInfoModal(session, null, cachedTasks, null, null);
|
|
8127
|
-
|
|
8491
|
+
artifactsSection.load(sessionId);
|
|
8492
|
+
// The modal reaches any card's session, not just the open one, so this is the second
|
|
8493
|
+
// place a pad can first be seen. Linking belongs on the two open events rather than
|
|
8494
|
+
// in renderLinkedDocsHtml, which only builds a string and runs on every repaint.
|
|
8495
|
+
loadSessionPads(sessionId);
|
|
8128
8496
|
|
|
8129
8497
|
const rerender = (teamConfig, tasks, planContent, parentInfo) => {
|
|
8130
8498
|
if (_planSessionId !== sessionId) return; // user opened a different modal
|
|
@@ -8196,7 +8564,8 @@ function renderScratchpadRow(session) {
|
|
|
8196
8564
|
<button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
|
|
8197
8565
|
<button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
8198
8566
|
</span>
|
|
8199
|
-
</div
|
|
8567
|
+
</div>
|
|
8568
|
+
${renderScratchFilesHtml(session.id)}`;
|
|
8200
8569
|
}
|
|
8201
8570
|
|
|
8202
8571
|
function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
@@ -8446,11 +8815,36 @@ let spPeekedId = null;
|
|
|
8446
8815
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
8447
8816
|
const spPeekCache = new Map();
|
|
8448
8817
|
|
|
8818
|
+
// A session outside the sidebar filter has no name to fuzzy-match against, so a full id
|
|
8819
|
+
// is read as a request for that exact session and resolved against the server instead.
|
|
8820
|
+
// Values: 'pending' while the request is out, then the session or null.
|
|
8821
|
+
const spGlobal = new Map();
|
|
8822
|
+
|
|
8823
|
+
async function spResolveGlobal(id) {
|
|
8824
|
+
spGlobal.set(id, 'pending');
|
|
8825
|
+
spGlobal.set(id, await fetchSessionById(id));
|
|
8826
|
+
if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
|
|
8827
|
+
}
|
|
8828
|
+
|
|
8829
|
+
// The row to show for a query nothing in the list matched, as `{ hit, empty }` — one of
|
|
8830
|
+
// the two is always null. Starts the lookup when the query is an id not asked about yet.
|
|
8831
|
+
function spResolveMiss(query) {
|
|
8832
|
+
if (!SESSION_UUID_RE.test(query)) {
|
|
8833
|
+
return { hit: null, empty: spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter' };
|
|
8834
|
+
}
|
|
8835
|
+
const id = query.toLowerCase();
|
|
8836
|
+
if (!spGlobal.has(id)) spResolveGlobal(id);
|
|
8837
|
+
const found = spGlobal.get(id);
|
|
8838
|
+
if (found === 'pending') return { hit: null, empty: 'Looking up session…' };
|
|
8839
|
+
return found ? { hit: found, empty: null } : { hit: null, empty: 'No session with that id' };
|
|
8840
|
+
}
|
|
8841
|
+
|
|
8449
8842
|
function openSessionPicker() {
|
|
8450
8843
|
const input = document.getElementById('session-picker-input');
|
|
8451
8844
|
input.value = '';
|
|
8452
8845
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
8453
8846
|
spPeekCache.clear();
|
|
8847
|
+
spGlobal.clear();
|
|
8454
8848
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
8455
8849
|
renderSessionPicker();
|
|
8456
8850
|
input.focus();
|
|
@@ -8541,11 +8935,14 @@ function renderSessionPicker() {
|
|
|
8541
8935
|
const list = document.getElementById('session-picker-list');
|
|
8542
8936
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
8543
8937
|
spRows = spSource.filter((s) => spMatches(s, query));
|
|
8544
|
-
|
|
8545
8938
|
if (!spRows.length) {
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8939
|
+
const { hit, empty } = spResolveMiss(query);
|
|
8940
|
+
if (hit) spRows = [hit];
|
|
8941
|
+
else {
|
|
8942
|
+
spIdx = -1;
|
|
8943
|
+
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
8944
|
+
return;
|
|
8945
|
+
}
|
|
8549
8946
|
}
|
|
8550
8947
|
|
|
8551
8948
|
list.innerHTML = spRows
|
|
@@ -8599,6 +8996,19 @@ function initSessionPicker() {
|
|
|
8599
8996
|
const row = e.target.closest('.sp-row');
|
|
8600
8997
|
if (row) spOpen(Number(row.dataset.idx));
|
|
8601
8998
|
});
|
|
8999
|
+
// Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
|
|
9000
|
+
// A blur that no click in the picker caused, while the window keeps focus, is that Escape.
|
|
9001
|
+
const modal = document.getElementById('session-picker-modal');
|
|
9002
|
+
let pointerDown = false;
|
|
9003
|
+
modal.addEventListener('mousedown', () => {
|
|
9004
|
+
pointerDown = true;
|
|
9005
|
+
});
|
|
9006
|
+
document.addEventListener('mouseup', () => {
|
|
9007
|
+
pointerDown = false;
|
|
9008
|
+
});
|
|
9009
|
+
input.addEventListener('blur', () => {
|
|
9010
|
+
if (!pointerDown && modal.classList.contains('visible') && document.hasFocus()) closeSessionPicker();
|
|
9011
|
+
});
|
|
8602
9012
|
}
|
|
8603
9013
|
//#endregion
|
|
8604
9014
|
|
|
@@ -9465,22 +9875,25 @@ window.addEventListener('popstate', () => {
|
|
|
9465
9875
|
//#endregion
|
|
9466
9876
|
|
|
9467
9877
|
// #region HUB_INTEGRATION
|
|
9878
|
+
// e.code travels with e.key because macOS composes Option+<key> into a character (Option+P is
|
|
9879
|
+
// 'π'), so the key alone cannot identify the binding. The hub owns the keymap and normalizes;
|
|
9880
|
+
// these tests only decide whether a press is the hub's to handle.
|
|
9468
9881
|
document.addEventListener('keydown', (e) => {
|
|
9469
9882
|
if (!window.__HUB__?.enabled) return;
|
|
9470
|
-
const fwd = (
|
|
9883
|
+
const fwd = () => {
|
|
9471
9884
|
e.preventDefault();
|
|
9472
|
-
hubPost({ type: 'hub:keydown', key, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
9885
|
+
hubPost({ type: 'hub:keydown', key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey });
|
|
9473
9886
|
};
|
|
9474
9887
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
9475
|
-
fwd(
|
|
9888
|
+
fwd();
|
|
9476
9889
|
}
|
|
9477
9890
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9478
9891
|
// keymap and ignores unbound letters.
|
|
9479
|
-
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
|
|
9480
|
-
fwd(
|
|
9892
|
+
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (/^[a-z]$/i.test(e.key) || /^Key[A-Z]$/.test(e.code))) {
|
|
9893
|
+
fwd();
|
|
9481
9894
|
}
|
|
9482
|
-
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
|
|
9483
|
-
fwd(
|
|
9895
|
+
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code))) {
|
|
9896
|
+
fwd();
|
|
9484
9897
|
}
|
|
9485
9898
|
});
|
|
9486
9899
|
|