claude-code-kanban 4.29.0 → 4.30.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 +3 -2
- package/cli.js +383 -70
- package/install.js +7 -8
- 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 +56 -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/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/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +563 -313
- package/public/index.html +46 -23
- package/public/project-match.js +27 -0
- package/public/style.css +306 -94
- package/public/sw.js +1 -1
- package/server.js +167 -74
- 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;
|
|
315
|
+
try {
|
|
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) {
|
|
260
336
|
try {
|
|
261
|
-
const r = await fetch(`/api/sessions?limit=1&include=${encodeURIComponent(
|
|
262
|
-
|
|
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;
|
|
@@ -3073,6 +3176,8 @@ let revealedStorageSessionId = null;
|
|
|
3073
3176
|
// Opens a session and scrolls the sidebar to it, refetching first when the id
|
|
3074
3177
|
// fell outside the session list the current filters asked for.
|
|
3075
3178
|
async function revealSession(id) {
|
|
3179
|
+
// Explicit opens restore a dismissed session; activity and auto-follow do not.
|
|
3180
|
+
setSessionDismissed(id, false);
|
|
3076
3181
|
let session = sessions.find((s) => s.id === id);
|
|
3077
3182
|
if (!session) {
|
|
3078
3183
|
lastSessionsHash = '';
|
|
@@ -3107,53 +3212,18 @@ async function revealPlanSession(planSessionId) {
|
|
|
3107
3212
|
await revealSession(planSessionId);
|
|
3108
3213
|
}
|
|
3109
3214
|
|
|
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();
|
|
3215
|
+
function showNoSession() {
|
|
3216
|
+
viewMode = 'none';
|
|
3217
|
+
if (agentLogMode) exitAgentLogMode();
|
|
3218
|
+
currentSessionId = null;
|
|
3219
|
+
ownerFilter = '';
|
|
3220
|
+
resetAgentState();
|
|
3221
|
+
currentTasks = [];
|
|
3222
|
+
updateUrl();
|
|
3223
|
+
sessionView.classList.remove('visible');
|
|
3224
|
+
noSession.style.display = '';
|
|
3225
|
+
renderSessions();
|
|
3226
|
+
renderActivityChip();
|
|
3157
3227
|
}
|
|
3158
3228
|
|
|
3159
3229
|
// Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
|
|
@@ -3260,8 +3330,7 @@ function renderSessions() {
|
|
|
3260
3330
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3261
3331
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3262
3332
|
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3263
|
-
|
|
3264
|
-
updateProjectDropdown();
|
|
3333
|
+
refreshProjectList();
|
|
3265
3334
|
|
|
3266
3335
|
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3267
3336
|
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
@@ -3279,11 +3348,11 @@ function renderSessions() {
|
|
|
3279
3348
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3280
3349
|
emptyHint = 'Try a different search term or clear the search';
|
|
3281
3350
|
} else if (filterProject && sessionFilter === 'active') {
|
|
3282
|
-
emptyMsg = 'No active sessions for this project';
|
|
3283
|
-
emptyHint = 'Try "All Sessions" or
|
|
3351
|
+
emptyMsg = 'No active sessions for this project filter';
|
|
3352
|
+
emptyHint = 'Try "All Sessions" or clear the project filter';
|
|
3284
3353
|
} else if (filterProject) {
|
|
3285
|
-
emptyMsg = 'No sessions for this project';
|
|
3286
|
-
emptyHint = '
|
|
3354
|
+
emptyMsg = 'No sessions for this project filter';
|
|
3355
|
+
emptyHint = 'Clear the project filter to see all';
|
|
3287
3356
|
} else if (sessionFilter === 'active') {
|
|
3288
3357
|
emptyMsg = 'No active sessions';
|
|
3289
3358
|
emptyHint = 'Select "All Sessions" to see all';
|
|
@@ -3338,7 +3407,6 @@ function renderSessions() {
|
|
|
3338
3407
|
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3339
3408
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3340
3409
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
3341
|
-
${renderGoalSubtitle(session)}
|
|
3342
3410
|
<div class="session-progress">
|
|
3343
3411
|
<span class="session-indicators">
|
|
3344
3412
|
${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 +3797,8 @@ function renderProjectView() {
|
|
|
3729
3797
|
|
|
3730
3798
|
function renderTaskCard(task) {
|
|
3731
3799
|
const isBlocked = task.blockedBy && task.blockedBy.length > 0;
|
|
3732
|
-
const useSlug = viewMode === '
|
|
3800
|
+
const useSlug = viewMode === 'project';
|
|
3733
3801
|
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
3802
|
const statusClass = task.status.replace('_', '-');
|
|
3736
3803
|
const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
|
|
3737
3804
|
|
|
@@ -3762,7 +3829,6 @@ function renderTaskCard(task) {
|
|
|
3762
3829
|
}
|
|
3763
3830
|
</div>
|
|
3764
3831
|
<div class="task-title">${escapeHtml(task.subject)}</div>
|
|
3765
|
-
${sessionLabel ? `<div class="task-session">${escapeHtml(sessionLabel)}</div>` : ''}
|
|
3766
3832
|
${task.status === 'in_progress' && task.activeForm ? `<div class="task-active">${escapeHtml(task.activeForm)}</div>` : ''}
|
|
3767
3833
|
${isBlocked ? `<div class="task-blocked">Waiting on ${task.blockedBy.map((id) => `#${id}`).join(', ')}</div>` : ''}
|
|
3768
3834
|
${task.description ? `<div class="task-desc">${escapeHtml(task.description.split('\n')[0])}</div>` : ''}
|
|
@@ -4196,7 +4262,6 @@ function sgAssign(groupId, type, ref, opts = {}) {
|
|
|
4196
4262
|
if (loose) member.loose = true;
|
|
4197
4263
|
group.members.push(member);
|
|
4198
4264
|
}
|
|
4199
|
-
if (collapsedProjectGroups.delete(sgKey(groupId))) persistCollapsedGroups();
|
|
4200
4265
|
persistSessionGroups();
|
|
4201
4266
|
}
|
|
4202
4267
|
|
|
@@ -4468,7 +4533,11 @@ function sgOnDrop(e) {
|
|
|
4468
4533
|
if (hit.groupId) {
|
|
4469
4534
|
if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
|
|
4470
4535
|
else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
|
|
4471
|
-
else
|
|
4536
|
+
else {
|
|
4537
|
+
sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
|
|
4538
|
+
// A drop shows where the card landed; a move from the context menu leaves the group as it was.
|
|
4539
|
+
if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
|
|
4540
|
+
}
|
|
4472
4541
|
} else {
|
|
4473
4542
|
sgDetach(drag.kind, drag.ref);
|
|
4474
4543
|
if (drag.kind === 'session') sgReleased.add(drag.ref);
|
|
@@ -4505,6 +4574,10 @@ function initSessionGroupsDnd() {
|
|
|
4505
4574
|
sessionsList.addEventListener('drop', sgOnDrop);
|
|
4506
4575
|
sessionsList.addEventListener('dragend', sgOnDragEnd);
|
|
4507
4576
|
sessionsList.addEventListener('contextmenu', sgOnContextMenu);
|
|
4577
|
+
// The Menu key fires contextmenu at the focused element, which by then is our menu.
|
|
4578
|
+
document.addEventListener('contextmenu', (e) => {
|
|
4579
|
+
if (e.target.closest?.('.sg-menu')) e.preventDefault();
|
|
4580
|
+
});
|
|
4508
4581
|
sessionsList.addEventListener('keydown', (e) => {
|
|
4509
4582
|
const input = e.target.closest?.('.sg-name-input');
|
|
4510
4583
|
if (!input) return;
|
|
@@ -4539,32 +4612,68 @@ function initSessionGroupsDnd() {
|
|
|
4539
4612
|
let sgLongPressTimer = null;
|
|
4540
4613
|
|
|
4541
4614
|
function sgCloseMenu() {
|
|
4615
|
+
document.removeEventListener('keydown', sgMenuKeydown, true);
|
|
4542
4616
|
const menu = document.getElementById('sg-menu');
|
|
4543
4617
|
if (menu) menu.remove();
|
|
4544
4618
|
}
|
|
4545
4619
|
|
|
4620
|
+
// Capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
|
|
4621
|
+
function sgMenuKeydown(e) {
|
|
4622
|
+
const menu = document.getElementById('sg-menu');
|
|
4623
|
+
if (!menu || e.ctrlKey || e.altKey || e.metaKey) return;
|
|
4624
|
+
const items = [...menu.querySelectorAll('.sg-menu-item')];
|
|
4625
|
+
const i = items.indexOf(document.activeElement);
|
|
4626
|
+
e.stopPropagation();
|
|
4627
|
+
if (e.key === 'Enter' || e.key === ' ') return;
|
|
4628
|
+
e.preventDefault();
|
|
4629
|
+
let next = null;
|
|
4630
|
+
if (matchKey(e, 'ArrowDown')) next = (i + 1) % items.length;
|
|
4631
|
+
else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
|
|
4632
|
+
else if (e.key === 'Home') next = 0;
|
|
4633
|
+
else if (e.key === 'End') next = items.length - 1;
|
|
4634
|
+
else if (e.key === 'Escape' || e.key === 'Tab') sgCloseMenu();
|
|
4635
|
+
if (next !== null) items[next]?.focus();
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
function sgOpenMenuForKbSelection() {
|
|
4639
|
+
const el = sessionsList.querySelector('.kb-selected');
|
|
4640
|
+
if (!el?.matches('.session-item, .project-group-header')) return false;
|
|
4641
|
+
const payload = sgDragPayload(el);
|
|
4642
|
+
if (!payload?.ref) return false;
|
|
4643
|
+
const rect = el.getBoundingClientRect();
|
|
4644
|
+
sgOpenMenu(rect.left + 16, rect.bottom, payload.kind, payload.ref);
|
|
4645
|
+
return true;
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4546
4648
|
function sgOpenMenu(x, y, kind, ref) {
|
|
4547
4649
|
sgCloseMenu();
|
|
4548
4650
|
const current = sgGroupOf(kind, ref);
|
|
4549
4651
|
const label = kind === 'project' ? 'project' : 'session';
|
|
4550
4652
|
const rows = sessionGroups
|
|
4551
4653
|
.filter((g) => g.id !== current?.id)
|
|
4552
|
-
.map(
|
|
4654
|
+
.map(
|
|
4655
|
+
(g) =>
|
|
4656
|
+
`<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
|
|
4657
|
+
)
|
|
4553
4658
|
.join('');
|
|
4554
4659
|
const menu = document.createElement('div');
|
|
4555
4660
|
menu.id = 'sg-menu';
|
|
4556
4661
|
menu.className = 'sg-menu';
|
|
4662
|
+
menu.setAttribute('role', 'menu');
|
|
4663
|
+
menu.setAttribute('aria-label', `Move ${label} to group`);
|
|
4557
4664
|
menu.dataset.sgKind = kind;
|
|
4558
4665
|
menu.dataset.sgRef = ref;
|
|
4559
4666
|
menu.innerHTML = `
|
|
4560
|
-
<div class="sg-menu-label">Move ${label} to group</div>
|
|
4667
|
+
<div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
|
|
4561
4668
|
${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>` : ''}`;
|
|
4669
|
+
<button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
|
|
4670
|
+
${current ? `<button class="sg-menu-item sg-menu-remove" role="menuitem" data-sg-move="__none__">Remove from “${escapeHtml(current.name)}”</button>` : ''}`;
|
|
4564
4671
|
document.body.appendChild(menu);
|
|
4565
4672
|
const rect = menu.getBoundingClientRect();
|
|
4566
4673
|
menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
|
|
4567
4674
|
menu.style.top = `${Math.min(y, window.innerHeight - rect.height - 8)}px`;
|
|
4675
|
+
document.addEventListener('keydown', sgMenuKeydown, true);
|
|
4676
|
+
menu.querySelector('.sg-menu-item')?.focus();
|
|
4568
4677
|
}
|
|
4569
4678
|
|
|
4570
4679
|
function sgOnContextMenu(e) {
|
|
@@ -5198,13 +5307,7 @@ async function saveTaskField(taskId, sessionId, field, value) {
|
|
|
5198
5307
|
|
|
5199
5308
|
if (res.ok) {
|
|
5200
5309
|
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
|
-
}
|
|
5310
|
+
await fetchTasks(sessionId);
|
|
5208
5311
|
showTaskDetail(taskId, sessionId);
|
|
5209
5312
|
}
|
|
5210
5313
|
} catch (error) {
|
|
@@ -5288,11 +5391,11 @@ function deleteTask(taskId, sessionId) {
|
|
|
5288
5391
|
if (e.key === 'Escape') {
|
|
5289
5392
|
e.preventDefault();
|
|
5290
5393
|
closeDeleteConfirmModal();
|
|
5291
|
-
} else if (matchKey(e, 'ArrowLeft'
|
|
5394
|
+
} else if (matchKey(e, 'ArrowLeft')) {
|
|
5292
5395
|
e.preventDefault();
|
|
5293
5396
|
focusIdx = 0;
|
|
5294
5397
|
buttons[focusIdx].focus();
|
|
5295
|
-
} else if (matchKey(e, 'ArrowRight'
|
|
5398
|
+
} else if (matchKey(e, 'ArrowRight')) {
|
|
5296
5399
|
e.preventDefault();
|
|
5297
5400
|
focusIdx = 1;
|
|
5298
5401
|
buttons[focusIdx].focus();
|
|
@@ -5354,10 +5457,10 @@ const SHORTCUT_PAIRS = [
|
|
|
5354
5457
|
{
|
|
5355
5458
|
title: 'Navigate',
|
|
5356
5459
|
rows: [
|
|
5357
|
-
{ keys: ['
|
|
5358
|
-
{ keys: ['
|
|
5359
|
-
{ keys: ['
|
|
5360
|
-
{ keys: ['
|
|
5460
|
+
{ keys: ['↓'], label: 'Next item' },
|
|
5461
|
+
{ keys: ['↑'], label: 'Previous item' },
|
|
5462
|
+
{ keys: ['←'], label: 'Left column / collapse group' },
|
|
5463
|
+
{ keys: ['→'], label: 'Right column / expand group' },
|
|
5361
5464
|
{ keys: ['Tab'], label: 'Switch sidebar ↔ board' },
|
|
5362
5465
|
{ keys: ['Enter', 'Space'], label: 'Open selected item' },
|
|
5363
5466
|
],
|
|
@@ -5366,14 +5469,15 @@ const SHORTCUT_PAIRS = [
|
|
|
5366
5469
|
title: 'Session',
|
|
5367
5470
|
rows: [
|
|
5368
5471
|
{ keys: ['P'], label: 'Open plan' },
|
|
5369
|
-
{ keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5472
|
+
{ keys: ['Ctrl', 'Shift', 'P'], combo: true, label: 'Session picker' },
|
|
5370
5473
|
{ keys: ['I'], label: 'Session info' },
|
|
5371
5474
|
{ keys: ['.'], label: 'Pin / unpin' },
|
|
5372
5475
|
{ keys: ['>'], label: 'Toggle sticky' },
|
|
5476
|
+
{ keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
|
|
5373
5477
|
{ keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
|
|
5374
5478
|
{ keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
|
|
5375
5479
|
{ keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
|
|
5376
|
-
{ keys: ['
|
|
5480
|
+
{ keys: ['↑', '↓'], label: 'Previous / next message in detail' },
|
|
5377
5481
|
{ keys: ['Ctrl', 'Enter'], combo: true, label: 'Allow / approve the waiting prompt' },
|
|
5378
5482
|
],
|
|
5379
5483
|
},
|
|
@@ -5490,9 +5594,7 @@ function closeHelpModal() {
|
|
|
5490
5594
|
}
|
|
5491
5595
|
|
|
5492
5596
|
async function refreshCurrentView() {
|
|
5493
|
-
if (
|
|
5494
|
-
await showAllTasks();
|
|
5495
|
-
} else if (currentSessionId) {
|
|
5597
|
+
if (currentSessionId) {
|
|
5496
5598
|
await fetchTasks(currentSessionId);
|
|
5497
5599
|
} else {
|
|
5498
5600
|
await fetchSessions();
|
|
@@ -6025,10 +6127,12 @@ function _storagePreviewLinkedDoc(path) {
|
|
|
6025
6127
|
|
|
6026
6128
|
function _storageUnlinkDoc(sessionId, path) {
|
|
6027
6129
|
setSessionDocLink(sessionId, path, true);
|
|
6130
|
+
forgetServerLinkedDoc(sessionId, path);
|
|
6028
6131
|
}
|
|
6029
6132
|
|
|
6030
6133
|
function _storageClearLinkedDocs(sessionId) {
|
|
6031
6134
|
store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
|
|
6135
|
+
forgetServerLinkedDoc(sessionId);
|
|
6032
6136
|
// The auto-link record deliberately survives: clearing is unlinking every row at
|
|
6033
6137
|
// once, and an unlinked pad must not come back on the next read of the transcript.
|
|
6034
6138
|
afterLinkedDocsChanged(sessionId);
|
|
@@ -6148,11 +6252,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6148
6252
|
return;
|
|
6149
6253
|
}
|
|
6150
6254
|
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;
|
|
6255
|
+
const step = matchKey(e, 'ArrowLeft', 'ArrowUp') ? -1 : matchKey(e, 'ArrowRight', 'ArrowDown') ? 1 : 0;
|
|
6156
6256
|
if (step) {
|
|
6157
6257
|
e.preventDefault();
|
|
6158
6258
|
const buttons = [...terminalPrompt.querySelectorAll('button')];
|
|
@@ -6172,7 +6272,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6172
6272
|
return;
|
|
6173
6273
|
}
|
|
6174
6274
|
|
|
6175
|
-
// Modal guard — only Escape, Shift+M, and msg-detail
|
|
6275
|
+
// Modal guard — only Escape, Shift+M, and msg-detail arrow navigation pass through
|
|
6176
6276
|
if (isAnyModalOpen()) {
|
|
6177
6277
|
if (e.key === 'Escape') {
|
|
6178
6278
|
if (_scratchpadModal.classList.contains('visible')) {
|
|
@@ -6206,7 +6306,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6206
6306
|
) {
|
|
6207
6307
|
e.preventDefault();
|
|
6208
6308
|
respondWaiting({ behavior: 'allow' });
|
|
6209
|
-
} else if (matchKey(e, 'ArrowDown'
|
|
6309
|
+
} else if (matchKey(e, 'ArrowDown')) {
|
|
6210
6310
|
e.preventDefault();
|
|
6211
6311
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
6212
6312
|
msgDetailFollowLatest = true;
|
|
@@ -6221,7 +6321,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
6221
6321
|
msgDetailFollowLatest = true;
|
|
6222
6322
|
showMsgDetail(currentMsgDetailIdx);
|
|
6223
6323
|
}
|
|
6224
|
-
} else if (matchKey(e, 'ArrowUp'
|
|
6324
|
+
} else if (matchKey(e, 'ArrowUp')) {
|
|
6225
6325
|
e.preventDefault();
|
|
6226
6326
|
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
6227
6327
|
if (currentMessages.length) {
|
|
@@ -6272,12 +6372,6 @@ document.addEventListener('keydown', (e) => {
|
|
|
6272
6372
|
toggleZenMode();
|
|
6273
6373
|
return;
|
|
6274
6374
|
}
|
|
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
6375
|
if (e.key === '.' || e.key === '>') {
|
|
6282
6376
|
const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
|
|
6283
6377
|
if (sid) {
|
|
@@ -6306,22 +6400,22 @@ document.addEventListener('keydown', (e) => {
|
|
|
6306
6400
|
|
|
6307
6401
|
// Sidebar navigation
|
|
6308
6402
|
if (focusZone === 'sidebar') {
|
|
6309
|
-
if (matchKey(e, 'ArrowDown'
|
|
6403
|
+
if (matchKey(e, 'ArrowDown')) {
|
|
6310
6404
|
e.preventDefault();
|
|
6311
6405
|
navigateSession(1);
|
|
6312
6406
|
return;
|
|
6313
6407
|
}
|
|
6314
|
-
if (matchKey(e, 'ArrowUp'
|
|
6408
|
+
if (matchKey(e, 'ArrowUp')) {
|
|
6315
6409
|
e.preventDefault();
|
|
6316
6410
|
navigateSession(-1);
|
|
6317
6411
|
return;
|
|
6318
6412
|
}
|
|
6319
|
-
if (matchKey(e, 'ArrowLeft'
|
|
6413
|
+
if (matchKey(e, 'ArrowLeft')) {
|
|
6320
6414
|
e.preventDefault();
|
|
6321
6415
|
handleSidebarHorizontal(-1);
|
|
6322
6416
|
return;
|
|
6323
6417
|
}
|
|
6324
|
-
if (matchKey(e, 'ArrowRight'
|
|
6418
|
+
if (matchKey(e, 'ArrowRight')) {
|
|
6325
6419
|
e.preventDefault();
|
|
6326
6420
|
handleSidebarHorizontal(1);
|
|
6327
6421
|
return;
|
|
@@ -6331,6 +6425,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6331
6425
|
activateSelectedSession();
|
|
6332
6426
|
return;
|
|
6333
6427
|
}
|
|
6428
|
+
const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
|
|
6429
|
+
if (menuKey && sgOpenMenuForKbSelection()) {
|
|
6430
|
+
e.preventDefault();
|
|
6431
|
+
return;
|
|
6432
|
+
}
|
|
6334
6433
|
if (e.key === 'Escape') {
|
|
6335
6434
|
// Plain unfocus — drop the keyboard cursor without jumping into the board
|
|
6336
6435
|
clearKbSelection();
|
|
@@ -6341,16 +6440,16 @@ document.addEventListener('keydown', (e) => {
|
|
|
6341
6440
|
|
|
6342
6441
|
// Board navigation
|
|
6343
6442
|
if (focusZone === 'board') {
|
|
6344
|
-
if (matchKey(e, 'ArrowDown', '
|
|
6443
|
+
if (matchKey(e, 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight')) {
|
|
6345
6444
|
e.preventDefault();
|
|
6346
6445
|
if (!selectedTaskId && !document.querySelector('.task-card.selected')) {
|
|
6347
6446
|
setFocusZone('sidebar');
|
|
6348
6447
|
return;
|
|
6349
6448
|
}
|
|
6350
|
-
if (matchKey(e, 'ArrowDown'
|
|
6351
|
-
else if (matchKey(e, 'ArrowUp'
|
|
6352
|
-
else if (matchKey(e, 'ArrowLeft'
|
|
6353
|
-
else if (matchKey(e, 'ArrowRight'
|
|
6449
|
+
if (matchKey(e, 'ArrowDown')) navigateVertical(1);
|
|
6450
|
+
else if (matchKey(e, 'ArrowUp')) navigateVertical(-1);
|
|
6451
|
+
else if (matchKey(e, 'ArrowLeft')) navigateHorizontal(-1);
|
|
6452
|
+
else if (matchKey(e, 'ArrowRight')) navigateHorizontal(1);
|
|
6354
6453
|
|
|
6355
6454
|
if (selectedTaskId && detailPanel.classList.contains('visible')) {
|
|
6356
6455
|
showTaskDetail(selectedTaskId, selectedSessionId);
|
|
@@ -6433,12 +6532,8 @@ document.addEventListener('keydown', (e) => {
|
|
|
6433
6532
|
}
|
|
6434
6533
|
if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
|
|
6435
6534
|
e.preventDefault();
|
|
6436
|
-
if (!contextSid || dismissedSessionIds.has(contextSid)) return;
|
|
6437
6535
|
const prevIdx = selectedSessionIdx;
|
|
6438
|
-
|
|
6439
|
-
updateDismissBtnState();
|
|
6440
|
-
renderSessions();
|
|
6441
|
-
renderActivityChip();
|
|
6536
|
+
if (!contextSid || !setSessionDismissed(contextSid, true)) return;
|
|
6442
6537
|
const newItems = getNavigableItems();
|
|
6443
6538
|
const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
|
|
6444
6539
|
// If the dismissed session is currently open, navigate to the previous one
|
|
@@ -6449,10 +6544,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
6449
6544
|
if (targetSid) {
|
|
6450
6545
|
fetchTasks(targetSid).then(() => selectSessionByIndex(targetIdx, getNavigableItems()));
|
|
6451
6546
|
} else {
|
|
6452
|
-
|
|
6547
|
+
showNoSession();
|
|
6548
|
+
selectSessionByIndex(targetIdx, getNavigableItems());
|
|
6453
6549
|
}
|
|
6454
6550
|
} else {
|
|
6455
|
-
|
|
6551
|
+
showNoSession();
|
|
6456
6552
|
}
|
|
6457
6553
|
} else if (targetIdx >= 0) {
|
|
6458
6554
|
selectSessionByIndex(targetIdx, newItems);
|
|
@@ -6555,6 +6651,31 @@ function setSessionDocLink(sessionId, filePath, unlink) {
|
|
|
6555
6651
|
afterLinkedDocsChanged(sessionId);
|
|
6556
6652
|
}
|
|
6557
6653
|
|
|
6654
|
+
// Links sent while no tab was open reach the browser only through the server copy.
|
|
6655
|
+
async function mergeServerLinkedDocs() {
|
|
6656
|
+
let bySession;
|
|
6657
|
+
try {
|
|
6658
|
+
const res = await fetch('/api/document/links');
|
|
6659
|
+
if (!res.ok) return;
|
|
6660
|
+
bySession = await res.json();
|
|
6661
|
+
} catch {
|
|
6662
|
+
return;
|
|
6663
|
+
}
|
|
6664
|
+
for (const [sessionId, paths] of Object.entries(bySession || {})) {
|
|
6665
|
+
const local = getSessionPreviewPaths(sessionId);
|
|
6666
|
+
const known = new Set(local.map(canonicalPath));
|
|
6667
|
+
const missing = (paths || []).filter((p) => !known.has(canonicalPath(p)));
|
|
6668
|
+
if (!missing.length) continue;
|
|
6669
|
+
store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify([...missing, ...local].slice(0, 20)));
|
|
6670
|
+
afterLinkedDocsChanged(sessionId);
|
|
6671
|
+
}
|
|
6672
|
+
}
|
|
6673
|
+
|
|
6674
|
+
function forgetServerLinkedDoc(sessionId, filePath) {
|
|
6675
|
+
const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
|
|
6676
|
+
fetch(`/api/document/links/${encodeURIComponent(sessionId)}${q}`, { method: 'DELETE' }).catch(() => {});
|
|
6677
|
+
}
|
|
6678
|
+
|
|
6558
6679
|
function afterLinkedDocsChanged(sessionId) {
|
|
6559
6680
|
if (_infoModalSessionId === sessionId) refreshInfoModalLinkedDocs();
|
|
6560
6681
|
renderSessions();
|
|
@@ -6770,6 +6891,7 @@ function handleSessionOpenEvent(data) {
|
|
|
6770
6891
|
if (!isSessionActive(target)) {
|
|
6771
6892
|
stickySessionIds.add(id);
|
|
6772
6893
|
}
|
|
6894
|
+
setSessionDismissed(id, false);
|
|
6773
6895
|
fetchTasks(id);
|
|
6774
6896
|
}
|
|
6775
6897
|
|
|
@@ -7228,6 +7350,7 @@ function bindLinkedDocsHandlers(container, sessionId) {
|
|
|
7228
7350
|
copyWithFeedback(hit.dataset.path, hit);
|
|
7229
7351
|
} else if (hit.classList.contains('linked-doc-remove')) {
|
|
7230
7352
|
removeSessionPreviewPath(sessionId, hit.dataset.path);
|
|
7353
|
+
forgetServerLinkedDoc(sessionId, hit.dataset.path);
|
|
7231
7354
|
afterLinkedDocsChanged(sessionId);
|
|
7232
7355
|
} else {
|
|
7233
7356
|
openLinkedDoc(hit.dataset.path, getSessionBaseDir(sessionId));
|
|
@@ -7324,12 +7447,6 @@ function setupEventSource() {
|
|
|
7324
7447
|
const pendingTaskSessionIds = new Set();
|
|
7325
7448
|
const pendingAgentSessionIds = new Set();
|
|
7326
7449
|
|
|
7327
|
-
function refreshAllView() {
|
|
7328
|
-
currentTasks = filterProject ? allTasksCache.filter((t) => matchesProjectFilter(t.project)) : allTasksCache;
|
|
7329
|
-
renderAllTasks();
|
|
7330
|
-
renderActivityChip();
|
|
7331
|
-
}
|
|
7332
|
-
|
|
7333
7450
|
async function refreshSessionDetail(sessionId) {
|
|
7334
7451
|
await fetchAgents(sessionId);
|
|
7335
7452
|
if (!agentLogMode) fetchMessages(sessionId);
|
|
@@ -7349,6 +7466,7 @@ function setupEventSource() {
|
|
|
7349
7466
|
.catch(() => {});
|
|
7350
7467
|
}
|
|
7351
7468
|
wasConnected = true;
|
|
7469
|
+
mergeServerLinkedDocs();
|
|
7352
7470
|
retryDelay = 1000;
|
|
7353
7471
|
hideOffline();
|
|
7354
7472
|
};
|
|
@@ -7383,9 +7501,7 @@ function setupEventSource() {
|
|
|
7383
7501
|
taskRefreshTimer = setTimeout(async () => {
|
|
7384
7502
|
if (skipOffScreen()) return;
|
|
7385
7503
|
await fetchSessions().catch((err) => console.error('[SSE] fetchSessions failed:', err));
|
|
7386
|
-
if (viewMode === '
|
|
7387
|
-
refreshAllView();
|
|
7388
|
-
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7504
|
+
if (viewMode === 'project' && currentProjectPath) {
|
|
7389
7505
|
const hasUpdate = currentProjectSessionIds.some((id) => pendingTaskSessionIds.has(id));
|
|
7390
7506
|
if (hasUpdate) fetchProjectView(currentProjectPath);
|
|
7391
7507
|
} else if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
|
|
@@ -7472,10 +7588,7 @@ function setupEventSource() {
|
|
|
7472
7588
|
pendingTaskSessionIds.clear();
|
|
7473
7589
|
pendingAgentSessionIds.clear();
|
|
7474
7590
|
const sessionsLoaded = fetchSessions().catch(() => {});
|
|
7475
|
-
if (viewMode === '
|
|
7476
|
-
await sessionsLoaded;
|
|
7477
|
-
refreshAllView();
|
|
7478
|
-
} else if (viewMode === 'project' && currentProjectPath) {
|
|
7591
|
+
if (viewMode === 'project' && currentProjectPath) {
|
|
7479
7592
|
await sessionsLoaded;
|
|
7480
7593
|
fetchProjectView(currentProjectPath);
|
|
7481
7594
|
} else if (currentSessionId) {
|
|
@@ -8270,18 +8383,106 @@ function toggleFilterMenu(e) {
|
|
|
8270
8383
|
document.getElementById('filter-menu-btn')?.setAttribute('aria-expanded', 'true');
|
|
8271
8384
|
renderFilterState();
|
|
8272
8385
|
document.addEventListener('click', closeFilterMenu, { once: true });
|
|
8273
|
-
// Capture phase: the global keydown handler bails out on
|
|
8274
|
-
//
|
|
8386
|
+
// Capture phase: the global keydown handler bails out on form fields, and the path box
|
|
8387
|
+
// has focus — Escape would never reach it otherwise.
|
|
8275
8388
|
document.addEventListener('keydown', filterMenuKeydown, true);
|
|
8276
|
-
|
|
8389
|
+
pfIdx = -1;
|
|
8390
|
+
renderProjectOptions();
|
|
8391
|
+
const input = document.getElementById('project-filter');
|
|
8392
|
+
input?.focus();
|
|
8393
|
+
input?.select();
|
|
8277
8394
|
}
|
|
8278
8395
|
|
|
8279
8396
|
function filterMenuKeydown(e) {
|
|
8280
|
-
if (e.key
|
|
8281
|
-
|
|
8282
|
-
|
|
8397
|
+
if (e.key === 'Escape') {
|
|
8398
|
+
e.stopPropagation();
|
|
8399
|
+
if (closeFilterMenu()) document.getElementById('filter-menu-btn')?.focus();
|
|
8400
|
+
return;
|
|
8401
|
+
}
|
|
8402
|
+
if (e.target.id !== 'project-filter') return;
|
|
8403
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
8404
|
+
e.preventDefault();
|
|
8405
|
+
const n = pfOptions.length;
|
|
8406
|
+
if (!n) return;
|
|
8407
|
+
pfIdx = e.key === 'ArrowDown' ? (pfIdx + 1) % n : (pfIdx - 1 + n) % n;
|
|
8408
|
+
renderProjectOptions();
|
|
8409
|
+
} else if (e.key === 'Enter') {
|
|
8410
|
+
e.preventDefault();
|
|
8411
|
+
if (pfIdx >= 0 && pfOptions[pfIdx]) pickProject(pfOptions[pfIdx].path);
|
|
8412
|
+
else closeFilterMenu();
|
|
8413
|
+
}
|
|
8414
|
+
}
|
|
8415
|
+
|
|
8416
|
+
// The menu's project list: projects the path box matches, newest activity first.
|
|
8417
|
+
const PF_MAX = 7;
|
|
8418
|
+
let pfOptions = [];
|
|
8419
|
+
let pfIdx = -1;
|
|
8420
|
+
|
|
8421
|
+
function renderProjectOptions() {
|
|
8422
|
+
const list = document.getElementById('project-filter-options');
|
|
8423
|
+
const input = document.getElementById('project-filter');
|
|
8424
|
+
if (!list || !input) return;
|
|
8425
|
+
const text = projectFilterText();
|
|
8426
|
+
const q = normalizeProjectPath(text.trim());
|
|
8427
|
+
const partial = !!q && !isExactProjectPath(q);
|
|
8428
|
+
const needle = partial ? q : '';
|
|
8429
|
+
const selected = q && !partial ? q : null;
|
|
8430
|
+
const byRecent = (a, b) => (b.modifiedAt || '').localeCompare(a.modifiedAt || '');
|
|
8431
|
+
pfOptions = (projectsCache || [])
|
|
8432
|
+
.map((p) => ({ ...p, norm: normalizeProjectPath(p.path) }))
|
|
8433
|
+
.filter((p) => !needle || p.norm.includes(needle))
|
|
8434
|
+
.sort(byRecent)
|
|
8435
|
+
.slice(0, PF_MAX);
|
|
8436
|
+
if (pfIdx >= pfOptions.length) pfIdx = pfOptions.length - 1;
|
|
8437
|
+
list.innerHTML = pfOptions.length
|
|
8438
|
+
? pfOptions
|
|
8439
|
+
.map((p, i) => {
|
|
8440
|
+
const cut = Math.max(p.path.lastIndexOf('/'), p.path.lastIndexOf('\\')) + 1;
|
|
8441
|
+
const at = needle ? p.norm.indexOf(needle) : -1;
|
|
8442
|
+
const range = at >= 0 ? [at, at + needle.length] : null;
|
|
8443
|
+
const isSel = p.norm === selected;
|
|
8444
|
+
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)}">
|
|
8445
|
+
<span class="pf-name">${markRange(p.path, cut, p.path.length, range)}</span>
|
|
8446
|
+
<span class="pf-dir"><bdi>${markRange(p.path, 0, cut, range)}</bdi></span>
|
|
8447
|
+
<span class="pf-age">${p.modifiedAt ? shortAge(p.modifiedAt) : ''}</span>
|
|
8448
|
+
</li>`;
|
|
8449
|
+
})
|
|
8450
|
+
.join('')
|
|
8451
|
+
: `<li class="pf-empty">${
|
|
8452
|
+
partial ? `No project path contains “${escapeHtml(text.trim())}”` : 'No projects yet'
|
|
8453
|
+
}</li>`;
|
|
8454
|
+
if (pfIdx >= 0) input.setAttribute('aria-activedescendant', `pf-opt-${pfIdx}`);
|
|
8455
|
+
else input.removeAttribute('aria-activedescendant');
|
|
8456
|
+
list.querySelector('.pf-opt.hl')?.scrollIntoView({ block: 'nearest' });
|
|
8457
|
+
}
|
|
8458
|
+
|
|
8459
|
+
function shortAge(iso) {
|
|
8460
|
+
const min = Math.max(0, (Date.now() - new Date(iso).getTime()) / 60000);
|
|
8461
|
+
if (min < 60) return `${Math.floor(min)}m`;
|
|
8462
|
+
if (min < 1440) return `${Math.floor(min / 60)}h`;
|
|
8463
|
+
if (min < 60 * 24 * 60) return `${Math.floor(min / 1440)}d`;
|
|
8464
|
+
return `${Math.floor(min / (1440 * 30))}mo`;
|
|
8465
|
+
}
|
|
8466
|
+
|
|
8467
|
+
// The slice [from, to) of text, with the part inside range marked.
|
|
8468
|
+
function markRange(text, from, to, range) {
|
|
8469
|
+
if (!range || range[1] <= from || range[0] >= to) return escapeHtml(text.slice(from, to));
|
|
8470
|
+
const a = Math.max(from, range[0]);
|
|
8471
|
+
const b = Math.min(to, range[1]);
|
|
8472
|
+
return `${escapeHtml(text.slice(from, a))}<mark>${escapeHtml(text.slice(a, b))}</mark>${escapeHtml(text.slice(b, to))}`;
|
|
8473
|
+
}
|
|
8474
|
+
|
|
8475
|
+
function pickProject(path) {
|
|
8476
|
+
filterByProject(path);
|
|
8477
|
+
closeFilterMenu();
|
|
8478
|
+
document.getElementById('filter-menu-btn')?.focus();
|
|
8283
8479
|
}
|
|
8284
8480
|
|
|
8481
|
+
document.getElementById('project-filter-options')?.addEventListener('click', (e) => {
|
|
8482
|
+
const opt = e.target.closest('.pf-opt');
|
|
8483
|
+
if (opt) pickProject(opt.dataset.path);
|
|
8484
|
+
});
|
|
8485
|
+
|
|
8285
8486
|
function closeFilterMenu() {
|
|
8286
8487
|
const menu = document.getElementById('filter-menu');
|
|
8287
8488
|
if (!menu?.classList.contains('open')) return false;
|
|
@@ -8291,50 +8492,53 @@ function closeFilterMenu() {
|
|
|
8291
8492
|
return true;
|
|
8292
8493
|
}
|
|
8293
8494
|
|
|
8294
|
-
// One row per filter
|
|
8295
|
-
// default, and two label forms — terse tokens for the header strip (which has ~15 characters
|
|
8495
|
+
// 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
8496
|
// before the sidebar's width ellipsises it) and long form for its tooltip.
|
|
8297
8497
|
const FILTERS = [
|
|
8298
8498
|
{
|
|
8299
|
-
id: 'project-filter',
|
|
8300
|
-
value: () => filterProject ?? '',
|
|
8301
8499
|
active: () => filterProject !== FILTER_DEFAULTS.project,
|
|
8302
|
-
short: () =>
|
|
8303
|
-
|
|
8500
|
+
short: () => {
|
|
8501
|
+
if (filterProject === '__recent__') return 'recent';
|
|
8502
|
+
if (!filterProject) return 'all proj';
|
|
8503
|
+
const name = pathBasename(filterProject);
|
|
8504
|
+
return isExactProjectFilter(filterProject) ? name : `*${name}*`;
|
|
8505
|
+
},
|
|
8506
|
+
long: () => {
|
|
8507
|
+
if (filterProject === '__recent__') return 'projects active in the last 24h';
|
|
8508
|
+
if (!filterProject) return 'all projects';
|
|
8509
|
+
return isExactProjectFilter(filterProject)
|
|
8510
|
+
? `project ${filterProject}`
|
|
8511
|
+
: `project path contains "${filterProject}"`;
|
|
8512
|
+
},
|
|
8304
8513
|
},
|
|
8305
8514
|
{
|
|
8306
|
-
id: 'session-filter',
|
|
8307
|
-
value: () => sessionFilter,
|
|
8308
8515
|
active: () => sessionFilter !== FILTER_DEFAULTS.session,
|
|
8309
8516
|
short: () => 'inactive',
|
|
8310
8517
|
long: () => 'including inactive sessions',
|
|
8311
8518
|
},
|
|
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
8519
|
];
|
|
8320
8520
|
|
|
8321
|
-
// Keeps the header (summary + funnel tint) and the popover's
|
|
8521
|
+
// Keeps the header (summary + funnel tint) and the popover's controls in sync with state.
|
|
8322
8522
|
// Safe to call on every filter change — it only touches classes, values, and text.
|
|
8323
8523
|
function renderFilterState() {
|
|
8324
8524
|
const short = [];
|
|
8325
8525
|
const long = [];
|
|
8326
8526
|
for (const f of FILTERS) {
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
|
|
8332
|
-
|
|
8333
|
-
|
|
8334
|
-
|
|
8335
|
-
|
|
8336
|
-
|
|
8527
|
+
if (!f.active()) continue;
|
|
8528
|
+
short.push(f.short());
|
|
8529
|
+
long.push(f.long());
|
|
8530
|
+
}
|
|
8531
|
+
const input = document.getElementById('project-filter');
|
|
8532
|
+
if (input) {
|
|
8533
|
+
const value = projectFilterText();
|
|
8534
|
+
if (input.value !== value) input.value = value;
|
|
8535
|
+
}
|
|
8536
|
+
const recent = document.getElementById('project-recent-btn');
|
|
8537
|
+
recent?.setAttribute('aria-pressed', String(filterProject === '__recent__'));
|
|
8538
|
+
for (const b of document.querySelectorAll('[data-session-filter]')) {
|
|
8539
|
+
b.setAttribute('aria-checked', String(b.dataset.sessionFilter === sessionFilter));
|
|
8337
8540
|
}
|
|
8541
|
+
if (document.getElementById('filter-menu')?.classList.contains('open')) renderProjectOptions();
|
|
8338
8542
|
|
|
8339
8543
|
const summary = document.getElementById('filter-summary');
|
|
8340
8544
|
if (summary) {
|
|
@@ -8364,7 +8568,9 @@ function renderZenState() {
|
|
|
8364
8568
|
|
|
8365
8569
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8366
8570
|
function filterBySessions(value) {
|
|
8571
|
+
if (value === sessionFilter) return;
|
|
8367
8572
|
sessionFilter = value;
|
|
8573
|
+
resetSessionPage();
|
|
8368
8574
|
if (value !== 'active') activityFilter.clear();
|
|
8369
8575
|
renderFilterState();
|
|
8370
8576
|
updateUrl();
|
|
@@ -8376,18 +8582,18 @@ function filterBySessions(value) {
|
|
|
8376
8582
|
fetchSessions(false);
|
|
8377
8583
|
}
|
|
8378
8584
|
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
sessionLimit = value;
|
|
8382
|
-
renderFilterState();
|
|
8383
|
-
updateUrl();
|
|
8384
|
-
fetchSessions();
|
|
8585
|
+
function projectFilterText() {
|
|
8586
|
+
return filterProject && filterProject !== '__recent__' ? filterProject : '';
|
|
8385
8587
|
}
|
|
8386
8588
|
|
|
8589
|
+
let filterMatcher = { query: null, match: null };
|
|
8387
8590
|
function matchesProjectFilter(project) {
|
|
8388
8591
|
if (!filterProject) return true;
|
|
8389
8592
|
if (filterProject === '__recent__') return recentProjects.has(project);
|
|
8390
|
-
|
|
8593
|
+
if (filterMatcher.query !== filterProject) {
|
|
8594
|
+
filterMatcher = { query: filterProject, match: projectMatcher(filterProject) };
|
|
8595
|
+
}
|
|
8596
|
+
return filterMatcher.match(project);
|
|
8391
8597
|
}
|
|
8392
8598
|
|
|
8393
8599
|
//#endregion
|
|
@@ -8507,11 +8713,31 @@ document.addEventListener('click', (e) => {
|
|
|
8507
8713
|
|
|
8508
8714
|
// Used in HTML; also called by the hub project shim, so no biome suppression is needed.
|
|
8509
8715
|
function filterByProject(project) {
|
|
8716
|
+
clearTimeout(projectFilterTimer);
|
|
8510
8717
|
filterProject = project || null;
|
|
8718
|
+
resetSessionPage();
|
|
8511
8719
|
renderFilterState();
|
|
8512
|
-
updateUrl();
|
|
8513
8720
|
fetchSessions(false);
|
|
8514
|
-
|
|
8721
|
+
showNoSession();
|
|
8722
|
+
}
|
|
8723
|
+
|
|
8724
|
+
// The sidebar narrows on every key from the rows it has; the server round trip for rows past
|
|
8725
|
+
// the limit waits for a pause in typing.
|
|
8726
|
+
const PROJECT_FILTER_DEBOUNCE_MS = 250;
|
|
8727
|
+
let projectFilterTimer = null;
|
|
8728
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8729
|
+
function typeProjectFilter(value) {
|
|
8730
|
+
filterProject = value.trim() ? value : null;
|
|
8731
|
+
pfIdx = -1;
|
|
8732
|
+
updateUrl();
|
|
8733
|
+
renderSessions();
|
|
8734
|
+
clearTimeout(projectFilterTimer);
|
|
8735
|
+
projectFilterTimer = setTimeout(() => filterByProject(filterProject), PROJECT_FILTER_DEBOUNCE_MS);
|
|
8736
|
+
}
|
|
8737
|
+
|
|
8738
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
8739
|
+
function toggleRecentProjects() {
|
|
8740
|
+
filterByProject(filterProject === '__recent__' ? null : '__recent__');
|
|
8515
8741
|
}
|
|
8516
8742
|
|
|
8517
8743
|
let projectsCache = null;
|
|
@@ -8532,22 +8758,20 @@ async function loadProjects() {
|
|
|
8532
8758
|
return projects;
|
|
8533
8759
|
}
|
|
8534
8760
|
|
|
8535
|
-
async function
|
|
8536
|
-
const dropdown = document.getElementById('project-filter');
|
|
8537
|
-
|
|
8761
|
+
async function refreshProjectList() {
|
|
8538
8762
|
if (!projectsCacheDirty && projectsCache) {
|
|
8539
|
-
|
|
8763
|
+
renderFilterState();
|
|
8540
8764
|
return;
|
|
8541
8765
|
}
|
|
8542
8766
|
|
|
8543
8767
|
const projects = await loadProjects();
|
|
8544
|
-
const cutoff = Date.now() -
|
|
8768
|
+
const cutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
|
|
8545
8769
|
const prevRecent = recentProjects;
|
|
8546
8770
|
recentProjects = new Set(
|
|
8547
8771
|
projects.filter((p) => p.modifiedAt && new Date(p.modifiedAt).getTime() > cutoff).map((p) => p.path),
|
|
8548
8772
|
);
|
|
8549
8773
|
|
|
8550
|
-
|
|
8774
|
+
renderFilterState();
|
|
8551
8775
|
|
|
8552
8776
|
// recentProjects was empty before — sidebar rendered with __recent__ filter
|
|
8553
8777
|
// dropping every session. Re-render now that we know which projects qualify.
|
|
@@ -8556,28 +8780,6 @@ async function updateProjectDropdown() {
|
|
|
8556
8780
|
}
|
|
8557
8781
|
}
|
|
8558
8782
|
|
|
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
8783
|
function updateThemeColor(isLight) {
|
|
8582
8784
|
document.querySelectorAll('meta[name="theme-color"]').forEach((m) => {
|
|
8583
8785
|
m.setAttribute('content', isLight ? '#e8e6e3' : '#101114');
|
|
@@ -8993,13 +9195,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
8993
9195
|
});
|
|
8994
9196
|
html += `</div>`;
|
|
8995
9197
|
|
|
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
9198
|
if (session.contextStatus) {
|
|
9004
9199
|
html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
|
|
9005
9200
|
html += renderContextDetail(session.contextStatus);
|
|
@@ -9121,14 +9316,7 @@ function openSessionFromInfo(sessionId) {
|
|
|
9121
9316
|
|
|
9122
9317
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
9123
9318
|
function toggleDismissSession(sessionId) {
|
|
9124
|
-
|
|
9125
|
-
dismissedSessionIds.delete(sessionId);
|
|
9126
|
-
} else {
|
|
9127
|
-
dismissedSessionIds.add(sessionId);
|
|
9128
|
-
}
|
|
9129
|
-
updateDismissBtnState();
|
|
9130
|
-
renderSessions();
|
|
9131
|
-
renderActivityChip();
|
|
9319
|
+
setSessionDismissed(sessionId, !dismissedSessionIds.has(sessionId));
|
|
9132
9320
|
}
|
|
9133
9321
|
|
|
9134
9322
|
function updateDismissBtnState() {
|
|
@@ -9152,47 +9340,67 @@ let spIdx = 0;
|
|
|
9152
9340
|
let spPeekTimer = null;
|
|
9153
9341
|
let spPeekToken = 0;
|
|
9154
9342
|
let spPeekedId = null;
|
|
9343
|
+
let spFromTerminal = false;
|
|
9344
|
+
let spRenderedQuery = null;
|
|
9155
9345
|
// Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
|
|
9156
9346
|
const spPeekCache = new Map();
|
|
9157
9347
|
|
|
9158
|
-
// A session
|
|
9159
|
-
//
|
|
9160
|
-
|
|
9161
|
-
|
|
9348
|
+
// A query also searches session ids and names in every transcript on the server, so a
|
|
9349
|
+
// session outside the sidebar filter is found too. Values: 'pending', then the rows.
|
|
9350
|
+
const spSearch = new Map();
|
|
9351
|
+
let spSearchTimer = null;
|
|
9162
9352
|
|
|
9163
|
-
|
|
9164
|
-
|
|
9165
|
-
spGlobal.set(id, await fetchSessionById(id));
|
|
9166
|
-
if (document.getElementById('session-picker-input').value.trim().toLowerCase() === id) renderSessionPicker();
|
|
9353
|
+
function spSearchKey(query) {
|
|
9354
|
+
return query.length >= 3 ? query.toLowerCase() : null;
|
|
9167
9355
|
}
|
|
9168
9356
|
|
|
9169
|
-
|
|
9170
|
-
|
|
9171
|
-
|
|
9172
|
-
|
|
9173
|
-
|
|
9174
|
-
|
|
9175
|
-
|
|
9176
|
-
|
|
9177
|
-
|
|
9178
|
-
|
|
9179
|
-
|
|
9357
|
+
function spScheduleSearch(key) {
|
|
9358
|
+
clearTimeout(spSearchTimer);
|
|
9359
|
+
spSearchTimer = setTimeout(async () => {
|
|
9360
|
+
spSearch.set(key, 'pending');
|
|
9361
|
+
let ids = [];
|
|
9362
|
+
try {
|
|
9363
|
+
const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
|
|
9364
|
+
if (r.ok) ids = await r.json();
|
|
9365
|
+
} catch (_) {}
|
|
9366
|
+
spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
|
|
9367
|
+
renderSessionPicker();
|
|
9368
|
+
}, 100);
|
|
9369
|
+
}
|
|
9370
|
+
|
|
9371
|
+
// The server rows not already matched in the sidebar list, and whether a search is still out.
|
|
9372
|
+
function spSearchRows(key, localRows) {
|
|
9373
|
+
if (!key) return { rows: [], pending: false };
|
|
9374
|
+
const found = spSearch.get(key);
|
|
9375
|
+
if (!found) spScheduleSearch(key);
|
|
9376
|
+
if (!Array.isArray(found)) return { rows: [], pending: true };
|
|
9377
|
+
const seen = new Set(localRows.map((s) => s.id));
|
|
9378
|
+
return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
|
|
9180
9379
|
}
|
|
9181
9380
|
|
|
9182
9381
|
function openSessionPicker() {
|
|
9382
|
+
spFromTerminal = terminalPaneFocused();
|
|
9183
9383
|
const input = document.getElementById('session-picker-input');
|
|
9184
9384
|
input.value = '';
|
|
9185
9385
|
spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
|
|
9186
9386
|
spPeekCache.clear();
|
|
9187
|
-
|
|
9387
|
+
spSearch.clear();
|
|
9388
|
+
spRenderedQuery = null;
|
|
9188
9389
|
document.getElementById('session-picker-modal').classList.add('visible');
|
|
9189
9390
|
renderSessionPicker();
|
|
9190
9391
|
input.focus();
|
|
9191
9392
|
}
|
|
9192
9393
|
|
|
9193
|
-
|
|
9394
|
+
// refocus: false when a session is being opened, which moves the terminal focus itself.
|
|
9395
|
+
function closeSessionPicker({ refocus = true } = {}) {
|
|
9194
9396
|
spSetPeeking(false);
|
|
9195
9397
|
hideModalOverlay('session-picker-modal');
|
|
9398
|
+
if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
|
|
9399
|
+
}
|
|
9400
|
+
|
|
9401
|
+
function toggleSessionPicker() {
|
|
9402
|
+
if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
|
|
9403
|
+
else openSessionPicker();
|
|
9196
9404
|
}
|
|
9197
9405
|
|
|
9198
9406
|
function spIsPeeking() {
|
|
@@ -9274,21 +9482,30 @@ function spDotHtml(session) {
|
|
|
9274
9482
|
function renderSessionPicker() {
|
|
9275
9483
|
const list = document.getElementById('session-picker-list');
|
|
9276
9484
|
const query = document.getElementById('session-picker-input').value.trim();
|
|
9277
|
-
|
|
9485
|
+
// Search rows that arrive for the same query append below, so the cursor stays on its row.
|
|
9486
|
+
const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
|
|
9487
|
+
spRenderedQuery = query;
|
|
9488
|
+
const key = spSearchKey(query);
|
|
9489
|
+
const local = spSource.filter((s) => spMatches(s, query));
|
|
9490
|
+
const found = spSearchRows(key, local);
|
|
9491
|
+
spRows = [...local, ...found.rows];
|
|
9278
9492
|
if (!spRows.length) {
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
}
|
|
9493
|
+
spIdx = -1;
|
|
9494
|
+
const empty = found.pending
|
|
9495
|
+
? 'Searching all sessions…'
|
|
9496
|
+
: spSource.length || key
|
|
9497
|
+
? 'No session matches'
|
|
9498
|
+
: 'No sessions in the current sidebar filter';
|
|
9499
|
+
list.innerHTML = `<div class="sp-empty">${empty}</div>`;
|
|
9500
|
+
return;
|
|
9286
9501
|
}
|
|
9287
9502
|
|
|
9503
|
+
// Rows past the sidebar list come from the server search and show their full project path.
|
|
9288
9504
|
list.innerHTML = spRows
|
|
9289
9505
|
.map((s, i) => {
|
|
9290
|
-
const
|
|
9291
|
-
|
|
9506
|
+
const outside = i >= local.length;
|
|
9507
|
+
const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
|
|
9508
|
+
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
9509
|
${spDotHtml(s)}
|
|
9293
9510
|
<span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
|
|
9294
9511
|
<span class="sp-project">${escapeHtml(project)}</span>
|
|
@@ -9298,7 +9515,8 @@ function renderSessionPicker() {
|
|
|
9298
9515
|
</button>`;
|
|
9299
9516
|
})
|
|
9300
9517
|
.join('');
|
|
9301
|
-
|
|
9518
|
+
const keep = spRows.findIndex((s) => s.id === selectedId);
|
|
9519
|
+
spSelect(Math.max(0, keep));
|
|
9302
9520
|
}
|
|
9303
9521
|
|
|
9304
9522
|
function spSelect(idx) {
|
|
@@ -9314,7 +9532,8 @@ function spSelect(idx) {
|
|
|
9314
9532
|
async function spOpen(idx) {
|
|
9315
9533
|
const session = spRows[idx];
|
|
9316
9534
|
if (!session) return;
|
|
9317
|
-
closeSessionPicker();
|
|
9535
|
+
closeSessionPicker({ refocus: false });
|
|
9536
|
+
if (spFromTerminal) termState.focusNext = true;
|
|
9318
9537
|
await revealSession(session.id);
|
|
9319
9538
|
}
|
|
9320
9539
|
|
|
@@ -9325,7 +9544,7 @@ function initSessionPicker() {
|
|
|
9325
9544
|
input.addEventListener('keydown', (e) => {
|
|
9326
9545
|
if (e.key === 'Escape') closeSessionPicker();
|
|
9327
9546
|
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);
|
|
9547
|
+
else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
|
|
9329
9548
|
else if (e.key === 'Enter') spOpen(spIdx);
|
|
9330
9549
|
else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
|
|
9331
9550
|
else return;
|
|
@@ -9455,16 +9674,6 @@ function renderLoopModalBody(data) {
|
|
|
9455
9674
|
body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
|
|
9456
9675
|
}
|
|
9457
9676
|
|
|
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
9677
|
function renderLoopBadge(session) {
|
|
9469
9678
|
const li = session.loopInfo;
|
|
9470
9679
|
const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
|
|
@@ -10059,6 +10268,8 @@ function terminalPaneFocused() {
|
|
|
10059
10268
|
function terminalShortcut(e) {
|
|
10060
10269
|
const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
|
|
10061
10270
|
if (ctrlAlt && e.code === 'KeyS') return swapToPreviousSession;
|
|
10271
|
+
// Cancelling the default also stops Chrome's system print dialog, which this page has no use for.
|
|
10272
|
+
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && e.code === 'KeyP') return toggleSessionPicker;
|
|
10062
10273
|
if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
|
|
10063
10274
|
return () => openNewSession(null, e.code === 'KeyR');
|
|
10064
10275
|
}
|
|
@@ -10073,6 +10284,22 @@ function terminalShortcut(e) {
|
|
|
10073
10284
|
return null;
|
|
10074
10285
|
}
|
|
10075
10286
|
|
|
10287
|
+
function readSwapPair() {
|
|
10288
|
+
try {
|
|
10289
|
+
const pair = JSON.parse(store.getItem(SWAP_KEY));
|
|
10290
|
+
if (Array.isArray(pair) && pair.length === 2) return pair;
|
|
10291
|
+
} catch {}
|
|
10292
|
+
return [null, null];
|
|
10293
|
+
}
|
|
10294
|
+
|
|
10295
|
+
function setSwapPair(last, previous) {
|
|
10296
|
+
lastSessionId = last;
|
|
10297
|
+
previousSessionId = previous;
|
|
10298
|
+
try {
|
|
10299
|
+
store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
|
|
10300
|
+
} catch {}
|
|
10301
|
+
}
|
|
10302
|
+
|
|
10076
10303
|
function swapToPreviousSession() {
|
|
10077
10304
|
const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
|
|
10078
10305
|
if (!target || target === currentSessionId) return;
|
|
@@ -10100,7 +10327,10 @@ function syncTerminal() {
|
|
|
10100
10327
|
if (view !== termState.syncedView) {
|
|
10101
10328
|
termState.syncedView = view;
|
|
10102
10329
|
// The terminal shows the same conversation, so the log panel only takes width; it comes back when the terminal goes.
|
|
10103
|
-
if (on &&
|
|
10330
|
+
if (on && logAutoOpened) {
|
|
10331
|
+
setMessagePanelVisible(false);
|
|
10332
|
+
logAutoOpened = false;
|
|
10333
|
+
} else if (on && messagePanelOpen) {
|
|
10104
10334
|
toggleMessagePanel();
|
|
10105
10335
|
termState.panelHidden = true;
|
|
10106
10336
|
} else if (!on && arrived && termState.panelHidden) toggleMessagePanel();
|
|
@@ -10129,6 +10359,7 @@ function syncTerminal() {
|
|
|
10129
10359
|
|
|
10130
10360
|
// Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
|
|
10131
10361
|
function openSession(sessionId) {
|
|
10362
|
+
setSessionDismissed(sessionId, false);
|
|
10132
10363
|
const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
|
|
10133
10364
|
if (!alreadyOpen) termState.openedByUser = sessionId;
|
|
10134
10365
|
else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
|
|
@@ -10562,8 +10793,8 @@ function terminalOpenMode(sessionId) {
|
|
|
10562
10793
|
// replays the same screen, and the placeholder gives way to the real card.
|
|
10563
10794
|
function adoptPickedSession(oldId, id) {
|
|
10564
10795
|
const focused = terminalPaneFocused();
|
|
10565
|
-
|
|
10566
|
-
|
|
10796
|
+
const adopt = (x) => (x === oldId ? id : x);
|
|
10797
|
+
setSwapPair(adopt(lastSessionId), adopt(previousSessionId));
|
|
10567
10798
|
setTerminalMode(id, true);
|
|
10568
10799
|
forgetPlaceholder(oldId);
|
|
10569
10800
|
if (currentSessionId !== oldId || viewMode !== 'session') return renderSessions();
|
|
@@ -10842,7 +11073,7 @@ async function onDispatchUpdate() {
|
|
|
10842
11073
|
// With no first message there is no transcript, so an ended placeholder leaves nothing to come back to.
|
|
10843
11074
|
function dropPlaceholder(id) {
|
|
10844
11075
|
if (!forgetPlaceholder(id)) return;
|
|
10845
|
-
if (currentSessionId === id && viewMode === 'session')
|
|
11076
|
+
if (currentSessionId === id && viewMode === 'session') showNoSession();
|
|
10846
11077
|
else renderSessions();
|
|
10847
11078
|
}
|
|
10848
11079
|
|
|
@@ -10852,8 +11083,8 @@ function forgetPlaceholder(id) {
|
|
|
10852
11083
|
// Before the socket's close handler runs, or it would reconnect and start the session again.
|
|
10853
11084
|
if (termState.sessionId === id) detachTerminal();
|
|
10854
11085
|
sessions = sessions.filter((s) => s.id !== id);
|
|
10855
|
-
|
|
10856
|
-
|
|
11086
|
+
const forget = (x) => (x === id ? null : x);
|
|
11087
|
+
setSwapPair(forget(lastSessionId), forget(previousSessionId));
|
|
10857
11088
|
return true;
|
|
10858
11089
|
}
|
|
10859
11090
|
|
|
@@ -11391,7 +11622,6 @@ fetch('/api/version')
|
|
|
11391
11622
|
const urlState = getUrlState();
|
|
11392
11623
|
const lastView = loadLastView();
|
|
11393
11624
|
sessionFilter = urlState.filter || FILTER_DEFAULTS.session;
|
|
11394
|
-
sessionLimit = urlState.limit || FILTER_DEFAULTS.limit;
|
|
11395
11625
|
// The URL wins; the persisted view only fills in when it carries a project key, so 'all' (null)
|
|
11396
11626
|
// restores as 'all' and pre-existing blobs without the key still default to '__recent__'.
|
|
11397
11627
|
filterProject = urlState.project || (lastView && 'project' in lastView ? lastView.project : FILTER_DEFAULTS.project);
|
|
@@ -11436,27 +11666,26 @@ Promise.all([
|
|
|
11436
11666
|
try {
|
|
11437
11667
|
await fetchProjectView(atob(urlState.projectView));
|
|
11438
11668
|
} catch (_) {
|
|
11439
|
-
|
|
11669
|
+
showNoSession();
|
|
11440
11670
|
}
|
|
11441
11671
|
} else if (urlState.session) {
|
|
11442
11672
|
await fetchTasks(urlState.session);
|
|
11443
|
-
} else if (urlState.view === 'all') {
|
|
11444
|
-
showAllTasks();
|
|
11445
11673
|
} else {
|
|
11446
11674
|
const last = loadLastView();
|
|
11447
11675
|
if (last?.view === 'project' && last.projectPath && sessions.some((s) => s.project === last.projectPath)) {
|
|
11448
11676
|
try {
|
|
11449
11677
|
await fetchProjectView(last.projectPath);
|
|
11450
11678
|
} catch (_) {
|
|
11451
|
-
|
|
11679
|
+
showNoSession();
|
|
11452
11680
|
}
|
|
11453
11681
|
} else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
|
|
11454
11682
|
await fetchTasks(last.session);
|
|
11455
11683
|
} else {
|
|
11456
|
-
|
|
11684
|
+
showNoSession();
|
|
11457
11685
|
}
|
|
11458
11686
|
}
|
|
11459
11687
|
if (urlState.messages && currentSessionId) {
|
|
11688
|
+
if (logAutoOpened) setMessagePanelVisible(false);
|
|
11460
11689
|
toggleMessagePanel();
|
|
11461
11690
|
// Re-render after panel layout settles so scroll dimensions are correct
|
|
11462
11691
|
requestAnimationFrame(() => {
|
|
@@ -11472,7 +11701,7 @@ Promise.all([
|
|
|
11472
11701
|
window.addEventListener('popstate', () => {
|
|
11473
11702
|
const s = getUrlState();
|
|
11474
11703
|
sessionFilter = s.filter || FILTER_DEFAULTS.session;
|
|
11475
|
-
|
|
11704
|
+
resetSessionPage();
|
|
11476
11705
|
filterProject = s.project || FILTER_DEFAULTS.project;
|
|
11477
11706
|
ownerFilter = s.owner || '';
|
|
11478
11707
|
searchQuery = s.search || '';
|
|
@@ -11484,10 +11713,10 @@ window.addEventListener('popstate', () => {
|
|
|
11484
11713
|
try {
|
|
11485
11714
|
fetchProjectView(atob(s.projectView));
|
|
11486
11715
|
} catch (_) {
|
|
11487
|
-
|
|
11716
|
+
showNoSession();
|
|
11488
11717
|
}
|
|
11489
11718
|
} else if (s.session) fetchTasks(s.session);
|
|
11490
|
-
else
|
|
11719
|
+
else showNoSession();
|
|
11491
11720
|
if (s.messages !== messagePanelOpen) toggleMessagePanel();
|
|
11492
11721
|
});
|
|
11493
11722
|
//#endregion
|
|
@@ -11498,6 +11727,7 @@ window.addEventListener('popstate', () => {
|
|
|
11498
11727
|
// these tests only decide whether a press is the hub's to handle.
|
|
11499
11728
|
function isHubKey(e) {
|
|
11500
11729
|
if (!window.__HUB__?.enabled) return false;
|
|
11730
|
+
if (hubKeys) return hubKeys.has(hubCombo(e));
|
|
11501
11731
|
if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) return true;
|
|
11502
11732
|
// Own branch: the Alt+digit case below requires !ctrlKey. The hub owns the Ctrl+Alt+letter
|
|
11503
11733
|
// keymap and ignores unbound letters.
|
|
@@ -11514,6 +11744,19 @@ function isHubKey(e) {
|
|
|
11514
11744
|
return e.altKey && !e.ctrlKey && !e.shiftKey && !e.metaKey && (/^[1-9]$/.test(e.key) || /^Digit[1-9]$/.test(e.code));
|
|
11515
11745
|
}
|
|
11516
11746
|
|
|
11747
|
+
// The combos the hub binds, from its hub:keys message. Null until one arrives: a hub from before
|
|
11748
|
+
// hub:keys sends none, and the fallback filter above is what such a hub expects.
|
|
11749
|
+
let hubKeys = null;
|
|
11750
|
+
|
|
11751
|
+
// A copy of the hub's comboOf(): its names must match the hub:keys list.
|
|
11752
|
+
function hubCombo(e) {
|
|
11753
|
+
const lower = (e.key || '').toLowerCase();
|
|
11754
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
11755
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
11756
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
11757
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
11758
|
+
}
|
|
11759
|
+
|
|
11517
11760
|
document.addEventListener('keydown', (e) => {
|
|
11518
11761
|
if (!isHubKey(e)) return;
|
|
11519
11762
|
e.preventDefault();
|
|
@@ -11586,6 +11829,14 @@ function hubPost(message) {
|
|
|
11586
11829
|
});
|
|
11587
11830
|
})();
|
|
11588
11831
|
|
|
11832
|
+
(function initHubKeys() {
|
|
11833
|
+
window.addEventListener('message', (e) => {
|
|
11834
|
+
if (e.source !== window.parent || e.origin !== hubOrigin()) return;
|
|
11835
|
+
if (e.data?.type !== 'hub:keys' || !Array.isArray(e.data.keys)) return;
|
|
11836
|
+
hubKeys = new Set(e.data.keys.filter((k) => typeof k === 'string'));
|
|
11837
|
+
});
|
|
11838
|
+
})();
|
|
11839
|
+
|
|
11589
11840
|
(function initHubProject() {
|
|
11590
11841
|
window.addEventListener('message', (e) => {
|
|
11591
11842
|
if (e.source !== window.parent || e.origin !== hubOrigin()) return;
|
|
@@ -11593,7 +11844,6 @@ function hubPost(message) {
|
|
|
11593
11844
|
const dirPath = e.data.project;
|
|
11594
11845
|
if (typeof dirPath !== 'string' || !dirPath || dirPath === filterProject) return;
|
|
11595
11846
|
filterByProject(dirPath);
|
|
11596
|
-
updateProjectDropdown();
|
|
11597
11847
|
});
|
|
11598
11848
|
})();
|
|
11599
11849
|
// #endregion HUB_INTEGRATION
|