claude-code-kanban 4.25.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/README.md +1 -1
- package/install.js +16 -1
- package/lib/approvals.js +34 -3
- package/lib/inline-assets.js +1 -1
- package/lib/parsers.js +177 -0
- package/lib/scratch-files.js +47 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +53 -18
- package/public/app.js +667 -79
- package/public/index.html +19 -0
- package/public/style.css +355 -18
- package/server.js +527 -89
package/public/app.js
CHANGED
|
@@ -30,6 +30,12 @@ let sessionFilter = FILTER_DEFAULTS.session;
|
|
|
30
30
|
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
31
31
|
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
32
32
|
let sessionLimit = FILTER_DEFAULTS.limit;
|
|
33
|
+
// Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
|
|
34
|
+
// is not a filter — it has no select, no summary token, and never reaches the session picker.
|
|
35
|
+
// Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
|
|
36
|
+
// URL, so a query string would not survive a hub reload.
|
|
37
|
+
const ZEN_KEY = 'zenMode';
|
|
38
|
+
let zenMode = store.getItem(ZEN_KEY) === 'true';
|
|
33
39
|
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
34
40
|
let recentProjects = new Set();
|
|
35
41
|
let projectsCacheDirty = true;
|
|
@@ -144,6 +150,9 @@ function resetState() {
|
|
|
144
150
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
145
151
|
sessionLimit = FILTER_DEFAULTS.limit;
|
|
146
152
|
filterProject = FILTER_DEFAULTS.project;
|
|
153
|
+
zenMode = false;
|
|
154
|
+
store.removeItem(ZEN_KEY);
|
|
155
|
+
renderZenState();
|
|
147
156
|
ownerFilter = '';
|
|
148
157
|
searchQuery = '';
|
|
149
158
|
viewMode = 'all';
|
|
@@ -191,15 +200,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
191
200
|
const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
|
|
192
201
|
if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
|
|
193
202
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
194
|
-
// When server filters by activity, the focused session may not be active —
|
|
195
|
-
// include it in pinned so the server still returns it.
|
|
196
|
-
if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
197
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)}` : '';
|
|
198
208
|
const projectParam =
|
|
199
209
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
200
210
|
const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
|
|
201
211
|
const sessionsPromise = fetch(
|
|
202
|
-
`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
|
|
212
|
+
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
203
213
|
).then((r) => r.json());
|
|
204
214
|
|
|
205
215
|
let newSessions, newTasks;
|
|
@@ -228,6 +238,16 @@ async function fetchSessions(includeTasks = true) {
|
|
|
228
238
|
}
|
|
229
239
|
}
|
|
230
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
|
+
|
|
231
251
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
232
252
|
function handleSearch(query) {
|
|
233
253
|
searchQuery = query.toLowerCase().trim();
|
|
@@ -407,14 +427,8 @@ async function fetchTasks(sessionId) {
|
|
|
407
427
|
document.getElementById('message-toggle')?.style.removeProperty('display');
|
|
408
428
|
const res = await fetch(`/api/sessions/${sessionId}`);
|
|
409
429
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
newTasks = await res.json();
|
|
413
|
-
} else if (res.status === 404) {
|
|
414
|
-
newTasks = [];
|
|
415
|
-
} else {
|
|
416
|
-
throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
417
|
-
}
|
|
430
|
+
if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
|
|
431
|
+
const newTasks = await res.json();
|
|
418
432
|
|
|
419
433
|
const hash = JSON.stringify(newTasks);
|
|
420
434
|
if (sessionId === currentSessionId && hash === lastCurrentTasksHash) {
|
|
@@ -426,6 +440,7 @@ async function fetchTasks(sessionId) {
|
|
|
426
440
|
if (agentLogMode && sessionId !== currentSessionId) exitAgentLogMode();
|
|
427
441
|
if (sessionId !== currentSessionId && document.getElementById('scratchpad-modal').classList.contains('visible'))
|
|
428
442
|
closeScratchpad();
|
|
443
|
+
if (sessionId !== currentSessionId && detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
429
444
|
if (revealedPlanSessionId && sessionId !== revealedPlanSessionId) {
|
|
430
445
|
revealedPlanSessionId = null;
|
|
431
446
|
}
|
|
@@ -493,6 +508,7 @@ async function fetchAgents(sessionId) {
|
|
|
493
508
|
lastWaitingHash = waitHash;
|
|
494
509
|
if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
|
|
495
510
|
maybeFollowLatest();
|
|
511
|
+
maybeAutoOpenWaiting();
|
|
496
512
|
}
|
|
497
513
|
} catch (e) {
|
|
498
514
|
console.error('[fetchAgents]', e);
|
|
@@ -1144,11 +1160,22 @@ function renderMessageListBody(messages) {
|
|
|
1144
1160
|
|
|
1145
1161
|
const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
|
|
1146
1162
|
const pinBtn = renderMsgPinBtn(m, i);
|
|
1163
|
+
|
|
1147
1164
|
if (m.type === 'user') {
|
|
1148
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>' : '';
|
|
1149
1176
|
parts.push(`<div class="msg-item msg-system" ${clickable}>
|
|
1150
|
-
${MSG_ICON_SYSTEM}
|
|
1151
|
-
<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}
|
|
1152
1179
|
</div>`);
|
|
1153
1180
|
} else {
|
|
1154
1181
|
const cmd = parseCommandMessage(m.text);
|
|
@@ -1382,9 +1409,13 @@ function clearWaitingUi() {
|
|
|
1382
1409
|
waitingCustomDraft = {};
|
|
1383
1410
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
1384
1411
|
// In follow mode stay in the modal and swap to the latest message —
|
|
1385
|
-
// closing it would silently drop the user out of following.
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
+
}
|
|
1388
1419
|
}
|
|
1389
1420
|
// The saved-plan modal stays open after a decision — only its approval row goes
|
|
1390
1421
|
document.getElementById('plan-approval-footer')?.remove();
|
|
@@ -1975,7 +2006,7 @@ function showMsgDetail(idx) {
|
|
|
1975
2006
|
const rawText = stripAnsi(m.fullText || m.text || '');
|
|
1976
2007
|
const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
|
|
1977
2008
|
document.getElementById('msg-detail-title').textContent =
|
|
1978
|
-
m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
|
|
2009
|
+
m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
|
|
1979
2010
|
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
1980
2011
|
const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
|
|
1981
2012
|
if (m.compactSummary) {
|
|
@@ -2013,6 +2044,7 @@ function showMsgDetail(idx) {
|
|
|
2013
2044
|
function closeMsgDetailModal() {
|
|
2014
2045
|
hideModalOverlay('msg-detail-modal');
|
|
2015
2046
|
msgDetailFollowLatest = false;
|
|
2047
|
+
waitingDetailAutoOpened = false;
|
|
2016
2048
|
// Drop the message highlight on close, mirroring task-card behavior.
|
|
2017
2049
|
msgHighlightDimmed = true;
|
|
2018
2050
|
const container = document.getElementById('message-panel-content');
|
|
@@ -2181,6 +2213,10 @@ function isZoomableModalOpen() {
|
|
|
2181
2213
|
return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
|
|
2182
2214
|
}
|
|
2183
2215
|
|
|
2216
|
+
function isAnyModalOpen() {
|
|
2217
|
+
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2184
2220
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2185
2221
|
|
|
2186
2222
|
let _toastTimer = null;
|
|
@@ -3007,6 +3043,13 @@ async function revealSession(id) {
|
|
|
3007
3043
|
await fetchSessions();
|
|
3008
3044
|
session = sessions.find((s) => s.id === id);
|
|
3009
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
|
+
}
|
|
3010
3053
|
const uncollapsed = session ? uncollapseFor(session) : false;
|
|
3011
3054
|
if (uncollapsed) persistCollapsedGroups();
|
|
3012
3055
|
expandSidebar();
|
|
@@ -3110,7 +3153,10 @@ function getFilteredSessions() {
|
|
|
3110
3153
|
}
|
|
3111
3154
|
}
|
|
3112
3155
|
if (filterProject) {
|
|
3113
|
-
|
|
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);
|
|
3114
3160
|
}
|
|
3115
3161
|
|
|
3116
3162
|
if (activityFilter.size > 0) {
|
|
@@ -3167,20 +3213,32 @@ function getFilteredSessions() {
|
|
|
3167
3213
|
return filteredSessions;
|
|
3168
3214
|
}
|
|
3169
3215
|
|
|
3216
|
+
// A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
|
|
3217
|
+
// would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
|
|
3218
|
+
function zenPanelIsEditing() {
|
|
3219
|
+
return zenMode && !!sessionsList.querySelector('.linked-doc-input');
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3170
3222
|
function renderSessions() {
|
|
3171
3223
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3172
3224
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3173
|
-
if (sgDrag || sgIsEditing()) return;
|
|
3225
|
+
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3174
3226
|
// Update project dropdown
|
|
3175
3227
|
updateProjectDropdown();
|
|
3176
3228
|
|
|
3177
|
-
|
|
3229
|
+
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3230
|
+
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3231
|
+
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3232
|
+
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
|
|
3178
3233
|
|
|
3179
3234
|
if (filteredSessions.length === 0) {
|
|
3180
3235
|
let emptyMsg = 'No sessions found';
|
|
3181
3236
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3182
3237
|
|
|
3183
|
-
if (
|
|
3238
|
+
if (zenMode) {
|
|
3239
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3240
|
+
emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
|
|
3241
|
+
} else if (searchQuery) {
|
|
3184
3242
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3185
3243
|
emptyHint = 'Try a different search term or clear the search';
|
|
3186
3244
|
} else if (filterProject && sessionFilter === 'active') {
|
|
@@ -3209,9 +3267,7 @@ function renderSessions() {
|
|
|
3209
3267
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3210
3268
|
const isLive = isSessionLive(session);
|
|
3211
3269
|
const sessionName = sessionDisplayName(session);
|
|
3212
|
-
const
|
|
3213
|
-
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
3214
|
-
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
3270
|
+
const projectHtml = renderProjectIdentity(session);
|
|
3215
3271
|
|
|
3216
3272
|
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
3217
3273
|
const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
|
|
@@ -3220,7 +3276,9 @@ function renderSessions() {
|
|
|
3220
3276
|
session.createdAt && createdDisplay !== modifiedDisplay
|
|
3221
3277
|
? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
|
|
3222
3278
|
: modifiedDisplay;
|
|
3223
|
-
const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3279
|
+
const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3280
|
+
.filter(Boolean)
|
|
3281
|
+
.join(' | ');
|
|
3224
3282
|
const isTeam = session.isTeam;
|
|
3225
3283
|
const memberCount = session.memberCount || 0;
|
|
3226
3284
|
|
|
@@ -3228,7 +3286,8 @@ function renderSessions() {
|
|
|
3228
3286
|
const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
|
|
3229
3287
|
const pinTitle =
|
|
3230
3288
|
pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
|
|
3231
|
-
|
|
3289
|
+
// Zen renders the full context detail below the card, so the compact bar would just repeat it.
|
|
3290
|
+
const showCtx = !!session.contextStatus && !zenMode;
|
|
3232
3291
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3233
3292
|
const bookmarksCount = loadPins(session.id).length;
|
|
3234
3293
|
const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
|
|
@@ -3237,8 +3296,8 @@ function renderSessions() {
|
|
|
3237
3296
|
return `
|
|
3238
3297
|
<button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3239
3298
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3240
|
-
<div class="session-name">${escapeHtml(
|
|
3241
|
-
${
|
|
3299
|
+
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3300
|
+
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3242
3301
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3243
3302
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
3244
3303
|
${renderGoalSubtitle(session)}
|
|
@@ -3267,6 +3326,24 @@ function renderSessions() {
|
|
|
3267
3326
|
`;
|
|
3268
3327
|
};
|
|
3269
3328
|
|
|
3329
|
+
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3330
|
+
// that chain would build the whole list only to discard it.
|
|
3331
|
+
if (zenMode) {
|
|
3332
|
+
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3333
|
+
<div class="zen-panel">
|
|
3334
|
+
${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3335
|
+
${renderScratchpadRow(zenSession)}
|
|
3336
|
+
${renderLinkedDocsHtml(zenSession.id)}
|
|
3337
|
+
${renderArtifactsHtml(zenSession.id)}
|
|
3338
|
+
</div>
|
|
3339
|
+
${zenSession.hasWorkflow ? renderWorkflowLiveHtml(zenSession.id) : ''}`;
|
|
3340
|
+
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
3341
|
+
artifactsSection.load(zenSession.id);
|
|
3342
|
+
scratchFilesSection.load(zenSession.id);
|
|
3343
|
+
if (zenSession.hasWorkflow) pollWorkflowLive(zenSession.id);
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
|
|
3270
3347
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3271
3348
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3272
3349
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
@@ -3539,13 +3616,18 @@ function renderSession() {
|
|
|
3539
3616
|
const session = sessions.find((s) => s.id === currentSessionId);
|
|
3540
3617
|
if (!session) return;
|
|
3541
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
|
+
|
|
3542
3624
|
const displayName =
|
|
3543
3625
|
session.customTitle || session.name || session.gitBranch || session.description || currentSessionId;
|
|
3544
3626
|
|
|
3545
3627
|
sessionTitle.textContent = displayName;
|
|
3546
3628
|
|
|
3547
3629
|
// Build meta text with project path and description
|
|
3548
|
-
const projectName = session.project ? session.project
|
|
3630
|
+
const projectName = session.project ? pathBasename(session.project) : null;
|
|
3549
3631
|
const metaParts = [`${currentTasks.length} tasks`];
|
|
3550
3632
|
if (projectName) {
|
|
3551
3633
|
metaParts.push(projectName);
|
|
@@ -4649,16 +4731,18 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
4649
4731
|
}
|
|
4650
4732
|
|
|
4651
4733
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
4652
|
-
// the project or the session itself, the pinned sub-
|
|
4653
|
-
//
|
|
4734
|
+
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
4735
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
|
|
4736
|
+
// depends on the view — every section header. Returns whether anything changed.
|
|
4654
4737
|
function uncollapseFor(session) {
|
|
4655
4738
|
const project = session.project || '__ungrouped__';
|
|
4656
4739
|
const group = sgGroupForSession(session);
|
|
4740
|
+
const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
|
|
4657
4741
|
const keys = [
|
|
4658
4742
|
project,
|
|
4659
|
-
pinKey(project),
|
|
4743
|
+
inPinned && pinKey(project),
|
|
4660
4744
|
group && sgKey(group.id),
|
|
4661
|
-
group && `__pinned_group_${group.id}__`,
|
|
4745
|
+
group && inPinned && `__pinned_group_${group.id}__`,
|
|
4662
4746
|
SECTION_GROUPS,
|
|
4663
4747
|
SECTION_PROJECTS,
|
|
4664
4748
|
SECTION_SESSIONS,
|
|
@@ -5194,6 +5278,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5194
5278
|
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5195
5279
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5196
5280
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5281
|
+
{ keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5197
5282
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5198
5283
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5199
5284
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -5407,7 +5492,7 @@ function _groupByProject(sessionIds) {
|
|
|
5407
5492
|
|
|
5408
5493
|
function _projectLabel(project) {
|
|
5409
5494
|
if (project === '(no project)') return '(no project)';
|
|
5410
|
-
return project
|
|
5495
|
+
return pathBasename(project);
|
|
5411
5496
|
}
|
|
5412
5497
|
|
|
5413
5498
|
function _renderProjectGroup(label, meta, innerHtml) {
|
|
@@ -5672,12 +5757,8 @@ function _renderStorageLinkedDocs() {
|
|
|
5672
5757
|
const entries = [];
|
|
5673
5758
|
for (const key of store.keys()) {
|
|
5674
5759
|
if (!key.startsWith(PREVIEW_STORAGE_PREFIX)) continue;
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
if (Array.isArray(arr) && arr.length) {
|
|
5678
|
-
entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths: arr });
|
|
5679
|
-
}
|
|
5680
|
-
} catch {}
|
|
5760
|
+
const paths = readStoredList(key);
|
|
5761
|
+
if (paths.length) entries.push({ sessionId: key.slice(PREVIEW_STORAGE_PREFIX.length), paths });
|
|
5681
5762
|
}
|
|
5682
5763
|
if (!entries.length) return '<div class="storage-empty">No linked documents</div>';
|
|
5683
5764
|
|
|
@@ -5738,6 +5819,8 @@ function _storageUnlinkDoc(sessionId, path) {
|
|
|
5738
5819
|
|
|
5739
5820
|
function _storageClearLinkedDocs(sessionId) {
|
|
5740
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.
|
|
5741
5824
|
afterLinkedDocsChanged(sessionId);
|
|
5742
5825
|
}
|
|
5743
5826
|
|
|
@@ -5754,6 +5837,8 @@ function _findOrphanedKeys() {
|
|
|
5754
5837
|
if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
|
|
5755
5838
|
} else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
|
|
5756
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);
|
|
5757
5842
|
}
|
|
5758
5843
|
}
|
|
5759
5844
|
return orphaned;
|
|
@@ -5844,7 +5929,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
5844
5929
|
}
|
|
5845
5930
|
|
|
5846
5931
|
// Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
|
|
5847
|
-
if (
|
|
5932
|
+
if (isAnyModalOpen()) {
|
|
5848
5933
|
if (e.key === 'Escape') {
|
|
5849
5934
|
if (_scratchpadModal.classList.contains('visible')) {
|
|
5850
5935
|
closeScratchpad();
|
|
@@ -5929,6 +6014,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
5929
6014
|
showStorageManager();
|
|
5930
6015
|
return;
|
|
5931
6016
|
}
|
|
6017
|
+
if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6018
|
+
e.preventDefault();
|
|
6019
|
+
toggleZenMode();
|
|
6020
|
+
return;
|
|
6021
|
+
}
|
|
5932
6022
|
// Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
|
|
5933
6023
|
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5934
6024
|
e.preventDefault();
|
|
@@ -6156,27 +6246,40 @@ document.addEventListener('keydown', (e) => {
|
|
|
6156
6246
|
const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
|
|
6157
6247
|
let currentPreviewPath = null;
|
|
6158
6248
|
|
|
6159
|
-
function
|
|
6160
|
-
if (!sessionId) return [];
|
|
6249
|
+
function readStoredList(key) {
|
|
6161
6250
|
try {
|
|
6162
|
-
const
|
|
6163
|
-
const arr = raw ? JSON.parse(raw) : [];
|
|
6251
|
+
const arr = JSON.parse(store.getItem(key) || '[]');
|
|
6164
6252
|
return Array.isArray(arr) ? arr : [];
|
|
6165
6253
|
} catch {
|
|
6166
6254
|
return [];
|
|
6167
6255
|
}
|
|
6168
6256
|
}
|
|
6169
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
|
+
|
|
6170
6271
|
function addSessionPreviewPath(sessionId, filePath) {
|
|
6171
6272
|
if (!sessionId || !filePath) return;
|
|
6172
|
-
const
|
|
6273
|
+
const key = canonicalPath(filePath);
|
|
6274
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6173
6275
|
paths.unshift(filePath);
|
|
6174
6276
|
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, 20)));
|
|
6175
6277
|
}
|
|
6176
6278
|
|
|
6177
6279
|
function removeSessionPreviewPath(sessionId, filePath) {
|
|
6178
6280
|
if (!sessionId) return;
|
|
6179
|
-
const
|
|
6281
|
+
const key = canonicalPath(filePath);
|
|
6282
|
+
const paths = getSessionPreviewPaths(sessionId).filter((p) => canonicalPath(p) !== key);
|
|
6180
6283
|
if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths));
|
|
6181
6284
|
else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6182
6285
|
}
|
|
@@ -6246,23 +6349,48 @@ function bindPreviewRelativeLinks(bodyEl) {
|
|
|
6246
6349
|
const isAbsolutePath = href.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(href);
|
|
6247
6350
|
if (isAbsoluteUrl) return;
|
|
6248
6351
|
const cleanHref = href.replace(/#.*$/, '');
|
|
6249
|
-
if (!isPreviewablePath(cleanHref)) return;
|
|
6250
6352
|
e.preventDefault();
|
|
6251
|
-
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath);
|
|
6353
|
+
openPreviewByPath(cleanHref, isAbsolutePath ? undefined : currentPreviewPath, openFileInEditor);
|
|
6252
6354
|
});
|
|
6253
6355
|
bodyEl.dataset.relLinkBound = '1';
|
|
6254
6356
|
}
|
|
6255
6357
|
|
|
6256
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
|
+
|
|
6257
6376
|
function openPreviewModal(filePath, content, kind) {
|
|
6258
6377
|
currentPreviewPath = filePath;
|
|
6259
|
-
|
|
6378
|
+
const fileName = filePath.split(/[\\/]/).pop();
|
|
6379
|
+
document.getElementById('preview-modal-title').textContent = fileName;
|
|
6260
6380
|
const bodyEl = document.getElementById('preview-modal-body');
|
|
6261
6381
|
const isHtml = kind === 'html';
|
|
6262
6382
|
document.querySelector('#preview-modal .modal').classList.toggle('preview-html', isHtml);
|
|
6263
6383
|
bindPreviewRelativeLinks(bodyEl);
|
|
6264
6384
|
if (isHtml) {
|
|
6265
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);
|
|
6266
6394
|
} else {
|
|
6267
6395
|
const { fm, body } = splitFrontmatter(content);
|
|
6268
6396
|
bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
|
|
@@ -6274,7 +6402,8 @@ function openPreviewModal(filePath, content, kind) {
|
|
|
6274
6402
|
|
|
6275
6403
|
function isPreviewLinkedToCurrentSession() {
|
|
6276
6404
|
if (!currentPreviewPath || !currentSessionId) return false;
|
|
6277
|
-
|
|
6405
|
+
const key = canonicalPath(currentPreviewPath);
|
|
6406
|
+
return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
|
|
6278
6407
|
}
|
|
6279
6408
|
|
|
6280
6409
|
function updatePreviewLinkBtn() {
|
|
@@ -6325,13 +6454,20 @@ function closePreviewModal() {
|
|
|
6325
6454
|
currentPreviewPath = null;
|
|
6326
6455
|
}
|
|
6327
6456
|
|
|
6457
|
+
function openFileInEditor(filePath) {
|
|
6458
|
+
postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
|
|
6459
|
+
}
|
|
6460
|
+
|
|
6328
6461
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
6329
6462
|
function openPreviewInEditor() {
|
|
6330
6463
|
if (!currentPreviewPath) return;
|
|
6331
|
-
|
|
6464
|
+
openFileInEditor(currentPreviewPath);
|
|
6332
6465
|
}
|
|
6333
6466
|
|
|
6334
|
-
|
|
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) {
|
|
6335
6471
|
if (!filePath) return;
|
|
6336
6472
|
try {
|
|
6337
6473
|
const qs = new URLSearchParams({ path: filePath });
|
|
@@ -6342,6 +6478,15 @@ async function openPreviewByPath(filePath, base) {
|
|
|
6342
6478
|
return;
|
|
6343
6479
|
}
|
|
6344
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
|
+
}
|
|
6345
6490
|
openPreviewModal(data.path, data.content, data.kind);
|
|
6346
6491
|
} catch {
|
|
6347
6492
|
showToast('Failed to load preview');
|
|
@@ -6395,10 +6540,69 @@ function getSessionBaseDir(sessionId) {
|
|
|
6395
6540
|
return s?.cwd || s?.project || '';
|
|
6396
6541
|
}
|
|
6397
6542
|
|
|
6398
|
-
// Linked paths are stored as bare strings, so
|
|
6399
|
-
//
|
|
6400
|
-
|
|
6401
|
-
|
|
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);
|
|
6402
6606
|
}
|
|
6403
6607
|
|
|
6404
6608
|
// A scratchpad manifest opens in the external `scratch` viewer, not the editor —
|
|
@@ -6427,11 +6631,14 @@ const LINKED_DOC_OPENERS = {
|
|
|
6427
6631
|
},
|
|
6428
6632
|
};
|
|
6429
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.
|
|
6430
6637
|
function linkedDocOpener(p) {
|
|
6431
|
-
if (
|
|
6432
|
-
//
|
|
6433
|
-
|
|
6434
|
-
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';
|
|
6435
6642
|
}
|
|
6436
6643
|
|
|
6437
6644
|
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
@@ -6444,7 +6651,7 @@ function linkedDocLabel(p) {
|
|
|
6444
6651
|
function openLinkedDoc(p, baseDir) {
|
|
6445
6652
|
const opener = linkedDocOpener(p);
|
|
6446
6653
|
if (opener === 'preview') {
|
|
6447
|
-
openPreviewByPath(p, baseDir);
|
|
6654
|
+
openPreviewByPath(p, baseDir, openFileInEditor);
|
|
6448
6655
|
return;
|
|
6449
6656
|
}
|
|
6450
6657
|
const { url, body, toast } = LINKED_DOC_OPENERS[opener];
|
|
@@ -6453,6 +6660,9 @@ function openLinkedDoc(p, baseDir) {
|
|
|
6453
6660
|
|
|
6454
6661
|
function renderLinkedDocsHtml(sessionId) {
|
|
6455
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);
|
|
6456
6666
|
const baseDir = getSessionBaseDir(sessionId);
|
|
6457
6667
|
const items = paths
|
|
6458
6668
|
.map((p) => {
|
|
@@ -6476,11 +6686,11 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6476
6686
|
const body = paths.length
|
|
6477
6687
|
? `<ul class="linked-doc-list">${items}</ul>`
|
|
6478
6688
|
: '<div class="linked-docs-empty">No linked files yet</div>';
|
|
6479
|
-
return `<div class="linked-docs-section
|
|
6480
|
-
<div
|
|
6689
|
+
return `<div class="linked-docs-section panel-section">
|
|
6690
|
+
<div class="panel-section-header">
|
|
6481
6691
|
${linkSvg(12)}
|
|
6482
6692
|
<span>Linked documents</span>
|
|
6483
|
-
<span
|
|
6693
|
+
<span class="panel-section-count">${paths.length}</span>
|
|
6484
6694
|
<button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
|
|
6485
6695
|
</div>
|
|
6486
6696
|
<div class="linked-doc-editor-slot"></div>
|
|
@@ -6488,6 +6698,262 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6488
6698
|
</div>`;
|
|
6489
6699
|
}
|
|
6490
6700
|
|
|
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
|
+
}
|
|
6736
|
+
|
|
6737
|
+
function artifactLabel(a) {
|
|
6738
|
+
if (a.title) return a.title;
|
|
6739
|
+
return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
|
|
6740
|
+
}
|
|
6741
|
+
|
|
6742
|
+
function artifactsInnerHtml(sessionId) {
|
|
6743
|
+
const list = artifactsSection.get(sessionId);
|
|
6744
|
+
if (!list.length) return '';
|
|
6745
|
+
const items = list
|
|
6746
|
+
.map(
|
|
6747
|
+
(a) => `<li class="artifact-item">
|
|
6748
|
+
<a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
|
|
6749
|
+
<span class="row-actions artifact-actions">
|
|
6750
|
+
<button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
|
|
6751
|
+
</span>
|
|
6752
|
+
</li>`,
|
|
6753
|
+
)
|
|
6754
|
+
.join('');
|
|
6755
|
+
return `<div class="panel-section-header">
|
|
6756
|
+
${ICON_OPEN_EXTERNAL}
|
|
6757
|
+
<span>Artifacts</span>
|
|
6758
|
+
<span class="panel-section-count">${list.length}</span>
|
|
6759
|
+
</div>
|
|
6760
|
+
<ul class="artifact-list">${items}</ul>`;
|
|
6761
|
+
}
|
|
6762
|
+
|
|
6763
|
+
function renderArtifactsHtml(sessionId) {
|
|
6764
|
+
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
6765
|
+
}
|
|
6766
|
+
|
|
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: [] };
|
|
6933
|
+
try {
|
|
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 || [] };
|
|
6936
|
+
} catch (_) {
|
|
6937
|
+
// Reads "Failed to load"; closing and reopening the folder retries.
|
|
6938
|
+
}
|
|
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);
|
|
6943
|
+
}
|
|
6944
|
+
}
|
|
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
|
+
|
|
6491
6957
|
// One delegated listener per section: the list is re-rendered on every change, so
|
|
6492
6958
|
// per-row handlers would only be rebound each time anyway.
|
|
6493
6959
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
@@ -6557,10 +7023,13 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
6557
7023
|
if (base) qs.set('base', base);
|
|
6558
7024
|
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
6559
7025
|
const data = await r.json().catch(() => ({}));
|
|
6560
|
-
if (!r.ok) {
|
|
7026
|
+
if (!r.ok || !data.exists) {
|
|
6561
7027
|
fail(data.error || 'File not found');
|
|
6562
7028
|
return;
|
|
6563
7029
|
}
|
|
7030
|
+
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
7031
|
+
// input is open, so leaving it up would hide the file that was just linked.
|
|
7032
|
+
if (slot) slot.innerHTML = '';
|
|
6564
7033
|
setSessionDocLink(sessionId, data.path, false);
|
|
6565
7034
|
showToast('Linked to session', 'success');
|
|
6566
7035
|
} catch {
|
|
@@ -6884,6 +7353,24 @@ function maybeFollowLatest() {
|
|
|
6884
7353
|
}
|
|
6885
7354
|
}
|
|
6886
7355
|
|
|
7356
|
+
// A new answerable ask opens its modal on its own, but only onto an empty
|
|
7357
|
+
// screen: an open modal means the user is reading something, so the ask stays
|
|
7358
|
+
// on the card and the badge until they get to it. Each ask id is offered once,
|
|
7359
|
+
// so closing the modal does not bring it back on the next poll.
|
|
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;
|
|
7365
|
+
function maybeAutoOpenWaiting() {
|
|
7366
|
+
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7367
|
+
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7368
|
+
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7369
|
+
msgDetailFollowLatest = true;
|
|
7370
|
+
waitingDetailAutoOpened = true;
|
|
7371
|
+
maybeFollowLatest();
|
|
7372
|
+
}
|
|
7373
|
+
|
|
6887
7374
|
function isWaitingFresh() {
|
|
6888
7375
|
if (!currentWaiting?.timestamp) return false;
|
|
6889
7376
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
@@ -7087,6 +7574,24 @@ function sessionDisplayName(session) {
|
|
|
7087
7574
|
return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
|
|
7088
7575
|
}
|
|
7089
7576
|
|
|
7577
|
+
// Project paths arrive in the OS spelling, so a Windows path is backslash-separated.
|
|
7578
|
+
function pathBasename(p) {
|
|
7579
|
+
return p.split(/[/\\]/).filter(Boolean).pop() || p;
|
|
7580
|
+
}
|
|
7581
|
+
|
|
7582
|
+
// `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
|
|
7583
|
+
// worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
|
|
7584
|
+
function renderProjectIdentity(session) {
|
|
7585
|
+
if (!session.project) return null;
|
|
7586
|
+
const wt = session.worktree;
|
|
7587
|
+
if (!wt) return escapeHtml(pathBasename(session.project));
|
|
7588
|
+
const repo = pathBasename(wt.repo);
|
|
7589
|
+
// Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
|
|
7590
|
+
// repo inside a chip that already sits next to it wastes the whole width of the card.
|
|
7591
|
+
const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
|
|
7592
|
+
return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
|
|
7593
|
+
}
|
|
7594
|
+
|
|
7090
7595
|
function formatDate(dateStr) {
|
|
7091
7596
|
const date = new Date(dateStr);
|
|
7092
7597
|
const now = new Date();
|
|
@@ -7524,6 +8029,24 @@ function renderFilterState() {
|
|
|
7524
8029
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
7525
8030
|
}
|
|
7526
8031
|
|
|
8032
|
+
function toggleZenMode() {
|
|
8033
|
+
zenMode = !zenMode;
|
|
8034
|
+
store.setItem(ZEN_KEY, String(zenMode));
|
|
8035
|
+
renderZenState();
|
|
8036
|
+
if (zenMode) closeFilterMenu();
|
|
8037
|
+
renderSessions();
|
|
8038
|
+
// The open session may be outside the current server page/filter; refetch pins it in.
|
|
8039
|
+
if (zenMode) fetchSessions(false);
|
|
8040
|
+
}
|
|
8041
|
+
|
|
8042
|
+
function renderZenState() {
|
|
8043
|
+
const btn = document.getElementById('zen-mode-btn');
|
|
8044
|
+
if (!btn) return;
|
|
8045
|
+
btn.classList.toggle('active', zenMode);
|
|
8046
|
+
btn.setAttribute('aria-pressed', String(zenMode));
|
|
8047
|
+
btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
|
|
8048
|
+
}
|
|
8049
|
+
|
|
7527
8050
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
7528
8051
|
function filterBySessions(value) {
|
|
7529
8052
|
sessionFilter = value;
|
|
@@ -7965,6 +8488,11 @@ async function showSessionInfoModal(sessionId) {
|
|
|
7965
8488
|
_planSessionId = sessionId;
|
|
7966
8489
|
const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
|
|
7967
8490
|
showInfoModal(session, null, cachedTasks, null, null);
|
|
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);
|
|
7968
8496
|
|
|
7969
8497
|
const rerender = (teamConfig, tasks, planContent, parentInfo) => {
|
|
7970
8498
|
if (_planSessionId !== sessionId) return; // user opened a different modal
|
|
@@ -8023,7 +8551,21 @@ function updateStickyBtnState() {
|
|
|
8023
8551
|
// carry information. The full path stays in the tooltip and the copy button.
|
|
8024
8552
|
function abbreviateScratchpadDir(dir) {
|
|
8025
8553
|
const sep = dir.includes('\\') ? '\\' : '/';
|
|
8026
|
-
return
|
|
8554
|
+
return dir.split(/[/\\]/).slice(-2).join(sep);
|
|
8555
|
+
}
|
|
8556
|
+
|
|
8557
|
+
function renderScratchpadRow(session) {
|
|
8558
|
+
const dir = session?.scratchpadDir;
|
|
8559
|
+
if (!dir) return '';
|
|
8560
|
+
return `<div class="zen-scratchpad">
|
|
8561
|
+
<span class="zen-scratchpad-label">Scratchpad</span>
|
|
8562
|
+
<span class="zen-scratchpad-path" title="${escapeHtml(dir)}">${escapeHtml(abbreviateScratchpadDir(dir))}</span>
|
|
8563
|
+
<span class="row-actions">
|
|
8564
|
+
<button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
|
|
8565
|
+
<button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
8566
|
+
</span>
|
|
8567
|
+
</div>
|
|
8568
|
+
${renderScratchFilesHtml(session.id)}`;
|
|
8027
8569
|
}
|
|
8028
8570
|
|
|
8029
8571
|
function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
@@ -8138,6 +8680,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
8138
8680
|
}
|
|
8139
8681
|
|
|
8140
8682
|
html += renderLinkedDocsHtml(session.id);
|
|
8683
|
+
html += renderArtifactsHtml(session.id);
|
|
8141
8684
|
|
|
8142
8685
|
// Team info section
|
|
8143
8686
|
if (teamConfig) {
|
|
@@ -8272,11 +8815,36 @@ let spPeekedId = null;
|
|
|
8272
8815
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
8273
8816
|
const spPeekCache = new Map();
|
|
8274
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
|
+
|
|
8275
8842
|
function openSessionPicker() {
|
|
8276
8843
|
const input = document.getElementById('session-picker-input');
|
|
8277
8844
|
input.value = '';
|
|
8278
8845
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
8279
8846
|
spPeekCache.clear();
|
|
8847
|
+
spGlobal.clear();
|
|
8280
8848
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
8281
8849
|
renderSessionPicker();
|
|
8282
8850
|
input.focus();
|
|
@@ -8367,11 +8935,14 @@ function renderSessionPicker() {
|
|
|
8367
8935
|
const list = document.getElementById('session-picker-list');
|
|
8368
8936
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
8369
8937
|
spRows = spSource.filter((s) => spMatches(s, query));
|
|
8370
|
-
|
|
8371
8938
|
if (!spRows.length) {
|
|
8372
|
-
|
|
8373
|
-
|
|
8374
|
-
|
|
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
|
+
}
|
|
8375
8946
|
}
|
|
8376
8947
|
|
|
8377
8948
|
list.innerHTML = spRows
|
|
@@ -8425,6 +8996,19 @@ function initSessionPicker() {
|
|
|
8425
8996
|
const row = e.target.closest('.sp-row');
|
|
8426
8997
|
if (row) spOpen(Number(row.dataset.idx));
|
|
8427
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
|
+
});
|
|
8428
9012
|
}
|
|
8429
9013
|
//#endregion
|
|
8430
9014
|
|
|
@@ -9207,6 +9791,7 @@ ownerFilter = urlState.owner || '';
|
|
|
9207
9791
|
searchQuery = urlState.search || '';
|
|
9208
9792
|
|
|
9209
9793
|
renderFilterState();
|
|
9794
|
+
renderZenState();
|
|
9210
9795
|
pinnedSessionIds = loadPinnedSessions();
|
|
9211
9796
|
stickySessionIds = loadStickySessions();
|
|
9212
9797
|
setupEventSource();
|
|
@@ -9290,22 +9875,25 @@ window.addEventListener('popstate', () => {
|
|
|
9290
9875
|
//#endregion
|
|
9291
9876
|
|
|
9292
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.
|
|
9293
9881
|
document.addEventListener('keydown', (e) => {
|
|
9294
9882
|
if (!window.__HUB__?.enabled) return;
|
|
9295
|
-
const fwd = (
|
|
9883
|
+
const fwd = () => {
|
|
9296
9884
|
e.preventDefault();
|
|
9297
|
-
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 });
|
|
9298
9886
|
};
|
|
9299
9887
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
|
9300
|
-
fwd(
|
|
9888
|
+
fwd();
|
|
9301
9889
|
}
|
|
9302
9890
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
9303
9891
|
// keymap and ignores unbound letters.
|
|
9304
|
-
if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && /^[a-z]$/i.test(e.key)) {
|
|
9305
|
-
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();
|
|
9306
9894
|
}
|
|
9307
|
-
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && /^[1-9]$/.test(e.key)) {
|
|
9308
|
-
fwd(
|
|
9895
|
+
if (e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code))) {
|
|
9896
|
+
fwd();
|
|
9309
9897
|
}
|
|
9310
9898
|
});
|
|
9311
9899
|
|