claude-code-kanban 4.29.0 → 4.31.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 +5 -4
- package/cli.js +406 -75
- package/install.js +9 -9
- package/lib/approvals.js +2 -2
- package/lib/contain.js +2 -2
- package/lib/dispatch-groups.js +28 -11
- package/lib/dispatch.js +1 -0
- package/lib/file-url.js +1 -1
- package/lib/inline-assets.js +2 -2
- package/lib/linked-docs.js +58 -0
- package/lib/net-guard.js +1 -1
- package/lib/open-editor.js +5 -3
- package/lib/parent-cache.js +58 -0
- package/lib/parsers.js +175 -168
- package/lib/plugin-status.js +38 -0
- package/lib/session-cache.js +34 -0
- package/lib/session-events.js +2 -1
- package/lib/task-counts.js +90 -0
- package/lib/terminal.js +9 -4
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +817 -376
- package/public/index.html +51 -25
- package/public/link-url.js +15 -0
- package/public/project-match.js +27 -0
- package/public/style.css +385 -103
- package/public/sw.js +1 -1
- package/public/themes.css +49 -0
- package/server.js +185 -86
- package/skill-guides/dispatch.md +5 -4
package/public/app.js
CHANGED
|
@@ -4,6 +4,7 @@ const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
|
|
|
4
4
|
// Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
|
|
5
5
|
const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
|
|
6
6
|
const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
|
|
7
|
+
const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher } = projectMatch;
|
|
7
8
|
const store = {
|
|
8
9
|
keys() {
|
|
9
10
|
const out = [];
|
|
@@ -20,18 +21,24 @@ const store = {
|
|
|
20
21
|
|
|
21
22
|
// The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
|
|
22
23
|
// from the query string, and what decides whether a control tints ember in the header summary.
|
|
23
|
-
const FILTER_DEFAULTS = { project: '__recent__', session: 'active'
|
|
24
|
+
const FILTER_DEFAULTS = { project: '__recent__', session: 'active' };
|
|
25
|
+
const RECENT_PROJECT_HOURS = 24;
|
|
24
26
|
|
|
25
27
|
let sessions = [];
|
|
26
28
|
let currentSessionId = null;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
// Kept in the store because a config-dir switch in the hub reloads this page.
|
|
30
|
+
const SWAP_KEY = 'swap-sessions';
|
|
31
|
+
let [lastSessionId, previousSessionId] = readSwapPair();
|
|
29
32
|
let currentTasks = [];
|
|
30
33
|
let viewMode = 'session';
|
|
31
34
|
let sessionFilter = FILTER_DEFAULTS.session;
|
|
32
35
|
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
33
36
|
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
34
|
-
|
|
37
|
+
// The sidebar asks for one page and grows by a page when it is scrolled to the end. A live
|
|
38
|
+
// refresh asks for the same count, so the loaded rows stay loaded.
|
|
39
|
+
const SESSION_PAGE = 20;
|
|
40
|
+
let sessionLimit = SESSION_PAGE;
|
|
41
|
+
let sessionsHasMore = false;
|
|
35
42
|
// Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
|
|
36
43
|
// is not a filter — it has no select, no summary token, and never reaches the session picker.
|
|
37
44
|
// Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
|
|
@@ -58,6 +65,8 @@ let currentWaiting = null;
|
|
|
58
65
|
let lastWaitingHash = '';
|
|
59
66
|
let lastAgentsHash = '';
|
|
60
67
|
let messagePanelOpen = false;
|
|
68
|
+
let logAutoOpened = false;
|
|
69
|
+
const logDismissed = new Set();
|
|
61
70
|
let lastMessagesHash = '';
|
|
62
71
|
let currentMessages = [];
|
|
63
72
|
let agentDurationInterval = null;
|
|
@@ -91,7 +100,50 @@ let msgUserScrolledUp = false;
|
|
|
91
100
|
const MSG_MAX_LOADED = 200;
|
|
92
101
|
let currentProjectPath = null;
|
|
93
102
|
let currentProjectSessionIds = [];
|
|
94
|
-
|
|
103
|
+
// Session id -> dismissed-at. Kept for a day: past that the session has usually gone stale,
|
|
104
|
+
// and a reload (the hub reloads this page on a config-dir switch) must not bring it back.
|
|
105
|
+
const DISMISSED_KEY = 'dismissed-sessions';
|
|
106
|
+
const DISMISS_TTL_MS = 24 * 60 * 60 * 1000;
|
|
107
|
+
const isDismissFresh = (at) => typeof at === 'number' && at > Date.now() - DISMISS_TTL_MS;
|
|
108
|
+
const dismissedSessionIds = loadDismissedSessions();
|
|
109
|
+
let dismissSaveWarned = false;
|
|
110
|
+
|
|
111
|
+
function loadDismissedSessions() {
|
|
112
|
+
try {
|
|
113
|
+
const saved = Object.entries(JSON.parse(store.getItem(DISMISSED_KEY) || '{}'));
|
|
114
|
+
return new Map(saved.filter(([, at]) => isDismissFresh(at)));
|
|
115
|
+
} catch {
|
|
116
|
+
return new Map();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function persistDismissedSessions() {
|
|
121
|
+
for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
|
|
122
|
+
try {
|
|
123
|
+
store.setItem(DISMISSED_KEY, JSON.stringify(Object.fromEntries(dismissedSessionIds)));
|
|
124
|
+
} catch (e) {
|
|
125
|
+
console.error('[dismissed-sessions]', e);
|
|
126
|
+
// The map still works in memory, so only the next reload is affected. Say so once.
|
|
127
|
+
if (!dismissSaveWarned) {
|
|
128
|
+
dismissSaveWarned = true;
|
|
129
|
+
showToast(
|
|
130
|
+
'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
|
|
131
|
+
'error',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function setSessionDismissed(id, dismissed) {
|
|
138
|
+
if (dismissed === dismissedSessionIds.has(id)) return false;
|
|
139
|
+
if (dismissed) dismissedSessionIds.set(id, Date.now());
|
|
140
|
+
else dismissedSessionIds.delete(id);
|
|
141
|
+
persistDismissedSessions();
|
|
142
|
+
updateDismissBtnState();
|
|
143
|
+
renderSessions();
|
|
144
|
+
renderActivityChip();
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
95
147
|
|
|
96
148
|
function resetMessageScrollState() {
|
|
97
149
|
msgUserScrolledUp = false;
|
|
@@ -107,9 +159,7 @@ function getUrlState() {
|
|
|
107
159
|
const params = new URLSearchParams(window.location.search);
|
|
108
160
|
return {
|
|
109
161
|
session: params.get('session'),
|
|
110
|
-
view: params.get('view'),
|
|
111
162
|
filter: params.get('filter'),
|
|
112
|
-
limit: params.get('limit'),
|
|
113
163
|
project: params.get('project'),
|
|
114
164
|
owner: params.get('owner'),
|
|
115
165
|
search: params.get('search'),
|
|
@@ -120,15 +170,13 @@ function getUrlState() {
|
|
|
120
170
|
|
|
121
171
|
function updateUrl() {
|
|
122
172
|
const params = new URLSearchParams();
|
|
123
|
-
if (viewMode === 'all') params.set('view', 'all');
|
|
124
173
|
if (viewMode === 'project' && currentProjectPath) params.set('projectView', btoa(currentProjectPath));
|
|
125
174
|
if (currentSessionId) params.set('session', currentSessionId);
|
|
126
175
|
if (sessionFilter !== FILTER_DEFAULTS.session) params.set('filter', sessionFilter);
|
|
127
|
-
if (sessionLimit !== FILTER_DEFAULTS.limit) params.set('limit', sessionLimit);
|
|
128
176
|
if (filterProject && filterProject !== FILTER_DEFAULTS.project) params.set('project', filterProject);
|
|
129
177
|
if (ownerFilter) params.set('owner', ownerFilter);
|
|
130
178
|
if (searchQuery) params.set('search', searchQuery);
|
|
131
|
-
if (messagePanelOpen) params.set('messages', '1');
|
|
179
|
+
if (messagePanelOpen && !logAutoOpened) params.set('messages', '1');
|
|
132
180
|
const qs = params.toString();
|
|
133
181
|
const url = qs ? `?${qs}` : window.location.pathname;
|
|
134
182
|
history.replaceState(null, '', url);
|
|
@@ -165,16 +213,12 @@ function resetState() {
|
|
|
165
213
|
store.removeItem(LAST_VIEW_KEY);
|
|
166
214
|
} catch (_) {}
|
|
167
215
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
168
|
-
sessionLimit =
|
|
216
|
+
sessionLimit = SESSION_PAGE;
|
|
169
217
|
filterProject = FILTER_DEFAULTS.project;
|
|
170
218
|
zenMode = false;
|
|
171
219
|
store.removeItem(ZEN_KEY);
|
|
172
220
|
renderZenState();
|
|
173
|
-
ownerFilter = '';
|
|
174
221
|
searchQuery = '';
|
|
175
|
-
viewMode = 'all';
|
|
176
|
-
if (agentLogMode) exitAgentLogMode();
|
|
177
|
-
currentSessionId = null;
|
|
178
222
|
currentProjectPath = null;
|
|
179
223
|
currentProjectSessionIds = [];
|
|
180
224
|
resetMessageScrollState();
|
|
@@ -182,7 +226,8 @@ function resetState() {
|
|
|
182
226
|
if (searchInput) searchInput.value = '';
|
|
183
227
|
document.getElementById('search-clear-btn')?.classList.remove('visible');
|
|
184
228
|
renderFilterState();
|
|
185
|
-
|
|
229
|
+
showNoSession();
|
|
230
|
+
fetchSessions();
|
|
186
231
|
}
|
|
187
232
|
|
|
188
233
|
//#endregion
|
|
@@ -223,51 +268,90 @@ async function fetchSessions(includeTasks = true) {
|
|
|
223
268
|
// opened from outside the current filter would leave the view on the previous one.
|
|
224
269
|
const includeParam = currentSessionId ? `&include=${encodeURIComponent(currentSessionId)}` : '';
|
|
225
270
|
const projectParam =
|
|
226
|
-
filterProject
|
|
271
|
+
filterProject === '__recent__'
|
|
272
|
+
? `&recentHours=${RECENT_PROJECT_HOURS}`
|
|
273
|
+
: filterProject
|
|
274
|
+
? `&project=${encodeURIComponent(filterProject)}`
|
|
275
|
+
: '';
|
|
227
276
|
const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
|
|
228
277
|
const sessionsPromise = fetch(
|
|
229
278
|
`/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
|
|
230
|
-
).then((r) =>
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
[newSessions, newTasks] = await Promise.all([sessionsPromise, fetch('/api/tasks/all').then((r) => r.json())]);
|
|
235
|
-
} else {
|
|
236
|
-
newSessions = await sessionsPromise;
|
|
237
|
-
}
|
|
279
|
+
).then((r) => {
|
|
280
|
+
sessionsHasMore = r.headers.get('X-Has-More') === 'true';
|
|
281
|
+
return r.json();
|
|
282
|
+
});
|
|
238
283
|
|
|
284
|
+
const newSessions = await sessionsPromise;
|
|
239
285
|
const sessionsHash = JSON.stringify(newSessions);
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
} else {
|
|
246
|
-
if (sessionsHash === lastSessionsHash) return;
|
|
286
|
+
if (sessionsHash !== lastSessionsHash) {
|
|
287
|
+
lastSessionsHash = sessionsHash;
|
|
288
|
+
sessions = mergePlaceholders(newSessions);
|
|
289
|
+
renderSessions();
|
|
290
|
+
renderActivityChip();
|
|
247
291
|
}
|
|
248
|
-
|
|
292
|
+
// A list too short to scroll gives no scroll event, so fill it until it can scroll.
|
|
293
|
+
if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
|
|
249
294
|
|
|
250
|
-
|
|
251
|
-
renderSessions();
|
|
252
|
-
renderActivityChip();
|
|
295
|
+
if (includeTasks && searchQuery) await loadSearchTasks();
|
|
253
296
|
} catch (error) {
|
|
254
297
|
console.error('Failed to fetch sessions:', error);
|
|
255
298
|
}
|
|
256
299
|
}
|
|
257
300
|
|
|
258
|
-
|
|
259
|
-
|
|
301
|
+
async function loadSearchTasks() {
|
|
302
|
+
const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
|
|
303
|
+
const tasksHash = JSON.stringify(newTasks);
|
|
304
|
+
if (tasksHash === lastTasksHash) return;
|
|
305
|
+
lastTasksHash = tasksHash;
|
|
306
|
+
allTasksCache = newTasks;
|
|
307
|
+
if (searchQuery) renderSessions();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let sessionsLoadingMore = false;
|
|
311
|
+
async function loadMoreSessions() {
|
|
312
|
+
if (!sessionsHasMore || sessionsLoadingMore) return;
|
|
313
|
+
sessionsLoadingMore = true;
|
|
314
|
+
sessionLimit += SESSION_PAGE;
|
|
260
315
|
try {
|
|
261
|
-
|
|
262
|
-
|
|
316
|
+
await fetchSessions(false);
|
|
317
|
+
} finally {
|
|
318
|
+
sessionsLoadingMore = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function resetSessionPage() {
|
|
323
|
+
sessionLimit = SESSION_PAGE;
|
|
324
|
+
sessionsList.scrollTop = 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const LOAD_MORE_MARGIN_PX = 240;
|
|
328
|
+
function loadMoreIfNearEnd() {
|
|
329
|
+
const { scrollTop, clientHeight, scrollHeight } = sessionsList;
|
|
330
|
+
if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) loadMoreSessions();
|
|
331
|
+
}
|
|
332
|
+
sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
|
|
333
|
+
|
|
334
|
+
// Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
|
|
335
|
+
async function fetchSessionsByIds(ids) {
|
|
336
|
+
try {
|
|
337
|
+
const r = await fetch(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`);
|
|
338
|
+
if (!r.ok) return [];
|
|
339
|
+
const byId = new Map((await r.json()).map((s) => [s.id, s]));
|
|
340
|
+
return ids.map((id) => byId.get(id)).filter(Boolean);
|
|
263
341
|
} catch (_) {
|
|
264
|
-
return
|
|
342
|
+
return [];
|
|
265
343
|
}
|
|
266
344
|
}
|
|
267
345
|
|
|
346
|
+
async function fetchSessionById(id) {
|
|
347
|
+
return (await fetchSessionsByIds([id]))[0] || null;
|
|
348
|
+
}
|
|
349
|
+
|
|
268
350
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
269
351
|
function handleSearch(query) {
|
|
352
|
+
const wasSearching = !!searchQuery;
|
|
270
353
|
searchQuery = query.toLowerCase().trim();
|
|
354
|
+
if (searchQuery && !wasSearching) loadSearchTasks().catch(() => {});
|
|
271
355
|
|
|
272
356
|
// Show/hide clear button
|
|
273
357
|
const clearBtn = document.getElementById('search-clear-btn');
|
|
@@ -465,11 +549,10 @@ async function fetchTasks(sessionId) {
|
|
|
465
549
|
revealedStorageSessionId = null;
|
|
466
550
|
}
|
|
467
551
|
if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
|
|
468
|
-
if (lastSessionId !== sessionId)
|
|
469
|
-
|
|
470
|
-
lastSessionId = sessionId;
|
|
471
|
-
}
|
|
552
|
+
if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
|
|
553
|
+
const switched = sessionId !== currentSessionId;
|
|
472
554
|
currentSessionId = sessionId;
|
|
555
|
+
if (switched) autoRevealLog(sessionId, newTasks.length === 0);
|
|
473
556
|
currentPins = loadPins(sessionId);
|
|
474
557
|
ownerFilter = '';
|
|
475
558
|
resetMessageScrollState();
|
|
@@ -632,7 +715,27 @@ async function refreshProjectAgents() {
|
|
|
632
715
|
}
|
|
633
716
|
|
|
634
717
|
//#region MESSAGE_PANEL
|
|
718
|
+
function setMessagePanelVisible(open) {
|
|
719
|
+
messagePanelOpen = open;
|
|
720
|
+
document.getElementById('message-panel').classList.toggle('visible', open);
|
|
721
|
+
document.getElementById('message-toggle')?.classList.toggle('active', open);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// An empty board has nothing to show, so the log opens in its place. It is not the user's choice,
|
|
725
|
+
// so it is not stored, it closes again on a session with tasks, and a manual close sticks for that session.
|
|
726
|
+
function autoRevealLog(sessionId, empty) {
|
|
727
|
+
if (empty && !messagePanelOpen && !logDismissed.has(sessionId) && !wantsTerminal()) {
|
|
728
|
+
setMessagePanelVisible(true);
|
|
729
|
+
logAutoOpened = true;
|
|
730
|
+
} else if (!empty && logAutoOpened) {
|
|
731
|
+
setMessagePanelVisible(false);
|
|
732
|
+
logAutoOpened = false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
635
736
|
function toggleMessagePanel() {
|
|
737
|
+
if (logAutoOpened && messagePanelOpen && currentSessionId) logDismissed.add(currentSessionId);
|
|
738
|
+
logAutoOpened = false;
|
|
636
739
|
const panel = document.getElementById('message-panel');
|
|
637
740
|
messagePanelOpen = !messagePanelOpen;
|
|
638
741
|
termState.panelHidden = false;
|
|
@@ -646,6 +749,34 @@ function toggleMessagePanel() {
|
|
|
646
749
|
updateUrl();
|
|
647
750
|
}
|
|
648
751
|
|
|
752
|
+
function applyMessagePanelFull(on) {
|
|
753
|
+
document.getElementById('message-panel').classList.toggle('full-width', on);
|
|
754
|
+
const btn = document.getElementById('message-panel-full-btn');
|
|
755
|
+
btn.classList.toggle('active', on);
|
|
756
|
+
btn.setAttribute('aria-pressed', String(on));
|
|
757
|
+
const label = on ? 'Restore width' : 'Expand to full width';
|
|
758
|
+
btn.title = label;
|
|
759
|
+
btn.setAttribute('aria-label', label);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
763
|
+
function toggleMessagePanelFull() {
|
|
764
|
+
const on = !document.getElementById('message-panel').classList.contains('full-width');
|
|
765
|
+
store.setItem('message-panel-full', on);
|
|
766
|
+
applyMessagePanelFull(on);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function initMessagePanelFull() {
|
|
770
|
+
const panel = document.getElementById('message-panel');
|
|
771
|
+
const sidebar = document.querySelector('.sidebar');
|
|
772
|
+
if (sidebar) {
|
|
773
|
+
const sync = () => panel.style.setProperty('--sidebar-edge', `${sidebar.getBoundingClientRect().right}px`);
|
|
774
|
+
new ResizeObserver(sync).observe(sidebar);
|
|
775
|
+
sync();
|
|
776
|
+
}
|
|
777
|
+
applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
|
|
778
|
+
}
|
|
779
|
+
|
|
649
780
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
650
781
|
async function openSessionWithBookmarks(sessionId) {
|
|
651
782
|
if (!messagePanelOpen) {
|
|
@@ -2243,22 +2374,41 @@ function isAnyModalOpen() {
|
|
|
2243
2374
|
|
|
2244
2375
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2245
2376
|
|
|
2377
|
+
function zoomDelta(e) {
|
|
2378
|
+
if (!(e.ctrlKey || e.metaKey) || e.altKey) return undefined;
|
|
2379
|
+
return ZOOM_KEYS[e.key] ?? ZOOM_KEYS[e.code];
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2246
2382
|
let _toastTimer = null;
|
|
2247
2383
|
let _manualRefreshing = false;
|
|
2248
2384
|
//#endregion
|
|
2249
2385
|
|
|
2250
2386
|
//#region TOAST
|
|
2251
|
-
|
|
2387
|
+
// `action` ({label, onClick}) adds a button and keeps the toast up long enough to reach it.
|
|
2388
|
+
function showToast(msg, type, action) {
|
|
2252
2389
|
const el = document.getElementById('toast');
|
|
2253
2390
|
clearTimeout(_toastTimer);
|
|
2254
2391
|
el.style.transition = 'none';
|
|
2255
|
-
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info');
|
|
2392
|
+
el.classList.remove('visible', 'toast-success', 'toast-error', 'toast-info', 'has-action');
|
|
2256
2393
|
void el.offsetHeight;
|
|
2257
2394
|
el.style.transition = '';
|
|
2258
2395
|
el.textContent = msg;
|
|
2396
|
+
const hide = () => el.classList.remove('visible', 'has-action');
|
|
2397
|
+
if (action) {
|
|
2398
|
+
const btn = document.createElement('button');
|
|
2399
|
+
btn.type = 'button';
|
|
2400
|
+
btn.className = 'toast-action';
|
|
2401
|
+
btn.textContent = action.label;
|
|
2402
|
+
btn.addEventListener('click', () => {
|
|
2403
|
+
hide();
|
|
2404
|
+
action.onClick();
|
|
2405
|
+
});
|
|
2406
|
+
el.append(btn);
|
|
2407
|
+
el.classList.add('has-action');
|
|
2408
|
+
}
|
|
2259
2409
|
if (type) el.classList.add(`toast-${type}`);
|
|
2260
2410
|
el.classList.add('visible');
|
|
2261
|
-
_toastTimer = setTimeout(
|
|
2411
|
+
_toastTimer = setTimeout(hide, action ? 8000 : 2000);
|
|
2262
2412
|
}
|
|
2263
2413
|
|
|
2264
2414
|
async function copyWithFeedback(text, btn) {
|
|
@@ -3073,6 +3223,8 @@ let revealedStorageSessionId = null;
|
|
|
3073
3223
|
// Opens a session and scrolls the sidebar to it, refetching first when the id
|
|
3074
3224
|
// fell outside the session list the current filters asked for.
|
|
3075
3225
|
async function revealSession(id) {
|
|
3226
|
+
// Explicit opens restore a dismissed session; activity and auto-follow do not.
|
|
3227
|
+
setSessionDismissed(id, false);
|
|
3076
3228
|
let session = sessions.find((s) => s.id === id);
|
|
3077
3229
|
if (!session) {
|
|
3078
3230
|
lastSessionsHash = '';
|
|
@@ -3107,53 +3259,18 @@ async function revealPlanSession(planSessionId) {
|
|
|
3107
3259
|
await revealSession(planSessionId);
|
|
3108
3260
|
}
|
|
3109
3261
|
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
}
|
|
3123
|
-
currentTasks = tasks;
|
|
3124
|
-
updateUrl();
|
|
3125
|
-
renderAllTasks();
|
|
3126
|
-
renderSessions();
|
|
3127
|
-
renderActivityChip();
|
|
3128
|
-
} catch (error) {
|
|
3129
|
-
console.error('Failed to fetch all tasks:', error);
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
function renderAllTasks() {
|
|
3134
|
-
noSession.style.display = 'none';
|
|
3135
|
-
sessionView.classList.add('visible');
|
|
3136
|
-
document.getElementById('owner-filter-bar').classList.remove('visible');
|
|
3137
|
-
|
|
3138
|
-
const visibleTasks = currentTasks.filter((t) => !isInternalTask(t));
|
|
3139
|
-
const totalTasks = visibleTasks.length;
|
|
3140
|
-
const completed = visibleTasks.filter((t) => t.status === 'completed').length;
|
|
3141
|
-
const percent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0;
|
|
3142
|
-
|
|
3143
|
-
const isFiltered = filterProject && filterProject !== '__recent__';
|
|
3144
|
-
const projectName = isFiltered ? filterProject.split(/[/\\]/).pop() : null;
|
|
3145
|
-
sessionTitle.textContent = isFiltered
|
|
3146
|
-
? `Tasks: ${projectName}`
|
|
3147
|
-
: filterProject === '__recent__'
|
|
3148
|
-
? 'Recent Tasks'
|
|
3149
|
-
: 'All Tasks';
|
|
3150
|
-
sessionMeta.textContent = isFiltered
|
|
3151
|
-
? `${totalTasks} tasks in this project`
|
|
3152
|
-
: `${totalTasks} tasks across ${sessions.length} sessions`;
|
|
3153
|
-
progressPercent.textContent = `${percent}%`;
|
|
3154
|
-
progressBar.style.width = `${percent}%`;
|
|
3155
|
-
|
|
3156
|
-
renderKanban();
|
|
3262
|
+
function showNoSession() {
|
|
3263
|
+
viewMode = 'none';
|
|
3264
|
+
if (agentLogMode) exitAgentLogMode();
|
|
3265
|
+
currentSessionId = null;
|
|
3266
|
+
ownerFilter = '';
|
|
3267
|
+
resetAgentState();
|
|
3268
|
+
currentTasks = [];
|
|
3269
|
+
updateUrl();
|
|
3270
|
+
sessionView.classList.remove('visible');
|
|
3271
|
+
noSession.style.display = '';
|
|
3272
|
+
renderSessions();
|
|
3273
|
+
renderActivityChip();
|
|
3157
3274
|
}
|
|
3158
3275
|
|
|
3159
3276
|
// Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
|
|
@@ -3260,8 +3377,7 @@ function renderSessions() {
|
|
|
3260
3377
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3261
3378
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3262
3379
|
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3263
|
-
|
|
3264
|
-
updateProjectDropdown();
|
|
3380
|
+
refreshProjectList();
|
|
3265
3381
|
|
|
3266
3382
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3267
3383
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
@@ -3274,16 +3390,16 @@ function renderSessions() {
|
|
|
3274
3390
|
|
|
3275
3391
|
if (zenMode) {
|
|
3276
3392
|
emptyMsg = 'Zen mode: no session open';
|
|
3277
|
-
emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
|
|
3393
|
+
emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
|
|
3278
3394
|
} else if (searchQuery) {
|
|
3279
3395
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3280
3396
|
emptyHint = 'Try a different search term or clear the search';
|
|
3281
3397
|
} else if (filterProject && sessionFilter === 'active') {
|
|
3282
|
-
emptyMsg = 'No active sessions for this project';
|
|
3283
|
-
emptyHint = 'Try "All Sessions" or
|
|
3398
|
+
emptyMsg = 'No active sessions for this project filter';
|
|
3399
|
+
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3284
3400
|
} else if (filterProject) {
|
|
3285
|
-
emptyMsg = 'No sessions for this project';
|
|
3286
|
-
emptyHint = '
|
|
3401
|
+
emptyMsg = 'No sessions for this project filter';
|
|
3402
|
+
emptyHint = 'Clear the project filter to see all';
|
|
3287
3403
|
} else if (sessionFilter === 'active') {
|
|
3288
3404
|
emptyMsg = 'No active sessions';
|
|
3289
3405
|
emptyHint = 'Select "All Sessions" to see all';
|
|
@@ -3338,7 +3454,6 @@ function renderSessions() {
|
|
|
3338
3454
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3339
3455
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3340
3456
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
3341
|
-
${renderGoalSubtitle(session)}
|
|
3342
3457
|
<div class="session-progress">
|
|
3343
3458
|
<span class="session-indicators">
|
|
3344
3459
|
${isTeam ? `<span class="team-badge" title="${memberCount} team members"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
|
|
@@ -3729,9 +3844,8 @@ function renderProjectView() {
|
|
|
3729
3844
|
|
|
3730
3845
|
function renderTaskCard(task) {
|
|
3731
3846
|
const isBlocked = task.blockedBy && task.blockedBy.length > 0;
|
|
3732
|
-
const useSlug = viewMode === '
|
|
3847
|
+
const useSlug = viewMode === 'project';
|
|
3733
3848
|
const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
|
|
3734
|
-
const sessionLabel = viewMode === 'all' && task.sessionName ? task.sessionName : null;
|
|
3735
3849
|
const statusClass = task.status.replace('_', '-');
|
|
3736
3850
|
const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
|
|
3737
3851
|
|
|
@@ -3762,7 +3876,6 @@ function renderTaskCard(task) {
|
|
|
3762
3876
|
}
|
|
3763
3877
|
</div>
|
|
3764
3878
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3765
|
-
${sessionLabel ? `<div class="task-session">${escapeHtml(sessionLabel)}</div>` : ''}
|
|
3766
3879
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3767
3880
|
${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3768
3881
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
@@ -4196,7 +4309,6 @@ function sgAssign(groupId, type, ref, opts = {}) {
|
|
|
4196
4309
|
if (loose) member.loose = true;
|
|
4197
4310
|
group.members.push(member);
|
|
4198
4311
|
}
|
|
4199
|
-
if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
|
|
4200
4312
|
persistSessionGroups();
|
|
4201
4313
|
}
|
|
4202
4314
|
|
|
@@ -4468,7 +4580,11 @@ function sgOnDrop(e) {
|
|
|
4468
4580
|
if (hit.groupId) {
|
|
4469
4581
|
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
4470
4582
|
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
4471
|
-
else
|
|
4583
|
+
else {
|
|
4584
|
+
sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4585
|
+
// A drop shows where the card landed; a move from the context menu leaves the group as it was.
|
|
4586
|
+
if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
|
|
4587
|
+
}
|
|
4472
4588
|
} else {
|
|
4473
4589
|
sgDetach(drag.kind, drag.ref);
|
|
4474
4590
|
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
@@ -4505,6 +4621,10 @@ function initSessionGroupsDnd() {
|
|
|
4505
4621
|
sessionsList.addEventListener('drop', sgOnDrop);
|
|
4506
4622
|
sessionsList.addEventListener('dragend', sgOnDragEnd);
|
|
4507
4623
|
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4624
|
+
// The Menu key fires contextmenu at the focused element, which by then is our menu.
|
|
4625
|
+
document.addEventListener('contextmenu', (e) => {
|
|
4626
|
+
if (e.target.closest?.('.sg-menu')) e.preventDefault();
|
|
4627
|
+
});
|
|
4508
4628
|
sessionsList.addEventListener('keydown', (e) => {
|
|
4509
4629
|
const input = e.target.closest?.('.sg-name-input');
|
|
4510
4630
|
if (!input) return;
|
|
@@ -4539,32 +4659,68 @@ function initSessionGroupsDnd() {
|
|
|
4539
4659
|
let sgLongPressTimer = null;
|
|
4540
4660
|
|
|
4541
4661
|
function sgCloseMenu() {
|
|
4662
|
+
document.removeEventListener('keydown', sgMenuKeydown, true);
|
|
4542
4663
|
const menu = document.getElementById('sg-menu');
|
|
4543
4664
|
if (menu) menu.remove();
|
|
4544
4665
|
}
|
|
4545
4666
|
|
|
4667
|
+
// Capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
4668
|
+
function sgMenuKeydown(e) {
|
|
4669
|
+
const menu = document.getElementById('sg-menu');
|
|
4670
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4671
|
+
const items = [...menu.querySelectorAll('.sg-menu-item')];
|
|
4672
|
+
const i = items.indexOf(document.activeElement);
|
|
4673
|
+
e.stopPropagation();
|
|
4674
|
+
if (e.key === 'Enter' || e.key === ' ') return;
|
|
4675
|
+
e.preventDefault();
|
|
4676
|
+
let next = null;
|
|
4677
|
+
if (matchKey(e, 'ArrowDown')) next = (i + 1) % items.length;
|
|
4678
|
+
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
4679
|
+
else if (e.key === 'Home') next = 0;
|
|
4680
|
+
else if (e.key === 'End') next = items.length - 1;
|
|
4681
|
+
else if (e.key === 'Escape' || e.key === 'Tab') sgCloseMenu();
|
|
4682
|
+
if (next !== null) items[next]?.focus();
|
|
4683
|
+
}
|
|
4684
|
+
|
|
4685
|
+
function sgOpenMenuForKbSelection() {
|
|
4686
|
+
const el = sessionsList.querySelector('.kb-selected');
|
|
4687
|
+
if (!el?.matches('.session-item, .project-group-header')) return false;
|
|
4688
|
+
const payload = sgDragPayload(el);
|
|
4689
|
+
if (!payload?.ref) return false;
|
|
4690
|
+
const rect = el.getBoundingClientRect();
|
|
4691
|
+
sgOpenMenu(rect.left + 16, rect.bottom, payload.kind, payload.ref);
|
|
4692
|
+
return true;
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4546
4695
|
function sgOpenMenu(x, y, kind, ref) {
|
|
4547
4696
|
sgCloseMenu();
|
|
4548
4697
|
const current = sgGroupOf(kind, ref);
|
|
4549
4698
|
const label = kind === 'project' ? 'project' : 'session';
|
|
4550
4699
|
const rows = sessionGroups
|
|
4551
4700
|
.filter((g) => g.id !== current?.id)
|
|
4552
|
-
.map(
|
|
4701
|
+
.map(
|
|
4702
|
+
(g) =>
|
|
4703
|
+
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
|
|
4704
|
+
)
|
|
4553
4705
|
.join('');
|
|
4554
4706
|
const menu = document.createElement('div');
|
|
4555
4707
|
menu.id = 'sg-menu';
|
|
4556
4708
|
menu.className = 'sg-menu';
|
|
4709
|
+
menu.setAttribute('role', 'menu');
|
|
4710
|
+
menu.setAttribute('aria-label', `Move ${label} to group`);
|
|
4557
4711
|
menu.dataset.sgKind = kind;
|
|
4558
4712
|
menu.dataset.sgRef = ref;
|
|
4559
4713
|
menu.innerHTML = `
|
|
4560
|
-
<div class="sg-menu-label">Move ${label} to group</div>
|
|
4714
|
+
<div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
|
|
4561
4715
|
${rows}
|
|
4562
|
-
<button class="sg-menu-item" data-sg-move="__new__">+ New group…</button>
|
|
4563
|
-
${current ? `<button class="sg-menu-item sg-menu-remove" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4716
|
+
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
4717
|
+
${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4564
4718
|
document.body.appendChild(menu);
|
|
4565
4719
|
const rect = menu.getBoundingClientRect();
|
|
4566
4720
|
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4567
4721
|
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4722
|
+
document.addEventListener('keydown', sgMenuKeydown, true);
|
|
4723
|
+
menu.querySelector('.sg-menu-item')?.focus();
|
|
4568
4724
|
}
|
|
4569
4725
|
|
|
4570
4726
|
function sgOnContextMenu(e) {
|
|
@@ -5198,13 +5354,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
5198
5354
|
|
|
5199
5355
|
if (res.ok) {
|
|
5200
5356
|
lastCurrentTasksHash = null;
|
|
5201
|
-
|
|
5202
|
-
const tasksRes = await fetch('/api/tasks/all');
|
|
5203
|
-
currentTasks = await tasksRes.json();
|
|
5204
|
-
renderKanban();
|
|
5205
|
-
} else {
|
|
5206
|
-
await fetchTasks(sessionId);
|
|
5207
|
-
}
|
|
5357
|
+
await fetchTasks(sessionId);
|
|
5208
5358
|
showTaskDetail(taskId, sessionId);
|
|
5209
5359
|
}
|
|
5210
5360
|
} catch (error) {
|
|
@@ -5288,11 +5438,11 @@ function deleteTask(taskId, sessionId) {
|
|
|
5288
5438
|
if (e.key === 'Escape') {
|
|
5289
5439
|
e.preventDefault();
|
|
5290
5440
|
closeDeleteConfirmModal();
|
|
5291
|
-
} else if (matchKey(e, 'ArrowLeft'
|
|
5441
|
+
} else if (matchKey(e, 'ArrowLeft')) {
|
|
5292
5442
|
e.preventDefault();
|
|
5293
5443
|
focusIdx = 0;
|
|
5294
5444
|
buttons[focusIdx].focus();
|
|
5295
|
-
} else if (matchKey(e, 'ArrowRight'
|
|
5445
|
+
} else if (matchKey(e, 'ArrowRight')) {
|
|
5296
5446
|
e.preventDefault();
|
|
5297
5447
|
focusIdx = 1;
|
|
5298
5448
|
buttons[focusIdx].focus();
|
|
@@ -5354,10 +5504,10 @@ const SHORTCUT_PAIRS = [
|
|
|
5354
5504
|
{
|
|
5355
5505
|
title: 'Navigate',
|
|
5356
5506
|
rows: [
|
|
5357
|
-
{ keys: ['
|
|
5358
|
-
{ keys: ['
|
|
5359
|
-
{ keys: ['
|
|
5360
|
-
{ keys: ['
|
|
5507
|
+
{ keys: ['↓'], label: 'Next item' },
|
|
5508
|
+
{ keys: ['↑'], label: 'Previous item' },
|
|
5509
|
+
{ keys: ['←'], label: 'Left column / collapse group' },
|
|
5510
|
+
{ keys: ['→'], label: 'Right column / expand group' },
|
|
5361
5511
|
{ keys: ['Tab'], label: 'Switch sidebar ↔ board' },
|
|
5362
5512
|
{ keys: ['Enter', 'Space'], label: 'Open selected item' },
|
|
5363
5513
|
],
|
|
@@ -5366,14 +5516,15 @@ const SHORTCUT_PAIRS = [
|
|
|
5366
5516
|
title: 'Session',
|
|
5367
5517
|
rows: [
|
|
5368
5518
|
{ keys: ['P'], label: 'Open plan' },
|
|
5369
|
-
{ keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5519
|
+
{ keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5370
5520
|
{ keys: ['I'], label: 'Session info' },
|
|
5371
5521
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5372
5522
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5523
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5373
5524
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5374
5525
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5375
5526
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5376
|
-
{ keys: ['
|
|
5527
|
+
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
5377
5528
|
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5378
5529
|
],
|
|
5379
5530
|
},
|
|
@@ -5395,7 +5546,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5395
5546
|
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5396
5547
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5397
5548
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5398
|
-
{ keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5549
|
+
{ keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5399
5550
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5400
5551
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5401
5552
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -5432,6 +5583,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5432
5583
|
{ keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
|
|
5433
5584
|
{ keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
|
|
5434
5585
|
{ keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
|
|
5586
|
+
{ keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
|
|
5435
5587
|
],
|
|
5436
5588
|
},
|
|
5437
5589
|
{
|
|
@@ -5490,9 +5642,7 @@ function closeHelpModal() {
|
|
|
5490
5642
|
}
|
|
5491
5643
|
|
|
5492
5644
|
async function refreshCurrentView() {
|
|
5493
|
-
if (
|
|
5494
|
-
await showAllTasks();
|
|
5495
|
-
} else if (currentSessionId) {
|
|
5645
|
+
if (currentSessionId) {
|
|
5496
5646
|
await fetchTasks(currentSessionId);
|
|
5497
5647
|
} else {
|
|
5498
5648
|
await fetchSessions();
|
|
@@ -5979,11 +6129,12 @@ function _renderStorageLinkedDocs() {
|
|
|
5979
6129
|
const name = linkedDocLabel(p);
|
|
5980
6130
|
const sid = escAttrJs(sessionId);
|
|
5981
6131
|
const jsPath = escAttrJs(p);
|
|
6132
|
+
const what = LINKED_DOC_OPENERS[linkedDocOpener(p)].noun;
|
|
5982
6133
|
return `<div class="storage-item" style="padding-left:24px;">
|
|
5983
6134
|
<span class="storage-item-id" title="${escapeHtml(p)}">${escapeHtml(name)}</span>
|
|
5984
6135
|
<div class="storage-item-actions">
|
|
5985
6136
|
<button onclick="_storagePreviewLinkedDoc('${jsPath}')">View</button>
|
|
5986
|
-
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy
|
|
6137
|
+
<button onclick="copyWithFeedback('${jsPath}', this)" title="Copy ${what}" aria-label="Copy ${what}">${ICON_COPY}</button>
|
|
5987
6138
|
<button class="danger" onclick="_storageUnlinkDoc('${sid}','${jsPath}')">Unlink</button>
|
|
5988
6139
|
</div>
|
|
5989
6140
|
</div>`;
|
|
@@ -6025,10 +6176,12 @@ function _storagePreviewLinkedDoc(path) {
|
|
|
6025
6176
|
|
|
6026
6177
|
function _storageUnlinkDoc(sessionId, path) {
|
|
6027
6178
|
setSessionDocLink(sessionId, path, true);
|
|
6179
|
+
forgetServerLinkedDoc(sessionId, path);
|
|
6028
6180
|
}
|
|
6029
6181
|
|
|
6030
6182
|
function _storageClearLinkedDocs(sessionId) {
|
|
6031
6183
|
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6184
|
+
forgetServerLinkedDoc(sessionId);
|
|
6032
6185
|
// The auto-link record deliberately survives: clearing is unlinking every row at
|
|
6033
6186
|
// once, and an unlinked pad must not come back on the next read of the transcript.
|
|
6034
6187
|
afterLinkedDocsChanged(sessionId);
|
|
@@ -6130,13 +6283,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6130
6283
|
// Scale the open modal's reading surface instead of letting the browser zoom
|
|
6131
6284
|
// the whole page. Sits above the text-field guard so it still works with the
|
|
6132
6285
|
// caret in a field inside the modal.
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
6137
|
-
|
|
6138
|
-
return;
|
|
6139
|
-
}
|
|
6286
|
+
const zoom = zoomDelta(e);
|
|
6287
|
+
if (zoom !== undefined && isZoomableModalOpen()) {
|
|
6288
|
+
e.preventDefault();
|
|
6289
|
+
adjustModalZoom(zoom);
|
|
6290
|
+
return;
|
|
6140
6291
|
}
|
|
6141
6292
|
|
|
6142
6293
|
// Above the text-field guard: xterm's input is a textarea, and the toggle must work from it.
|
|
@@ -6148,11 +6299,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6148
6299
|
return;
|
|
6149
6300
|
}
|
|
6150
6301
|
if (e.key === 'Enter' || e.key === ' ') return;
|
|
6151
|
-
const step = matchKey(e, 'ArrowLeft', 'ArrowUp', '
|
|
6152
|
-
? -1
|
|
6153
|
-
: matchKey(e, 'ArrowRight', 'ArrowDown', 'KeyL', 'KeyJ')
|
|
6154
|
-
? 1
|
|
6155
|
-
: 0;
|
|
6302
|
+
const step = matchKey(e, 'ArrowLeft', 'ArrowUp') ? -1 : matchKey(e, 'ArrowRight', 'ArrowDown') ? 1 : 0;
|
|
6156
6303
|
if (step) {
|
|
6157
6304
|
e.preventDefault();
|
|
6158
6305
|
const buttons = [...terminalPrompt.querySelectorAll('button')];
|
|
@@ -6172,7 +6319,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6172
6319
|
return;
|
|
6173
6320
|
}
|
|
6174
6321
|
|
|
6175
|
-
// Modal guard — only Escape, Shift+M, and msg-detail
|
|
6322
|
+
// Modal guard — only Escape, Shift+M, and msg-detail arrow navigation pass through
|
|
6176
6323
|
if (isAnyModalOpen()) {
|
|
6177
6324
|
if (e.key === 'Escape') {
|
|
6178
6325
|
if (_scratchpadModal.classList.contains('visible')) {
|
|
@@ -6206,7 +6353,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6206
6353
|
) {
|
|
6207
6354
|
e.preventDefault();
|
|
6208
6355
|
respondWaiting({ behavior: 'allow' });
|
|
6209
|
-
} else if (matchKey(e, 'ArrowDown'
|
|
6356
|
+
} else if (matchKey(e, 'ArrowDown')) {
|
|
6210
6357
|
e.preventDefault();
|
|
6211
6358
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
6212
6359
|
msgDetailFollowLatest = true;
|
|
@@ -6221,7 +6368,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6221
6368
|
msgDetailFollowLatest = true;
|
|
6222
6369
|
showMsgDetail(currentMsgDetailIdx);
|
|
6223
6370
|
}
|
|
6224
|
-
} else if (matchKey(e, 'ArrowUp'
|
|
6371
|
+
} else if (matchKey(e, 'ArrowUp')) {
|
|
6225
6372
|
e.preventDefault();
|
|
6226
6373
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
6227
6374
|
if (currentMessages.length) {
|
|
@@ -6272,12 +6419,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
6272
6419
|
toggleZenMode();
|
|
6273
6420
|
return;
|
|
6274
6421
|
}
|
|
6275
|
-
// Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
|
|
6276
|
-
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
6277
|
-
e.preventDefault();
|
|
6278
|
-
openSessionPicker();
|
|
6279
|
-
return;
|
|
6280
|
-
}
|
|
6281
6422
|
if (e.key === '.' || e.key === '>') {
|
|
6282
6423
|
const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
|
|
6283
6424
|
if (sid) {
|
|
@@ -6306,22 +6447,22 @@ document.addEventListener('keydown', (e) => {
|
|
|
6306
6447
|
|
|
6307
6448
|
// Sidebar navigation
|
|
6308
6449
|
if (focusZone === 'sidebar') {
|
|
6309
|
-
if (matchKey(e, 'ArrowDown'
|
|
6450
|
+
if (matchKey(e, 'ArrowDown')) {
|
|
6310
6451
|
e.preventDefault();
|
|
6311
6452
|
navigateSession(1);
|
|
6312
6453
|
return;
|
|
6313
6454
|
}
|
|
6314
|
-
if (matchKey(e, 'ArrowUp'
|
|
6455
|
+
if (matchKey(e, 'ArrowUp')) {
|
|
6315
6456
|
e.preventDefault();
|
|
6316
6457
|
navigateSession(-1);
|
|
6317
6458
|
return;
|
|
6318
6459
|
}
|
|
6319
|
-
if (matchKey(e, 'ArrowLeft'
|
|
6460
|
+
if (matchKey(e, 'ArrowLeft')) {
|
|
6320
6461
|
e.preventDefault();
|
|
6321
6462
|
handleSidebarHorizontal(-1);
|
|
6322
6463
|
return;
|
|
6323
6464
|
}
|
|
6324
|
-
if (matchKey(e, 'ArrowRight'
|
|
6465
|
+
if (matchKey(e, 'ArrowRight')) {
|
|
6325
6466
|
e.preventDefault();
|
|
6326
6467
|
handleSidebarHorizontal(1);
|
|
6327
6468
|
return;
|
|
@@ -6331,6 +6472,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6331
6472
|
activateSelectedSession();
|
|
6332
6473
|
return;
|
|
6333
6474
|
}
|
|
6475
|
+
const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
6476
|
+
if (menuKey && sgOpenMenuForKbSelection()) {
|
|
6477
|
+
e.preventDefault();
|
|
6478
|
+
return;
|
|
6479
|
+
}
|
|
6334
6480
|
if (e.key === 'Escape') {
|
|
6335
6481
|
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
6336
6482
|
clearKbSelection();
|
|
@@ -6341,16 +6487,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
6341
6487
|
|
|
6342
6488
|
// Board navigation
|
|
6343
6489
|
if (focusZone === 'board') {
|
|
6344
|
-
if (matchKey(e, 'ArrowDown', '
|
|
6490
|
+
if (matchKey(e, 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight')) {
|
|
6345
6491
|
e.preventDefault();
|
|
6346
6492
|
if (!selectedTaskId && !document.querySelector('.task-card.selected')) {
|
|
6347
6493
|
setFocusZone('sidebar');
|
|
6348
6494
|
return;
|
|
6349
6495
|
}
|
|
6350
|
-
if (matchKey(e, 'ArrowDown'
|
|
6351
|
-
else if (matchKey(e, 'ArrowUp'
|
|
6352
|
-
else if (matchKey(e, 'ArrowLeft'
|
|
6353
|
-
else if (matchKey(e, 'ArrowRight'
|
|
6496
|
+
if (matchKey(e, 'ArrowDown')) navigateVertical(1);
|
|
6497
|
+
else if (matchKey(e, 'ArrowUp')) navigateVertical(-1);
|
|
6498
|
+
else if (matchKey(e, 'ArrowLeft')) navigateHorizontal(-1);
|
|
6499
|
+
else if (matchKey(e, 'ArrowRight')) navigateHorizontal(1);
|
|
6354
6500
|
|
|
6355
6501
|
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
6356
6502
|
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
@@ -6433,12 +6579,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
6433
6579
|
}
|
|
6434
6580
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
6435
6581
|
e.preventDefault();
|
|
6436
|
-
if (!contextSid || dismissedSessionIds.has(contextSid)) return;
|
|
6437
6582
|
const prevIdx = selectedSessionIdx;
|
|
6438
|
-
|
|
6439
|
-
updateDismissBtnState();
|
|
6440
|
-
renderSessions();
|
|
6441
|
-
renderActivityChip();
|
|
6583
|
+
if (!contextSid || !setSessionDismissed(contextSid, true)) return;
|
|
6442
6584
|
const newItems = getNavigableItems();
|
|
6443
6585
|
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6444
6586
|
// If the dismissed session is currently open, navigate to the previous one
|
|
@@ -6449,10 +6591,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6449
6591
|
if (targetSid) {
|
|
6450
6592
|
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
6451
6593
|
} else {
|
|
6452
|
-
|
|
6594
|
+
showNoSession();
|
|
6595
|
+
selectSessionByIndex(targetIdx, getNavigableItems());
|
|
6453
6596
|
}
|
|
6454
6597
|
} else {
|
|
6455
|
-
|
|
6598
|
+
showNoSession();
|
|
6456
6599
|
}
|
|
6457
6600
|
} else if (targetIdx >= 0) {
|
|
6458
6601
|
selectSessionByIndex(targetIdx, newItems);
|
|
@@ -6555,6 +6698,31 @@ function setSessionDocLink(sessionId, filePath, unlink) {
|
|
|
6555
6698
|
afterLinkedDocsChanged(sessionId);
|
|
6556
6699
|
}
|
|
6557
6700
|
|
|
6701
|
+
// Links sent while no tab was open reach the browser only through the server copy.
|
|
6702
|
+
async function mergeServerLinkedDocs() {
|
|
6703
|
+
let bySession;
|
|
6704
|
+
try {
|
|
6705
|
+
const res = await fetch('/api/document/links');
|
|
6706
|
+
if (!res.ok) return;
|
|
6707
|
+
bySession = await res.json();
|
|
6708
|
+
} catch {
|
|
6709
|
+
return;
|
|
6710
|
+
}
|
|
6711
|
+
for (const [sessionId, paths] of Object.entries(bySession || {})) {
|
|
6712
|
+
const local = getSessionPreviewPaths(sessionId);
|
|
6713
|
+
const known = new Set(local.map(canonicalPath));
|
|
6714
|
+
const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
|
|
6715
|
+
if (!missing.length) continue;
|
|
6716
|
+
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify([...missing, ...local].slice(0, 20)));
|
|
6717
|
+
afterLinkedDocsChanged(sessionId);
|
|
6718
|
+
}
|
|
6719
|
+
}
|
|
6720
|
+
|
|
6721
|
+
function forgetServerLinkedDoc(sessionId, filePath) {
|
|
6722
|
+
const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
|
|
6723
|
+
fetch(`/api/document/links/${encodeURIComponent(sessionId)}${q}`, { method: 'DELETE' }).catch(() => {});
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6558
6726
|
function afterLinkedDocsChanged(sessionId) {
|
|
6559
6727
|
if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
|
|
6560
6728
|
renderSessions();
|
|
@@ -6770,6 +6938,7 @@ function handleSessionOpenEvent(data) {
|
|
|
6770
6938
|
if (!isSessionActive(target)) {
|
|
6771
6939
|
stickySessionIds.add(id);
|
|
6772
6940
|
}
|
|
6941
|
+
setSessionDismissed(id, false);
|
|
6773
6942
|
fetchTasks(id);
|
|
6774
6943
|
}
|
|
6775
6944
|
|
|
@@ -6789,10 +6958,13 @@ async function handlePreviewOpenEvent(data) {
|
|
|
6789
6958
|
// Linked docs live in localStorage, so the CLI can only reach them through a tab:
|
|
6790
6959
|
// the server broadcasts the resolved path and every tab applies it idempotently.
|
|
6791
6960
|
function handleDocumentLinkEvent(data) {
|
|
6792
|
-
const { path: filePath, sessionId, unlink } = data;
|
|
6961
|
+
const { path: filePath, sessionId, unlink, open } = data;
|
|
6793
6962
|
if (!filePath || !sessionId) return;
|
|
6794
6963
|
setSessionDocLink(sessionId, filePath, unlink);
|
|
6795
|
-
|
|
6964
|
+
const msg = `${unlink ? 'Unlinked' : 'Linked'} ${linkedDocLabel(filePath)}`;
|
|
6965
|
+
// Every tab gets the event; only the one on screen offers to open the URL.
|
|
6966
|
+
if (open && isOnScreen()) showToast(msg, 'info', { label: 'Open', onClick: () => openExternal(filePath) });
|
|
6967
|
+
else showToast(msg);
|
|
6796
6968
|
}
|
|
6797
6969
|
|
|
6798
6970
|
function getSessionBaseDir(sessionId) {
|
|
@@ -6874,10 +7046,13 @@ function isScratchpadPath(p) {
|
|
|
6874
7046
|
// `linkedDocOpener` is the only place a linked path is classified; the row's tag,
|
|
6875
7047
|
// tooltip and action are all read from here, so a new destination is one entry.
|
|
6876
7048
|
const LINKED_DOC_OPENERS = {
|
|
6877
|
-
preview: { tag: '', hint: '' },
|
|
7049
|
+
preview: { tag: '', hint: '', cls: '', noun: 'path' },
|
|
7050
|
+
url: { tag: '', hint: ' — opens in a new tab', cls: ' is-url', noun: 'URL' },
|
|
6878
7051
|
scratch: {
|
|
6879
7052
|
tag: '(scratchpad)',
|
|
6880
7053
|
hint: ' — opens in the scratch viewer',
|
|
7054
|
+
cls: ' is-editor',
|
|
7055
|
+
noun: 'path',
|
|
6881
7056
|
url: '/api/scratchpad/open',
|
|
6882
7057
|
body: (p) => ({ path: p }),
|
|
6883
7058
|
toast: 'in the scratch viewer',
|
|
@@ -6885,6 +7060,8 @@ const LINKED_DOC_OPENERS = {
|
|
|
6885
7060
|
editor: {
|
|
6886
7061
|
tag: '(editor)',
|
|
6887
7062
|
hint: ' — opens in editor',
|
|
7063
|
+
cls: ' is-editor',
|
|
7064
|
+
noun: 'path',
|
|
6888
7065
|
url: '/api/open-in-editor',
|
|
6889
7066
|
body: (p) => ({ file: p }),
|
|
6890
7067
|
toast: 'in editor',
|
|
@@ -6895,6 +7072,7 @@ const LINKED_DOC_OPENERS = {
|
|
|
6895
7072
|
// text, but the pad is what the user linked, so the viewer wins over the raw file.
|
|
6896
7073
|
// Without the CLI there is nothing to launch, so it falls back to the editor.
|
|
6897
7074
|
function linkedDocOpener(p) {
|
|
7075
|
+
if (linkUrl(p)) return 'url';
|
|
6898
7076
|
if (isScratchpadPath(p)) return appConfig.scratchAvailable ? 'scratch' : 'editor';
|
|
6899
7077
|
// A path whose kind has not arrived yet renders as previewable, which is what the
|
|
6900
7078
|
// click does anyway — openPreviewByPath falls back to the editor on a null kind.
|
|
@@ -6904,12 +7082,17 @@ function linkedDocOpener(p) {
|
|
|
6904
7082
|
// Every manifest is called scratchpad.json, so the pad's folder names it. Kept
|
|
6905
7083
|
// independent of the opener: the pad is still the pad when the CLI is missing.
|
|
6906
7084
|
function linkedDocLabel(p) {
|
|
7085
|
+
if (linkUrl(p)) return p.replace(/^https?:\/\//i, '').replace(/\/$/, '');
|
|
6907
7086
|
const parts = p.split(/[\\/]/).filter(Boolean);
|
|
6908
7087
|
return isScratchpadPath(p) ? parts[parts.length - 2] || parts[parts.length - 1] : parts[parts.length - 1];
|
|
6909
7088
|
}
|
|
6910
7089
|
|
|
6911
7090
|
function openLinkedDoc(p, baseDir) {
|
|
6912
7091
|
const opener = linkedDocOpener(p);
|
|
7092
|
+
if (opener === 'url') {
|
|
7093
|
+
openExternal(p);
|
|
7094
|
+
return;
|
|
7095
|
+
}
|
|
6913
7096
|
if (opener === 'preview') {
|
|
6914
7097
|
openPreviewByPath(p, baseDir, openFileInEditor);
|
|
6915
7098
|
return;
|
|
@@ -6922,21 +7105,24 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6922
7105
|
const paths = getSessionPreviewPaths(sessionId);
|
|
6923
7106
|
// Kicked off from the render rather than the three call sites, so a new surface that
|
|
6924
7107
|
// shows linked docs cannot forget it. Returns at once once every path is cached.
|
|
6925
|
-
|
|
7108
|
+
const rows = paths.map((p) => ({ p, opener: linkedDocOpener(p) }));
|
|
7109
|
+
const filePaths = rows.filter((r) => r.opener !== 'url').map((r) => r.p);
|
|
7110
|
+
loadPreviewKinds(sessionId, filePaths);
|
|
6926
7111
|
const baseDir = getSessionBaseDir(sessionId);
|
|
6927
|
-
const items =
|
|
6928
|
-
.map((p) => {
|
|
6929
|
-
const
|
|
6930
|
-
const { tag, hint } = LINKED_DOC_OPENERS[opener];
|
|
7112
|
+
const items = rows
|
|
7113
|
+
.map(({ p, opener }) => {
|
|
7114
|
+
const { tag, hint, cls, noun } = LINKED_DOC_OPENERS[opener];
|
|
6931
7115
|
const name = linkedDocLabel(p);
|
|
7116
|
+
const isUrl = opener === 'url';
|
|
6932
7117
|
const rel = baseDir ? toRelativeIfUnder(p, baseDir) : null;
|
|
6933
7118
|
const pathSpan = rel ? `<span class="linked-doc-path" title="${escapeHtml(p)}">${escapeHtml(rel)}</span>` : '';
|
|
6934
7119
|
const attr = escapeHtml(p);
|
|
6935
|
-
|
|
6936
|
-
|
|
7120
|
+
const href = isUrl ? `href="${attr}" target="_blank" rel="noopener"` : 'href="#"';
|
|
7121
|
+
return `<li class="linked-doc-item${cls}">
|
|
7122
|
+
<a ${href} class="linked-doc-link" data-path="${attr}" title="${escapeHtml(p + hint)}">${escapeHtml(name)}</a>${isUrl ? '<span class="linked-doc-external" aria-hidden="true">↗</span>' : ''}
|
|
6937
7123
|
${pathSpan}${tag ? `<span class="linked-doc-path">${tag}</span>` : ''}
|
|
6938
7124
|
<span class="row-actions linked-doc-actions">
|
|
6939
|
-
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy
|
|
7125
|
+
<button type="button" class="linked-doc-copy" data-path="${attr}" title="Copy ${noun}" aria-label="Copy ${noun} of ${escapeHtml(name)}">${ICON_COPY}</button>
|
|
6940
7126
|
<button type="button" class="linked-doc-remove" data-path="${attr}" title="Unlink" aria-label="Unlink ${escapeHtml(name)}">×</button>
|
|
6941
7127
|
</span>
|
|
6942
7128
|
</li>`;
|
|
@@ -6945,13 +7131,13 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6945
7131
|
// Rendered even when empty — the add button has to stay reachable.
|
|
6946
7132
|
const body = paths.length
|
|
6947
7133
|
? `<ul class="linked-doc-list">${items}</ul>`
|
|
6948
|
-
: '<div class="linked-docs-empty">No linked
|
|
7134
|
+
: '<div class="linked-docs-empty">No linked documents yet</div>';
|
|
6949
7135
|
return `<div class="linked-docs-section panel-section">
|
|
6950
7136
|
<div class="panel-section-header">
|
|
6951
7137
|
${linkSvg(12)}
|
|
6952
7138
|
<span>Linked documents</span>
|
|
6953
7139
|
<span class="panel-section-count">${paths.length}</span>
|
|
6954
|
-
<button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
|
|
7140
|
+
<button type="button" class="linked-docs-add-btn" title="Link a file or URL" aria-label="Link a file or URL">+</button>
|
|
6955
7141
|
</div>
|
|
6956
7142
|
<div class="linked-doc-editor-slot"></div>
|
|
6957
7143
|
${body}
|
|
@@ -7220,7 +7406,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7220
7406
|
if (!container) return;
|
|
7221
7407
|
container.addEventListener('click', (e) => {
|
|
7222
7408
|
const hit = e.target.closest('.linked-doc-link, .linked-doc-copy, .linked-doc-remove, .linked-docs-add-btn');
|
|
7223
|
-
if (!hit) return;
|
|
7409
|
+
if (!hit || hit.target === '_blank') return;
|
|
7224
7410
|
e.preventDefault();
|
|
7225
7411
|
if (hit.classList.contains('linked-docs-add-btn')) {
|
|
7226
7412
|
startLinkedDocInput(container, sessionId);
|
|
@@ -7228,6 +7414,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7228
7414
|
copyWithFeedback(hit.dataset.path, hit);
|
|
7229
7415
|
} else if (hit.classList.contains('linked-doc-remove')) {
|
|
7230
7416
|
removeSessionPreviewPath(sessionId, hit.dataset.path);
|
|
7417
|
+
forgetServerLinkedDoc(sessionId, hit.dataset.path);
|
|
7231
7418
|
afterLinkedDocsChanged(sessionId);
|
|
7232
7419
|
} else {
|
|
7233
7420
|
openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
@@ -7238,7 +7425,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7238
7425
|
function startLinkedDocInput(container, sessionId) {
|
|
7239
7426
|
const slot = container.querySelector('.linked-doc-editor-slot');
|
|
7240
7427
|
if (!slot) return;
|
|
7241
|
-
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="
|
|
7428
|
+
slot.innerHTML = `<input class="linked-doc-input" type="text" spellcheck="false" placeholder="Path, file:// or https:// URL">
|
|
7242
7429
|
<div class="linked-doc-hint">${escapeHtml(getSessionBaseDir(sessionId) || 'no session cwd — absolute paths only')}</div>
|
|
7243
7430
|
<div class="linked-doc-error"></div>
|
|
7244
7431
|
<div class="edit-actions">
|
|
@@ -7252,6 +7439,9 @@ function startLinkedDocInput(container, sessionId) {
|
|
|
7252
7439
|
};
|
|
7253
7440
|
slot.querySelector('.edit-save').addEventListener('click', save);
|
|
7254
7441
|
slot.querySelector('.edit-cancel').addEventListener('click', cancel);
|
|
7442
|
+
input.addEventListener('input', () => {
|
|
7443
|
+
slot.querySelector('.linked-doc-error').textContent = '';
|
|
7444
|
+
});
|
|
7255
7445
|
input.addEventListener('keydown', (e) => {
|
|
7256
7446
|
if (e.key === 'Enter') {
|
|
7257
7447
|
e.preventDefault();
|
|
@@ -7274,27 +7464,38 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
7274
7464
|
else showToast(msg, 'error');
|
|
7275
7465
|
};
|
|
7276
7466
|
if (!value) {
|
|
7277
|
-
fail('Enter a file path');
|
|
7467
|
+
fail('Enter a file path or URL');
|
|
7278
7468
|
return;
|
|
7279
7469
|
}
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
if (
|
|
7284
|
-
|
|
7285
|
-
|
|
7286
|
-
|
|
7287
|
-
|
|
7470
|
+
let target;
|
|
7471
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^file:/i.test(value)) {
|
|
7472
|
+
target = linkUrl(value);
|
|
7473
|
+
if (!target) {
|
|
7474
|
+
fail('Only http and https URLs can be linked');
|
|
7475
|
+
return;
|
|
7476
|
+
}
|
|
7477
|
+
} else {
|
|
7478
|
+
try {
|
|
7479
|
+
const qs = new URLSearchParams({ path: value });
|
|
7480
|
+
const base = getSessionBaseDir(sessionId);
|
|
7481
|
+
if (base) qs.set('base', base);
|
|
7482
|
+
const r = await fetch(`/api/file/resolve?${qs}`);
|
|
7483
|
+
const data = await r.json().catch(() => ({}));
|
|
7484
|
+
if (!r.ok || !data.exists) {
|
|
7485
|
+
fail(data.error || 'File not found');
|
|
7486
|
+
return;
|
|
7487
|
+
}
|
|
7488
|
+
target = data.path;
|
|
7489
|
+
} catch {
|
|
7490
|
+
fail('Failed to resolve file');
|
|
7288
7491
|
return;
|
|
7289
7492
|
}
|
|
7290
|
-
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
7291
|
-
// input is open, so leaving it up would hide the file that was just linked.
|
|
7292
|
-
if (slot) slot.innerHTML = '';
|
|
7293
|
-
setSessionDocLink(sessionId, data.path, false);
|
|
7294
|
-
showToast('Linked to session', 'success');
|
|
7295
|
-
} catch {
|
|
7296
|
-
fail('Failed to resolve file');
|
|
7297
7493
|
}
|
|
7494
|
+
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
7495
|
+
// input is open, so leaving it up would hide the file that was just linked.
|
|
7496
|
+
if (slot) slot.innerHTML = '';
|
|
7497
|
+
setSessionDocLink(sessionId, target, false);
|
|
7498
|
+
showToast('Linked to session', 'success');
|
|
7298
7499
|
}
|
|
7299
7500
|
//#endregion
|
|
7300
7501
|
|
|
@@ -7324,12 +7525,6 @@ function setupEventSource() {
|
|
|
7324
7525
|
const pendingTaskSessionIds = new Set();
|
|
7325
7526
|
const pendingAgentSessionIds = new Set();
|
|
7326
7527
|
|
|
7327
|
-
function refreshAllView() {
|
|
7328
|
-
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
7329
|
-
renderAllTasks();
|
|
7330
|
-
renderActivityChip();
|
|
7331
|
-
}
|
|
7332
|
-
|
|
7333
7528
|
async function refreshSessionDetail(sessionId) {
|
|
7334
7529
|
await fetchAgents(sessionId);
|
|
7335
7530
|
if (!agentLogMode) fetchMessages(sessionId);
|
|
@@ -7349,6 +7544,7 @@ function setupEventSource() {
|
|
|
7349
7544
|
.catch(() => {});
|
|
7350
7545
|
}
|
|
7351
7546
|
wasConnected = true;
|
|
7547
|
+
mergeServerLinkedDocs();
|
|
7352
7548
|
retryDelay = 1000;
|
|
7353
7549
|
hideOffline();
|
|
7354
7550
|
};
|
|
@@ -7383,9 +7579,7 @@ function setupEventSource() {
|
|
|
7383
7579
|
taskRefreshTimer = setTimeout(async () => {
|
|
7384
7580
|
if (skipOffScreen()) return;
|
|
7385
7581
|
await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7386
|
-
if (viewMode === '
|
|
7387
|
-
refreshAllView();
|
|
7388
|
-
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7582
|
+
if (viewMode === 'project' && currentProjectPath) {
|
|
7389
7583
|
const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
|
|
7390
7584
|
if (hasUpdate) fetchProjectView(currentProjectPath);
|
|
7391
7585
|
} else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
|
|
@@ -7472,10 +7666,7 @@ function setupEventSource() {
|
|
|
7472
7666
|
pendingTaskSessionIds.clear();
|
|
7473
7667
|
pendingAgentSessionIds.clear();
|
|
7474
7668
|
const sessionsLoaded = fetchSessions().catch(() => {});
|
|
7475
|
-
if (viewMode === '
|
|
7476
|
-
await sessionsLoaded;
|
|
7477
|
-
refreshAllView();
|
|
7478
|
-
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7669
|
+
if (viewMode === 'project' && currentProjectPath) {
|
|
7479
7670
|
await sessionsLoaded;
|
|
7480
7671
|
fetchProjectView(currentProjectPath);
|
|
7481
7672
|
} else if (currentSessionId) {
|
|
@@ -8133,10 +8324,44 @@ function getMermaidTheme() {
|
|
|
8133
8324
|
return isLightTheme() ? 'default' : 'dark';
|
|
8134
8325
|
}
|
|
8135
8326
|
|
|
8327
|
+
// Mermaid is about 3 MB, so it loads on the first diagram instead of on the page's critical path.
|
|
8328
|
+
const MERMAID_SCRIPT = {
|
|
8329
|
+
src: 'https://cdn.jsdelivr.net/npm/mermaid@12.0.0/dist/mermaid.min.js',
|
|
8330
|
+
integrity: 'sha384-xzghz1GQ5u9HCpVskeDPqMsdogD1yvuMQbEK53+wi+G70+6J1AG0L2cfi9PHjDWI',
|
|
8331
|
+
};
|
|
8332
|
+
let mermaidLoading = null;
|
|
8333
|
+
|
|
8334
|
+
function loadMermaid() {
|
|
8335
|
+
mermaidLoading ??= new Promise((resolve, reject) => {
|
|
8336
|
+
const script = document.createElement('script');
|
|
8337
|
+
script.src = MERMAID_SCRIPT.src;
|
|
8338
|
+
script.integrity = MERMAID_SCRIPT.integrity;
|
|
8339
|
+
script.crossOrigin = 'anonymous';
|
|
8340
|
+
script.onload = () => {
|
|
8341
|
+
mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
|
|
8342
|
+
resolve();
|
|
8343
|
+
};
|
|
8344
|
+
script.onerror = () => {
|
|
8345
|
+
script.remove();
|
|
8346
|
+
mermaidLoading = null;
|
|
8347
|
+
reject(new Error('mermaid failed to load'));
|
|
8348
|
+
};
|
|
8349
|
+
document.head.appendChild(script);
|
|
8350
|
+
});
|
|
8351
|
+
return mermaidLoading;
|
|
8352
|
+
}
|
|
8353
|
+
|
|
8136
8354
|
function initMermaidBlocks(container) {
|
|
8137
|
-
if (typeof mermaid === 'undefined') return;
|
|
8138
8355
|
const blocks = (container || document).querySelectorAll('pre.mermaid:not([data-processed])');
|
|
8139
|
-
if (blocks.length)
|
|
8356
|
+
if (!blocks.length) return;
|
|
8357
|
+
if (typeof mermaid === 'undefined') {
|
|
8358
|
+
loadMermaid().then(
|
|
8359
|
+
() => initMermaidBlocks(container),
|
|
8360
|
+
(err) => console.warn(err.message),
|
|
8361
|
+
);
|
|
8362
|
+
return;
|
|
8363
|
+
}
|
|
8364
|
+
mermaid.run({ nodes: [...blocks] });
|
|
8140
8365
|
}
|
|
8141
8366
|
|
|
8142
8367
|
function reinitMermaidTheme() {
|
|
@@ -8270,18 +8495,106 @@ function toggleFilterMenu(e) {
|
|
|
8270
8495
|
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
8271
8496
|
renderFilterState();
|
|
8272
8497
|
document.addEventListener('click', closeFilterMenu, { once: true });
|
|
8273
|
-
// Capture phase: the global keydown handler bails out on
|
|
8274
|
-
//
|
|
8498
|
+
// Capture phase: the global keydown handler bails out on form fields, and the path box
|
|
8499
|
+
// has focus — Escape would never reach it otherwise.
|
|
8275
8500
|
document.addEventListener('keydown', filterMenuKeydown, true);
|
|
8276
|
-
|
|
8501
|
+
pfIdx = -1;
|
|
8502
|
+
renderProjectOptions();
|
|
8503
|
+
const input = document.getElementById('project-filter');
|
|
8504
|
+
input?.focus();
|
|
8505
|
+
input?.select();
|
|
8277
8506
|
}
|
|
8278
8507
|
|
|
8279
8508
|
function filterMenuKeydown(e) {
|
|
8280
|
-
if (e.key
|
|
8281
|
-
|
|
8282
|
-
|
|
8509
|
+
if (e.key === 'Escape') {
|
|
8510
|
+
e.stopPropagation();
|
|
8511
|
+
if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
|
|
8512
|
+
return;
|
|
8513
|
+
}
|
|
8514
|
+
if (e.target.id !== 'project-filter') return;
|
|
8515
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
8516
|
+
e.preventDefault();
|
|
8517
|
+
const n = pfOptions.length;
|
|
8518
|
+
if (!n) return;
|
|
8519
|
+
pfIdx = e.key === 'ArrowDown' ? (pfIdx + 1) % n : (pfIdx - 1 + n) % n;
|
|
8520
|
+
renderProjectOptions();
|
|
8521
|
+
} else if (e.key === 'Enter') {
|
|
8522
|
+
e.preventDefault();
|
|
8523
|
+
if (pfIdx >= 0 && pfOptions[pfIdx]) pickProject(pfOptions[pfIdx].path);
|
|
8524
|
+
else closeFilterMenu();
|
|
8525
|
+
}
|
|
8526
|
+
}
|
|
8527
|
+
|
|
8528
|
+
// The menu's project list: projects the path box matches, newest activity first.
|
|
8529
|
+
const PF_MAX = 7;
|
|
8530
|
+
let pfOptions = [];
|
|
8531
|
+
let pfIdx = -1;
|
|
8532
|
+
|
|
8533
|
+
function renderProjectOptions() {
|
|
8534
|
+
const list = document.getElementById('project-filter-options');
|
|
8535
|
+
const input = document.getElementById('project-filter');
|
|
8536
|
+
if (!list || !input) return;
|
|
8537
|
+
const text = projectFilterText();
|
|
8538
|
+
const q = normalizeProjectPath(text.trim());
|
|
8539
|
+
const partial = !!q && !isExactProjectPath(q);
|
|
8540
|
+
const needle = partial ? q : '';
|
|
8541
|
+
const selected = q && !partial ? q : null;
|
|
8542
|
+
const byRecent = (a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || '');
|
|
8543
|
+
pfOptions = (projectsCache || [])
|
|
8544
|
+
.map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
|
|
8545
|
+
.filter((p) => !needle || p.norm.includes(needle))
|
|
8546
|
+
.sort(byRecent)
|
|
8547
|
+
.slice(0, PF_MAX);
|
|
8548
|
+
if (pfIdx >= pfOptions.length) pfIdx = pfOptions.length - 1;
|
|
8549
|
+
list.innerHTML = pfOptions.length
|
|
8550
|
+
? pfOptions
|
|
8551
|
+
.map((p, i) => {
|
|
8552
|
+
const cut = Math.max(p.path.lastIndexOf('/'), p.path.lastIndexOf('\\')) + 1;
|
|
8553
|
+
const at = needle ? p.norm.indexOf(needle) : -1;
|
|
8554
|
+
const range = at >= 0 ? [at, at + needle.length] : null;
|
|
8555
|
+
const isSel = p.norm === selected;
|
|
8556
|
+
return `<li id="pf-opt-${i}" role="option" class="pf-opt${i === pfIdx ? ' hl' : ''}" aria-selected="${isSel ? 'true' : 'false'}" data-path="${escapeHtml(p.path)}" title="${escapeHtml(p.path)}">
|
|
8557
|
+
<span class="pf-name">${markRange(p.path, cut, p.path.length, range)}</span>
|
|
8558
|
+
<span class="pf-dir"><bdi>${markRange(p.path, 0, cut, range)}</bdi></span>
|
|
8559
|
+
<span class="pf-age">${p.modifiedAt ? shortAge(p.modifiedAt) : ''}</span>
|
|
8560
|
+
</li>`;
|
|
8561
|
+
})
|
|
8562
|
+
.join('')
|
|
8563
|
+
: `<li class="pf-empty">${
|
|
8564
|
+
partial ? `No project path contains “${escapeHtml(text.trim())}”` : 'No projects yet'
|
|
8565
|
+
}</li>`;
|
|
8566
|
+
if (pfIdx >= 0) input.setAttribute('aria-activedescendant', `pf-opt-${pfIdx}`);
|
|
8567
|
+
else input.removeAttribute('aria-activedescendant');
|
|
8568
|
+
list.querySelector('.pf-opt.hl')?.scrollIntoView({ block: 'nearest' });
|
|
8283
8569
|
}
|
|
8284
8570
|
|
|
8571
|
+
function shortAge(iso) {
|
|
8572
|
+
const min = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000);
|
|
8573
|
+
if (min < 60) return `${Math.floor(min)}m`;
|
|
8574
|
+
if (min < 1440) return `${Math.floor(min / 60)}h`;
|
|
8575
|
+
if (min < 60 * 24 * 60) return `${Math.floor(min / 1440)}d`;
|
|
8576
|
+
return `${Math.floor(min / (1440 * 30))}mo`;
|
|
8577
|
+
}
|
|
8578
|
+
|
|
8579
|
+
// The slice [from, to) of text, with the part inside range marked.
|
|
8580
|
+
function markRange(text, from, to, range) {
|
|
8581
|
+
if (!range || range[1] <= from || range[0] >= to) return escapeHtml(text.slice(from, to));
|
|
8582
|
+
const a = Math.max(from, range[0]);
|
|
8583
|
+
const b = Math.min(to, range[1]);
|
|
8584
|
+
return `${escapeHtml(text.slice(from, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>${escapeHtml(text.slice(b, to))}`;
|
|
8585
|
+
}
|
|
8586
|
+
|
|
8587
|
+
function pickProject(path) {
|
|
8588
|
+
filterByProject(path);
|
|
8589
|
+
closeFilterMenu();
|
|
8590
|
+
document.getElementById('filter-menu-btn')?.focus();
|
|
8591
|
+
}
|
|
8592
|
+
|
|
8593
|
+
document.getElementById('project-filter-options')?.addEventListener('click', (e) => {
|
|
8594
|
+
const opt = e.target.closest('.pf-opt');
|
|
8595
|
+
if (opt) pickProject(opt.dataset.path);
|
|
8596
|
+
});
|
|
8597
|
+
|
|
8285
8598
|
function closeFilterMenu() {
|
|
8286
8599
|
const menu = document.getElementById('filter-menu');
|
|
8287
8600
|
if (!menu?.classList.contains('open')) return false;
|
|
@@ -8291,50 +8604,53 @@ function closeFilterMenu() {
|
|
|
8291
8604
|
return true;
|
|
8292
8605
|
}
|
|
8293
8606
|
|
|
8294
|
-
// One row per filter
|
|
8295
|
-
// default, and two label forms — terse tokens for the header strip (which has ~15 characters
|
|
8607
|
+
// One row per filter: whether it differs from the default, and two label forms — terse tokens for the header strip (which has ~15 characters
|
|
8296
8608
|
// before the sidebar's width ellipsises it) and long form for its tooltip.
|
|
8297
8609
|
const FILTERS = [
|
|
8298
8610
|
{
|
|
8299
|
-
id: 'project-filter',
|
|
8300
|
-
value: () => filterProject ?? '',
|
|
8301
8611
|
active: () => filterProject !== FILTER_DEFAULTS.project,
|
|
8302
|
-
short: () =>
|
|
8303
|
-
|
|
8612
|
+
short: () => {
|
|
8613
|
+
if (filterProject === '__recent__') return 'recent';
|
|
8614
|
+
if (!filterProject) return 'all proj';
|
|
8615
|
+
const name = pathBasename(filterProject);
|
|
8616
|
+
return isExactProjectFilter(filterProject) ? name : `*${name}*`;
|
|
8617
|
+
},
|
|
8618
|
+
long: () => {
|
|
8619
|
+
if (filterProject === '__recent__') return 'projects active in the last 24h';
|
|
8620
|
+
if (!filterProject) return 'all projects';
|
|
8621
|
+
return isExactProjectFilter(filterProject)
|
|
8622
|
+
? `project ${filterProject}`
|
|
8623
|
+
: `project path contains "${filterProject}"`;
|
|
8624
|
+
},
|
|
8304
8625
|
},
|
|
8305
8626
|
{
|
|
8306
|
-
id: 'session-filter',
|
|
8307
|
-
value: () => sessionFilter,
|
|
8308
8627
|
active: () => sessionFilter !== FILTER_DEFAULTS.session,
|
|
8309
8628
|
short: () => 'inactive',
|
|
8310
8629
|
long: () => 'including inactive sessions',
|
|
8311
8630
|
},
|
|
8312
|
-
{
|
|
8313
|
-
id: 'session-limit',
|
|
8314
|
-
value: () => sessionLimit,
|
|
8315
|
-
active: () => sessionLimit !== FILTER_DEFAULTS.limit,
|
|
8316
|
-
short: () => (sessionLimit === 'all' ? 'n=∞' : `n=${sessionLimit}`),
|
|
8317
|
-
long: () => (sessionLimit === 'all' ? 'no session limit' : `showing ${sessionLimit}`),
|
|
8318
|
-
},
|
|
8319
8631
|
];
|
|
8320
8632
|
|
|
8321
|
-
// Keeps the header (summary + funnel tint) and the popover's
|
|
8633
|
+
// Keeps the header (summary + funnel tint) and the popover's controls in sync with state.
|
|
8322
8634
|
// Safe to call on every filter change — it only touches classes, values, and text.
|
|
8323
8635
|
function renderFilterState() {
|
|
8324
8636
|
const short = [];
|
|
8325
8637
|
const long = [];
|
|
8326
8638
|
for (const f of FILTERS) {
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
if (el.value !== value) el.value = value;
|
|
8336
|
-
el.classList.toggle('non-default', active);
|
|
8639
|
+
if (!f.active()) continue;
|
|
8640
|
+
short.push(f.short());
|
|
8641
|
+
long.push(f.long());
|
|
8642
|
+
}
|
|
8643
|
+
const input = document.getElementById('project-filter');
|
|
8644
|
+
if (input) {
|
|
8645
|
+
const value = projectFilterText();
|
|
8646
|
+
if (input.value !== value) input.value = value;
|
|
8337
8647
|
}
|
|
8648
|
+
const recent = document.getElementById('project-recent-btn');
|
|
8649
|
+
recent?.setAttribute('aria-pressed', String(filterProject === '__recent__'));
|
|
8650
|
+
for (const b of document.querySelectorAll('[data-session-filter]')) {
|
|
8651
|
+
b.setAttribute('aria-checked', String(b.dataset.sessionFilter === sessionFilter));
|
|
8652
|
+
}
|
|
8653
|
+
if (document.getElementById('filter-menu')?.classList.contains('open')) renderProjectOptions();
|
|
8338
8654
|
|
|
8339
8655
|
const summary = document.getElementById('filter-summary');
|
|
8340
8656
|
if (summary) {
|
|
@@ -8359,12 +8675,16 @@ function renderZenState() {
|
|
|
8359
8675
|
if (!btn) return;
|
|
8360
8676
|
btn.classList.toggle('active', zenMode);
|
|
8361
8677
|
btn.setAttribute('aria-pressed', String(zenMode));
|
|
8362
|
-
btn.title = zenMode
|
|
8678
|
+
btn.title = zenMode
|
|
8679
|
+
? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
|
|
8680
|
+
: 'Zen mode: current session only (Ctrl+Shift+Z)';
|
|
8363
8681
|
}
|
|
8364
8682
|
|
|
8365
8683
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8366
8684
|
function filterBySessions(value) {
|
|
8685
|
+
if (value === sessionFilter) return;
|
|
8367
8686
|
sessionFilter = value;
|
|
8687
|
+
resetSessionPage();
|
|
8368
8688
|
if (value !== 'active') activityFilter.clear();
|
|
8369
8689
|
renderFilterState();
|
|
8370
8690
|
updateUrl();
|
|
@@ -8376,18 +8696,18 @@ function filterBySessions(value) {
|
|
|
8376
8696
|
fetchSessions(false);
|
|
8377
8697
|
}
|
|
8378
8698
|
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
sessionLimit = value;
|
|
8382
|
-
renderFilterState();
|
|
8383
|
-
updateUrl();
|
|
8384
|
-
fetchSessions();
|
|
8699
|
+
function projectFilterText() {
|
|
8700
|
+
return filterProject && filterProject !== '__recent__' ? filterProject : '';
|
|
8385
8701
|
}
|
|
8386
8702
|
|
|
8703
|
+
let filterMatcher = { query: null, match: null };
|
|
8387
8704
|
function matchesProjectFilter(project) {
|
|
8388
8705
|
if (!filterProject) return true;
|
|
8389
8706
|
if (filterProject === '__recent__') return recentProjects.has(project);
|
|
8390
|
-
|
|
8707
|
+
if (filterMatcher.query !== filterProject) {
|
|
8708
|
+
filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
|
|
8709
|
+
}
|
|
8710
|
+
return filterMatcher.match(project);
|
|
8391
8711
|
}
|
|
8392
8712
|
|
|
8393
8713
|
//#endregion
|
|
@@ -8507,11 +8827,31 @@ document.addEventListener('click', (e) => {
|
|
|
8507
8827
|
|
|
8508
8828
|
// Used in HTML; also called by the hub project shim, so no biome suppression is needed.
|
|
8509
8829
|
function filterByProject(project) {
|
|
8830
|
+
clearTimeout(projectFilterTimer);
|
|
8510
8831
|
filterProject = project || null;
|
|
8832
|
+
resetSessionPage();
|
|
8511
8833
|
renderFilterState();
|
|
8512
|
-
updateUrl();
|
|
8513
8834
|
fetchSessions(false);
|
|
8514
|
-
|
|
8835
|
+
showNoSession();
|
|
8836
|
+
}
|
|
8837
|
+
|
|
8838
|
+
// The sidebar narrows on every key from the rows it has; the server round trip for rows past
|
|
8839
|
+
// the limit waits for a pause in typing.
|
|
8840
|
+
const PROJECT_FILTER_DEBOUNCE_MS = 250;
|
|
8841
|
+
let projectFilterTimer = null;
|
|
8842
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8843
|
+
function typeProjectFilter(value) {
|
|
8844
|
+
filterProject = value.trim() ? value : null;
|
|
8845
|
+
pfIdx = -1;
|
|
8846
|
+
updateUrl();
|
|
8847
|
+
renderSessions();
|
|
8848
|
+
clearTimeout(projectFilterTimer);
|
|
8849
|
+
projectFilterTimer = setTimeout(() => filterByProject(filterProject), PROJECT_FILTER_DEBOUNCE_MS);
|
|
8850
|
+
}
|
|
8851
|
+
|
|
8852
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8853
|
+
function toggleRecentProjects() {
|
|
8854
|
+
filterByProject(filterProject === '__recent__' ? null : '__recent__');
|
|
8515
8855
|
}
|
|
8516
8856
|
|
|
8517
8857
|
let projectsCache = null;
|
|
@@ -8532,22 +8872,20 @@ async function loadProjects() {
|
|
|
8532
8872
|
return projects;
|
|
8533
8873
|
}
|
|
8534
8874
|
|
|
8535
|
-
async function
|
|
8536
|
-
const dropdown = document.getElementById('project-filter');
|
|
8537
|
-
|
|
8875
|
+
async function refreshProjectList() {
|
|
8538
8876
|
if (!projectsCacheDirty && projectsCache) {
|
|
8539
|
-
|
|
8877
|
+
renderFilterState();
|
|
8540
8878
|
return;
|
|
8541
8879
|
}
|
|
8542
8880
|
|
|
8543
8881
|
const projects = await loadProjects();
|
|
8544
|
-
const cutoff = Date.now() -
|
|
8882
|
+
const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
8545
8883
|
const prevRecent = recentProjects;
|
|
8546
8884
|
recentProjects = new Set(
|
|
8547
8885
|
projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
|
|
8548
8886
|
);
|
|
8549
8887
|
|
|
8550
|
-
|
|
8888
|
+
renderFilterState();
|
|
8551
8889
|
|
|
8552
8890
|
// recentProjects was empty before — sidebar rendered with __recent__ filter
|
|
8553
8891
|
// dropping every session. Re-render now that we know which projects qualify.
|
|
@@ -8556,28 +8894,6 @@ async function updateProjectDropdown() {
|
|
|
8556
8894
|
}
|
|
8557
8895
|
}
|
|
8558
8896
|
|
|
8559
|
-
function renderProjectDropdown(dropdown, projects) {
|
|
8560
|
-
const recentSelected = filterProject === '__recent__' ? ' selected' : '';
|
|
8561
|
-
// A hub-pushed project with no session history isn't in /api/projects, so synthesize its
|
|
8562
|
-
// option here rather than in the hub shim — this also survives the SSE re-render below.
|
|
8563
|
-
const list =
|
|
8564
|
-
filterProject && filterProject !== '__recent__' && !projects.some((p) => p.path === filterProject)
|
|
8565
|
-
? [...projects, { path: filterProject, modifiedAt: null }]
|
|
8566
|
-
: projects;
|
|
8567
|
-
dropdown.innerHTML =
|
|
8568
|
-
'<option value="">All Projects</option>' +
|
|
8569
|
-
`<option value="__recent__"${recentSelected}>Recent (24h)</option>` +
|
|
8570
|
-
list
|
|
8571
|
-
.map((p) => {
|
|
8572
|
-
const name = p.path.split(/[/\\]/).pop();
|
|
8573
|
-
const selected = p.path === filterProject ? ' selected' : '';
|
|
8574
|
-
return `<option value="${escapeHtml(p.path)}"${selected} title="${escapeHtml(p.path)}">${escapeHtml(name)}</option>`;
|
|
8575
|
-
})
|
|
8576
|
-
.join('');
|
|
8577
|
-
// The option list just changed under the select — re-assert tint and header summary.
|
|
8578
|
-
renderFilterState();
|
|
8579
|
-
}
|
|
8580
|
-
|
|
8581
8897
|
function updateThemeColor(isLight) {
|
|
8582
8898
|
document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
|
|
8583
8899
|
m.setAttribute('content', isLight ? '#e8e6e3' : '#101114');
|
|
@@ -8993,13 +9309,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
8993
9309
|
});
|
|
8994
9310
|
html += `</div>`;
|
|
8995
9311
|
|
|
8996
|
-
if (session.goal?.condition) {
|
|
8997
|
-
html += `<div class="info-goal-card">
|
|
8998
|
-
<div class="info-goal-head"><span class="info-goal-icon">◎</span>Goal</div>
|
|
8999
|
-
<div class="info-goal-text">${escapeHtml(session.goal.condition)}</div>
|
|
9000
|
-
</div>`;
|
|
9001
|
-
}
|
|
9002
|
-
|
|
9003
9312
|
if (session.contextStatus) {
|
|
9004
9313
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
9005
9314
|
html += renderContextDetail(session.contextStatus);
|
|
@@ -9121,14 +9430,7 @@ function openSessionFromInfo(sessionId) {
|
|
|
9121
9430
|
|
|
9122
9431
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9123
9432
|
function toggleDismissSession(sessionId) {
|
|
9124
|
-
|
|
9125
|
-
dismissedSessionIds.delete(sessionId);
|
|
9126
|
-
} else {
|
|
9127
|
-
dismissedSessionIds.add(sessionId);
|
|
9128
|
-
}
|
|
9129
|
-
updateDismissBtnState();
|
|
9130
|
-
renderSessions();
|
|
9131
|
-
renderActivityChip();
|
|
9433
|
+
setSessionDismissed(sessionId, !dismissedSessionIds.has(sessionId));
|
|
9132
9434
|
}
|
|
9133
9435
|
|
|
9134
9436
|
function updateDismissBtnState() {
|
|
@@ -9152,47 +9454,67 @@ let spIdx = 0;
|
|
|
9152
9454
|
let spPeekTimer = null;
|
|
9153
9455
|
let spPeekToken = 0;
|
|
9154
9456
|
let spPeekedId = null;
|
|
9457
|
+
let spFromTerminal = false;
|
|
9458
|
+
let spRenderedQuery = null;
|
|
9155
9459
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
9156
9460
|
const spPeekCache = new Map();
|
|
9157
9461
|
|
|
9158
|
-
// A session
|
|
9159
|
-
//
|
|
9160
|
-
|
|
9161
|
-
|
|
9462
|
+
// A query also searches session ids and names in every transcript on the server, so a
|
|
9463
|
+
// session outside the sidebar filter is found too. Values: 'pending', then the rows.
|
|
9464
|
+
const spSearch = new Map();
|
|
9465
|
+
let spSearchTimer = null;
|
|
9162
9466
|
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
spGlobal.set(id, await fetchSessionById(id));
|
|
9166
|
-
if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
|
|
9467
|
+
function spSearchKey(query) {
|
|
9468
|
+
return query.length >= 3 ? query.toLowerCase() : null;
|
|
9167
9469
|
}
|
|
9168
9470
|
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9471
|
+
function spScheduleSearch(key) {
|
|
9472
|
+
clearTimeout(spSearchTimer);
|
|
9473
|
+
spSearchTimer = setTimeout(async () => {
|
|
9474
|
+
spSearch.set(key, 'pending');
|
|
9475
|
+
let ids = [];
|
|
9476
|
+
try {
|
|
9477
|
+
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
9478
|
+
if (r.ok) ids = await r.json();
|
|
9479
|
+
} catch (_) {}
|
|
9480
|
+
spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
|
|
9481
|
+
renderSessionPicker();
|
|
9482
|
+
}, 100);
|
|
9483
|
+
}
|
|
9484
|
+
|
|
9485
|
+
// The server rows not already matched in the sidebar list, and whether a search is still out.
|
|
9486
|
+
function spSearchRows(key, localRows) {
|
|
9487
|
+
if (!key) return { rows: [], pending: false };
|
|
9488
|
+
const found = spSearch.get(key);
|
|
9489
|
+
if (!found) spScheduleSearch(key);
|
|
9490
|
+
if (!Array.isArray(found)) return { rows: [], pending: true };
|
|
9491
|
+
const seen = new Set(localRows.map((s) => s.id));
|
|
9492
|
+
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
9180
9493
|
}
|
|
9181
9494
|
|
|
9182
9495
|
function openSessionPicker() {
|
|
9496
|
+
spFromTerminal = terminalPaneFocused();
|
|
9183
9497
|
const input = document.getElementById('session-picker-input');
|
|
9184
9498
|
input.value = '';
|
|
9185
9499
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
9186
9500
|
spPeekCache.clear();
|
|
9187
|
-
|
|
9501
|
+
spSearch.clear();
|
|
9502
|
+
spRenderedQuery = null;
|
|
9188
9503
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
9189
9504
|
renderSessionPicker();
|
|
9190
9505
|
input.focus();
|
|
9191
9506
|
}
|
|
9192
9507
|
|
|
9193
|
-
|
|
9508
|
+
// refocus: false when a session is being opened, which moves the terminal focus itself.
|
|
9509
|
+
function closeSessionPicker({ refocus = true } = {}) {
|
|
9194
9510
|
spSetPeeking(false);
|
|
9195
9511
|
hideModalOverlay('session-picker-modal');
|
|
9512
|
+
if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
|
|
9513
|
+
}
|
|
9514
|
+
|
|
9515
|
+
function toggleSessionPicker() {
|
|
9516
|
+
if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
|
|
9517
|
+
else openSessionPicker();
|
|
9196
9518
|
}
|
|
9197
9519
|
|
|
9198
9520
|
function spIsPeeking() {
|
|
@@ -9274,21 +9596,30 @@ function spDotHtml(session) {
|
|
|
9274
9596
|
function renderSessionPicker() {
|
|
9275
9597
|
const list = document.getElementById('session-picker-list');
|
|
9276
9598
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
9277
|
-
|
|
9599
|
+
// Search rows that arrive for the same query append below, so the cursor stays on its row.
|
|
9600
|
+
const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
9601
|
+
spRenderedQuery = query;
|
|
9602
|
+
const key = spSearchKey(query);
|
|
9603
|
+
const local = spSource.filter((s) => spMatches(s, query));
|
|
9604
|
+
const found = spSearchRows(key, local);
|
|
9605
|
+
spRows = [...local, ...found.rows];
|
|
9278
9606
|
if (!spRows.length) {
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
}
|
|
9607
|
+
spIdx = -1;
|
|
9608
|
+
const empty = found.pending
|
|
9609
|
+
? 'Searching all sessions…'
|
|
9610
|
+
: spSource.length || key
|
|
9611
|
+
? 'No session matches'
|
|
9612
|
+
: 'No sessions in the current sidebar filter';
|
|
9613
|
+
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
9614
|
+
return;
|
|
9286
9615
|
}
|
|
9287
9616
|
|
|
9617
|
+
// Rows past the sidebar list come from the server search and show their full project path.
|
|
9288
9618
|
list.innerHTML = spRows
|
|
9289
9619
|
.map((s, i) => {
|
|
9290
|
-
const
|
|
9291
|
-
|
|
9620
|
+
const outside = i >= local.length;
|
|
9621
|
+
const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
|
|
9622
|
+
return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
|
|
9292
9623
|
${spDotHtml(s)}
|
|
9293
9624
|
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
9294
9625
|
<span class="sp-project">${escapeHtml(project)}</span>
|
|
@@ -9298,7 +9629,8 @@ function renderSessionPicker() {
|
|
|
9298
9629
|
</button>`;
|
|
9299
9630
|
})
|
|
9300
9631
|
.join('');
|
|
9301
|
-
|
|
9632
|
+
const keep = spRows.findIndex((s) => s.id === selectedId);
|
|
9633
|
+
spSelect(Math.max(0, keep));
|
|
9302
9634
|
}
|
|
9303
9635
|
|
|
9304
9636
|
function spSelect(idx) {
|
|
@@ -9314,7 +9646,8 @@ function spSelect(idx) {
|
|
|
9314
9646
|
async function spOpen(idx) {
|
|
9315
9647
|
const session = spRows[idx];
|
|
9316
9648
|
if (!session) return;
|
|
9317
|
-
closeSessionPicker();
|
|
9649
|
+
closeSessionPicker({ refocus: false });
|
|
9650
|
+
if (spFromTerminal) termState.focusNext = true;
|
|
9318
9651
|
await revealSession(session.id);
|
|
9319
9652
|
}
|
|
9320
9653
|
|
|
@@ -9325,7 +9658,7 @@ function initSessionPicker() {
|
|
|
9325
9658
|
input.addEventListener('keydown', (e) => {
|
|
9326
9659
|
if (e.key === 'Escape') closeSessionPicker();
|
|
9327
9660
|
else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) spSelect(spIdx + 1);
|
|
9328
|
-
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
9661
|
+
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
9329
9662
|
else if (e.key === 'Enter') spOpen(spIdx);
|
|
9330
9663
|
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
9331
9664
|
else return;
|
|
@@ -9455,16 +9788,6 @@ function renderLoopModalBody(data) {
|
|
|
9455
9788
|
body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
|
|
9456
9789
|
}
|
|
9457
9790
|
|
|
9458
|
-
function renderGoalSubtitle(session) {
|
|
9459
|
-
const g = session.goal;
|
|
9460
|
-
if (!g?.condition) return '';
|
|
9461
|
-
const short = g.condition.length > 70 ? `${g.condition.slice(0, 70)}…` : g.condition;
|
|
9462
|
-
// Only active (unmet) goals reach here — a met goal auto-clears. Clicking
|
|
9463
|
-
// opens the info modal (full text); stopPropagation so it doesn't also
|
|
9464
|
-
// trigger the card's fetchTasks.
|
|
9465
|
-
return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${escAttrJs(session.id)}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span><span class="session-goal-text">${escapeHtml(short)}</span></div>`;
|
|
9466
|
-
}
|
|
9467
|
-
|
|
9468
9791
|
function renderLoopBadge(session) {
|
|
9469
9792
|
const li = session.loopInfo;
|
|
9470
9793
|
const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
|
|
@@ -9944,6 +10267,9 @@ const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
|
|
|
9944
10267
|
// Swap to the previous session.
|
|
9945
10268
|
const CCK_CTRL_ALT_KEYS = new Set(['KeyN', 'KeyR', 'KeyS']);
|
|
9946
10269
|
const TERMINAL_MODES_KEY = 'terminal-sessions';
|
|
10270
|
+
const TERMINAL_FONT_KEY = 'terminal-font-size';
|
|
10271
|
+
const TERMINAL_FONT_MIN = 8;
|
|
10272
|
+
const TERMINAL_FONT_MAX = 32;
|
|
9947
10273
|
const ACK_BATCH_BYTES = 32 * 1024;
|
|
9948
10274
|
const TERMINAL_RETRY_MS = [500, 1000, 2000, 4000, 8000];
|
|
9949
10275
|
const TERMINAL_STABLE_MS = 5000;
|
|
@@ -10056,9 +10382,24 @@ function terminalPaneFocused() {
|
|
|
10056
10382
|
return document.getElementById('terminal-pane').contains(document.activeElement);
|
|
10057
10383
|
}
|
|
10058
10384
|
|
|
10385
|
+
const TEXT_FIELD_SELECTOR = 'input, textarea, select, [contenteditable]';
|
|
10386
|
+
|
|
10059
10387
|
function terminalShortcut(e) {
|
|
10060
10388
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10389
|
+
const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
|
|
10390
|
+
// Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
|
|
10391
|
+
const zoom = e.key === '_' ? undefined : zoomDelta(e);
|
|
10392
|
+
if (zoom !== undefined && termState.term && terminalPaneFocused()) {
|
|
10393
|
+
return () => adjustTerminalFontSize(Math.sign(zoom));
|
|
10394
|
+
}
|
|
10061
10395
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10396
|
+
// Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
|
|
10397
|
+
if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
|
|
10398
|
+
// Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
|
|
10399
|
+
if (ctrlShift && e.code === 'KeyZ') {
|
|
10400
|
+
const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
|
|
10401
|
+
return field && !document.getElementById('terminal-pane').contains(field) ? null : toggleZenMode;
|
|
10402
|
+
}
|
|
10062
10403
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10063
10404
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
10064
10405
|
}
|
|
@@ -10073,6 +10414,22 @@ function terminalShortcut(e) {
|
|
|
10073
10414
|
return null;
|
|
10074
10415
|
}
|
|
10075
10416
|
|
|
10417
|
+
function readSwapPair() {
|
|
10418
|
+
try {
|
|
10419
|
+
const pair = JSON.parse(store.getItem(SWAP_KEY));
|
|
10420
|
+
if (Array.isArray(pair) && pair.length === 2) return pair;
|
|
10421
|
+
} catch {}
|
|
10422
|
+
return [null, null];
|
|
10423
|
+
}
|
|
10424
|
+
|
|
10425
|
+
function setSwapPair(last, previous) {
|
|
10426
|
+
lastSessionId = last;
|
|
10427
|
+
previousSessionId = previous;
|
|
10428
|
+
try {
|
|
10429
|
+
store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
|
|
10430
|
+
} catch {}
|
|
10431
|
+
}
|
|
10432
|
+
|
|
10076
10433
|
function swapToPreviousSession() {
|
|
10077
10434
|
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
10078
10435
|
if (!target || target === currentSessionId) return;
|
|
@@ -10100,7 +10457,10 @@ function syncTerminal() {
|
|
|
10100
10457
|
if (view !== termState.syncedView) {
|
|
10101
10458
|
termState.syncedView = view;
|
|
10102
10459
|
// The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
|
|
10103
|
-
if (on &&
|
|
10460
|
+
if (on && logAutoOpened) {
|
|
10461
|
+
setMessagePanelVisible(false);
|
|
10462
|
+
logAutoOpened = false;
|
|
10463
|
+
} else if (on && messagePanelOpen) {
|
|
10104
10464
|
toggleMessagePanel();
|
|
10105
10465
|
termState.panelHidden = true;
|
|
10106
10466
|
} else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
|
|
@@ -10129,6 +10489,7 @@ function syncTerminal() {
|
|
|
10129
10489
|
|
|
10130
10490
|
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
10131
10491
|
function openSession(sessionId) {
|
|
10492
|
+
setSessionDismissed(sessionId, false);
|
|
10132
10493
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10133
10494
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10134
10495
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -10148,7 +10509,7 @@ function takeTerminalFocus(sessionId) {
|
|
|
10148
10509
|
// The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
|
|
10149
10510
|
function promptAwaitsUser(sessionId) {
|
|
10150
10511
|
if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
|
|
10151
|
-
return !document.activeElement?.matches(
|
|
10512
|
+
return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
|
|
10152
10513
|
}
|
|
10153
10514
|
|
|
10154
10515
|
function onTerminalShown(focus = true) {
|
|
@@ -10249,9 +10610,10 @@ function ensureTerm() {
|
|
|
10249
10610
|
const host = document.getElementById('terminal-host');
|
|
10250
10611
|
const fontFamily =
|
|
10251
10612
|
cfg.fontFamily || getComputedStyle(document.body).getPropertyValue('--font-mono').trim() || 'monospace';
|
|
10613
|
+
const fontSize = terminalFontSize();
|
|
10252
10614
|
const term = new window.Terminal({
|
|
10253
10615
|
fontFamily,
|
|
10254
|
-
fontSize
|
|
10616
|
+
fontSize,
|
|
10255
10617
|
scrollback: cfg.scrollback,
|
|
10256
10618
|
cursorBlink: true,
|
|
10257
10619
|
allowProposedApi: true,
|
|
@@ -10275,7 +10637,7 @@ function ensureTerm() {
|
|
|
10275
10637
|
// No WebGL: xterm falls back to its DOM renderer.
|
|
10276
10638
|
}
|
|
10277
10639
|
document.fonts
|
|
10278
|
-
?.load(`${
|
|
10640
|
+
?.load(`${fontSize}px ${fontFamily}`)
|
|
10279
10641
|
.then(repaintTerminal)
|
|
10280
10642
|
.catch(() => {});
|
|
10281
10643
|
term.attachCustomKeyEventHandler(terminalKeyFilter);
|
|
@@ -10355,6 +10717,27 @@ function fitTerminal() {
|
|
|
10355
10717
|
} catch (_) {}
|
|
10356
10718
|
}
|
|
10357
10719
|
|
|
10720
|
+
function clampTerminalFont(v) {
|
|
10721
|
+
return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, v));
|
|
10722
|
+
}
|
|
10723
|
+
|
|
10724
|
+
function terminalFontSize() {
|
|
10725
|
+
const saved = Number.parseInt(store.getItem(TERMINAL_FONT_KEY), 10);
|
|
10726
|
+
return saved ? clampTerminalFont(saved) : appConfig.terminal.fontSize;
|
|
10727
|
+
}
|
|
10728
|
+
|
|
10729
|
+
function adjustTerminalFontSize(step) {
|
|
10730
|
+
const term = termState.term;
|
|
10731
|
+
const size = step === 0 ? appConfig.terminal.fontSize : clampTerminalFont(term.options.fontSize + step);
|
|
10732
|
+
if (size !== term.options.fontSize) {
|
|
10733
|
+
if (step === 0) store.removeItem(TERMINAL_FONT_KEY);
|
|
10734
|
+
else store.setItem(TERMINAL_FONT_KEY, String(size));
|
|
10735
|
+
term.options.fontSize = size;
|
|
10736
|
+
fitTerminal();
|
|
10737
|
+
}
|
|
10738
|
+
showToast(`Terminal ${size}px`);
|
|
10739
|
+
}
|
|
10740
|
+
|
|
10358
10741
|
function terminalSend(msg) {
|
|
10359
10742
|
if (termState.ws?.readyState === WebSocket.OPEN) termState.ws.send(JSON.stringify(msg));
|
|
10360
10743
|
}
|
|
@@ -10562,8 +10945,8 @@ function terminalOpenMode(sessionId) {
|
|
|
10562
10945
|
// replays the same screen, and the placeholder gives way to the real card.
|
|
10563
10946
|
function adoptPickedSession(oldId, id) {
|
|
10564
10947
|
const focused = terminalPaneFocused();
|
|
10565
|
-
|
|
10566
|
-
|
|
10948
|
+
const adopt = (x) => (x === oldId ? id : x);
|
|
10949
|
+
setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
|
|
10567
10950
|
setTerminalMode(id, true);
|
|
10568
10951
|
forgetPlaceholder(oldId);
|
|
10569
10952
|
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
@@ -10842,7 +11225,7 @@ async function onDispatchUpdate() {
|
|
|
10842
11225
|
// With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
|
|
10843
11226
|
function dropPlaceholder(id) {
|
|
10844
11227
|
if (!forgetPlaceholder(id)) return;
|
|
10845
|
-
if (currentSessionId === id && viewMode === 'session')
|
|
11228
|
+
if (currentSessionId === id && viewMode === 'session') showNoSession();
|
|
10846
11229
|
else renderSessions();
|
|
10847
11230
|
}
|
|
10848
11231
|
|
|
@@ -10852,8 +11235,8 @@ function forgetPlaceholder(id) {
|
|
|
10852
11235
|
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
10853
11236
|
if (termState.sessionId === id) detachTerminal();
|
|
10854
11237
|
sessions = sessions.filter((s) => s.id !== id);
|
|
10855
|
-
|
|
10856
|
-
|
|
11238
|
+
const forget = (x) => (x === id ? null : x);
|
|
11239
|
+
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
10857
11240
|
return true;
|
|
10858
11241
|
}
|
|
10859
11242
|
|
|
@@ -11233,19 +11616,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
11233
11616
|
marked.use({ renderer });
|
|
11234
11617
|
}
|
|
11235
11618
|
|
|
11236
|
-
|
|
11237
|
-
|
|
11238
|
-
|
|
11239
|
-
|
|
11240
|
-
|
|
11241
|
-
mermaidPending =
|
|
11242
|
-
|
|
11243
|
-
mermaidPending = false;
|
|
11244
|
-
initMermaidBlocks();
|
|
11245
|
-
});
|
|
11619
|
+
let mermaidPending = false;
|
|
11620
|
+
const mo = new MutationObserver(() => {
|
|
11621
|
+
if (mermaidPending) return;
|
|
11622
|
+
mermaidPending = true;
|
|
11623
|
+
queueMicrotask(() => {
|
|
11624
|
+
mermaidPending = false;
|
|
11625
|
+
initMermaidBlocks();
|
|
11246
11626
|
});
|
|
11247
|
-
|
|
11248
|
-
}
|
|
11627
|
+
});
|
|
11628
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
11249
11629
|
});
|
|
11250
11630
|
|
|
11251
11631
|
loadSidebarState();
|
|
@@ -11269,6 +11649,7 @@ initModalResize();
|
|
|
11269
11649
|
loadPanelWidths();
|
|
11270
11650
|
initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
|
|
11271
11651
|
initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
|
|
11652
|
+
initMessagePanelFull();
|
|
11272
11653
|
|
|
11273
11654
|
const msgContentEl = document.getElementById('message-panel-content');
|
|
11274
11655
|
const jumpLatestBtn = document.createElement('button');
|
|
@@ -11305,7 +11686,7 @@ msgContentEl.addEventListener('wheel', function (e) {
|
|
|
11305
11686
|
}
|
|
11306
11687
|
});
|
|
11307
11688
|
|
|
11308
|
-
const footerState = { version: null, limitsKey: null, timer: null };
|
|
11689
|
+
const footerState = { version: null, plugin: null, limitsKey: null, timer: null };
|
|
11309
11690
|
function formatResetIn(epochSec) {
|
|
11310
11691
|
if (!epochSec) return null;
|
|
11311
11692
|
const ms = epochSec * 1000 - Date.now();
|
|
@@ -11351,12 +11732,45 @@ function renderSidebarFooter(rateLimits) {
|
|
|
11351
11732
|
const children = [];
|
|
11352
11733
|
if (footerState.version) {
|
|
11353
11734
|
const v = document.createElement('span');
|
|
11735
|
+
v.className = 'footer-version';
|
|
11354
11736
|
v.textContent = `v${footerState.version}`;
|
|
11737
|
+
const warn = pluginWarning(footerState.plugin);
|
|
11738
|
+
if (warn) v.append(warn);
|
|
11355
11739
|
children.push(v);
|
|
11356
11740
|
}
|
|
11357
11741
|
if (fh != null || sd != null) children.push(makeLimitSpan(rateLimits));
|
|
11358
11742
|
el.replaceChildren(...children);
|
|
11359
11743
|
}
|
|
11744
|
+
function pluginWarning(plugin) {
|
|
11745
|
+
const problem =
|
|
11746
|
+
plugin &&
|
|
11747
|
+
{
|
|
11748
|
+
missing: 'The claude-code-kanban plugin is not installed',
|
|
11749
|
+
disabled: `The claude-code-kanban plugin ${plugin.installed} is disabled`,
|
|
11750
|
+
mismatch: `Plugin ${plugin.installed} is installed, this board ships ${plugin.bundled}`,
|
|
11751
|
+
}[plugin.state];
|
|
11752
|
+
if (!problem) return null;
|
|
11753
|
+
const fix =
|
|
11754
|
+
plugin.state === 'disabled'
|
|
11755
|
+
? 'Enable it with /plugin in Claude Code.'
|
|
11756
|
+
: `Click to copy: ${plugin.installCommand}\nThen restart open sessions, they keep the old hooks.`;
|
|
11757
|
+
const btn = document.createElement('button');
|
|
11758
|
+
btn.type = 'button';
|
|
11759
|
+
btn.className = 'footer-plugin-warning';
|
|
11760
|
+
btn.textContent = '⚠';
|
|
11761
|
+
btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
|
|
11762
|
+
btn.setAttribute('aria-label', problem);
|
|
11763
|
+
if (plugin.state !== 'disabled') {
|
|
11764
|
+
btn.addEventListener('click', () =>
|
|
11765
|
+
navigator.clipboard
|
|
11766
|
+
.writeText(plugin.installCommand)
|
|
11767
|
+
.then(() => showToast('Install command copied', 'success'))
|
|
11768
|
+
.catch(() => showToast('Could not copy the command', 'error')),
|
|
11769
|
+
);
|
|
11770
|
+
}
|
|
11771
|
+
return btn;
|
|
11772
|
+
}
|
|
11773
|
+
|
|
11360
11774
|
function refreshRateLimits() {
|
|
11361
11775
|
if (footerState.timer) return;
|
|
11362
11776
|
footerState.timer = setTimeout(() => {
|
|
@@ -11383,6 +11797,7 @@ fetch('/api/version')
|
|
|
11383
11797
|
.then((r) => r.json())
|
|
11384
11798
|
.then((d) => {
|
|
11385
11799
|
footerState.version = d.version;
|
|
11800
|
+
footerState.plugin = d.plugin ?? null;
|
|
11386
11801
|
renderSidebarFooter(null);
|
|
11387
11802
|
refreshRateLimits();
|
|
11388
11803
|
})
|
|
@@ -11391,7 +11806,6 @@ fetch('/api/version')
|
|
|
11391
11806
|
const urlState = getUrlState();
|
|
11392
11807
|
const lastView = loadLastView();
|
|
11393
11808
|
sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
|
|
11394
|
-
sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
|
|
11395
11809
|
// The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
|
|
11396
11810
|
// restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
|
|
11397
11811
|
filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
|
|
@@ -11436,27 +11850,26 @@ Promise.all([
|
|
|
11436
11850
|
try {
|
|
11437
11851
|
await fetchProjectView(atob(urlState.projectView));
|
|
11438
11852
|
} catch (_) {
|
|
11439
|
-
|
|
11853
|
+
showNoSession();
|
|
11440
11854
|
}
|
|
11441
11855
|
} else if (urlState.session) {
|
|
11442
11856
|
await fetchTasks(urlState.session);
|
|
11443
|
-
} else if (urlState.view === 'all') {
|
|
11444
|
-
showAllTasks();
|
|
11445
11857
|
} else {
|
|
11446
11858
|
const last = loadLastView();
|
|
11447
11859
|
if (last?.view === 'project' && last.projectPath && sessions.some((s) => s.project === last.projectPath)) {
|
|
11448
11860
|
try {
|
|
11449
11861
|
await fetchProjectView(last.projectPath);
|
|
11450
11862
|
} catch (_) {
|
|
11451
|
-
|
|
11863
|
+
showNoSession();
|
|
11452
11864
|
}
|
|
11453
11865
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
11454
11866
|
await fetchTasks(last.session);
|
|
11455
11867
|
} else {
|
|
11456
|
-
|
|
11868
|
+
showNoSession();
|
|
11457
11869
|
}
|
|
11458
11870
|
}
|
|
11459
11871
|
if (urlState.messages && currentSessionId) {
|
|
11872
|
+
if (logAutoOpened) setMessagePanelVisible(false);
|
|
11460
11873
|
toggleMessagePanel();
|
|
11461
11874
|
// Re-render after panel layout settles so scroll dimensions are correct
|
|
11462
11875
|
requestAnimationFrame(() => {
|
|
@@ -11472,7 +11885,7 @@ Promise.all([
|
|
|
11472
11885
|
window.addEventListener('popstate', () => {
|
|
11473
11886
|
const s = getUrlState();
|
|
11474
11887
|
sessionFilter = s.filter || FILTER_DEFAULTS.session;
|
|
11475
|
-
|
|
11888
|
+
resetSessionPage();
|
|
11476
11889
|
filterProject = s.project || FILTER_DEFAULTS.project;
|
|
11477
11890
|
ownerFilter = s.owner || '';
|
|
11478
11891
|
searchQuery = s.search || '';
|
|
@@ -11484,10 +11897,10 @@ window.addEventListener('popstate', () => {
|
|
|
11484
11897
|
try {
|
|
11485
11898
|
fetchProjectView(atob(s.projectView));
|
|
11486
11899
|
} catch (_) {
|
|
11487
|
-
|
|
11900
|
+
showNoSession();
|
|
11488
11901
|
}
|
|
11489
11902
|
} else if (s.session) fetchTasks(s.session);
|
|
11490
|
-
else
|
|
11903
|
+
else showNoSession();
|
|
11491
11904
|
if (s.messages !== messagePanelOpen) toggleMessagePanel();
|
|
11492
11905
|
});
|
|
11493
11906
|
//#endregion
|
|
@@ -11498,6 +11911,7 @@ window.addEventListener('popstate', () => {
|
|
|
11498
11911
|
// these tests only decide whether a press is the hub's to handle.
|
|
11499
11912
|
function isHubKey(e) {
|
|
11500
11913
|
if (!window.__HUB__?.enabled) return false;
|
|
11914
|
+
if (hubKeys) return hubKeys.has(hubCombo(e));
|
|
11501
11915
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
|
|
11502
11916
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
11503
11917
|
// keymap and ignores unbound letters.
|
|
@@ -11514,6 +11928,19 @@ function isHubKey(e) {
|
|
|
11514
11928
|
return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
|
|
11515
11929
|
}
|
|
11516
11930
|
|
|
11931
|
+
// The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
|
|
11932
|
+
// hub:keys sends none, and the fallback filter above is what such a hub expects.
|
|
11933
|
+
let hubKeys = null;
|
|
11934
|
+
|
|
11935
|
+
// A copy of the hub's comboOf(): its names must match the hub:keys list.
|
|
11936
|
+
function hubCombo(e) {
|
|
11937
|
+
const lower = (e.key || '').toLowerCase();
|
|
11938
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
11939
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
11940
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
11941
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
11942
|
+
}
|
|
11943
|
+
|
|
11517
11944
|
document.addEventListener('keydown', (e) => {
|
|
11518
11945
|
if (!isHubKey(e)) return;
|
|
11519
11946
|
e.preventDefault();
|
|
@@ -11539,6 +11966,13 @@ document.addEventListener('click', (e) => {
|
|
|
11539
11966
|
hubPost({ type: 'hub:openExternal', url: url.href });
|
|
11540
11967
|
});
|
|
11541
11968
|
|
|
11969
|
+
// The click handler above only catches anchors. A script open goes the same way, because
|
|
11970
|
+
// in the hub's installed PWA window a framed app's own `_blank` open does nothing.
|
|
11971
|
+
function openExternal(url) {
|
|
11972
|
+
if (window.__HUB__?.enabled) hubPost({ type: 'hub:openExternal', url });
|
|
11973
|
+
else window.open(url, '_blank', 'noopener');
|
|
11974
|
+
}
|
|
11975
|
+
|
|
11542
11976
|
window.hubNavigate = function hubNavigate(app, url) {
|
|
11543
11977
|
if (!window.__HUB__?.enabled) return;
|
|
11544
11978
|
hubPost({ type: 'hub:navigate', app, url });
|
|
@@ -11586,6 +12020,14 @@ function hubPost(message) {
|
|
|
11586
12020
|
});
|
|
11587
12021
|
})();
|
|
11588
12022
|
|
|
12023
|
+
(function initHubKeys() {
|
|
12024
|
+
window.addEventListener('message', (e) => {
|
|
12025
|
+
if (e.source !== window.parent || e.origin !== hubOrigin()) return;
|
|
12026
|
+
if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
|
|
12027
|
+
hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
|
|
12028
|
+
});
|
|
12029
|
+
})();
|
|
12030
|
+
|
|
11589
12031
|
(function initHubProject() {
|
|
11590
12032
|
window.addEventListener('message', (e) => {
|
|
11591
12033
|
if (e.source !== window.parent || e.origin !== hubOrigin()) return;
|
|
@@ -11593,7 +12035,6 @@ function hubPost(message) {
|
|
|
11593
12035
|
const dirPath = e.data.project;
|
|
11594
12036
|
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
11595
12037
|
filterByProject(dirPath);
|
|
11596
|
-
updateProjectDropdown();
|
|
11597
12038
|
});
|
|
11598
12039
|
})();
|
|
11599
12040
|
// #endregion HUB_INTEGRATION
|