claude-code-kanban 7.3.0 → 7.5.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/public/app.js CHANGED
@@ -2,23 +2,69 @@
2
2
  // Set by the server in index.html: null for the default Claude dir, a per-dir prefix otherwise.
3
3
  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
- const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
5
+ const THEME_KEY = 'theme';
6
+ const COLOR_THEME_KEY = 'color-theme';
7
+ const GLOBAL_KEYS = new Set([THEME_KEY, COLOR_THEME_KEY]);
8
+ // localStorage names are kept as they are: renaming one drops what users saved under it.
9
+ const MESSAGE_PANEL_OPEN_KEY = 'message-panel-open';
10
+ const MESSAGE_PANEL_FULL_KEY = 'message-panel-full';
11
+ const ACTIVITY_FILTER_KEY = 'activityFilter';
12
+ const COLLAPSED_GROUPS_KEY = 'collapsedGroups';
13
+ const PINNED_SESSIONS_KEY = 'pinned-sessions';
14
+ const STICKY_SESSIONS_KEY = 'sticky-sessions';
15
+ const GROUP_PINNED_KEY = 'groupPinnedSessions';
16
+ const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed';
17
+ const SIDEBAR_WIDTH_KEY = 'sidebar-width';
18
+ const PINNED_MESSAGES_PREFIX = 'pinned-messages-';
19
+ const pinsKey = (sessionId) => PINNED_MESSAGES_PREFIX + sessionId;
20
+ const modalFullscreenKey = (modalId) => `modal-fullscreen-${modalId}`;
21
+ const sectionCollapsedKey = (containerId) => `${containerId}Collapsed`;
22
+ const panelWidthKey = (panelId) => `${panelId}-width`;
6
23
  const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
7
24
  const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey } =
8
25
  projectMatch;
9
- const store = {
10
- keys() {
11
- const out = [];
12
- for (let i = 0; i < localStorage.length; i++) {
13
- const k = localStorage.key(i);
14
- if (k.startsWith(STORAGE_NS)) out.push(k.slice(STORAGE_NS.length));
15
- }
16
- return out;
17
- },
18
- getItem: (k) => localStorage.getItem(nsKey(k)),
19
- setItem: (k, v) => localStorage.setItem(nsKey(k), v),
20
- removeItem: (k) => localStorage.removeItem(nsKey(k)),
21
- };
26
+ // Storage throws when the browser blocks site data or the quota is full. Saved UI state is
27
+ // optional, so a failed read gives null and a failed write returns false.
28
+ function namespacedStorage(area) {
29
+ const attempt = (fn, fallback) => {
30
+ try {
31
+ return fn(window[area]);
32
+ } catch {
33
+ return fallback;
34
+ }
35
+ };
36
+ return {
37
+ keys: () =>
38
+ attempt((s) => {
39
+ const out = [];
40
+ for (let i = 0; i < s.length; i++) {
41
+ const k = s.key(i);
42
+ if (k.startsWith(STORAGE_NS)) out.push(k.slice(STORAGE_NS.length));
43
+ }
44
+ return out;
45
+ }, []),
46
+ getItem: (k) => attempt((s) => s.getItem(nsKey(k)), null),
47
+ setItem: (k, v) =>
48
+ attempt((s) => {
49
+ s.setItem(nsKey(k), v);
50
+ return true;
51
+ }, false),
52
+ removeItem: (k) => attempt((s) => s.removeItem(nsKey(k))),
53
+ readJson(k, fallback = null) {
54
+ try {
55
+ return JSON.parse(this.getItem(k)) ?? fallback;
56
+ } catch {
57
+ return fallback;
58
+ }
59
+ },
60
+ writeJson(k, v) {
61
+ return this.setItem(k, JSON.stringify(v));
62
+ },
63
+ };
64
+ }
65
+ const store = namespacedStorage('localStorage');
66
+ // Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
67
+ const tabStore = namespacedStorage('sessionStorage');
22
68
 
23
69
  // The baseline "nothing is filtered" state: what resetState() returns to, what updateUrl() omits
24
70
  // from the query string, and what decides whether a control tints ember in the header summary.
@@ -53,6 +99,7 @@ const collapsedProjectGroups = new Set();
53
99
  const SECTION_GROUPS = '__section_groups__';
54
100
  const SECTION_PROJECTS = '__section_projects__';
55
101
  const SECTION_SESSIONS = '__section_sessions__';
102
+ const SECTION_OUTSIDE = '__section_outside__';
56
103
  // Project paths and session ids in Active-view sidebar order, kept across reloads so live
57
104
  // activity does not reshuffle the list. Each holds only what the loaded list still has.
58
105
  const PROJECT_ORDER_KEY = 'project-order';
@@ -117,28 +164,19 @@ const dismissedSessionIds = loadDismissedSessions();
117
164
  let dismissSaveWarned = false;
118
165
 
119
166
  function loadDismissedSessions() {
120
- try {
121
- const saved = Object.entries(JSON.parse(store.getItem(DISMISSED_KEY) || '{}'));
122
- return new Map(saved.filter(([, at]) => isDismissFresh(at)));
123
- } catch {
124
- return new Map();
125
- }
167
+ const saved = store.readJson(DISMISSED_KEY, {});
168
+ return new Map(Object.entries(saved).filter(([, at]) => isDismissFresh(at)));
126
169
  }
127
170
 
128
171
  function persistDismissedSessions() {
129
172
  for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
130
- try {
131
- store.setItem(DISMISSED_KEY, JSON.stringify(Object.fromEntries(dismissedSessionIds)));
132
- } catch (e) {
133
- console.error('[dismissed-sessions]', e);
134
- // The map still works in memory, so only the next reload is affected. Say so once.
135
- if (!dismissSaveWarned) {
136
- dismissSaveWarned = true;
137
- showToast(
138
- 'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
139
- 'error',
140
- );
141
- }
173
+ // The map still works in memory, so only the next reload is affected. Say so once.
174
+ if (!store.writeJson(DISMISSED_KEY, Object.fromEntries(dismissedSessionIds)) && !dismissSaveWarned) {
175
+ dismissSaveWarned = true;
176
+ showToast(
177
+ 'Could not save to browser storage: dismissed sessions come back on reload. If storage is full, Storage → Clean Orphaned frees space.',
178
+ 'error',
179
+ );
142
180
  }
143
181
  }
144
182
 
@@ -157,7 +195,7 @@ function resetMessageScrollState() {
157
195
  msgUserScrolledUp = false;
158
196
  msgHasMore = false;
159
197
  msgLoadingMore = false;
160
- currentMessages = [];
198
+ setCurrentMessages([]);
161
199
  lastMessagesHash = '';
162
200
  const btn = document.getElementById('msg-jump-latest');
163
201
  if (btn) btn.style.display = 'none';
@@ -171,7 +209,9 @@ function getUrlState() {
171
209
  project: params.get('project'),
172
210
  owner: params.get('owner'),
173
211
  search: params.get('search'),
174
- messages: params.has('messages') ? params.get('messages') === '1' : store.getItem('message-panel-open') === 'true',
212
+ messages: params.has('messages')
213
+ ? params.get('messages') === '1'
214
+ : store.getItem(MESSAGE_PANEL_OPEN_KEY) === 'true',
175
215
  projectView: params.get('projectView'),
176
216
  };
177
217
  }
@@ -195,32 +235,23 @@ function updateUrl() {
195
235
 
196
236
  const LAST_VIEW_KEY = 'lastView';
197
237
  function persistLastView() {
198
- try {
199
- const data = {
200
- view: viewMode,
201
- session: currentSessionId,
202
- projectPath: viewMode === 'project' ? currentProjectPath : null,
203
- // The project filter also lives in the URL, but the hub recreates each iframe at the app's
204
- // base URL on reload, so the query string alone doesn't survive a hub refresh.
205
- project: filterProject,
206
- };
207
- store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
208
- } catch (_) {}
238
+ store.writeJson(LAST_VIEW_KEY, {
239
+ view: viewMode,
240
+ session: currentSessionId,
241
+ projectPath: viewMode === 'project' ? currentProjectPath : null,
242
+ // The project filter also lives in the URL, but the hub recreates each iframe at the app's
243
+ // base URL on reload, so the query string alone doesn't survive a hub refresh.
244
+ project: filterProject,
245
+ });
209
246
  }
210
247
  function loadLastView() {
211
- try {
212
- return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
213
- } catch (_) {
214
- return null;
215
- }
248
+ return store.readJson(LAST_VIEW_KEY);
216
249
  }
217
250
 
218
251
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
219
252
  function resetState() {
220
253
  history.replaceState(null, '', window.location.pathname);
221
- try {
222
- store.removeItem(LAST_VIEW_KEY);
223
- } catch (_) {}
254
+ store.removeItem(LAST_VIEW_KEY);
224
255
  sessionFilter = FILTER_DEFAULTS.session;
225
256
  sessionLimit = SESSION_PAGE;
226
257
  filterProject = FILTER_DEFAULTS.project;
@@ -266,31 +297,52 @@ let lastTasksHash = '';
266
297
  //#endregion
267
298
 
268
299
  //#region DATA_FETCHING
300
+ function apiPath(strings, ...values) {
301
+ return strings.reduce((out, s, i) => out + encodeURIComponent(values[i - 1]) + s);
302
+ }
303
+
304
+ function api(path, { method, body, query, headers, cache } = {}) {
305
+ const qs = query ? new URLSearchParams(Object.entries(query).filter(([, v]) => v != null)).toString() : '';
306
+ const json = body != null;
307
+ return fetch(qs ? `${path}?${qs}` : path, {
308
+ method,
309
+ cache,
310
+ headers: json ? { 'Content-Type': 'application/json', ...headers } : headers,
311
+ body: json ? JSON.stringify(body) : undefined,
312
+ });
313
+ }
314
+
315
+ async function getJson(path, fallback = null, opts) {
316
+ try {
317
+ const res = await api(path, opts);
318
+ return res.ok ? await res.json() : fallback;
319
+ } catch {
320
+ return fallback;
321
+ }
322
+ }
323
+
269
324
  async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
270
325
  try {
271
326
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
272
327
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
273
328
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
274
- const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
275
- // The focused session must come back whatever the filters say: renderSession and the
276
- // info modal both look it up in `sessions` and bail when it is absent, so a session
277
- // opened from outside the current filter would leave the view on the previous one.
278
- const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
279
- const projectParam =
280
- filterProject === '__recent__'
281
- ? `&recentHours=${RECENT_PROJECT_HOURS}`
282
- : filterProject
283
- ? `&project=${encodeURIComponent(filterProject)}`
284
- : '';
285
- const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
286
- const sessionsPromise = fetch(
287
- `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
288
- ).then((r) => {
289
- sessionsHasMore = r.headers.get('X-Has-More') === 'true';
290
- return r.json();
329
+ const recent = filterProject === '__recent__';
330
+ const res = await api('/api/sessions', {
331
+ query: {
332
+ limit: sessionLimit,
333
+ pinned: allPinnedIds.size > 0 ? [...allPinnedIds].join(',') : null,
334
+ // The focused session must come back whatever the filters say: renderSession and the
335
+ // info modal both look it up in `sessions` and bail when it is absent, so a session
336
+ // opened from outside the current filter would leave the view on the previous one.
337
+ include: focusId || null,
338
+ recentHours: recent ? RECENT_PROJECT_HOURS : null,
339
+ project: !recent && filterProject ? filterProject : null,
340
+ filter: sessionFilter === 'active' ? 'active' : null,
341
+ },
291
342
  });
292
-
293
- const newSessions = await sessionsPromise;
343
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
344
+ sessionsHasMore = res.headers.get('X-Has-More') === 'true';
345
+ const newSessions = await res.json();
294
346
  const sessionsHash = JSON.stringify(newSessions);
295
347
  if (sessionsHash !== lastSessionsHash) {
296
348
  lastSessionsHash = sessionsHash;
@@ -315,9 +367,7 @@ function markSessionRead(sessionId) {
315
367
  if (!session?.unread) return;
316
368
  session.unread = false;
317
369
  document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
318
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/read`, { method: 'POST' }).catch((e) =>
319
- console.error('[markSessionRead]', e),
320
- );
370
+ api(apiPath`/api/sessions/${sessionId}/read`, { method: 'POST' }).catch((e) => console.error('[markSessionRead]', e));
321
371
  }
322
372
 
323
373
  function markOpenSessionRead() {
@@ -325,7 +375,8 @@ function markOpenSessionRead() {
325
375
  }
326
376
 
327
377
  async function loadSearchTasks() {
328
- const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
378
+ const newTasks = await getJson('/api/tasks/all');
379
+ if (!newTasks) return;
329
380
  const tasksHash = JSON.stringify(newTasks);
330
381
  if (tasksHash === lastTasksHash) return;
331
382
  lastTasksHash = tasksHash;
@@ -359,20 +410,70 @@ sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
359
410
 
360
411
  // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
361
412
  async function fetchSessionsByIds(ids) {
362
- try {
363
- const r = await fetch(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`);
364
- if (!r.ok) return [];
365
- const byId = new Map((await r.json()).map((s) => [s.id, s]));
366
- return ids.map((id) => byId.get(id)).filter(Boolean);
367
- } catch (_) {
368
- return [];
369
- }
413
+ const rows = await getJson('/api/sessions', [], { query: { limit: 1, include: ids.join(',') } });
414
+ const byId = new Map(rows.map((s) => [s.id, s]));
415
+ return ids.map((id) => byId.get(id)).filter(Boolean);
370
416
  }
371
417
 
372
418
  async function fetchSessionById(id) {
373
419
  return (await fetchSessionsByIds([id]))[0] || null;
374
420
  }
375
421
 
422
+ // The id may be outside the list the filters asked for. renderSession bails on a session
423
+ // missing from `sessions`, so pull that one row in before opening it; later polls
424
+ // keep it, because fetchSessions pins currentSessionId.
425
+ async function ensureSessionLoaded(id) {
426
+ const loaded = sessions.find((s) => s.id === id);
427
+ if (loaded) return loaded;
428
+ const session = await fetchSessionById(id);
429
+ if (!session) return null;
430
+ const raced = sessions.find((s) => s.id === id);
431
+ if (raced) return raced;
432
+ sessions.push(session);
433
+ return session;
434
+ }
435
+
436
+ // Matches SESSION_SEARCH_MIN in server.js, which answers [] below it.
437
+ const SESSION_SEARCH_MIN = 3;
438
+ const SESSION_SEARCH_DEBOUNCE_MS = 100;
439
+
440
+ // Searches session ids and names in every transcript on the server, so a session outside the
441
+ // sidebar filter is found too. `rows` answers the server rows not in `localRows`, and whether
442
+ // the search for that query is still out; `onFound()` runs when the current query's rows arrive.
443
+ // Only the current query is kept, and a short query drops it, so a later identical query fetches again.
444
+ function sessionSearch(onFound) {
445
+ let current = null;
446
+ let found = null;
447
+ let timer = null;
448
+ const run = async (key) => {
449
+ const ids = await getJson('/api/sessions/search', [], { query: { q: key } });
450
+ if (key !== current) return;
451
+ const rows = ids.length ? await fetchSessionsByIds(ids) : [];
452
+ if (key !== current) return;
453
+ found = rows;
454
+ onFound();
455
+ };
456
+ return {
457
+ rows(query, localRows) {
458
+ const key = query.length < SESSION_SEARCH_MIN ? null : query.toLowerCase();
459
+ // Set once per query: the sidebar re-renders on every live update, and resetting the
460
+ // timer each time would starve the search.
461
+ if (key !== current) {
462
+ clearTimeout(timer);
463
+ current = key;
464
+ found = null;
465
+ if (key) timer = setTimeout(() => run(key), SESSION_SEARCH_DEBOUNCE_MS);
466
+ }
467
+ if (!key) return { rows: [], pending: false };
468
+ if (!found) return { rows: [], pending: true };
469
+ const seen = new Set(localRows.map((s) => s.id));
470
+ return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
471
+ },
472
+ };
473
+ }
474
+
475
+ const sidebarSearch = sessionSearch(renderSessions);
476
+
376
477
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
377
478
  function handleSearch(query) {
378
479
  const wasSearching = !!searchQuery;
@@ -432,13 +533,14 @@ function fuzzyMatch(text, query) {
432
533
 
433
534
  //#endregion
434
535
 
536
+ //#region SESSION_ACTIVITY
435
537
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
436
538
  function toggleSection(containerId, chevronId) {
437
539
  const container = document.getElementById(containerId);
438
540
  const chevron = document.getElementById(chevronId);
439
541
  const collapsed = container.classList.toggle('collapsed');
440
542
  chevron.classList.toggle('rotated', collapsed);
441
- store.setItem(`${containerId}Collapsed`, collapsed);
543
+ store.setItem(sectionCollapsedKey(containerId), collapsed);
442
544
  }
443
545
 
444
546
  function isWaitingSession(s) {
@@ -575,7 +677,7 @@ function setActivityFilter(kind) {
575
677
  } else {
576
678
  toggleActivityKind(kind);
577
679
  }
578
- store.setItem('activityFilter', JSON.stringify([...activityFilter]));
680
+ store.writeJson(ACTIVITY_FILTER_KEY, [...activityFilter]);
579
681
  // active/waiting only make sense with the active session filter on
580
682
  const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
581
683
  if (targetFilter !== sessionFilter) {
@@ -598,7 +700,7 @@ async function fetchTasks(sessionId) {
598
700
  openTerminal(sessionId, terminalOpenMode(sessionId));
599
701
  viewMode = 'session';
600
702
  document.getElementById('message-toggle')?.style.removeProperty('display');
601
- const res = await fetch(`/api/sessions/${sessionId}`);
703
+ const res = await api(apiPath`/api/sessions/${sessionId}`);
602
704
 
603
705
  if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
604
706
  const newTasks = await res.json();
@@ -650,14 +752,16 @@ async function fetchTasks(sessionId) {
650
752
  renderSession();
651
753
  }
652
754
  }
755
+ //#endregion
653
756
 
654
- // #region TIMINGS
757
+ //#region TIMINGS
655
758
  const WAITING_TTL_MS = 30 * 60 * 1000;
656
759
  const AGENT_LOG_MAX = 8;
657
760
  const LIVE_INDICATOR_MS = 10 * 1000;
658
761
  const JUST_NOW_MS = 60 * 1000;
659
- // #endregion
762
+ //#endregion
660
763
 
764
+ //#region AGENT_FETCHING
661
765
  function resetAgentState() {
662
766
  currentAgents = [];
663
767
  currentWaiting = null;
@@ -668,7 +772,7 @@ function resetAgentState() {
668
772
 
669
773
  async function fetchAgents(sessionId) {
670
774
  try {
671
- const res = await fetch(`/api/sessions/${sessionId}/agents`);
775
+ const res = await api(apiPath`/api/sessions/${sessionId}/agents`);
672
776
  if (!res.ok) {
673
777
  resetAgentState();
674
778
  return;
@@ -700,7 +804,7 @@ async function fetchProjectView(projectPath) {
700
804
  viewMode = 'project';
701
805
  currentProjectPath = projectPath;
702
806
  currentSessionId = null;
703
- currentMessages = [];
807
+ setCurrentMessages([]);
704
808
  lastMessagesHash = '';
705
809
  if (messagePanelOpen) toggleMessagePanel();
706
810
  document.getElementById('message-toggle')?.style.setProperty('display', 'none');
@@ -714,19 +818,8 @@ async function fetchProjectView(projectPath) {
714
818
 
715
819
  const encoded = btoa(projectPath);
716
820
  const [tasksResult, agentResults] = await Promise.all([
717
- fetch(`/api/projects/${encodeURIComponent(encoded)}/tasks`)
718
- .then((r) => r.json())
719
- .catch((e) => {
720
- console.error('[fetchProjectView] tasks:', e);
721
- return [];
722
- }),
723
- Promise.all(
724
- activeSessionIds.map((id) =>
725
- fetch(`/api/sessions/${id}/agents`)
726
- .then((r) => r.json())
727
- .catch(() => ({ agents: [] })),
728
- ),
729
- ),
821
+ getJson(apiPath`/api/projects/${encoded}/tasks`, []),
822
+ Promise.all(activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] }))),
730
823
  ]);
731
824
  currentTasks = tasksResult;
732
825
  const seen = new Set();
@@ -761,11 +854,7 @@ async function refreshProjectAgents() {
761
854
  const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
762
855
  const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
763
856
  const agentResults = await Promise.all(
764
- activeSessionIds.map((id) =>
765
- fetch(`/api/sessions/${id}/agents`)
766
- .then((r) => r.json())
767
- .catch(() => ({ agents: [] })),
768
- ),
857
+ activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] })),
769
858
  );
770
859
  const seen = new Set();
771
860
  currentAgents = [];
@@ -790,6 +879,7 @@ async function refreshProjectAgents() {
790
879
  lastAgentsHash = hash;
791
880
  renderAgentFooter();
792
881
  }
882
+ //#endregion
793
883
 
794
884
  //#region MESSAGE_PANEL
795
885
  function setMessagePanelVisible(open) {
@@ -819,7 +909,7 @@ function toggleMessagePanel() {
819
909
  const panel = document.getElementById('message-panel');
820
910
  messagePanelOpen = !messagePanelOpen;
821
911
  termState.panelHidden = false;
822
- store.setItem('message-panel-open', messagePanelOpen);
912
+ store.setItem(MESSAGE_PANEL_OPEN_KEY, messagePanelOpen);
823
913
  panel.classList.toggle('visible', messagePanelOpen);
824
914
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
825
915
  if (messagePanelOpen && currentSessionId) {
@@ -842,7 +932,7 @@ function applyMessagePanelFull(on) {
842
932
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
843
933
  function toggleMessagePanelFull() {
844
934
  const on = !document.getElementById('message-panel').classList.contains('full-width');
845
- store.setItem('message-panel-full', on);
935
+ store.setItem(MESSAGE_PANEL_FULL_KEY, on);
846
936
  applyMessagePanelFull(on);
847
937
  }
848
938
 
@@ -854,7 +944,7 @@ function initMessagePanelFull() {
854
944
  new ResizeObserver(sync).observe(sidebar);
855
945
  sync();
856
946
  }
857
- applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
947
+ applyMessagePanelFull(store.getItem(MESSAGE_PANEL_FULL_KEY) === 'true');
858
948
  }
859
949
 
860
950
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
@@ -862,7 +952,7 @@ async function openSessionWithBookmarks(sessionId) {
862
952
  if (!messagePanelOpen) {
863
953
  const panel = document.getElementById('message-panel');
864
954
  messagePanelOpen = true;
865
- store.setItem('message-panel-open', 'true');
955
+ store.setItem(MESSAGE_PANEL_OPEN_KEY, 'true');
866
956
  panel.classList.add('visible');
867
957
  document.getElementById('message-toggle')?.classList.add('active');
868
958
  }
@@ -904,12 +994,12 @@ async function viewAgentLog(agentId) {
904
994
  agentLogSSE.close();
905
995
  agentLogSSE = null;
906
996
  }
907
- agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
997
+ agentLogSSE = new EventSource(apiPath`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
908
998
  agentLogSSE.addEventListener('agent-log-update', (e) => {
909
999
  if (!agentLogMode || agentLogMode.agentId !== resolvedId) return;
910
1000
  try {
911
1001
  const data = JSON.parse(e.data);
912
- currentMessages = data.messages;
1002
+ setCurrentMessages(data.messages);
913
1003
  if (messagePanelOpen) renderMessages(data.messages);
914
1004
  maybeFollowLatest();
915
1005
  } catch (_) {}
@@ -938,11 +1028,11 @@ async function fetchAgentMessages() {
938
1028
  if (!agentLogMode) return;
939
1029
  const { sessionId, agentId } = agentLogMode;
940
1030
  try {
941
- const res = await fetch(`/api/sessions/${sessionId}/agents/${agentId}/messages?limit=100`);
942
- if (!res.ok || !agentLogMode || agentLogMode.agentId !== agentId) return;
943
- const data = await res.json();
944
- if (!agentLogMode || agentLogMode.agentId !== agentId) return;
945
- currentMessages = data.messages;
1031
+ const data = await getJson(apiPath`/api/sessions/${sessionId}/agents/${agentId}/messages`, null, {
1032
+ query: { limit: 100 },
1033
+ });
1034
+ if (!data || !agentLogMode || agentLogMode.agentId !== agentId) return;
1035
+ setCurrentMessages(data.messages);
946
1036
  if (messagePanelOpen) renderMessages(data.messages);
947
1037
  maybeFollowLatest();
948
1038
  } catch (e) {
@@ -960,10 +1050,8 @@ function openLiveLatestMessage() {
960
1050
 
961
1051
  const MSG_PAGE_LIMIT = 15;
962
1052
 
963
- async function fetchSessionMessagesPage(sessionId, { before } = {}) {
964
- const qs = new URLSearchParams({ limit: MSG_PAGE_LIMIT });
965
- if (before) qs.set('before', before);
966
- const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/messages?${qs}`);
1053
+ async function fetchSessionMessagesPage(sessionId, { before, limit = MSG_PAGE_LIMIT } = {}) {
1054
+ const res = await api(apiPath`/api/sessions/${sessionId}/messages`, { query: { limit, before: before || null } });
967
1055
  return res.ok ? res.json() : null;
968
1056
  }
969
1057
 
@@ -989,18 +1077,16 @@ async function fetchMessages(sessionId) {
989
1077
  if (hash === lastMessagesHash) return;
990
1078
  lastMessagesHash = hash;
991
1079
  msgHasMore = data.hasMore !== false;
992
- currentMessages = data.messages;
1080
+ setCurrentMessages(data.messages);
993
1081
  if (messagePanelOpen) renderMessages(data.messages);
994
1082
  } else {
995
1083
  if (data.messages.length && currentMessages.length) {
996
1084
  const lastKnown = currentMessages[currentMessages.length - 1].timestamp;
997
1085
  const newMsgs = data.messages.filter((m) => m.timestamp > lastKnown);
998
1086
  if (newMsgs.length) {
999
- currentMessages = [...currentMessages, ...newMsgs];
1000
- if (currentMessages.length > MSG_MAX_LOADED) {
1001
- currentMessages = currentMessages.slice(-MSG_MAX_LOADED);
1002
- msgHasMore = true;
1003
- }
1087
+ const merged = [...currentMessages, ...newMsgs];
1088
+ if (merged.length > MSG_MAX_LOADED) msgHasMore = true;
1089
+ setCurrentMessages(merged.slice(-MSG_MAX_LOADED));
1004
1090
  if (messagePanelOpen) renderMessages(currentMessages);
1005
1091
  }
1006
1092
  }
@@ -1027,10 +1113,7 @@ async function loadOlderMessages() {
1027
1113
  if (data.messages.length) {
1028
1114
  loader.remove();
1029
1115
  const prevHeight = container.scrollHeight;
1030
- currentMessages = [...data.messages, ...currentMessages];
1031
- if (currentMessages.length > MSG_MAX_LOADED) {
1032
- currentMessages = currentMessages.slice(0, MSG_MAX_LOADED);
1033
- }
1116
+ setCurrentMessages([...data.messages, ...currentMessages].slice(0, MSG_MAX_LOADED));
1034
1117
  renderMessages(currentMessages);
1035
1118
  container.scrollTop = container.scrollHeight - prevHeight;
1036
1119
  }
@@ -1615,10 +1698,9 @@ function rejectWaitingPlan(inputId) {
1615
1698
  async function respondWaiting(payload) {
1616
1699
  if (!currentSessionId || !currentWaiting?.id) return;
1617
1700
  try {
1618
- const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/respond`, {
1701
+ const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/respond`, {
1619
1702
  method: 'POST',
1620
- headers: { 'Content-Type': 'application/json' },
1621
- body: JSON.stringify({ id: currentWaiting.id, ...payload }),
1703
+ body: { id: currentWaiting.id, ...payload },
1622
1704
  });
1623
1705
  if (res.ok || res.status === 409 || res.status === 410) {
1624
1706
  clearWaitingUi();
@@ -1665,9 +1747,7 @@ function clearWaitingUi() {
1665
1747
  async function discardWaiting() {
1666
1748
  if (!currentSessionId) return;
1667
1749
  try {
1668
- const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
1669
- method: 'POST',
1670
- });
1750
+ const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/discard`, { method: 'POST' });
1671
1751
  if (res.ok) clearWaitingUi();
1672
1752
  } catch (e) {
1673
1753
  console.error('[discardWaiting]', e);
@@ -1792,15 +1872,12 @@ function getPinId(m) {
1792
1872
  }
1793
1873
 
1794
1874
  function loadPins(sessionId) {
1795
- try {
1796
- return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
1797
- } catch {
1798
- return [];
1799
- }
1875
+ return readStoredList(pinsKey(sessionId));
1800
1876
  }
1801
1877
 
1802
1878
  function savePins(sessionId, pins) {
1803
- store.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
1879
+ if (pins.length) store.writeJson(pinsKey(sessionId), pins);
1880
+ else store.removeItem(pinsKey(sessionId));
1804
1881
  }
1805
1882
 
1806
1883
  function isPinned(m) {
@@ -1931,6 +2008,7 @@ function showPinnedMsgDetail(pinIdx) {
1931
2008
  autoSizeModal(pinModal, body);
1932
2009
  const pinBtn = document.getElementById('msg-detail-pin-btn');
1933
2010
  if (pinBtn) pinBtn.classList.add('active');
2011
+ syncMsgDetailPaneBtn();
1934
2012
  document.getElementById('msg-detail-modal').classList.add('visible');
1935
2013
  mountReplyReview(pin, body, pinModal);
1936
2014
  }
@@ -1966,34 +2044,22 @@ let stickySessionIds = new Set();
1966
2044
  const deferredPinPlacement = new Set();
1967
2045
 
1968
2046
  function loadPinnedSessions() {
1969
- try {
1970
- return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
1971
- } catch {
1972
- return new Set();
1973
- }
2047
+ return new Set(readStoredList(PINNED_SESSIONS_KEY));
1974
2048
  }
1975
2049
 
1976
2050
  function loadStickySessions() {
1977
- try {
1978
- return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
1979
- } catch {
1980
- return new Set();
1981
- }
2051
+ return new Set(readStoredList(STICKY_SESSIONS_KEY));
1982
2052
  }
1983
2053
 
1984
2054
  function savePinnedSessions() {
1985
- store.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
1986
- store.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
2055
+ store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
2056
+ store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
1987
2057
  }
1988
2058
 
1989
2059
  // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
1990
2060
  function offloadSessionPin(sessionId) {
1991
2061
  const state = getSessionPinState(sessionId);
1992
- fetch('/api/session/pin', {
1993
- method: 'POST',
1994
- headers: { 'Content-Type': 'application/json' },
1995
- body: JSON.stringify({ id: sessionId, state }),
1996
- }).catch(() => {});
2062
+ api('/api/session/pin', { method: 'POST', body: { id: sessionId, state } }).catch(() => {});
1997
2063
  }
1998
2064
 
1999
2065
  function clearSessionPin(sessionId) {
@@ -2176,33 +2242,27 @@ function highlightSelectedMsg() {
2176
2242
  if (el) el.classList.add('selected');
2177
2243
  }
2178
2244
 
2179
- function showMsgDetail(idx) {
2180
- currentMsgDetailIdx = idx;
2181
- document.getElementById('msg-detail-waiting-footer').innerHTML = '';
2182
- msgHighlightDimmed = false;
2183
- const m = currentMessages[idx];
2184
- if (!m) return;
2185
- highlightSelectedMsg();
2186
- const body = document.getElementById('msg-detail-body');
2245
+ function msgDetailTitle(m) {
2246
+ if (m.type === 'tool_use') return m.tool;
2247
+ if (m.type === 'teammate') return m.teammateId || 'Teammate';
2248
+ return m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
2249
+ }
2250
+
2251
+ // The message dialog and a message pane render a message the same way.
2252
+ function msgDetailContent(m) {
2187
2253
  if (m.type === 'tool_use') {
2188
- document.getElementById('msg-detail-title').textContent = m.tool;
2189
2254
  const fullText = m.fullDetail || m.detail || '';
2190
2255
  const descHtml =
2191
2256
  m.description && m.description !== fullText
2192
2257
  ? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>`
2193
2258
  : '';
2194
2259
  let agentExtraHtml = '';
2195
- const agentBtn = document.getElementById('msg-detail-agent-btn');
2196
2260
  if (m.tool === 'Agent' && m.agentId) {
2197
2261
  const agentRespText = m.agentLastMessage ? stripAnsi(m.agentLastMessage.trim()) : null;
2198
2262
  const agentPromptText = m.agentPrompt || null;
2199
2263
  const respHtml = agentRespText ? renderMarkdown(agentRespText) : null;
2200
2264
  const promptHtml = agentPromptText ? renderMarkdown(agentPromptText) : null;
2201
2265
  agentExtraHtml += renderAgentTabs(promptHtml, respHtml, agentPromptText, agentRespText);
2202
- agentBtn.style.display = '';
2203
- agentBtn.dataset.agentId = m.agentId;
2204
- } else {
2205
- agentBtn.style.display = 'none';
2206
2266
  }
2207
2267
  const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
2208
2268
  const isFindings = m.tool === 'ReportFindings';
@@ -2236,7 +2296,7 @@ function showMsgDetail(idx) {
2236
2296
  }
2237
2297
  const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
2238
2298
  const toolResultImagesHtml = renderToolResultImagesHtml(m.toolResultImageCount, m.toolUseId);
2239
- body.innerHTML =
2299
+ return (
2240
2300
  mainHtml +
2241
2301
  toolParamsHtml +
2242
2302
  answersHtml +
@@ -2244,53 +2304,94 @@ function showMsgDetail(idx) {
2244
2304
  findingsHtml +
2245
2305
  (hasAgentTabs ? '' : toolResultHtml) +
2246
2306
  toolResultImagesHtml +
2247
- agentExtraHtml;
2248
- } else if (m.type === 'teammate') {
2249
- document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
2250
- document.getElementById('msg-detail-agent-btn').style.display = 'none';
2307
+ agentExtraHtml
2308
+ );
2309
+ }
2310
+ if (m.type === 'teammate') {
2251
2311
  if (m.isProtocol) {
2252
- body.innerHTML = m.protocolData
2312
+ return m.protocolData
2253
2313
  ? renderProtocolDetail(m.protocolData)
2254
2314
  : `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
2255
- } else {
2256
- const text = stripAnsi(m.fullText || m.text || '');
2257
- body.innerHTML = renderMarkdown(text);
2258
2315
  }
2259
- } else {
2260
- const rawText = stripAnsi(m.fullText || m.text || '');
2261
- const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
2262
- document.getElementById('msg-detail-title').textContent =
2263
- m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
2264
- document.getElementById('msg-detail-agent-btn').style.display = 'none';
2265
- const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
2266
- if (m.compactSummary) {
2267
- body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
2268
- } else if (cmd) {
2269
- const args = parseCommandArgs(rawText) || null;
2270
- const cleanBody = rawText
2271
- .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
2272
- .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
2273
- .trim();
2274
- let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
2275
- if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
2276
- body.innerHTML = cmdHtml + userExtras;
2277
- } else if (rawText) {
2278
- body.innerHTML = renderMarkdown(rawText) + userExtras;
2279
- } else {
2280
- body.innerHTML = userExtras || '<em>No content</em>';
2281
- }
2282
- }
2316
+ return renderMarkdown(stripAnsi(m.fullText || m.text || ''));
2317
+ }
2318
+ const rawText = stripAnsi(m.fullText || m.text || '');
2319
+ const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
2320
+ const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
2321
+ if (m.compactSummary) return renderMarkdown(m.compactSummary) + userExtras;
2322
+ if (cmd) {
2323
+ const args = parseCommandArgs(rawText) || null;
2324
+ const cleanBody = rawText
2325
+ .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
2326
+ .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
2327
+ .trim();
2328
+ let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
2329
+ if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
2330
+ return cmdHtml + userExtras;
2331
+ }
2332
+ if (rawText) return renderMarkdown(rawText) + userExtras;
2333
+ return userExtras || '<em>No content</em>';
2334
+ }
2335
+
2336
+ // The detail modal holds an index, so a list that shifts under it must move the index too.
2337
+ function setCurrentMessages(msgs) {
2338
+ const shown = currentMessages[currentMsgDetailIdx];
2339
+ currentMessages = msgs;
2340
+ if (!shown) return;
2341
+ const id = getPinId(shown);
2342
+ const same = msgs[currentMsgDetailIdx];
2343
+ const idx = same && getPinId(same) === id ? currentMsgDetailIdx : msgs.findIndex((m) => getPinId(m) === id);
2344
+ currentMsgDetailIdx = idx >= 0 ? idx : null;
2345
+ setMsgDetailMeta(shown, idx >= 0 && msgPosition(idx));
2346
+ syncMsgDetailPaneBtn();
2347
+ }
2348
+
2349
+ const msgPosition = (idx) => `${idx + 1} of ${currentMessages.length}`;
2350
+
2351
+ function showMsgDetail(idx) {
2352
+ currentMsgDetailIdx = idx;
2353
+ document.getElementById('msg-detail-waiting-footer').innerHTML = '';
2354
+ msgHighlightDimmed = false;
2355
+ const m = currentMessages[idx];
2356
+ if (!m) return;
2357
+ highlightSelectedMsg();
2358
+ renderMsgDetail(m, msgPosition(idx));
2359
+ }
2360
+
2361
+ // A message outside the loaded log has no index, so it shows like a pinned message, with follow off.
2362
+ function showUnloadedMsgDetail(m) {
2363
+ currentMsgDetailIdx = null;
2364
+ document.getElementById('msg-detail-waiting-footer').innerHTML = '';
2365
+ highlightSelectedMsg();
2366
+ msgDetailFollowLatest = false;
2367
+ renderMsgDetail(m);
2368
+ }
2369
+
2370
+ const msgDetailMeta = (m) => [m.model, formatDate(m.timestamp)].filter(Boolean);
2371
+
2372
+ function setMsgDetailMeta(m, position) {
2373
+ const meta = msgDetailMeta(m);
2374
+ if (position) meta.push(position);
2375
+ document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
2376
+ }
2377
+
2378
+ function renderMsgDetail(m, position) {
2379
+ const body = document.getElementById('msg-detail-body');
2380
+ document.getElementById('msg-detail-title').textContent = msgDetailTitle(m);
2381
+ body.innerHTML = msgDetailContent(m);
2382
+ const agentBtn = document.getElementById('msg-detail-agent-btn');
2383
+ const agentId = m.type === 'tool_use' && m.tool === 'Agent' ? m.agentId : null;
2384
+ agentBtn.style.display = agentId ? '' : 'none';
2385
+ if (agentId) agentBtn.dataset.agentId = agentId;
2283
2386
  const overlay = document.getElementById('msg-detail-modal');
2284
2387
  const modal = overlay.querySelector('.modal');
2285
2388
  autoSizeModal(modal, body);
2286
2389
  setMsgDetailFollow(msgDetailFollowLatest);
2287
2390
 
2288
- const meta = [formatDate(m.timestamp)];
2289
- if (m.model) meta.unshift(m.model);
2290
- meta.push(`${idx + 1} of ${currentMessages.length}`);
2291
- document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
2391
+ setMsgDetailMeta(m, position);
2292
2392
  currentPinDetailId = null;
2293
2393
  updateMsgDetailPinState();
2394
+ syncMsgDetailPaneBtn();
2294
2395
  overlay.classList.add('visible');
2295
2396
  mountReplyReview(m, body, modal);
2296
2397
  }
@@ -2390,12 +2491,12 @@ function _applyModalFullscreen(modalId, on) {
2390
2491
  function toggleModalFullscreen(modalId) {
2391
2492
  const on = !_modalEl(modalId).classList.contains('fullscreen');
2392
2493
  _applyModalFullscreen(modalId, on);
2393
- store.setItem(`modal-fullscreen-${modalId}`, String(on));
2494
+ store.setItem(modalFullscreenKey(modalId), String(on));
2394
2495
  }
2395
2496
 
2396
2497
  function loadModalFullscreen() {
2397
2498
  _forEachFullscreenModal((modalId) => {
2398
- if (store.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
2499
+ if (store.getItem(modalFullscreenKey(modalId)) === 'true') _applyModalFullscreen(modalId, true);
2399
2500
  });
2400
2501
  }
2401
2502
 
@@ -2579,6 +2680,16 @@ function sendMessageAddress(peerName) {
2579
2680
  return `SendMessage(${peerName})`;
2580
2681
  }
2581
2682
 
2683
+ async function copyText(text, what, okMsg = `Copied ${what}`) {
2684
+ const ok = await navigator.clipboard.writeText(text).then(
2685
+ () => true,
2686
+ () => false,
2687
+ );
2688
+ if (!ok) showToast(`Failed to copy ${what}`, 'error');
2689
+ else if (okMsg) showToast(okMsg, 'success');
2690
+ return ok;
2691
+ }
2692
+
2582
2693
  async function copyWithFeedback(text, btn) {
2583
2694
  if (btn.dataset.copying) return;
2584
2695
  try {
@@ -2592,6 +2703,7 @@ async function copyWithFeedback(text, btn) {
2592
2703
  }, 1500);
2593
2704
  } catch (e) {
2594
2705
  console.error('Failed to copy:', e);
2706
+ showToast('Failed to copy', 'error');
2595
2707
  }
2596
2708
  }
2597
2709
 
@@ -2707,25 +2819,25 @@ function renderAnswerPayloadHtml(answerPayload) {
2707
2819
  })
2708
2820
  .join('');
2709
2821
  return `<div style="margin-top:6px">
2710
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(q)}</div>
2822
+ <div class="tool-detail-label">${escapeHtml(q)}</div>
2711
2823
  <ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
2712
2824
  </div>`;
2713
2825
  })
2714
2826
  .join('');
2715
- return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
2716
- <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Answers</div>
2827
+ return `<div class="tool-detail-section-wide">
2828
+ <div class="tool-detail-heading">Answers</div>
2717
2829
  ${rows}
2718
2830
  </div>`;
2719
2831
  }
2720
2832
 
2721
2833
  //#region FINDINGS
2722
2834
  const FINDING_VERDICT_COLORS = {
2723
- CONFIRMED: 'var(--danger, #ef5350)',
2724
- PLAUSIBLE: 'var(--warning, #f0b429)',
2835
+ CONFIRMED: 'var(--danger)',
2836
+ PLAUSIBLE: 'var(--warning)',
2725
2837
  };
2726
2838
  const FINDING_OUTCOME_COLORS = {
2727
- fixed: 'var(--success, #3ecf8e)',
2728
- no_change_needed: 'var(--info, #60a5fa)',
2839
+ fixed: 'var(--success)',
2840
+ no_change_needed: 'var(--info)',
2729
2841
  skipped: 'var(--text-muted)',
2730
2842
  };
2731
2843
  function findingBadge(label, color) {
@@ -2818,7 +2930,7 @@ function renderToolParamsHtml(params) {
2818
2930
  blocks.push({ k, display, full });
2819
2931
  } else {
2820
2932
  badges.push(
2821
- `<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-secondary);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
2933
+ `<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-hover);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
2822
2934
  );
2823
2935
  }
2824
2936
  }
@@ -2833,20 +2945,20 @@ function renderToolParamsHtml(params) {
2833
2945
  html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span>${suffix}</div>`;
2834
2946
  }
2835
2947
  for (const { k, pretty } of jsonBlocks) {
2836
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2837
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(k)}</div>
2838
- <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto;font-size:0.75rem">${escapeHtml(pretty)}</pre>
2948
+ html += `<div class="tool-detail-section">
2949
+ <div class="tool-detail-label">${escapeHtml(k)}</div>
2950
+ <pre class="${TINTED_PRE_CLASS} tool-detail-pre-scroll" style="font-size:0.75rem">${escapeHtml(pretty)}</pre>
2839
2951
  </div>`;
2840
2952
  }
2841
2953
  if (params.old_string || params.new_string) {
2842
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
2954
+ html += `<div class="tool-detail-section">`;
2843
2955
  if (params.old_string) {
2844
- html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
2845
- <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #e55;padding-left:8px">${escapeHtml(params.old_string)}</pre>`;
2956
+ html += `<div class="tool-detail-label">old_string</div>
2957
+ <pre class="${TINTED_PRE_CLASS} msg-detail-pre-old">${escapeHtml(params.old_string)}</pre>`;
2846
2958
  }
2847
2959
  if (params.new_string) {
2848
- html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
2849
- <pre class="${TINTED_PRE_CLASS}" style="max-height:200px;overflow:auto;border-left:3px solid #5b5;padding-left:8px">${escapeHtml(params.new_string)}</pre>`;
2960
+ html += `<div class="tool-detail-label" style="margin-top:6px">new_string</div>
2961
+ <pre class="${TINTED_PRE_CLASS} msg-detail-pre-new">${escapeHtml(params.new_string)}</pre>`;
2850
2962
  }
2851
2963
  html += `</div>`;
2852
2964
  }
@@ -2856,29 +2968,32 @@ function renderToolParamsHtml(params) {
2856
2968
  // small writes render as before.
2857
2969
  const fullContent = params.contentFull || params.content;
2858
2970
  const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
2859
- const truncContent = isTruncated
2860
- ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}${params.content.length > CONTENT_TRUNCATE_MAX ? '\n... (truncated)' : ''}`
2861
- : params.content;
2971
+ const marker = '\n... (truncated)';
2972
+ let code = params.content.slice(0, CONTENT_TRUNCATE_MAX);
2973
+ if (code.endsWith(marker)) code = code.slice(0, -marker.length);
2974
+ const lang = langOfPath(params.file_path);
2975
+ const truncHtml = (highlightCode(code, lang) ?? escapeHtml(code)) + (isTruncated ? escapeHtml(marker) : '');
2862
2976
  let writeMoreBtn = '',
2863
2977
  fullBlock = '';
2864
2978
  if (isTruncated) {
2865
- const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(fullContent), {
2979
+ const toggle = makeExpandToggle(truncHtml, escapeHtml(fullContent), {
2866
2980
  fontSize: '0.75rem',
2867
2981
  maxHeight: '500px',
2868
2982
  tinted: true,
2983
+ highlightLang: lang,
2869
2984
  });
2870
2985
  writeMoreBtn = ` ${toggle.btn}`;
2871
2986
  fullBlock = toggle.full;
2872
2987
  }
2873
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2874
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
2875
- <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto">${escapeHtml(truncContent)}</pre>
2988
+ html += `<div class="tool-detail-section">
2989
+ <div class="tool-detail-label">content${writeMoreBtn}</div>
2990
+ <pre class="${TINTED_PRE_CLASS} tool-detail-pre-scroll">${truncHtml}</pre>
2876
2991
  ${fullBlock}
2877
2992
  </div>`;
2878
2993
  }
2879
2994
  if (params.plan) {
2880
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2881
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px">Plan</div>
2995
+ html += `<div class="tool-detail-section">
2996
+ <div class="tool-detail-label" style="margin-bottom:4px">Plan</div>
2882
2997
  <div class="markdown-body">${renderMarkdown(params.plan)}</div>
2883
2998
  </div>`;
2884
2999
  }
@@ -2911,20 +3026,22 @@ function toolOutputHtml(text) {
2911
3026
  return sanitizeOutputHtml(text);
2912
3027
  }
2913
3028
 
3029
+ // Strings and comments share one pass, so the apostrophe in "# don't" does not open a string.
2914
3030
  function highlightBash(escaped) {
2915
3031
  return escaped
2916
- .replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
2917
- .replace(/(&#x27;[\s\S]*?&#x27;|&quot;[\s\S]*?&quot;)/g, '<span style="color:#ce9178">$1</span>')
3032
+ .replace(/(&#39;[\s\S]*?&#39;|&quot;[\s\S]*?&quot;)|^(\s*)(#.*)$/gm, (_, str, indent, comment) =>
3033
+ str ? `<span class="hljs-string">${str}</span>` : `${indent}<span class="hljs-comment">${comment}</span>`,
3034
+ )
2918
3035
  .replace(
2919
3036
  /\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|function|return|in|select)\b/g,
2920
- '<span style="color:#c586c0">$1</span>',
3037
+ '<span class="hljs-keyword">$1</span>',
2921
3038
  )
2922
3039
  .replace(
2923
3040
  /\b(echo|cd|ls|cat|grep|awk|sed|rm|cp|mv|mkdir|chmod|chown|export|source|exit|test|read|printf|set|unset|eval|exec|trap|wait|kill|sudo|apt|npm|npx|git|docker|curl|wget|pip|python|node|make|dotnet)\b/g,
2924
- '<span style="color:#569cd6">$1</span>',
3041
+ '<span class="hljs-built_in">$1</span>',
2925
3042
  )
2926
- .replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span style="color:#9cdcfe">$1</span>')
2927
- .replace(/((?:^|\s)(?:&amp;&amp;|\|\||[|;])(?:\s|$))/g, '<span style="color:#d4d4d4;font-weight:bold">$1</span>');
3043
+ .replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span class="hljs-variable">$1</span>')
3044
+ .replace(/((?:^|\s)(?:&amp;&amp;|\|\||[|;])(?:\s|$))/g, '<span style="font-weight:bold">$1</span>');
2928
3045
  }
2929
3046
 
2930
3047
  const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
@@ -2932,6 +3049,12 @@ let _expandIdCounter = 0;
2932
3049
  function _applyExpandToggle(btn, fullEl) {
2933
3050
  const truncEl = btn.parentElement.nextElementSibling;
2934
3051
  const expand = fullEl.style.display === 'none';
3052
+ // The full text is highlighted on first expand, so opening a message with a large Write stays fast.
3053
+ if (expand && fullEl.dataset.hlLang) {
3054
+ const html = highlightCode(fullEl.textContent, fullEl.dataset.hlLang);
3055
+ if (html) fullEl.innerHTML = html;
3056
+ delete fullEl.dataset.hlLang;
3057
+ }
2935
3058
  fullEl.style.display = expand ? 'block' : 'none';
2936
3059
  if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
2937
3060
  btn.textContent = expand ? 'Show less' : 'Show more';
@@ -2952,7 +3075,8 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
2952
3075
  const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
2953
3076
  const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
2954
3077
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
2955
- const full = `<pre id="${id}" class="${cls}" style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
3078
+ const hl = opts.highlightLang ? ` data-hl-lang="${escapeHtml(opts.highlightLang)}"` : '';
3079
+ const full = `<pre id="${id}" class="${cls}"${hl} style="${mhStyle}overflow:auto;display:none">${fullHtml}</pre>`;
2956
3080
  return { btn, full };
2957
3081
  }
2958
3082
 
@@ -2991,8 +3115,8 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
2991
3115
  } else if (isTruncated) {
2992
3116
  truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
2993
3117
  }
2994
- return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
2995
- <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Output${truncLabel}</div>
3118
+ return `<div class="tool-detail-section-wide">
3119
+ <div class="tool-detail-heading">Output${truncLabel}</div>
2996
3120
  <pre class="msg-detail-pre" style="overflow:auto">${escaped}</pre>
2997
3121
  ${fullBlock}
2998
3122
  </div>`;
@@ -3018,9 +3142,7 @@ async function _toggleToolResultExpand(btn) {
3018
3142
  btn.disabled = true;
3019
3143
  btn.textContent = 'Loading…';
3020
3144
  try {
3021
- const r = await fetch(
3022
- `/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
3023
- );
3145
+ const r = await api(apiPath`/api/sessions/${currentSessionId}/tool-result/${btn.dataset.toolUseId}`);
3024
3146
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
3025
3147
  const { content } = await r.json();
3026
3148
  f.innerHTML = toolOutputHtml(content);
@@ -3028,7 +3150,7 @@ async function _toggleToolResultExpand(btn) {
3028
3150
  } catch (_e) {
3029
3151
  btn.textContent = 'Show more';
3030
3152
  btn.disabled = false;
3031
- showToast('Failed to load full output');
3153
+ showToast('Failed to load full output', 'error');
3032
3154
  return;
3033
3155
  }
3034
3156
  btn.disabled = false;
@@ -3061,14 +3183,10 @@ async function copyMsgToClipboard(btn) {
3061
3183
 
3062
3184
  async function postAndToast(url, body, label) {
3063
3185
  try {
3064
- const r = await fetch(url, {
3065
- method: 'POST',
3066
- headers: { 'Content-Type': 'application/json' },
3067
- body: JSON.stringify(body),
3068
- });
3069
- showToast(r.ok ? `Opened ${label}` : `Failed to open ${label}`);
3186
+ const r = await api(url, { method: 'POST', body });
3187
+ showToast(`${r.ok ? 'Opened' : 'Failed to open'} ${label}`, r.ok ? 'success' : 'error');
3070
3188
  } catch (_e) {
3071
- showToast(`Failed to open ${label}`);
3189
+ showToast(`Failed to open ${label}`, 'error');
3072
3190
  }
3073
3191
  }
3074
3192
 
@@ -3281,7 +3399,7 @@ function togglePinFromAgentModal() {
3281
3399
  async function dismissAgent(agentId) {
3282
3400
  if (!currentSessionId || !agentId) return;
3283
3401
  try {
3284
- const res = await fetch(`/api/sessions/${currentSessionId}/agents/${agentId}/stop`, { method: 'POST' });
3402
+ const res = await api(apiPath`/api/sessions/${currentSessionId}/agents/${agentId}/stop`, { method: 'POST' });
3285
3403
  if (res.ok) {
3286
3404
  currentWaiting = null;
3287
3405
  fetchAgents(currentSessionId);
@@ -3367,14 +3485,6 @@ function showAgentModal(agentId) {
3367
3485
  const dismissBtn = document.getElementById('agent-modal-dismiss-btn');
3368
3486
  dismissBtn.style.display = agent.status === 'active' || agent.status === 'idle' ? '' : 'none';
3369
3487
  modal.classList.add('visible');
3370
- const keyHandler = (e) => {
3371
- if (e.key === 'Escape') {
3372
- e.preventDefault();
3373
- closeAgentModal();
3374
- document.removeEventListener('keydown', keyHandler);
3375
- }
3376
- };
3377
- document.addEventListener('keydown', keyHandler);
3378
3488
  }
3379
3489
 
3380
3490
  function closeAgentModal() {
@@ -3406,15 +3516,8 @@ async function revealSession(id) {
3406
3516
  let session = sessions.find((s) => s.id === id);
3407
3517
  if (!session) {
3408
3518
  lastSessionsHash = '';
3409
- await fetchSessions();
3410
- session = sessions.find((s) => s.id === id);
3411
- }
3412
- if (!session) {
3413
- // The id is outside the list the filters asked for. renderSession bails on a session
3414
- // missing from `sessions`, so pull that one row in before opening it; later polls
3415
- // keep it, because fetchSessions pins currentSessionId.
3416
- session = await fetchSessionById(id);
3417
- if (session) sessions.push(session);
3519
+ await fetchSessions(true, id);
3520
+ session = await ensureSessionLoaded(id);
3418
3521
  }
3419
3522
  const uncollapsed = session ? uncollapseFor(session) : false;
3420
3523
  if (uncollapsed) persistCollapsedGroups();
@@ -3554,9 +3657,7 @@ function getFilteredSessions() {
3554
3657
 
3555
3658
  function saveOrder(key, prev, next) {
3556
3659
  if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
3557
- try {
3558
- store.setItem(key, JSON.stringify(next));
3559
- } catch (_) {}
3660
+ store.writeJson(key, next);
3560
3661
  return next;
3561
3662
  }
3562
3663
 
@@ -3603,36 +3704,8 @@ function renderSessions() {
3603
3704
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3604
3705
  const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3605
3706
 
3606
- if (filteredSessions.length === 0) {
3607
- let emptyMsg = 'No sessions found';
3608
- let emptyHint = 'Tasks appear when you use Claude Code';
3707
+ const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3609
3708
 
3610
- if (zenMode) {
3611
- emptyMsg = 'Zen mode: no session open';
3612
- emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3613
- } else if (searchQuery) {
3614
- emptyMsg = `No results for "${searchQuery}"`;
3615
- emptyHint = 'Try a different search term or clear the search';
3616
- } else if (filterProject && sessionFilter === 'active') {
3617
- emptyMsg = 'No active sessions for this project filter';
3618
- emptyHint = 'Try "All Sessions" or clear the project filter';
3619
- } else if (filterProject) {
3620
- emptyMsg = 'No sessions for this project filter';
3621
- emptyHint = 'Clear the project filter to see all';
3622
- } else if (sessionFilter === 'active') {
3623
- emptyMsg = 'No active sessions';
3624
- emptyHint = 'Select "All Sessions" to see all';
3625
- }
3626
- sessionsList.innerHTML = `
3627
- <div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
3628
- <p>${emptyMsg}</p>
3629
- <p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
3630
- </div>
3631
- `;
3632
- return;
3633
- }
3634
-
3635
- // Helper to render a single session card
3636
3709
  const renderSessionCard = (session) => {
3637
3710
  if (session.placeholder) return renderPlaceholderCard(session);
3638
3711
  const total = session.taskCount;
@@ -3703,6 +3776,63 @@ function renderSessions() {
3703
3776
  `;
3704
3777
  };
3705
3778
 
3779
+ // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3780
+ const sectionHtml = (key, text, ungroup, body, empty = false) => {
3781
+ const collapsed = collapsedProjectGroups.has(key);
3782
+ const escPath = escapeHtml(key);
3783
+ const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3784
+ const emptyCls = empty ? ' sg-section-empty' : '';
3785
+ return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3786
+ <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3787
+ };
3788
+
3789
+ const countHtml = (arr) => {
3790
+ const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3791
+ return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
3792
+ };
3793
+
3794
+ const outsideHtml = outside.rows.length
3795
+ ? sectionHtml(
3796
+ SECTION_OUTSIDE,
3797
+ `Outside current filters ${countHtml(outside.rows)}`,
3798
+ false,
3799
+ collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
3800
+ )
3801
+ : '';
3802
+
3803
+ if (filteredSessions.length === 0) {
3804
+ let emptyMsg = 'No sessions found';
3805
+ let emptyHint = 'Tasks appear when you use Claude Code';
3806
+
3807
+ if (zenMode) {
3808
+ emptyMsg = 'Zen mode: no session open';
3809
+ emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3810
+ } else if (searchQuery) {
3811
+ emptyMsg = `No results for "${escapeHtml(searchQuery)}"`;
3812
+ emptyHint = outside.rows.length
3813
+ ? 'Matches outside the current filters are below'
3814
+ : outside.pending
3815
+ ? 'Searching all sessions…'
3816
+ : 'Try a different search term or clear the search';
3817
+ } else if (filterProject && sessionFilter === 'active') {
3818
+ emptyMsg = 'No active sessions for this project filter';
3819
+ emptyHint = 'Try "All Sessions" or clear the project filter';
3820
+ } else if (filterProject) {
3821
+ emptyMsg = 'No sessions for this project filter';
3822
+ emptyHint = 'Clear the project filter to see all';
3823
+ } else if (sessionFilter === 'active') {
3824
+ emptyMsg = 'No active sessions';
3825
+ emptyHint = 'Select "All Sessions" to see all';
3826
+ }
3827
+ sessionsList.innerHTML = `
3828
+ <div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
3829
+ <p>${emptyMsg}</p>
3830
+ <p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
3831
+ </div>
3832
+ ${outsideHtml}`;
3833
+ return;
3834
+ }
3835
+
3706
3836
  // Zen draws one card and its panel, so it returns before the grouping below — leaving it in
3707
3837
  // that chain would build the whole list only to discard it.
3708
3838
  if (zenMode) {
@@ -3722,23 +3852,9 @@ function renderSessions() {
3722
3852
  return;
3723
3853
  }
3724
3854
 
3725
- const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3855
+ const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
3726
3856
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3727
3857
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3728
- const countHtml = (arr) => {
3729
- const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3730
- return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
3731
- };
3732
- // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3733
- const sectionHtml = (key, text, ungroup, body, empty = false) => {
3734
- const collapsed = collapsedProjectGroups.has(key);
3735
- const escPath = escapeHtml(key);
3736
- const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3737
- const emptyCls = empty ? ' sg-section-empty' : '';
3738
- return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3739
- <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3740
- };
3741
-
3742
3858
  const renderGroupSessions = (sessions, pinKey) => {
3743
3859
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3744
3860
  const gIdlePinned = sessions.filter(isInPinnedGroup);
@@ -3958,13 +4074,13 @@ function renderSessions() {
3958
4074
  const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
3959
4075
  html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
3960
4076
 
3961
- sessionsList.innerHTML = html;
4077
+ sessionsList.innerHTML = html + outsideHtml;
3962
4078
  } else {
3963
4079
  const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3964
4080
  const restHtml =
3965
4081
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3966
4082
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
3967
- sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
4083
+ sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
3968
4084
  }
3969
4085
 
3970
4086
  const navItems = getNavigableItems();
@@ -4283,11 +4399,7 @@ function startAddTask(tile) {
4283
4399
  input.disabled = true;
4284
4400
  const sessionId = currentSessionId;
4285
4401
  try {
4286
- const res = await fetch(`/api/tasks/${sessionId}`, {
4287
- method: 'POST',
4288
- headers: { 'Content-Type': 'application/json' },
4289
- body: JSON.stringify({ subject }),
4290
- });
4402
+ const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject } });
4291
4403
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
4292
4404
  // The response carries the finished task, and the watcher will resend it within the
4293
4405
  // SSE debounce anyway -- so show it now rather than paying a session refetch for it.
@@ -4379,11 +4491,7 @@ async function onColumnDrop(e) {
4379
4491
  task.status = newStatus;
4380
4492
  renderKanban();
4381
4493
  try {
4382
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
4383
- method: 'PUT',
4384
- headers: { 'Content-Type': 'application/json' },
4385
- body: JSON.stringify({ status: newStatus }),
4386
- });
4494
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { status: newStatus } });
4387
4495
  if (res.ok) return;
4388
4496
  } catch (_) {}
4389
4497
  if (task.status === newStatus) {
@@ -4420,34 +4528,28 @@ function pinKey(projectPath) {
4420
4528
  }
4421
4529
 
4422
4530
  function loadSessionGroups() {
4423
- try {
4424
- const list = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
4425
- sessionGroups = (Array.isArray(list) ? list : [])
4426
- .filter((g) => g && typeof g.id === 'string')
4427
- .map((g) => ({
4428
- id: g.id,
4429
- name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4430
- members: (Array.isArray(g.members) ? g.members : [])
4431
- .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4432
- .map((m) => {
4433
- const out = { type: m.type, ref: m.ref };
4434
- if (typeof m.under === 'string' && m.under) out.under = m.under;
4435
- if (m.loose === true) out.loose = true;
4436
- return out;
4437
- }),
4438
- }));
4439
- const released = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.released;
4440
- sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
4441
- } catch (_) {
4442
- sessionGroups = [];
4443
- sgReleased = new Set();
4444
- }
4531
+ const saved = store.readJson(SESSION_GROUPS_KEY);
4532
+ const list = saved?.groups;
4533
+ sessionGroups = (Array.isArray(list) ? list : [])
4534
+ .filter((g) => g && typeof g.id === 'string')
4535
+ .map((g) => ({
4536
+ id: g.id,
4537
+ name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4538
+ members: (Array.isArray(g.members) ? g.members : [])
4539
+ .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4540
+ .map((m) => {
4541
+ const out = { type: m.type, ref: m.ref };
4542
+ if (typeof m.under === 'string' && m.under) out.under = m.under;
4543
+ if (m.loose === true) out.loose = true;
4544
+ return out;
4545
+ }),
4546
+ }));
4547
+ const released = saved?.released;
4548
+ sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
4445
4549
  }
4446
4550
 
4447
4551
  function persistSessionGroups() {
4448
- try {
4449
- store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
4450
- } catch (_) {}
4552
+ store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
4451
4553
  }
4452
4554
 
4453
4555
  function sgGroupById(id) {
@@ -4962,10 +5064,13 @@ function initSessionGroupsDnd() {
4962
5064
  // Non-drag path: right-click or long-press a session / project header.
4963
5065
  let sgLongPressTimer = null;
4964
5066
 
4965
- function sgCloseMenu() {
5067
+ function sgCloseMenu(restore) {
4966
5068
  document.removeEventListener('keydown', sgMenuKeydown, true);
4967
5069
  const menu = document.getElementById('sg-menu');
4968
- if (menu) menu.remove();
5070
+ if (menu) {
5071
+ menu.remove();
5072
+ takeMenuFocus(restore);
5073
+ }
4969
5074
  }
4970
5075
 
4971
5076
  // Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
@@ -4979,11 +5084,25 @@ function menuKeydown(e, items, close) {
4979
5084
  else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
4980
5085
  else if (e.key === 'Home') next = 0;
4981
5086
  else if (e.key === 'End') next = items.length - 1;
4982
- else if (e.key === 'Escape' || e.key === 'Tab') close();
5087
+ else if (e.key === 'Escape' || e.key === 'Tab') close(true);
4983
5088
  if (next !== null) items[next]?.focus();
4984
5089
  }
4985
5090
 
5091
+ let menuReturnFocus = null;
5092
+
5093
+ // Only when nothing else has taken focus: it is on body, or still inside what is closing.
5094
+ function restoreFocusTo(el, closing) {
5095
+ const now = document.activeElement;
5096
+ if (el?.isConnected && (!now || now === document.body || closing?.contains(now))) el.focus();
5097
+ }
5098
+
5099
+ function takeMenuFocus(restore) {
5100
+ if (restore) restoreFocusTo(menuReturnFocus);
5101
+ menuReturnFocus = null;
5102
+ }
5103
+
4986
5104
  function placeMenu(menu, x, y) {
5105
+ menuReturnFocus = document.activeElement;
4987
5106
  document.body.appendChild(menu);
4988
5107
  const rect = menu.getBoundingClientRect();
4989
5108
  menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
@@ -5022,10 +5141,12 @@ function sgOpenMenu(x, y, kind, ref) {
5022
5141
  menu.id = 'sg-menu';
5023
5142
  menu.className = 'sg-menu';
5024
5143
  menu.setAttribute('role', 'menu');
5025
- menu.setAttribute('aria-label', `Move ${label} to group`);
5144
+ const canFork = kind === 'session' && !!forkableSession(ref);
5145
+ menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
5026
5146
  menu.dataset.sgKind = kind;
5027
5147
  menu.dataset.sgRef = ref;
5028
5148
  menu.innerHTML = `
5149
+ ${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
5029
5150
  <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
5030
5151
  ${rows}
5031
5152
  <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
@@ -5065,7 +5186,7 @@ function sgOnTouchStart(e) {
5065
5186
  document.addEventListener('click', (e) => {
5066
5187
  const item = e.target.closest('.sg-menu-item');
5067
5188
  if (!item) {
5068
- if (!e.target.closest('.sg-menu')) sgCloseMenu();
5189
+ if (!e.target.closest('.sg-menu')) sgCloseMenu(true);
5069
5190
  return;
5070
5191
  }
5071
5192
  e.stopPropagation();
@@ -5073,6 +5194,7 @@ document.addEventListener('click', (e) => {
5073
5194
  const { sgKind, sgRef } = menu.dataset;
5074
5195
  const move = item.dataset.sgMove;
5075
5196
  sgCloseMenu();
5197
+ if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
5076
5198
  if (move === '__none__') {
5077
5199
  sgDetach(sgKind, sgRef);
5078
5200
  persistSessionGroups();
@@ -5288,9 +5410,7 @@ function setGroupCollapsed(header, collapsed) {
5288
5410
  }
5289
5411
 
5290
5412
  function persistCollapsedGroups() {
5291
- try {
5292
- store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
5293
- } catch (_) {}
5413
+ store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
5294
5414
  }
5295
5415
 
5296
5416
  let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
@@ -5316,18 +5436,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5316
5436
  }
5317
5437
 
5318
5438
  // Drops every collapse key that could hide this session: its project, a named group wrapping
5319
- // the project or the session itself, the pinned sub-section only when the card renders inside
5320
- // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
5321
- // depends on the view — every section header. Returns whether anything changed.
5439
+ // the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
5440
+ // it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
5441
+ // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5442
+ // whether anything changed.
5322
5443
  function uncollapseFor(session) {
5323
5444
  const group = sgGroupForSession(session);
5324
5445
  const keys = [
5325
5446
  sessionProjectKey(session) || '__ungrouped__',
5326
5447
  ...pinnedCollapseKeys(session),
5327
- group && sgKey(group.id),
5328
- SECTION_GROUPS,
5329
- SECTION_PROJECTS,
5330
- SECTION_SESSIONS,
5448
+ ...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
5331
5449
  ];
5332
5450
  let changed = false;
5333
5451
  for (const key of keys) {
@@ -5479,42 +5597,6 @@ sessionsList.addEventListener('click', (e) => {
5479
5597
  if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
5480
5598
  });
5481
5599
 
5482
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5483
- function getAvailableTasksOptions(currentTaskId = null) {
5484
- const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
5485
- const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && t.id !== currentTaskId);
5486
- const completed = currentTasks.filter((t) => t.status === 'completed' && t.id !== currentTaskId);
5487
-
5488
- // Build options grouped by status
5489
- let options = '';
5490
-
5491
- if (pending.length > 0) {
5492
- options += '<optgroup label="Pending">';
5493
- pending.forEach((t, _idx) => {
5494
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5495
- });
5496
- options += '</optgroup>';
5497
- }
5498
-
5499
- if (inProgress.length > 0) {
5500
- options += '<optgroup label="In Progress">';
5501
- inProgress.forEach((t, _idx) => {
5502
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5503
- });
5504
- options += '</optgroup>';
5505
- }
5506
-
5507
- if (completed.length > 0) {
5508
- options += '<optgroup label="Completed">';
5509
- completed.forEach((t, _idx) => {
5510
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5511
- });
5512
- options += '</optgroup>';
5513
- }
5514
-
5515
- return options;
5516
- }
5517
-
5518
5600
  //#endregion
5519
5601
 
5520
5602
  //#region TASK_DETAIL
@@ -5525,15 +5607,9 @@ async function showTaskDetail(taskId, sessionId = null) {
5525
5607
 
5526
5608
  // If task not found in currentTasks, fetch it from the session
5527
5609
  if (!task && sessionId && sessionId !== 'undefined') {
5528
- try {
5529
- const res = await fetch(`/api/sessions/${sessionId}`);
5530
- const tasks = await res.json();
5531
- task = tasks.find((t) => t.id === taskId);
5532
- if (!task) return;
5533
- } catch (error) {
5534
- console.error('Failed to fetch task:', error);
5535
- return;
5536
- }
5610
+ const tasks = await getJson(apiPath`/api/sessions/${sessionId}`, []);
5611
+ task = tasks.find((t) => t.id === taskId);
5612
+ if (!task) return;
5537
5613
  }
5538
5614
 
5539
5615
  if (!task) return;
@@ -5713,11 +5789,7 @@ function editDescription(descEl, task, sessionId) {
5713
5789
 
5714
5790
  async function saveTaskField(taskId, sessionId, field, value) {
5715
5791
  try {
5716
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
5717
- method: 'PUT',
5718
- headers: { 'Content-Type': 'application/json' },
5719
- body: JSON.stringify({ [field]: value }),
5720
- });
5792
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { [field]: value } });
5721
5793
 
5722
5794
  if (res.ok) {
5723
5795
  lastCurrentTasksHash = null;
@@ -5736,47 +5808,6 @@ function closeDetailPanel() {
5736
5808
  taskHighlightDimmed = false;
5737
5809
  }
5738
5810
 
5739
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5740
- function showBlockedTaskModal(task) {
5741
- const messageDiv = document.getElementById('blocked-task-message');
5742
-
5743
- const blockedByList = task.blockedBy
5744
- .map((id) => {
5745
- const blockingTask = currentTasks.find((t) => t.id === id);
5746
- if (blockingTask) {
5747
- return `<li><strong>#${blockingTask.id}</strong> - ${escapeHtml(blockingTask.subject)}</li>`;
5748
- }
5749
- return `<li><strong>#${id}</strong></li>`;
5750
- })
5751
- .join('');
5752
-
5753
- messageDiv.innerHTML = `
5754
- <p style="margin-bottom: 12px;">Task <strong>#${task.id}</strong> - ${escapeHtml(task.subject)} is currently blocked by:</p>
5755
- <ul style="margin: 0 0 16px 20px; padding: 0;">${blockedByList}</ul>
5756
- <p style="margin: 0; color: var(--text-secondary); font-size: 13px;">
5757
- Please resolve these dependencies before moving this task to <strong>In Progress</strong>.
5758
- </p>
5759
- `;
5760
-
5761
- const modal = document.getElementById('blocked-task-modal');
5762
- modal.classList.add('visible');
5763
-
5764
- // Handle ESC key
5765
- const keyHandler = (e) => {
5766
- if (e.key === 'Escape') {
5767
- e.preventDefault();
5768
- closeBlockedTaskModal();
5769
- document.removeEventListener('keydown', keyHandler);
5770
- }
5771
- };
5772
- document.addEventListener('keydown', keyHandler);
5773
- }
5774
-
5775
- function closeBlockedTaskModal() {
5776
- const modal = document.getElementById('blocked-task-modal');
5777
- modal.classList.remove('visible');
5778
- }
5779
-
5780
5811
  //#endregion
5781
5812
 
5782
5813
  //#region DELETE_TASK
@@ -5789,6 +5820,7 @@ function confirmModal({ title, message, okLabel }) {
5789
5820
  document.getElementById('confirm-modal-message').textContent = message;
5790
5821
  const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5791
5822
  buttons[1].textContent = okLabel;
5823
+ const returnFocus = document.activeElement;
5792
5824
  modal.classList.add('visible');
5793
5825
  buttons[1].focus();
5794
5826
 
@@ -5807,6 +5839,7 @@ function confirmModal({ title, message, okLabel }) {
5807
5839
  modal.classList.remove('visible');
5808
5840
  document.removeEventListener('keydown', onKey);
5809
5841
  closeConfirmModal = null;
5842
+ if (!ok) restoreFocusTo(returnFocus, modal);
5810
5843
  resolve(ok);
5811
5844
  };
5812
5845
  });
@@ -5823,20 +5856,18 @@ async function deleteTask(taskId, sessionId) {
5823
5856
  if (!ok) return;
5824
5857
 
5825
5858
  try {
5826
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
5827
- method: 'DELETE',
5828
- });
5859
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
5829
5860
 
5830
5861
  if (res.ok) {
5831
5862
  closeDetailPanel();
5832
5863
  await refreshCurrentView();
5833
5864
  } else {
5834
- const error = await res.json();
5835
- alert(`Failed to delete task: ${error.error || 'Unknown error'}`);
5865
+ const error = await res.json().catch(() => ({}));
5866
+ showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
5836
5867
  }
5837
5868
  } catch (error) {
5838
5869
  console.error('Failed to delete task:', error);
5839
- alert('Failed to delete task');
5870
+ showToast('Failed to delete task', 'error');
5840
5871
  }
5841
5872
  }
5842
5873
 
@@ -5873,14 +5904,20 @@ const SHORTCUT_TABS = [
5873
5904
  ],
5874
5905
  },
5875
5906
  {
5876
- title: 'View',
5907
+ title: 'Tabs',
5877
5908
  rows: [
5878
- { keys: ['\\'], label: 'Toggle sidebar' },
5879
5909
  { keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
5880
5910
  { keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
5881
5911
  { keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
5882
5912
  { keys: ['{', '}'], label: 'Focus left / right side of a split' },
5883
- { keys: ['Alt', 'W'], combo: true, label: 'Close the focused tab' },
5913
+ { keys: ['Alt', 'W'], combo: true, label: 'Close the focused pane' },
5914
+ { keys: ['Shift', 'F10'], combo: true, label: 'Focused tab menu (or Menu key)' },
5915
+ ],
5916
+ },
5917
+ {
5918
+ title: 'View',
5919
+ rows: [
5920
+ { keys: ['\\'], label: 'Toggle sidebar' },
5884
5921
  { keys: ['T'], label: 'Toggle theme' },
5885
5922
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5886
5923
  { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
@@ -5890,13 +5927,6 @@ const SHORTCUT_TABS = [
5890
5927
  { keys: ['?'], label: 'Show this help' },
5891
5928
  ],
5892
5929
  },
5893
- {
5894
- title: 'Copy',
5895
- rows: [
5896
- { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5897
- { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
5898
- ],
5899
- },
5900
5930
  ],
5901
5931
  },
5902
5932
  {
@@ -5911,7 +5941,7 @@ const SHORTCUT_TABS = [
5911
5941
  { keys: ['I'], label: 'Session info' },
5912
5942
  { keys: ['.'], label: 'Pin / unpin' },
5913
5943
  { keys: ['>'], label: 'Toggle sticky' },
5914
- { keys: ['Shift', 'F10'], combo: true, label: 'Move to group (or Menu key)' },
5944
+ { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
5915
5945
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
5916
5946
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5917
5947
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
@@ -5925,6 +5955,8 @@ const SHORTCUT_TABS = [
5925
5955
  { keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
5926
5956
  { keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
5927
5957
  { keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
5958
+ { keys: ['Shift', 'C'], combo: true, label: 'Copy session id' },
5959
+ { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Copy session name (SendMessage address)' },
5928
5960
  ],
5929
5961
  },
5930
5962
  ],
@@ -5937,7 +5969,7 @@ const SHORTCUT_TABS = [
5937
5969
  rows: [
5938
5970
  { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
5939
5971
  { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
5940
- { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5972
+ { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End terminal' },
5941
5973
  { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
5942
5974
  { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
5943
5975
  ],
@@ -5951,9 +5983,9 @@ const SHORTCUT_TABS = [
5951
5983
  {
5952
5984
  title: 'Hub',
5953
5985
  rows: [
5954
- { keys: ['M'], label: 'Jump to marketplace' },
5955
- { keys: ['$'], label: 'Jump to cost' },
5956
- { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5986
+ { keys: ['M'], opens: 'project.plugins', label: 'Jump to marketplace' },
5987
+ { keys: ['$'], opens: 'session.cost', label: 'Jump to cost' },
5988
+ { keys: ['Ctrl', 'M'], opens: 'project.memory', combo: true, label: 'Jump to memory' },
5957
5989
  {
5958
5990
  keys: ['Ctrl', 'Alt', '←/→'],
5959
5991
  action: ['hub.prevApp', 'hub.nextApp'],
@@ -6035,8 +6067,14 @@ function buildHelpShortcuts(groups) {
6035
6067
  let helpTab = 0;
6036
6068
  const helpFilter = document.getElementById('help-filter');
6037
6069
 
6038
- // Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
6039
- const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
6070
+ // Hub keys do nothing outside Claude Code Hub, except the app links that the --*-url flags set.
6071
+ const hubRowWorks = (r) => (r.opens ? hub.can(r.opens) : hub.inHub);
6072
+ const helpTabs = () =>
6073
+ SHORTCUT_TABS.map((t) =>
6074
+ t.hub
6075
+ ? { ...t, groups: t.groups.map((g) => ({ ...g, rows: g.rows.filter(hubRowWorks) })).filter((g) => g.rows.length) }
6076
+ : t,
6077
+ ).filter((t) => t.groups.length);
6040
6078
 
6041
6079
  // The matching rows of every tab, in their groups.
6042
6080
  function filteredShortcutGroups(query) {
@@ -6044,7 +6082,7 @@ function filteredShortcutGroups(query) {
6044
6082
  .flatMap((t) => t.groups)
6045
6083
  .map((g) => ({
6046
6084
  title: g.title,
6047
- rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`, query)),
6085
+ rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`.replaceAll('−', '-'), query)),
6048
6086
  }))
6049
6087
  .filter((g) => g.rows.length);
6050
6088
  }
@@ -6425,7 +6463,7 @@ function _updateStorageTotal() {
6425
6463
  let _storageKnownSessions = null;
6426
6464
 
6427
6465
  async function _fetchKnownSessions() {
6428
- const res = await fetch('/api/sessions/known');
6466
+ const res = await api('/api/sessions/known');
6429
6467
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
6430
6468
  const known = new Map((await res.json()).map((s) => [s.id, s]));
6431
6469
  for (const s of sessions) known.set(s.id, s);
@@ -6518,12 +6556,10 @@ function _renderStorageSessions() {
6518
6556
 
6519
6557
  const msgMap = new Map();
6520
6558
  for (const key of store.keys()) {
6521
- if (!key.startsWith('pinned-messages-')) continue;
6522
- const sid = key.slice('pinned-messages-'.length);
6523
- try {
6524
- const pins = JSON.parse(store.getItem(key)) || [];
6525
- if (pins.length) msgMap.set(sid, { pins, key });
6526
- } catch {}
6559
+ if (!key.startsWith(PINNED_MESSAGES_PREFIX)) continue;
6560
+ const sid = key.slice(PINNED_MESSAGES_PREFIX.length);
6561
+ const pins = loadPins(sid);
6562
+ if (pins.length) msgMap.set(sid, { pins });
6527
6563
  }
6528
6564
 
6529
6565
  const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
@@ -6603,7 +6639,7 @@ function _storageUnpinSession(id) {
6603
6639
  }
6604
6640
 
6605
6641
  function _storageClearSessionPins(sessionId) {
6606
- store.removeItem(`pinned-messages-${sessionId}`);
6642
+ store.removeItem(pinsKey(sessionId));
6607
6643
  if (currentSessionId === sessionId) {
6608
6644
  currentPins = [];
6609
6645
  const el = document.getElementById('message-panel-pinned');
@@ -6614,20 +6650,16 @@ function _storageClearSessionPins(sessionId) {
6614
6650
  }
6615
6651
 
6616
6652
  function _storageUnpinMessage(sessionId, pinId) {
6617
- const key = `pinned-messages-${sessionId}`;
6618
- try {
6619
- const pins = JSON.parse(store.getItem(key)) || [];
6620
- const idx = pins.findIndex((p) => p.id === pinId);
6621
- if (idx < 0) return;
6622
- pins.splice(idx, 1);
6623
- if (pins.length) store.setItem(key, JSON.stringify(pins));
6624
- else store.removeItem(key);
6625
- if (currentSessionId === sessionId) {
6626
- currentPins = pins;
6627
- const el = document.getElementById('message-panel-pinned');
6628
- if (el) el.innerHTML = renderPinnedSection();
6629
- }
6630
- } catch {}
6653
+ const pins = loadPins(sessionId);
6654
+ const idx = pins.findIndex((p) => p.id === pinId);
6655
+ if (idx < 0) return;
6656
+ pins.splice(idx, 1);
6657
+ savePins(sessionId, pins);
6658
+ if (currentSessionId === sessionId) {
6659
+ currentPins = pins;
6660
+ const el = document.getElementById('message-panel-pinned');
6661
+ if (el) el.innerHTML = renderPinnedSection();
6662
+ }
6631
6663
  _renderStorageTab();
6632
6664
  _updateStorageTotal();
6633
6665
  }
@@ -6727,19 +6759,14 @@ function _storagePreviewScratchpad(key) {
6727
6759
 
6728
6760
  function _storagePreviewPin(sessionId, pinId) {
6729
6761
  closeStorageManager();
6730
- const key = `pinned-messages-${sessionId}`;
6731
- try {
6732
- const pins = JSON.parse(store.getItem(key)) || [];
6733
- const pin = pins.find((p) => p.id === pinId);
6734
- if (!pin) return;
6735
- document.getElementById('msg-detail-pin-btn').style.display = 'none';
6736
- currentMsgDetailIdx = null;
6737
- currentPinDetailId = null;
6738
- _renderPinToDetail(pin);
6739
- document.getElementById('msg-detail-modal').classList.add('visible');
6740
- } catch (e) {
6741
- console.error('_storagePreviewPin error:', e);
6742
- }
6762
+ const pin = loadPins(sessionId).find((p) => p.id === pinId);
6763
+ if (!pin) return;
6764
+ document.getElementById('msg-detail-pin-btn').style.display = 'none';
6765
+ currentMsgDetailIdx = null;
6766
+ currentPinDetailId = null;
6767
+ syncMsgDetailPaneBtn();
6768
+ _renderPinToDetail(pin);
6769
+ document.getElementById('msg-detail-modal').classList.add('visible');
6743
6770
  }
6744
6771
 
6745
6772
  function _storageDeleteScratchpad(key) {
@@ -6832,7 +6859,7 @@ function _findOrphanedKeys(known) {
6832
6859
  const orphaned = [];
6833
6860
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
6834
6861
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
6835
- const sessionKeyPrefixes = ['pinned-messages-', PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
6862
+ const sessionKeyPrefixes = [PINNED_MESSAGES_PREFIX, PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
6836
6863
  for (const key of store.keys()) {
6837
6864
  const pad = _parsePadKey(key);
6838
6865
  if (pad?.kind === 'group') {
@@ -6859,23 +6886,28 @@ async function cleanupOrphanedStorage() {
6859
6886
  try {
6860
6887
  known = await _fetchKnownSessions();
6861
6888
  } catch {
6862
- showToast('Could not list sessions — nothing was cleaned');
6889
+ showToast('Failed to list sessions — nothing was cleaned', 'error');
6863
6890
  return;
6864
6891
  }
6865
6892
  const orphaned = _findOrphanedKeys(known);
6866
- let pinsChanged = false;
6893
+ const unpinned = new Set();
6867
6894
  for (const key of orphaned) {
6868
6895
  if (key.startsWith('__pinned__')) {
6869
- pinnedSessionIds.delete(key.slice('__pinned__'.length));
6870
- pinsChanged = true;
6896
+ const id = key.slice('__pinned__'.length);
6897
+ pinnedSessionIds.delete(id);
6898
+ unpinned.add(id);
6871
6899
  } else if (key.startsWith('__sticky__')) {
6872
- stickySessionIds.delete(key.slice('__sticky__'.length));
6873
- pinsChanged = true;
6900
+ const id = key.slice('__sticky__'.length);
6901
+ stickySessionIds.delete(id);
6902
+ unpinned.add(id);
6874
6903
  } else {
6875
6904
  store.removeItem(key);
6905
+ // The server copy would bring the links back on the next merge.
6906
+ if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
6876
6907
  }
6877
6908
  }
6878
- if (pinsChanged) savePinnedSessions();
6909
+ if (unpinned.size) savePinnedSessions();
6910
+ for (const id of unpinned) offloadSessionPin(id);
6879
6911
  const removed = orphaned.length;
6880
6912
 
6881
6913
  showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
@@ -6892,6 +6924,10 @@ function matchKey(e, ...keys) {
6892
6924
  return keys.some((k) => e.key === k || e.code === k);
6893
6925
  }
6894
6926
 
6927
+ function shiftOnly(e, code) {
6928
+ return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
6929
+ }
6930
+
6895
6931
  function dismissSessionFromList(sid) {
6896
6932
  const prevIdx = selectedSessionIdx;
6897
6933
  if (!setSessionDismissed(sid, true)) return;
@@ -6940,10 +6976,10 @@ async function confirmDismissBlockers(sid) {
6940
6976
  const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
6941
6977
  const [title, verb, okLabel] =
6942
6978
  sticky && terminal
6943
- ? ['Unpin and Close Terminal', 'Unpin, close the terminal', 'Unpin, close and dismiss']
6979
+ ? ['Unpin and End Terminal', 'Unpin, end the terminal', 'Unpin, end and dismiss']
6944
6980
  : sticky
6945
6981
  ? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
6946
- : ['Close Terminal', 'Close the terminal', 'Close and dismiss'];
6982
+ : ['End Terminal', 'End the terminal', 'End and dismiss'];
6947
6983
  const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
6948
6984
  if (!ok) return false;
6949
6985
  if (sticky) unpinSession(sid);
@@ -7041,11 +7077,7 @@ document.addEventListener('keydown', (e) => {
7041
7077
  } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
7042
7078
  e.preventDefault();
7043
7079
  closeHelpModal();
7044
- } else if (
7045
- e.code === 'KeyM' &&
7046
- e.shiftKey &&
7047
- document.getElementById('msg-detail-modal').classList.contains('visible')
7048
- ) {
7080
+ } else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
7049
7081
  e.preventDefault();
7050
7082
  closeMsgDetailModal();
7051
7083
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
@@ -7108,6 +7140,10 @@ document.addEventListener('keydown', (e) => {
7108
7140
  e.preventDefault();
7109
7141
  return;
7110
7142
  }
7143
+ if (isMenuKey(e) && ((focusZone === 'sidebar' && sgOpenMenuForKbSelection()) || openFocusedPaneMenu())) {
7144
+ e.preventDefault();
7145
+ return;
7146
+ }
7111
7147
  const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
7112
7148
  if (plainShift && e.key === '|' && togglePaneSplit()) {
7113
7149
  e.preventDefault();
@@ -7117,17 +7153,17 @@ document.addEventListener('keydown', (e) => {
7117
7153
  e.preventDefault();
7118
7154
  return;
7119
7155
  }
7120
- if (e.code === 'KeyL' && e.shiftKey) {
7156
+ if (shiftOnly(e, 'KeyL')) {
7121
7157
  e.preventDefault();
7122
7158
  toggleMessagePanel();
7123
7159
  return;
7124
7160
  }
7125
- if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
7161
+ if (shiftOnly(e, 'KeyP')) {
7126
7162
  e.preventDefault();
7127
7163
  openProjectPicker();
7128
7164
  return;
7129
7165
  }
7130
- if (e.code === 'KeyM' && e.shiftKey) {
7166
+ if (shiftOnly(e, 'KeyM')) {
7131
7167
  e.preventDefault();
7132
7168
  const msgDetailModal = document.getElementById('msg-detail-modal');
7133
7169
  if (msgDetailModal.classList.contains('visible')) {
@@ -7141,12 +7177,12 @@ document.addEventListener('keydown', (e) => {
7141
7177
  }
7142
7178
  return;
7143
7179
  }
7144
- if (e.code === 'KeyS' && e.shiftKey) {
7180
+ if (shiftOnly(e, 'KeyS')) {
7145
7181
  e.preventDefault();
7146
7182
  showStorageManager();
7147
7183
  return;
7148
7184
  }
7149
- if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
7185
+ if (shiftOnly(e, 'KeyZ')) {
7150
7186
  e.preventDefault();
7151
7187
  toggleZenMode();
7152
7188
  return;
@@ -7202,11 +7238,6 @@ document.addEventListener('keydown', (e) => {
7202
7238
  activateSelectedSession();
7203
7239
  return;
7204
7240
  }
7205
- const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
7206
- if (menuKey && sgOpenMenuForKbSelection()) {
7207
- e.preventDefault();
7208
- return;
7209
- }
7210
7241
  }
7211
7242
 
7212
7243
  // Board navigation
@@ -7271,17 +7302,17 @@ document.addEventListener('keydown', (e) => {
7271
7302
  const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
7272
7303
  const contextSid =
7273
7304
  focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
7274
- if (matchKey(e, 'KeyP') && !e.shiftKey) {
7305
+ if (matchKey(e, 'KeyP')) {
7275
7306
  e.preventDefault();
7276
7307
  if (contextSid) openPlanForSession(contextSid);
7277
7308
  return;
7278
7309
  }
7279
- if (matchKey(e, 'KeyI') && !e.shiftKey) {
7310
+ if (matchKey(e, 'KeyI')) {
7280
7311
  e.preventDefault();
7281
7312
  if (contextSid) showSessionInfoModal(contextSid);
7282
7313
  return;
7283
7314
  }
7284
- if (matchKey(e, 'KeyN') && !e.shiftKey) {
7315
+ if (matchKey(e, 'KeyN')) {
7285
7316
  e.preventDefault();
7286
7317
  const cursorKey = _scratchpadKeyOf(cursorEl);
7287
7318
  if (cursorKey) showScratchpad(cursorKey);
@@ -7299,13 +7330,13 @@ document.addEventListener('keydown', (e) => {
7299
7330
  openMarketplace(mSession?.project);
7300
7331
  return;
7301
7332
  }
7302
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
7333
+ if (ClaudeHub.comboOf(e) === 'ctrl+m') {
7303
7334
  e.preventDefault();
7304
7335
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
7305
7336
  openMemory(mSession?.project);
7306
7337
  return;
7307
7338
  }
7308
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
7339
+ if (ClaudeHub.comboOf(e) === 'ctrl+d') {
7309
7340
  e.preventDefault();
7310
7341
  if (!contextSid) return;
7311
7342
  dismissSession(contextSid);
@@ -7325,10 +7356,7 @@ document.addEventListener('keydown', (e) => {
7325
7356
  showToast('No live session name: the session is not running');
7326
7357
  return;
7327
7358
  }
7328
- navigator.clipboard
7329
- .writeText(text)
7330
- .then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
7331
- .catch(() => showToast(`Failed to copy ${label}`));
7359
+ copyText(text, label, `Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`);
7332
7360
  return;
7333
7361
  }
7334
7362
  if (matchKey(e, 'KeyR')) {
@@ -7965,7 +7993,7 @@ async function sendReview() {
7965
7993
  const items = reviewItems();
7966
7994
  if (!items.length) return;
7967
7995
  try {
7968
- const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
7996
+ const res = await terminalFetch(apiPath`/api/sessions/${sessionId}/review`, 'POST', {
7969
7997
  source,
7970
7998
  comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
7971
7999
  });
@@ -7975,14 +8003,19 @@ async function sendReview() {
7975
8003
  else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
7976
8004
  else {
7977
8005
  const { markdown } = data;
7978
- const copy = () => navigator.clipboard.writeText(markdown).catch(() => {});
8006
+ const copy = async () => {
8007
+ const copied = await navigator.clipboard.writeText(markdown).then(
8008
+ () => true,
8009
+ () => false,
8010
+ );
8011
+ showToast(
8012
+ copied ? 'Copied the review. Paste it into the session.' : 'Failed to copy the review',
8013
+ 'info',
8014
+ { label: 'Copy again', onClick: copy },
8015
+ 'Sessions get reviews directly when the cck plugin runs there and board events are on.',
8016
+ );
8017
+ };
7979
8018
  await copy();
7980
- showToast(
7981
- 'Review copied. Paste it into the session.',
7982
- 'info',
7983
- { label: 'Copy again', onClick: copy },
7984
- 'Run /claude-code-kanban:follow there to send reviews directly.',
7985
- );
7986
8019
  }
7987
8020
  clearReviewDrafts(key);
7988
8021
  if (activeReview?.key === key) onSent?.();
@@ -7997,12 +8030,8 @@ const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
7997
8030
  let currentPreviewPath = null;
7998
8031
 
7999
8032
  function readStoredList(key) {
8000
- try {
8001
- const arr = JSON.parse(store.getItem(key) || '[]');
8002
- return Array.isArray(arr) ? arr : [];
8003
- } catch {
8004
- return [];
8005
- }
8033
+ const arr = store.readJson(key);
8034
+ return Array.isArray(arr) ? arr : [];
8006
8035
  }
8007
8036
 
8008
8037
  function getSessionPreviewPaths(sessionId) {
@@ -8036,7 +8065,7 @@ function removeSessionPreviewPath(sessionId, filePath) {
8036
8065
  }
8037
8066
 
8038
8067
  function saveSessionPreviewPaths(sessionId, paths) {
8039
- if (paths.length) store.setItem(PREVIEW_STORAGE_PREFIX + sessionId, JSON.stringify(paths.slice(0, MAX_LINKED_DOCS)));
8068
+ if (paths.length) store.writeJson(PREVIEW_STORAGE_PREFIX + sessionId, paths.slice(0, MAX_LINKED_DOCS));
8040
8069
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
8041
8070
  }
8042
8071
 
@@ -8053,14 +8082,7 @@ function setSessionDocLink(sessionId, filePath, unlink) {
8053
8082
 
8054
8083
  // Links sent while no tab was open reach the browser only through the server copy.
8055
8084
  async function mergeServerLinkedDocs() {
8056
- let bySession;
8057
- try {
8058
- const res = await fetch('/api/document/links');
8059
- if (!res.ok) return;
8060
- bySession = await res.json();
8061
- } catch {
8062
- return;
8063
- }
8085
+ const bySession = await getJson('/api/document/links', {});
8064
8086
  for (const [sessionId, paths] of Object.entries(bySession || {})) {
8065
8087
  const local = getSessionPreviewPaths(sessionId);
8066
8088
  const known = new Set(local.map(canonicalPath));
@@ -8072,8 +8094,9 @@ async function mergeServerLinkedDocs() {
8072
8094
  }
8073
8095
 
8074
8096
  function forgetServerLinkedDoc(sessionId, filePath) {
8075
- const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
8076
- fetch(`/api/document/links/${encodeURIComponent(sessionId)}${q}`, { method: 'DELETE' }).catch(() => {});
8097
+ api(apiPath`/api/document/links/${sessionId}`, { method: 'DELETE', query: { path: filePath || null } }).catch(
8098
+ () => {},
8099
+ );
8077
8100
  }
8078
8101
 
8079
8102
  function afterLinkedDocsChanged(sessionId) {
@@ -8154,13 +8177,24 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8154
8177
  // backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
8155
8178
  const HLJS_MAX_CHARS = 256 * 1024;
8156
8179
 
8180
+ function langOfPath(path) {
8181
+ return typeof path === 'string' ? (path.split('.').pop() || '').toLowerCase() : '';
8182
+ }
8183
+
8184
+ function highlightCode(text, lang) {
8185
+ if (typeof hljs === 'undefined' || text.length > HLJS_MAX_CHARS || !hljs.getLanguage(lang)) return null;
8186
+ try {
8187
+ return hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
8188
+ } catch (_) {
8189
+ return null;
8190
+ }
8191
+ }
8192
+
8157
8193
  function renderSourcePreview(filePath, content) {
8158
- const ext = (filePath.split('.').pop() || '').toLowerCase();
8159
- if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
8160
- try {
8161
- const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
8162
- return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
8163
- } catch (_) {}
8194
+ const ext = langOfPath(filePath);
8195
+ const value = highlightCode(content, ext);
8196
+ if (value != null) {
8197
+ return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
8164
8198
  }
8165
8199
  return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
8166
8200
  }
@@ -8235,7 +8269,7 @@ function updatePreviewLinkBtn() {
8235
8269
  btn.style.display = '';
8236
8270
  const linked = isPreviewLinkedToCurrentSession();
8237
8271
  btn.title = linked ? 'Unlink from current session' : 'Link to current session';
8238
- btn.style.color = linked ? 'var(--accent, #5b9a6b)' : '';
8272
+ btn.style.color = linked ? 'var(--accent)' : '';
8239
8273
  }
8240
8274
 
8241
8275
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -8290,16 +8324,14 @@ function openPreviewInEditor() {
8290
8324
  async function openPreviewByPath(filePath, base, onUnsupported) {
8291
8325
  if (!filePath) return;
8292
8326
  try {
8293
- const qs = new URLSearchParams({ path: filePath });
8294
- if (base) qs.set('base', base);
8295
- const r = await fetch(`/api/preview?${qs}`);
8327
+ const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
8296
8328
  if (!r.ok) {
8297
- showToast('Preview file unavailable');
8329
+ showToast('Preview file unavailable', 'error');
8298
8330
  return;
8299
8331
  }
8300
8332
  const data = await r.json();
8301
8333
  if (data.exists === false) {
8302
- showToast('File not found');
8334
+ showToast('File not found', 'error');
8303
8335
  return;
8304
8336
  }
8305
8337
  if (data.kind === null) {
@@ -8309,7 +8341,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
8309
8341
  }
8310
8342
  openPreviewModal(data.path, data.content, data.kind);
8311
8343
  } catch {
8312
- showToast('Failed to load preview');
8344
+ showToast('Failed to load preview', 'error');
8313
8345
  }
8314
8346
  }
8315
8347
 
@@ -8381,12 +8413,8 @@ async function loadPreviewKinds(sessionId, paths) {
8381
8413
  try {
8382
8414
  await Promise.all(
8383
8415
  pending.map(async (p) => {
8384
- let kind = null;
8385
- try {
8386
- const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
8387
- if (r.ok) kind = (await r.json()).kind;
8388
- } catch (_) {}
8389
- previewKindByPath.set(p, kind);
8416
+ const data = await getJson('/api/file/resolve', {}, { query: { path: p } });
8417
+ previewKindByPath.set(p, data.kind ?? null);
8390
8418
  }),
8391
8419
  );
8392
8420
  } finally {
@@ -8406,21 +8434,18 @@ function getOfferedPadPaths(sessionId) {
8406
8434
  }
8407
8435
 
8408
8436
  function setOfferedPadPaths(sessionId, paths) {
8409
- store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
8437
+ store.writeJson(PAD_LINKED_PREFIX + sessionId, paths);
8410
8438
  }
8411
8439
 
8412
8440
  async function loadSessionPads(sessionId) {
8413
8441
  if (!sessionId || padsFetched.has(sessionId)) return;
8414
8442
  padsFetched.add(sessionId);
8415
- let pads;
8416
- try {
8417
- const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
8418
- if (!r.ok) throw new Error(r.status);
8419
- pads = (await r.json()).pads || [];
8420
- } catch (_) {
8443
+ const data = await getJson(apiPath`/api/sessions/${sessionId}/pads`);
8444
+ if (!data) {
8421
8445
  padsFetched.delete(sessionId);
8422
8446
  return;
8423
8447
  }
8448
+ const pads = data.pads || [];
8424
8449
  const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
8425
8450
  const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
8426
8451
  if (!fresh.length) return;
@@ -8583,15 +8608,10 @@ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
8583
8608
  const cached = store.get(sessionId);
8584
8609
  if (cached && Date.now() - cached.at < ttlMs) return;
8585
8610
  inFlight.add(sessionId);
8586
- try {
8587
- const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
8588
- if (!res.ok) return;
8589
- store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
8590
- } catch (_) {
8591
- return;
8592
- } finally {
8593
- inFlight.delete(sessionId);
8594
- }
8611
+ const data = await getJson(apiPath`/api/sessions/${sessionId}/${endpoint}`);
8612
+ inFlight.delete(sessionId);
8613
+ if (!data) return;
8614
+ store.set(sessionId, { at: Date.now(), data: pick(data) });
8595
8615
  paintSectionSlots(attr, sessionId, innerHtml(sessionId));
8596
8616
  },
8597
8617
  };
@@ -8841,13 +8861,9 @@ async function toggleScratchFolder(sessionId, dirPath) {
8841
8861
  }
8842
8862
  scratchFolders.set(dirPath, { status: 'loading', children: [] });
8843
8863
  scratchFilesSection.repaint(sessionId);
8844
- let entry = { status: 'error', children: [] };
8845
- try {
8846
- const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
8847
- if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
8848
- } catch (_) {
8849
- // Reads "Failed to load"; closing and reopening the folder retries.
8850
- }
8864
+ const data = await getJson(apiPath`/api/sessions/${sessionId}/scratchpad-files`, null, { query: { path: dirPath } });
8865
+ // An error entry reads "Failed to load"; closing and reopening the folder retries.
8866
+ const entry = data ? { status: 'ready', children: data.files || [] } : { status: 'error', children: [] };
8851
8867
  // The user may have closed the folder while the request was in flight.
8852
8868
  if (scratchFolders.has(dirPath)) {
8853
8869
  scratchFolders.set(dirPath, entry);
@@ -8948,10 +8964,7 @@ async function linkFileByPath(sessionId, raw, slot) {
8948
8964
  }
8949
8965
  } else {
8950
8966
  try {
8951
- const qs = new URLSearchParams({ path: value });
8952
- const base = getSessionBaseDir(sessionId);
8953
- if (base) qs.set('base', base);
8954
- const r = await fetch(`/api/file/resolve?${qs}`);
8967
+ const r = await api('/api/file/resolve', { query: { path: value, base: getSessionBaseDir(sessionId) || null } });
8955
8968
  const data = await r.json().catch(() => ({}));
8956
8969
  if (!r.ok || !data.exists) {
8957
8970
  fail(data.error || 'File not found');
@@ -9178,7 +9191,12 @@ function setupEventSource() {
9178
9191
  connect();
9179
9192
  }
9180
9193
 
9181
- const CONTEXT_COLORS = { green: '#5b9a6b', yellow: '#b8a63e', orange: '#c07840', red: '#b85555' };
9194
+ const CONTEXT_COLORS = {
9195
+ green: 'var(--success)',
9196
+ yellow: 'var(--warning)',
9197
+ orange: 'color-mix(in srgb, var(--warning), var(--danger))',
9198
+ red: 'var(--danger)',
9199
+ };
9182
9200
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
9183
9201
  const MODEL_THRESHOLDS = [
9184
9202
  { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
@@ -9638,6 +9656,7 @@ function showWaitingDetail() {
9638
9656
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
9639
9657
  currentPinDetailId = null;
9640
9658
  updateMsgDetailPinState();
9659
+ syncMsgDetailPaneBtn();
9641
9660
  overlay.classList.add('visible');
9642
9661
  }
9643
9662
 
@@ -9895,10 +9914,7 @@ function renderMarkdown(text) {
9895
9914
  }
9896
9915
 
9897
9916
  function isLightTheme() {
9898
- const saved = store.getItem('theme');
9899
- return (
9900
- document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
9901
- );
9917
+ return document.body.classList.contains('light');
9902
9918
  }
9903
9919
 
9904
9920
  function getMermaidTheme() {
@@ -10004,28 +10020,13 @@ async function copyAgentTabActive(groupId, btn) {
10004
10020
  copyAgentTab(key, btn);
10005
10021
  }
10006
10022
 
10007
- const ownerColors = [
10008
- { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' }, // blue
10009
- { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' }, // purple
10010
- { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' }, // teal
10011
- { bg: 'rgba(220, 80, 30, 0.14)', color: '#c04a1a' }, // red-orange
10012
- { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' }, // amber
10013
- { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' }, // pink
10014
- { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
10015
- { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
10016
- ];
10017
- const namedColorMap = {
10018
- red: { bg: 'rgba(239, 68, 68, 0.14)', color: '#dc2626' },
10019
- blue: { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' },
10020
- green: { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' },
10021
- purple: { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' },
10022
- orange: { bg: 'rgba(234, 88, 12, 0.14)', color: '#c2410c' },
10023
- pink: { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' },
10024
- yellow: { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' },
10025
- teal: { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' },
10026
- indigo: { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' },
10027
- cyan: { bg: 'rgba(6, 182, 212, 0.14)', color: '#0891b2' },
10028
- };
10023
+ const ownerColor = (hue) => ({
10024
+ bg: `color-mix(in srgb, var(--owner-${hue}) 13%, transparent)`,
10025
+ color: `var(--owner-${hue})`,
10026
+ });
10027
+ const OWNER_HUES = ['blue', 'purple', 'teal', 'orange', 'yellow', 'pink', 'green', 'indigo'];
10028
+ const ownerColors = OWNER_HUES.map(ownerColor);
10029
+ const namedColorMap = Object.fromEntries([...OWNER_HUES, 'red', 'cyan'].map((h) => [h, ownerColor(h)]));
10029
10030
  const ownerColorCache = {};
10030
10031
  const teamColorMap = {};
10031
10032
  function isInternalTask(task) {
@@ -10337,7 +10338,7 @@ document.addEventListener('click', (e) => {
10337
10338
  if (breadcrumb) {
10338
10339
  e.stopPropagation();
10339
10340
  const path = breadcrumb.dataset.fullPath;
10340
- if (path) navigator.clipboard.writeText(path).catch(() => {});
10341
+ if (path) copyText(path, 'project path');
10341
10342
  return;
10342
10343
  }
10343
10344
 
@@ -10358,13 +10359,13 @@ document.addEventListener('click', (e) => {
10358
10359
 
10359
10360
  if (e.target.closest('.pinned-ungroup-btn')) {
10360
10361
  e.stopPropagation();
10361
- store.setItem('groupPinnedSessions', 'false');
10362
+ store.setItem(GROUP_PINNED_KEY, 'false');
10362
10363
  renderSessions();
10363
10364
  return;
10364
10365
  }
10365
10366
 
10366
10367
  if (e.target.closest('.pinned-regroup-banner')) {
10367
- store.setItem('groupPinnedSessions', 'true');
10368
+ store.setItem(GROUP_PINNED_KEY, 'true');
10368
10369
  renderSessions();
10369
10370
  return;
10370
10371
  }
@@ -10380,10 +10381,15 @@ document.addEventListener('click', (e) => {
10380
10381
  }
10381
10382
  if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
10382
10383
  const name = sgHeader.dataset.transientGroup;
10383
- if (confirm(`Delete group “${name}”? Its sessions go back to Projects.`)) {
10384
+ confirmModal({
10385
+ title: 'Delete Group',
10386
+ message: `Delete group “${name}”? Its sessions go back to Projects.`,
10387
+ okLabel: 'Delete',
10388
+ }).then((ok) => {
10389
+ if (!ok) return;
10384
10390
  sgDeleteTransient(name);
10385
10391
  renderSessions();
10386
- }
10392
+ });
10387
10393
  return;
10388
10394
  }
10389
10395
  const groupId = sgHeader.dataset.groupId;
@@ -10394,10 +10400,15 @@ document.addEventListener('click', (e) => {
10394
10400
  return;
10395
10401
  }
10396
10402
  if (e.target.closest('.sg-delete')) {
10397
- if (confirm(`Delete group “${group.name}”? Its sessions and projects go back to Projects.`)) {
10403
+ confirmModal({
10404
+ title: 'Delete Group',
10405
+ message: `Delete group “${group.name}”? Its sessions and projects go back to Projects.`,
10406
+ okLabel: 'Delete',
10407
+ }).then((ok) => {
10408
+ if (!ok) return;
10398
10409
  sgDeleteGroup(groupId);
10399
10410
  renderSessions();
10400
- }
10411
+ });
10401
10412
  return;
10402
10413
  }
10403
10414
  } else if (!sgHeader.dataset.transientGroup) return;
@@ -10442,15 +10453,9 @@ let projectsCache = null;
10442
10453
 
10443
10454
  async function loadProjects() {
10444
10455
  if (!projectsCacheDirty && projectsCache) return projectsCache;
10445
- let projects;
10446
- try {
10447
- const res = await fetch('/api/projects');
10448
- projects = await res.json();
10449
- } catch (_e) {
10450
- projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))]
10451
- .sort()
10452
- .map((p) => ({ path: p, modifiedAt: null }));
10453
- }
10456
+ const projects =
10457
+ (await getJson('/api/projects')) ??
10458
+ [...new Set(sessions.map((s) => s.project).filter(Boolean))].sort().map((p) => ({ path: p, modifiedAt: null }));
10454
10459
  projectsCache = projects;
10455
10460
  projectsCacheDirty = false;
10456
10461
  return projects;
@@ -10489,52 +10494,36 @@ function updateThemeColor(isLight) {
10489
10494
  //#endregion
10490
10495
 
10491
10496
  //#region THEME
10492
- function toggleTheme() {
10493
- const isCurrentlyLight = document.body.classList.contains('light');
10494
- if (isCurrentlyLight) {
10495
- document.body.classList.remove('light');
10496
- document.body.classList.add('dark-forced');
10497
- store.setItem('theme', 'dark');
10498
- } else {
10499
- document.body.classList.add('light');
10500
- document.body.classList.remove('dark-forced');
10501
- store.setItem('theme', 'light');
10502
- }
10503
- updateThemeIcon();
10504
- updateThemeColor(!isCurrentlyLight);
10505
- syncHljsTheme();
10506
- reinitMermaidTheme();
10507
- }
10508
-
10509
- function syncHljsTheme() {
10510
- const light = isLightTheme();
10497
+ function applyTheme(light) {
10498
+ document.body.classList.toggle('light', light);
10499
+ document.body.classList.toggle('dark-forced', !light);
10500
+ document.getElementById('theme-icon-dark').style.display = light ? 'none' : 'block';
10501
+ document.getElementById('theme-icon-light').style.display = light ? 'block' : 'none';
10502
+ updateThemeColor(light);
10511
10503
  const dark$ = document.getElementById('hljs-theme-dark');
10512
10504
  const light$ = document.getElementById('hljs-theme-light');
10513
10505
  if (dark$) dark$.disabled = light;
10514
10506
  if (light$) light$.disabled = !light;
10507
+ reinitMermaidTheme();
10515
10508
  }
10516
10509
 
10517
- function updateThemeIcon() {
10518
- const light = isLightTheme();
10519
- document.getElementById('theme-icon-dark').style.display = light ? 'none' : 'block';
10520
- document.getElementById('theme-icon-light').style.display = light ? 'block' : 'none';
10510
+ function toggleTheme() {
10511
+ const light = !isLightTheme();
10512
+ store.setItem(THEME_KEY, light ? 'light' : 'dark');
10513
+ applyTheme(light);
10521
10514
  }
10522
10515
 
10523
10516
  function loadTheme() {
10524
- const saved = store.getItem('theme');
10525
- if (saved === 'light') {
10526
- document.body.classList.add('light');
10527
- document.body.classList.remove('dark-forced');
10528
- } else if (saved === 'dark') {
10529
- document.body.classList.remove('light');
10530
- document.body.classList.add('dark-forced');
10531
- }
10532
- // If no saved preference, system prefers-color-scheme CSS handles it
10533
- updateThemeIcon();
10534
- updateThemeColor(document.body.classList.contains('light'));
10535
- syncHljsTheme();
10517
+ const saved = store.getItem(THEME_KEY);
10518
+ const system = window.matchMedia('(prefers-color-scheme: light)');
10519
+ applyTheme(saved ? saved === 'light' : system.matches);
10520
+ // The prefers-color-scheme CSS only covers the first paint; the class decides after that, so it
10521
+ // follows the system until the user picks a theme.
10522
+ system.addEventListener('change', (e) => {
10523
+ if (!store.getItem(THEME_KEY)) applyTheme(e.matches);
10524
+ });
10536
10525
  buildThemeMenu();
10537
- const colorTheme = store.getItem('color-theme');
10526
+ const colorTheme = store.getItem(COLOR_THEME_KEY);
10538
10527
  if (colorTheme) document.body.dataset.colorTheme = colorTheme;
10539
10528
  syncColorThemeSelect(colorTheme || 'ember');
10540
10529
  }
@@ -10592,10 +10581,10 @@ function syncColorThemeSelect(id) {
10592
10581
  function setColorTheme(id) {
10593
10582
  if (!id || id === 'ember') {
10594
10583
  delete document.body.dataset.colorTheme;
10595
- store.removeItem('color-theme');
10584
+ store.removeItem(COLOR_THEME_KEY);
10596
10585
  } else {
10597
10586
  document.body.dataset.colorTheme = id;
10598
- store.setItem('color-theme', id);
10587
+ store.setItem(COLOR_THEME_KEY, id);
10599
10588
  }
10600
10589
  syncColorThemeSelect(id);
10601
10590
  }
@@ -10607,13 +10596,13 @@ function expandSidebar() {
10607
10596
  const sidebar = document.querySelector('.sidebar');
10608
10597
  if (!sidebar.classList.contains('collapsed')) return;
10609
10598
  sidebar.classList.remove('collapsed');
10610
- store.setItem('sidebar-collapsed', false);
10599
+ store.setItem(SIDEBAR_COLLAPSED_KEY, false);
10611
10600
  }
10612
10601
 
10613
10602
  function toggleSidebar() {
10614
10603
  const sidebar = document.querySelector('.sidebar');
10615
10604
  const collapsed = sidebar.classList.toggle('collapsed');
10616
- store.setItem('sidebar-collapsed', collapsed);
10605
+ store.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
10617
10606
  if (collapsed) {
10618
10607
  sidebar.style.width = '';
10619
10608
  if (focusZone === 'sidebar') setFocusZone('board');
@@ -10625,10 +10614,10 @@ function toggleSidebar() {
10625
10614
 
10626
10615
  function loadSidebarState() {
10627
10616
  const sidebar = document.querySelector('.sidebar');
10628
- if (store.getItem('sidebar-collapsed') === 'true') {
10617
+ if (store.getItem(SIDEBAR_COLLAPSED_KEY) === 'true') {
10629
10618
  sidebar.classList.add('collapsed');
10630
10619
  }
10631
- const w = store.getItem('sidebar-width');
10620
+ const w = store.getItem(SIDEBAR_WIDTH_KEY);
10632
10621
  if (w) {
10633
10622
  sidebar.style.setProperty('--sidebar-width', w);
10634
10623
  }
@@ -10683,12 +10672,12 @@ function initSidebarResize() {
10683
10672
  },
10684
10673
  onEnd() {
10685
10674
  sidebar.classList.remove('resizing');
10686
- store.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
10675
+ store.setItem(SIDEBAR_WIDTH_KEY, sidebar.style.getPropertyValue('--sidebar-width'));
10687
10676
  },
10688
10677
  });
10689
10678
  }
10690
10679
 
10691
- function initPanelResize(panelId, handleId, cssVar, storageKey) {
10680
+ function initPanelResize(panelId, handleId, cssVar) {
10692
10681
  const panel = document.getElementById(panelId);
10693
10682
  const handle = document.getElementById(handleId);
10694
10683
  let startWidth;
@@ -10704,7 +10693,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
10704
10693
  },
10705
10694
  onEnd() {
10706
10695
  panel.classList.remove('resizing');
10707
- store.setItem(storageKey, panel.style.getPropertyValue(cssVar));
10696
+ store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
10708
10697
  },
10709
10698
  });
10710
10699
  }
@@ -10714,7 +10703,7 @@ function loadPanelWidths() {
10714
10703
  ['detail-panel', '--detail-panel-width'],
10715
10704
  ['message-panel', '--message-panel-width'],
10716
10705
  ].forEach(([id, cssVar]) => {
10717
- const w = store.getItem(`${id}-width`);
10706
+ const w = store.getItem(panelWidthKey(id));
10718
10707
  if (w) document.getElementById(id).style.setProperty(cssVar, w);
10719
10708
  });
10720
10709
  }
@@ -10746,29 +10735,18 @@ async function showSessionInfoModal(sessionId) {
10746
10735
  };
10747
10736
 
10748
10737
  const teamPromise = session.isTeam
10749
- ? fetch(`/api/teams/${session.teamName || sessionId}`)
10750
- .then((r) => (r.ok ? r.json() : null))
10751
- .catch(() => null)
10738
+ ? getJson(apiPath`/api/teams/${session.teamName || sessionId}`)
10752
10739
  : Promise.resolve(null);
10753
10740
 
10754
- const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
10755
- .then((r) => (r.ok ? r.json() : null))
10756
- .catch(() => null)
10757
- .then((data) => {
10758
- if (data?.content) _pendingPlanPath = data.path || null;
10759
- return data?.content || null;
10760
- });
10741
+ const planPromise = getJson(apiPath`/api/sessions/${sessionId}/plan`).then((data) => {
10742
+ if (data?.content) _pendingPlanPath = data.path || null;
10743
+ return data?.content || null;
10744
+ });
10761
10745
 
10762
10746
  const tasksPromise =
10763
- cachedTasks.length > 0
10764
- ? Promise.resolve(cachedTasks)
10765
- : fetch(`/api/sessions/${sessionId}`)
10766
- .then((r) => (r.ok ? r.json() : []))
10767
- .catch(() => []);
10747
+ cachedTasks.length > 0 ? Promise.resolve(cachedTasks) : getJson(apiPath`/api/sessions/${sessionId}`, []);
10768
10748
 
10769
- const parentPromise = fetch(`/api/sessions/${sessionId}/parent`)
10770
- .then((r) => (r.ok ? r.json() : null))
10771
- .catch(() => null);
10749
+ const parentPromise = getJson(apiPath`/api/sessions/${sessionId}/parent`);
10772
10750
 
10773
10751
  const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
10774
10752
  teamPromise,
@@ -10914,13 +10892,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
10914
10892
  _pendingPlanContent = planContent;
10915
10893
  const titleMatch = planContent.match(/^#\s+(.+)$/m);
10916
10894
  const planTitle = titleMatch ? titleMatch[1].trim() : null;
10917
- html += `<div data-plan-card="1" onclick="openPlanModal()" style="margin-bottom: 16px; padding: 10px 14px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; display: flex; align-items: center; gap: 10px; transition: all 0.15s ease;" onmouseover="this.style.borderColor='var(--accent)';this.style.background='var(--bg-hover)'" onmouseout="this.style.borderColor='var(--border)';this.style.background='var(--bg-elevated)'">
10918
- <span style="font-size: 14px;">📋</span>
10919
- <div style="flex: 1; min-width: 0;">
10920
- <div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
10921
- ${planTitle ? `<div style="font-size: 13px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(planTitle)}</div>` : ''}
10895
+ html += `<div class="plan-card" data-plan-card="1" onclick="openPlanModal()">
10896
+ <span class="plan-card-icon">📋</span>
10897
+ <div class="plan-card-text">
10898
+ <div class="plan-card-label">Plan</div>
10899
+ ${planTitle ? `<div class="plan-card-title">${escapeHtml(planTitle)}</div>` : ''}
10922
10900
  </div>
10923
- <svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" style="width: 16px; height: 16px; flex-shrink: 0;"><path d="M9 18l6-6-6-6"/></svg>
10901
+ <svg viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="2" class="plan-card-chevron"><path d="M9 18l6-6-6-6"/></svg>
10924
10902
  </div>`;
10925
10903
  }
10926
10904
 
@@ -10984,7 +10962,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
10984
10962
  // Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
10985
10963
  // would stack up once per open and fire the click handler N times.
10986
10964
  bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
10987
- const alreadyVisible = modal.classList.contains('visible');
10988
10965
  _infoModalSessionId = session.id;
10989
10966
  updateStickyBtnState();
10990
10967
  updateDismissBtnState();
@@ -10996,21 +10973,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
10996
10973
  if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
10997
10974
  if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
10998
10975
  modal.classList.add('visible');
10999
-
11000
- if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
11001
-
11002
- const keyHandler = (e) => {
11003
- if (e.key === 'Escape') {
11004
- // An inline editor inside the modal owns Escape first — it reverts itself.
11005
- if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
11006
- if (document.getElementById('plan-modal').classList.contains('visible')) return;
11007
- if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
11008
- e.preventDefault();
11009
- closeTeamModal();
11010
- document.removeEventListener('keydown', keyHandler);
11011
- }
11012
- };
11013
- document.addEventListener('keydown', keyHandler);
11014
10976
  }
11015
10977
 
11016
10978
  function closeTeamModal() {
@@ -11058,38 +11020,9 @@ let spRenderedQuery = null;
11058
11020
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
11059
11021
  const spPeekCache = new Map();
11060
11022
 
11061
- // A query also searches session ids and names in every transcript on the server, so a
11062
- // session outside the sidebar filter is found too. Values: 'pending', then the rows.
11063
- const spSearch = new Map();
11064
- let spSearchTimer = null;
11065
-
11066
- function spSearchKey(query) {
11067
- return query.length >= 3 ? query.toLowerCase() : null;
11068
- }
11069
-
11070
- function spScheduleSearch(key) {
11071
- clearTimeout(spSearchTimer);
11072
- spSearchTimer = setTimeout(async () => {
11073
- spSearch.set(key, 'pending');
11074
- let ids = [];
11075
- try {
11076
- const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
11077
- if (r.ok) ids = await r.json();
11078
- } catch (_) {}
11079
- spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
11080
- renderSessionPicker();
11081
- }, 100);
11082
- }
11083
-
11084
- // The server rows not already matched in the sidebar list, and whether a search is still out.
11085
- function spSearchRows(key, localRows) {
11086
- if (!key) return { rows: [], pending: false };
11087
- const found = spSearch.get(key);
11088
- if (!found) spScheduleSearch(key);
11089
- if (!Array.isArray(found)) return { rows: [], pending: true };
11090
- const seen = new Set(localRows.map((s) => s.id));
11091
- return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
11092
- }
11023
+ const spSearch = sessionSearch(() => {
11024
+ if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
11025
+ });
11093
11026
 
11094
11027
  function openSessionPicker() {
11095
11028
  spFromTerminal = terminalPaneFocused();
@@ -11097,7 +11030,6 @@ function openSessionPicker() {
11097
11030
  input.value = '';
11098
11031
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
11099
11032
  spPeekCache.clear();
11100
- spSearch.clear();
11101
11033
  spRenderedQuery = null;
11102
11034
  document.getElementById('session-picker-modal').classList.add('visible');
11103
11035
  renderSessionPicker();
@@ -11220,15 +11152,14 @@ function renderSessionPicker() {
11220
11152
  // Search rows that arrive for the same query append below, so the cursor stays on its row.
11221
11153
  const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
11222
11154
  spRenderedQuery = query;
11223
- const key = spSearchKey(query);
11224
11155
  const local = spSource.filter((s) => spMatches(s, query));
11225
- const found = spSearchRows(key, local);
11156
+ const found = spSearch.rows(query, local);
11226
11157
  spRows = [...local, ...found.rows];
11227
11158
  if (!spRows.length) {
11228
11159
  spIdx = -1;
11229
11160
  const empty = found.pending
11230
11161
  ? 'Searching all sessions…'
11231
- : spSource.length || key
11162
+ : spSource.length || query.length >= SESSION_SEARCH_MIN
11232
11163
  ? 'No session matches'
11233
11164
  : 'No sessions in the current sidebar filter';
11234
11165
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
@@ -11426,30 +11357,23 @@ function initProjectPicker() {
11426
11357
  //#region PLAN
11427
11358
  function refreshOpenPlan() {
11428
11359
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
11429
- fetch(`/api/sessions/${_planSessionId}/plan`)
11430
- .then((r) => (r.ok ? r.json() : null))
11431
- .then((data) => {
11432
- if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
11433
- _pendingPlanContent = data.content;
11434
- _pendingPlanPath = data.path || null;
11435
- document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
11436
- mountPlanReview();
11437
- }
11438
- })
11439
- .catch(() => {});
11360
+ getJson(apiPath`/api/sessions/${_planSessionId}/plan`).then((data) => {
11361
+ if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
11362
+ _pendingPlanContent = data.content;
11363
+ _pendingPlanPath = data.path || null;
11364
+ document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
11365
+ mountPlanReview();
11366
+ syncPlanPaneBtn();
11367
+ }
11368
+ });
11440
11369
  }
11441
11370
 
11442
11371
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
11443
11372
  function showLoopModal(sessionId) {
11444
11373
  const body = document.getElementById('loop-modal-body');
11445
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11374
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11446
11375
  document.getElementById('loop-modal').classList.add('visible');
11447
- fetch(`/api/sessions/${sessionId}/loop`)
11448
- .then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
11449
- .catch(() => ({ wakeups: [], crons: [] }))
11450
- .then((data) => {
11451
- renderLoopModalBody(data);
11452
- });
11376
+ getJson(apiPath`/api/sessions/${sessionId}/loop`, { wakeups: [], crons: [] }).then(renderLoopModalBody);
11453
11377
  }
11454
11378
 
11455
11379
  function fmtLoopDelay(s) {
@@ -11516,8 +11440,7 @@ function renderLoopModalBody(data) {
11516
11440
  const wakeups = data.wakeups || [];
11517
11441
  const crons = data.crons || [];
11518
11442
  if (!wakeups.length && !crons.length) {
11519
- body.innerHTML =
11520
- '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
11443
+ body.innerHTML = '<div class="modal-empty">No scheduled wakeups or cron jobs.</div>';
11521
11444
  return;
11522
11445
  }
11523
11446
  const section = (title, items, kind) =>
@@ -11566,23 +11489,19 @@ function showWorkflowModal(sessionId) {
11566
11489
  _workflowSessionId = sessionId;
11567
11490
  setWorkflowHeader('Workflows', null);
11568
11491
  const body = document.getElementById('workflow-modal-body');
11569
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11492
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11570
11493
  document.getElementById('workflow-modal').classList.add('visible');
11571
- fetch(`/api/sessions/${sessionId}/workflows`)
11572
- .then((r) => (r.ok ? r.json() : { workflows: [] }))
11573
- .catch(() => ({ workflows: [] }))
11574
- .then((data) => {
11575
- _workflowList = data.workflows || [];
11576
- if (!_workflowList.length) {
11577
- setWorkflowHeader('Workflows', null);
11578
- body.innerHTML =
11579
- '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
11580
- } else if (_workflowList.length === 1) {
11581
- showWorkflowRun(_workflowList[0].id);
11582
- } else {
11583
- renderWorkflowPicker(_workflowList);
11584
- }
11585
- });
11494
+ getJson(apiPath`/api/sessions/${sessionId}/workflows`, { workflows: [] }).then((data) => {
11495
+ _workflowList = data.workflows || [];
11496
+ if (!_workflowList.length) {
11497
+ setWorkflowHeader('Workflows', null);
11498
+ body.innerHTML = '<div class="modal-empty">No workflow scripts for this session.</div>';
11499
+ } else if (_workflowList.length === 1) {
11500
+ showWorkflowRun(_workflowList[0].id);
11501
+ } else {
11502
+ renderWorkflowPicker(_workflowList);
11503
+ }
11504
+ });
11586
11505
  }
11587
11506
 
11588
11507
  function setWorkflowHeader(name, wfId) {
@@ -11620,17 +11539,14 @@ function showWorkflowRun(wfId) {
11620
11539
  const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
11621
11540
  setWorkflowHeader(name, wfId);
11622
11541
  const body = document.getElementById('workflow-modal-body');
11623
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11624
- fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/run`)
11625
- .then((r) => (r.ok ? r.json() : null))
11626
- .catch(() => null)
11627
- .then((run) => {
11628
- if (!run) {
11629
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load run state.</div>';
11630
- return;
11631
- }
11632
- renderWorkflowRun(run);
11633
- });
11542
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11543
+ getJson(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/run`).then((run) => {
11544
+ if (!run) {
11545
+ body.innerHTML = '<div class="modal-status">Failed to load run state.</div>';
11546
+ return;
11547
+ }
11548
+ renderWorkflowRun(run);
11549
+ });
11634
11550
  }
11635
11551
 
11636
11552
  function fmtTokens(t) {
@@ -11714,27 +11630,24 @@ function renderWorkflowRun(run) {
11714
11630
 
11715
11631
  function loadWorkflowCode(wfId, codeEl) {
11716
11632
  if (!codeEl) return;
11717
- codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
11718
- fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}`)
11719
- .then((r) => (r.ok ? r.json() : null))
11720
- .catch(() => null)
11721
- .then((data) => {
11722
- if (!data?.content) {
11723
- codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Failed to load script.</div>';
11724
- return;
11725
- }
11726
- let highlighted;
11727
- if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
11728
- highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
11729
- } else {
11730
- highlighted = escapeHtml(data.content);
11731
- }
11732
- codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
11733
- });
11633
+ codeEl.innerHTML = '<div class="modal-status-tight">Loading…</div>';
11634
+ getJson(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}`).then((data) => {
11635
+ if (!data?.content) {
11636
+ codeEl.innerHTML = '<div class="modal-status-tight">Failed to load script.</div>';
11637
+ return;
11638
+ }
11639
+ let highlighted;
11640
+ if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
11641
+ highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
11642
+ } else {
11643
+ highlighted = escapeHtml(data.content);
11644
+ }
11645
+ codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
11646
+ });
11734
11647
  }
11735
11648
 
11736
11649
  function openWorkflowInEditor(wfId) {
11737
- postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
11650
+ postAndToast(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/open`, {}, 'in editor');
11738
11651
  }
11739
11652
 
11740
11653
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -11750,17 +11663,14 @@ function closeWorkflowModal() {
11750
11663
  }
11751
11664
 
11752
11665
  function openPlanForSession(sid) {
11753
- fetch(`/api/sessions/${sid}/plan`)
11754
- .then((r) => (r.ok ? r.json() : null))
11755
- .catch(() => null)
11756
- .then((data) => {
11757
- if (data?.content) {
11758
- _pendingPlanContent = data.content;
11759
- _pendingPlanPath = data.path || null;
11760
- _planSessionId = sid;
11761
- openPlanModal();
11762
- }
11763
- });
11666
+ getJson(apiPath`/api/sessions/${sid}/plan`).then((data) => {
11667
+ if (data?.content) {
11668
+ _pendingPlanContent = data.content;
11669
+ _pendingPlanPath = data.path || null;
11670
+ _planSessionId = sid;
11671
+ openPlanModal();
11672
+ }
11673
+ });
11764
11674
  }
11765
11675
 
11766
11676
  function openPlanModal() {
@@ -11776,6 +11686,7 @@ function openPlanModal() {
11776
11686
  controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
11777
11687
  document.querySelector('#plan-modal .modal-footer').prepend(controls);
11778
11688
  }
11689
+ syncPlanPaneBtn();
11779
11690
  document.getElementById('plan-modal').classList.add('visible');
11780
11691
  mountPlanReview();
11781
11692
  const keyHandler = (e) => {
@@ -11810,7 +11721,7 @@ function closePlanModal() {
11810
11721
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
11811
11722
  function openPlanInEditor() {
11812
11723
  if (!_planSessionId) return;
11813
- postAndToast(`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
11724
+ postAndToast(apiPath`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
11814
11725
  }
11815
11726
 
11816
11727
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -11857,22 +11768,19 @@ let _toolStatsData = null;
11857
11768
  function showToolStatsModal(sessionId) {
11858
11769
  if (!sessionId) return;
11859
11770
  const body = document.getElementById('tool-stats-modal-body');
11860
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11771
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11861
11772
  document.getElementById('tool-stats-modal').classList.add('visible');
11862
11773
 
11863
- fetch(`/api/sessions/${sessionId}/tool-stats`)
11864
- .then((r) => (r.ok ? r.json() : null))
11865
- .catch(() => null)
11866
- .then((data) => {
11867
- if (!data) {
11868
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load tool statistics.</div>';
11869
- return;
11870
- }
11871
- _toolStatsSortCol = 'count';
11872
- _toolStatsSortDir = 'desc';
11873
- _toolStatsData = data;
11874
- body.innerHTML = renderToolStatsBody(data);
11875
- });
11774
+ getJson(apiPath`/api/sessions/${sessionId}/tool-stats`).then((data) => {
11775
+ if (!data) {
11776
+ body.innerHTML = '<div class="modal-status">Failed to load tool statistics.</div>';
11777
+ return;
11778
+ }
11779
+ _toolStatsSortCol = 'count';
11780
+ _toolStatsSortDir = 'desc';
11781
+ _toolStatsData = data;
11782
+ body.innerHTML = renderToolStatsBody(data);
11783
+ });
11876
11784
  }
11877
11785
 
11878
11786
  function renderToolStatsBody(data) {
@@ -11889,7 +11797,7 @@ function renderToolStatsBody(data) {
11889
11797
  if (!tools?.length) {
11890
11798
  return (
11891
11799
  summary +
11892
- '<div style="padding:24px;text-align:center;color:var(--text-tertiary);">No tool calls recorded in this session.</div>'
11800
+ '<div class="modal-empty" style="color:var(--text-tertiary)">No tool calls recorded in this session.</div>'
11893
11801
  );
11894
11802
  }
11895
11803
 
@@ -12036,10 +11944,7 @@ function paneOwnOrigin(pane) {
12036
11944
 
12037
11945
  async function probePaneFraming(sid, pane) {
12038
11946
  const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
12039
- const { frameable } = await paneRequest(
12040
- 'GET',
12041
- `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
12042
- );
11947
+ const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
12043
11948
  if (frameable === false) paneRefused.add(pane.target);
12044
11949
  return frameable;
12045
11950
  }
@@ -12063,10 +11968,8 @@ function applyPaneLayout(sid, layout) {
12063
11968
  }
12064
11969
 
12065
11970
  async function fetchPaneLayout(sid) {
12066
- try {
12067
- const r = await fetch(`/api/panes/${encodeURIComponent(sid)}`);
12068
- if (r.ok) applyPaneLayout(sid, await r.json());
12069
- } catch {}
11971
+ const layout = await getJson(apiPath`/api/panes/${sid}`);
11972
+ if (layout) applyPaneLayout(sid, layout);
12070
11973
  }
12071
11974
 
12072
11975
  function refetchPaneLayout() {
@@ -12076,11 +11979,7 @@ function refetchPaneLayout() {
12076
11979
 
12077
11980
  async function paneRequest(method, path, body) {
12078
11981
  try {
12079
- const r = await fetch(path, {
12080
- method,
12081
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
12082
- body: body ? JSON.stringify(body) : undefined,
12083
- });
11982
+ const r = await api(path, { method, body });
12084
11983
  const data = await r.json().catch(() => ({}));
12085
11984
  return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
12086
11985
  } catch {
@@ -12092,11 +11991,8 @@ async function paneRequest(method, path, body) {
12092
11991
  const PANE_STATE_CAP = 20;
12093
11992
 
12094
11993
  function loadPaneState(key) {
12095
- try {
12096
- const map = JSON.parse(sessionStorage.getItem(key));
12097
- if (map && typeof map === 'object') return map;
12098
- } catch {}
12099
- return {};
11994
+ const map = tabStore.readJson(key);
11995
+ return map && typeof map === 'object' ? map : {};
12100
11996
  }
12101
11997
 
12102
11998
  // Puts `sid` last, so the cap drops the least recent session. A null value removes it.
@@ -12104,14 +12000,12 @@ function savePaneState(key, map, sid, value) {
12104
12000
  delete map[sid];
12105
12001
  if (value != null) map[sid] = value;
12106
12002
  const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
12107
- try {
12108
- sessionStorage.setItem(key, JSON.stringify(capped));
12109
- } catch {}
12003
+ tabStore.writeJson(key, capped);
12110
12004
  return capped;
12111
12005
  }
12112
12006
 
12113
- // `cck.paneActive`: {[sessionId]: paneId}.
12114
- const PANE_ACTIVE_KEY = 'cck.paneActive';
12007
+ // {[sessionId]: paneId}.
12008
+ const PANE_ACTIVE_KEY = 'pane-active';
12115
12009
  let paneActive = loadPaneState(PANE_ACTIVE_KEY);
12116
12010
 
12117
12011
  function getActivePaneId(sid) {
@@ -12124,10 +12018,10 @@ function setActivePaneId(sid, id) {
12124
12018
  notePairFocus(sid, id);
12125
12019
  }
12126
12020
 
12127
- // `cck.panePairs`: {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
12021
+ // {[sessionId]: [{l, r, f}]}; sides are pane ids or 'board', `f` the side focused
12128
12022
  // last. A tab is in one pair at most, and the pair that holds the active tab is the split on
12129
12023
  // screen. Clicks never change a pair; only the split keys and drags do.
12130
- const PANE_PAIRS_KEY = 'cck.panePairs';
12024
+ const PANE_PAIRS_KEY = 'pane-pairs';
12131
12025
  const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
12132
12026
  let panePairs = loadPaneState(PANE_PAIRS_KEY);
12133
12027
  const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
@@ -12264,6 +12158,8 @@ const PANE_ICONS = {
12264
12158
  '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 5v14"/><path d="M12 5v8"/><path d="M18 5v11"/></svg>',
12265
12159
  url: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/></svg>',
12266
12160
  file: '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>',
12161
+ message:
12162
+ '<svg class="kind" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
12267
12163
  warning:
12268
12164
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12" y2="17"/></svg>',
12269
12165
  };
@@ -12369,9 +12265,11 @@ function markPaneSide(el, side, focused) {
12369
12265
  el.classList.toggle('pane-focus', focused);
12370
12266
  }
12371
12267
 
12268
+ const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
12269
+
12372
12270
  function renderPaneTabs(sid, layout, split) {
12373
- // A rebuild mid-drag removes the dragged tab, and then no dragend fires.
12374
- if (paneDragId) return;
12271
+ // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
12272
+ if (paneDragId || paneRenameId) return;
12375
12273
  const active = getActivePaneId(sid);
12376
12274
  const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
12377
12275
  const tab = (id, cls, inner, tip) =>
@@ -12392,8 +12290,8 @@ function renderPaneTabs(sid, layout, split) {
12392
12290
  return tab(
12393
12291
  p.id,
12394
12292
  '',
12395
- `${p.kind === 'url' ? PANE_ICONS.url : PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(frame)}" title="${escapeHtml(frameTip)}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane (Alt+W)" aria-label="Close pane">×</button>`,
12396
- p.target,
12293
+ `${PANE_ICONS[p.kind] || PANE_ICONS.file}<span class="pane-title">${escapeHtml(p.title)}</span><span class="state ${escapeHtml(frame)}" title="${escapeHtml(frameTip)}"></span><button class="pane-x" data-close="${escapeHtml(p.id)}" title="Close pane (Alt+W)" aria-label="Close pane">×</button>`,
12294
+ paneLabel(p),
12397
12295
  );
12398
12296
  };
12399
12297
  const itemHtml = ({ ids, pair }) =>
@@ -12433,7 +12331,8 @@ function markPaneFrameFocus() {
12433
12331
  }
12434
12332
 
12435
12333
  function paneCardHtml(title, text, button) {
12436
- return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
12334
+ const btn = button ? `<button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button>` : '';
12335
+ return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p>${btn}</div></div>`;
12437
12336
  }
12438
12337
 
12439
12338
  // Returns the key of the view to show.
@@ -12496,11 +12395,11 @@ async function loadPaneView(sid, pane, view, scroll) {
12496
12395
  }
12497
12396
  return;
12498
12397
  }
12499
- let data = null;
12500
- try {
12501
- const r = await fetch(`/api/preview?${new URLSearchParams({ path: pane.target })}`);
12502
- if (r.ok) data = await r.json();
12503
- } catch {}
12398
+ if (pane.kind === 'message') {
12399
+ await loadMessagePaneView(sid, pane, view);
12400
+ return;
12401
+ }
12402
+ const data = await getJson('/api/preview', null, { query: { path: pane.target } });
12504
12403
  if (data?.kind === 'html') {
12505
12404
  // The frame's origin is opaque, so the page restores its own scroll on load.
12506
12405
  const restore = scroll
@@ -12513,14 +12412,9 @@ async function loadPaneView(sid, pane, view, scroll) {
12513
12412
  return;
12514
12413
  }
12515
12414
  if (data?.kind) {
12516
- const doc = document.createElement('div');
12517
- doc.className = 'pane-doc';
12518
- doc.dataset.path = data.path;
12519
- const body = document.createElement('div');
12520
- body.className = 'pane-doc-body rendered-md modal-zoomable';
12415
+ const body = appendPaneDoc(view);
12416
+ body.parentElement.dataset.path = data.path;
12521
12417
  renderPreviewContent(body, data.path, data.content, data.kind);
12522
- doc.append(body, paneDocGrip(doc));
12523
- view.appendChild(doc);
12524
12418
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
12525
12419
  return;
12526
12420
  }
@@ -12533,6 +12427,49 @@ async function loadPaneView(sid, pane, view, scroll) {
12533
12427
  view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
12534
12428
  }
12535
12429
 
12430
+ // The target is the message's pin id, `type|timestamp|text`. The log holds only the latest pages,
12431
+ // so a message not loaded comes from the page that ends at its timestamp.
12432
+ async function findPaneMessage(sid, target) {
12433
+ const own = (list) => list.find((m) => getPinId(m) === target);
12434
+ if (sid === currentSessionId && !agentLogMode) {
12435
+ const loaded = own(currentMessages);
12436
+ if (loaded) return loaded;
12437
+ }
12438
+ const ts = Date.parse(target.split('|')[1]);
12439
+ if (Number.isFinite(ts)) {
12440
+ try {
12441
+ // Parallel tool calls share one timestamp, so the page is wider than one message.
12442
+ const page = await fetchSessionMessagesPage(sid, { before: new Date(ts + 1).toISOString(), limit: 50 });
12443
+ const found = page && own(page.messages);
12444
+ if (found) return found;
12445
+ } catch {}
12446
+ }
12447
+ return sid === currentSessionId ? own(currentPins) || null : null;
12448
+ }
12449
+
12450
+ function appendPaneDoc(view) {
12451
+ const doc = document.createElement('div');
12452
+ doc.className = 'pane-doc';
12453
+ const body = document.createElement('div');
12454
+ body.className = 'pane-doc-body rendered-md modal-zoomable';
12455
+ doc.append(body, paneDocGrip(doc));
12456
+ view.appendChild(doc);
12457
+ return body;
12458
+ }
12459
+
12460
+ async function loadMessagePaneView(sid, pane, view) {
12461
+ const m = await findPaneMessage(sid, pane.target);
12462
+ if (!m) {
12463
+ view.insertAdjacentHTML(
12464
+ 'beforeend',
12465
+ paneCardHtml('Message not found', 'The session log no longer has this message.'),
12466
+ );
12467
+ return;
12468
+ }
12469
+ const meta = [msgDetailTitle(m), ...msgDetailMeta(m)].join(' · ');
12470
+ appendPaneDoc(view).innerHTML = `<div class="pane-msg-meta">${escapeHtml(meta)}</div>${msgDetailContent(m)}`;
12471
+ }
12472
+
12536
12473
  function onPaneFileChanged(filePath) {
12537
12474
  for (const [key, view] of paneFrames) {
12538
12475
  const [sid, paneId] = key.split('/');
@@ -12689,9 +12626,25 @@ function cyclePaneStrip(step) {
12689
12626
  return true;
12690
12627
  }
12691
12628
 
12692
- function closeFocusedPane() {
12629
+ function isMenuKey(e) {
12630
+ return e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
12631
+ }
12632
+
12633
+ function focusedPaneId() {
12693
12634
  const sid = paneSessionId();
12694
- const id = sid && !wantsTerminal() && getActivePaneId(sid);
12635
+ return sid && !wantsTerminal() && getActivePaneId(sid);
12636
+ }
12637
+
12638
+ function openFocusedPaneMenu() {
12639
+ const id = focusedPaneId();
12640
+ const tab = id && paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
12641
+ if (!tab?.offsetParent) return false;
12642
+ const rect = tab.getBoundingClientRect();
12643
+ return openPaneMenu(rect.left + 16, rect.bottom, id);
12644
+ }
12645
+
12646
+ function closeFocusedPane() {
12647
+ const id = focusedPaneId();
12695
12648
  if (!id || id === 'board') return false;
12696
12649
  closePane(id);
12697
12650
  return true;
@@ -12713,7 +12666,7 @@ async function closePane(paneId) {
12713
12666
  }
12714
12667
  unmountPane(sid, paneId);
12715
12668
  syncPanes();
12716
- const res = await paneRequest('DELETE', `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(paneId)}`);
12669
+ const res = await paneRequest('DELETE', apiPath`/api/panes/${sid}/${paneId}`);
12717
12670
  if (res.layout) applyPaneLayout(sid, res.layout);
12718
12671
  else if (res.code === 'no_pane') fetchPaneLayout(sid);
12719
12672
  else showToast(res.error, 'error');
@@ -12743,21 +12696,39 @@ function reloadPane(id) {
12743
12696
  function openPaneExternally(id) {
12744
12697
  const pane = paneById(id);
12745
12698
  if (!pane) return;
12699
+ if (pane.kind === 'message') {
12700
+ openPaneMessageDetail(pane);
12701
+ return;
12702
+ }
12746
12703
  openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
12747
12704
  }
12748
12705
 
12706
+ async function openPaneMessageDetail(pane) {
12707
+ const sid = paneSessionId();
12708
+ const m = await findPaneMessage(sid, pane.target);
12709
+ if (sid !== paneSessionId()) return;
12710
+ if (!m) {
12711
+ showToast('Message not found');
12712
+ return;
12713
+ }
12714
+ const idx = currentMessages.indexOf(m);
12715
+ if (idx >= 0) showMsgDetail(idx);
12716
+ else showUnloadedMsgDetail(m);
12717
+ }
12718
+
12749
12719
  function copyPaneTarget(id) {
12750
12720
  const pane = paneById(id);
12751
12721
  if (!pane) return;
12752
- navigator.clipboard.writeText(pane.target).then(
12753
- () => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
12754
- () => showToast('Copy failed', 'error'),
12755
- );
12722
+ copyText(pane.target, pane.kind === 'url' ? 'URL' : 'path');
12756
12723
  }
12757
12724
 
12758
- function closePaneMenu() {
12725
+ function closePaneMenu(restore) {
12759
12726
  document.removeEventListener('keydown', paneMenuKeydown, true);
12760
- document.getElementById('pane-menu')?.remove();
12727
+ const menu = document.getElementById('pane-menu');
12728
+ if (menu) {
12729
+ menu.remove();
12730
+ takeMenuFocus(restore);
12731
+ }
12761
12732
  }
12762
12733
 
12763
12734
  function paneMenuKeydown(e) {
@@ -12782,7 +12753,7 @@ async function deleteBoardTasks(onlyCompleted) {
12782
12753
  });
12783
12754
  if (!ok) return;
12784
12755
  const q = onlyCompleted ? '?status=completed' : '';
12785
- const res = await paneRequest('DELETE', `/api/tasks/${encodeURIComponent(sid)}${q}`);
12756
+ const res = await paneRequest('DELETE', `${apiPath`/api/tasks/${sid}`}${q}`);
12786
12757
  if (res.error) {
12787
12758
  showToast(`Failed to delete tasks: ${res.error}`, 'error');
12788
12759
  return;
@@ -12792,6 +12763,42 @@ async function deleteBoardTasks(onlyCompleted) {
12792
12763
  await refreshCurrentView();
12793
12764
  }
12794
12765
 
12766
+ let paneRenameId = null;
12767
+
12768
+ function beginPaneRename(id) {
12769
+ const pane = paneById(id);
12770
+ const tab = paneStrip.querySelector(`.pane-tab[data-pane="${CSS.escape(id)}"]`);
12771
+ const title = tab?.querySelector('.pane-title');
12772
+ if (!pane || !title) return;
12773
+ paneRenameId = id;
12774
+ tab.draggable = false;
12775
+ const input = document.createElement('input');
12776
+ input.className = 'pane-title-input';
12777
+ input.value = pane.title;
12778
+ input.setAttribute('aria-label', 'Pane title');
12779
+ title.replaceWith(input);
12780
+ input.focus();
12781
+ input.select();
12782
+ }
12783
+
12784
+ async function endPaneRename(input, save) {
12785
+ const id = paneRenameId;
12786
+ if (!id) return;
12787
+ paneRenameId = null;
12788
+ paneTabsHtml = '';
12789
+ const sid = paneSessionId();
12790
+ const pane = paneById(id);
12791
+ const title = input.value.trim();
12792
+ if (save && sid && pane && title !== pane.title) {
12793
+ const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}/${id}`, {
12794
+ title,
12795
+ });
12796
+ if (res.error) showToast(res.error, 'error');
12797
+ else applyPaneLayout(sid, res.layout);
12798
+ }
12799
+ syncPanes();
12800
+ }
12801
+
12795
12802
  const paneMenuItem = (act, label) =>
12796
12803
  `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
12797
12804
 
@@ -12806,22 +12813,26 @@ function openPaneMenu(x, y, id) {
12806
12813
  paneMenuItem('deleteCompleted', 'Delete completed tasks') +
12807
12814
  paneMenuItem('deleteAll', 'Delete all tasks'),
12808
12815
  );
12809
- return;
12816
+ return true;
12810
12817
  }
12811
12818
  const pane = paneById(id);
12812
- if (!pane) return;
12819
+ if (!pane) return false;
12813
12820
  const url = pane.kind === 'url';
12821
+ const message = pane.kind === 'message';
12822
+ const label = paneLabel(pane);
12814
12823
  showPaneMenu(
12815
12824
  x,
12816
12825
  y,
12817
12826
  pane.id,
12818
12827
  'Pane actions',
12819
- `<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
12820
- paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
12821
- paneMenuItem('open', url ? 'Open in new tab' : 'Open in preview') +
12828
+ `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>` +
12829
+ (message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
12830
+ paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
12831
+ paneMenuItem('rename', 'Rename') +
12822
12832
  paneMenuItem('reload', 'Reload') +
12823
12833
  paneMenuItem('close', 'Close pane'),
12824
12834
  );
12835
+ return true;
12825
12836
  }
12826
12837
 
12827
12838
  function showPaneMenu(x, y, id, ariaLabel, html) {
@@ -12842,6 +12853,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
12842
12853
  const PANE_MENU_ACTIONS = {
12843
12854
  copy: copyPaneTarget,
12844
12855
  open: openPaneExternally,
12856
+ rename: beginPaneRename,
12845
12857
  reload: reloadPane,
12846
12858
  close: closePane,
12847
12859
  deleteCompleted: () => deleteBoardTasks(true),
@@ -12864,32 +12876,81 @@ async function addPane(raw) {
12864
12876
  const t = sid && paneTargetFrom(raw);
12865
12877
  if (!t) return;
12866
12878
  if (t.error) {
12867
- showToast(t.error);
12879
+ showToast(t.error, 'error');
12868
12880
  return;
12869
12881
  }
12870
- const res = await paneRequest('POST', `/api/panes/${encodeURIComponent(sid)}`, {
12871
- target: t.target,
12872
- base: getSessionBaseDir(sid) || undefined,
12873
- });
12882
+ if (!(await postPane(sid, { target: t.target, base: getSessionBaseDir(sid) || undefined }))) return;
12883
+ closePanePop();
12884
+ syncPanes();
12885
+ }
12886
+
12887
+ // Shows the added pane. True when its session is still on screen, null when the add failed.
12888
+ async function postPane(sid, body) {
12889
+ const res = await paneRequest('POST', apiPath`/api/panes/${sid}`, body);
12874
12890
  if (!res.pane) {
12875
12891
  showToast(res.error, 'error');
12876
- return;
12892
+ return null;
12877
12893
  }
12878
12894
  setActivePaneId(sid, res.pane.id);
12879
12895
  applyPaneLayout(sid, res.layout);
12880
- if (sid !== paneSessionId()) return;
12881
- closePanePop();
12882
- syncPanes();
12896
+ return sid === paneSessionId();
12883
12897
  }
12884
12898
 
12885
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
12886
- function openPreviewInPane() {
12887
- const filePath = currentPreviewPath;
12899
+ function openFileInPane(filePath, closeModal) {
12888
12900
  if (!filePath || !paneSessionId()) return;
12889
- closePreviewModal();
12901
+ closeModal();
12890
12902
  addPane(filePath);
12891
12903
  }
12892
12904
 
12905
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
12906
+ function openPreviewInPane() {
12907
+ openFileInPane(currentPreviewPath, closePreviewModal);
12908
+ }
12909
+
12910
+ // The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
12911
+ const planPaneAllowed = () => !!_pendingPlanPath && !!_planSessionId;
12912
+
12913
+ function syncPlanPaneBtn() {
12914
+ document.getElementById('plan-pane-btn').style.display = planPaneAllowed() ? '' : 'none';
12915
+ }
12916
+
12917
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
12918
+ async function openPlanInPane() {
12919
+ if (!planPaneAllowed()) return;
12920
+ const sid = _planSessionId;
12921
+ const target = _pendingPlanPath;
12922
+ closePlanModal();
12923
+ if (sid === paneSessionId()) return addPane(target);
12924
+ if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
12925
+ const s = sessions.find((x) => x.id === sid);
12926
+ showToast(`Plan added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
12927
+ label: 'Open',
12928
+ onClick: () => openSession(sid),
12929
+ });
12930
+ }
12931
+
12932
+ function msgDetailPaneSource() {
12933
+ if (agentLogMode || !paneSessionId()) return null;
12934
+ if (currentMsgDetailIdx != null) return currentMessages[currentMsgDetailIdx] || null;
12935
+ const pin = currentPinDetailId && currentPins.find((p) => p.id === currentPinDetailId);
12936
+ return pin && pin.type !== 'agent' ? pin : null;
12937
+ }
12938
+
12939
+ function syncMsgDetailPaneBtn() {
12940
+ document.getElementById('msg-detail-pane-btn').style.display = msgDetailPaneSource()?.timestamp ? '' : 'none';
12941
+ }
12942
+
12943
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
12944
+ async function openMsgInPane() {
12945
+ const m = msgDetailPaneSource();
12946
+ if (!m?.timestamp) return;
12947
+ const sid = paneSessionId();
12948
+ const d = new Date(m.timestamp);
12949
+ const title = `${msgDetailTitle(m) || 'Message'} ${d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}`;
12950
+ closeMsgDetailModal();
12951
+ if (await postPane(sid, { kind: 'message', target: getPinId(m), title })) syncPanes();
12952
+ }
12953
+
12893
12954
  let panePopMatches = [];
12894
12955
  // -1 means Enter adds the typed value.
12895
12956
  let panePopIdx = -1;
@@ -12942,7 +13003,19 @@ function closePanePop() {
12942
13003
  panePop.classList.remove('visible');
12943
13004
  }
12944
13005
 
13006
+ paneStrip.addEventListener('keydown', (e) => {
13007
+ const input = e.target.closest?.('.pane-title-input');
13008
+ if (input && (e.key === 'Enter' || e.key === 'Escape')) {
13009
+ e.preventDefault();
13010
+ endPaneRename(input, e.key === 'Enter');
13011
+ }
13012
+ });
13013
+ paneStrip.addEventListener('focusout', (e) => {
13014
+ const input = e.target.closest?.('.pane-title-input');
13015
+ if (input) endPaneRename(input, true);
13016
+ });
12945
13017
  paneStrip.addEventListener('click', (e) => {
13018
+ if (e.target.closest('.pane-title-input')) return;
12946
13019
  const close = e.target.closest('[data-close]');
12947
13020
  if (close) {
12948
13021
  closePane(close.dataset.close);
@@ -12966,7 +13039,8 @@ document.addEventListener('click', (e) => {
12966
13039
  if (!menu) return;
12967
13040
  const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
12968
13041
  if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
12969
- if (act || !menu.contains(e.target)) closePaneMenu();
13042
+ if (act) closePaneMenu();
13043
+ else if (!menu.contains(e.target)) closePaneMenu(true);
12970
13044
  });
12971
13045
  function focusSideOf(el) {
12972
13046
  if (el?.classList.contains('side-l')) focusPaneSide('l');
@@ -13022,18 +13096,13 @@ paneSplitBox.addEventListener(
13022
13096
  function endPaneDrag() {
13023
13097
  if (!paneDragId) return;
13024
13098
  paneDragId = null;
13099
+ markPaneDrop(null);
13025
13100
  paneSplitBox.classList.remove('dropping');
13026
- paneStrip.classList.remove('drop-hot');
13027
13101
  for (const el of paneDrop.children) el.classList.remove('hot');
13028
13102
  paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
13029
13103
  syncPanes();
13030
13104
  }
13031
13105
 
13032
- function draggedPair() {
13033
- const sid = paneSessionId();
13034
- return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
13035
- }
13036
-
13037
13106
  paneStrip.addEventListener('dragstart', (e) => {
13038
13107
  const tab = e.target.closest('.pane-tab');
13039
13108
  if (!tab || !paneSessionId() || wantsTerminal()) return;
@@ -13043,24 +13112,64 @@ paneStrip.addEventListener('dragstart', (e) => {
13043
13112
  tab.classList.add('dragging');
13044
13113
  paneSplitBox.classList.add('dropping');
13045
13114
  });
13115
+
13116
+ // A split shows as one strip item, so a drop moves the dragged tab's whole item; the first item
13117
+ // holds the board and stays put. Elsewhere on the strip, a split tab's drop ends its split.
13118
+ function paneDropAction(e) {
13119
+ const sid = paneSessionId();
13120
+ const tab = e.target.closest?.('.pane-tab');
13121
+ if (!sid || !paneDragId) return null;
13122
+ if (tab) {
13123
+ const [fixed, ...movable] = paneStripItems(sid);
13124
+ const itemOf = (id) => movable.find((it) => it.ids.includes(id));
13125
+ const from = itemOf(paneDragId);
13126
+ const to = itemOf(tab.dataset.pane);
13127
+ if (from && to && from !== to) {
13128
+ const el = to.pair ? tab.closest('.pane-pair') : tab;
13129
+ const box = el.getBoundingClientRect();
13130
+ return { kind: 'move', sid, el, fixed, movable, from, to, after: e.clientX > box.left + box.width / 2 };
13131
+ }
13132
+ }
13133
+ const pair = pairOf(sid, paneDragId);
13134
+ return pair ? { kind: 'unpair', sid, pair } : null;
13135
+ }
13136
+
13137
+ function markPaneDrop(action) {
13138
+ paneStrip.classList.toggle('drop-hot', action?.kind === 'unpair');
13139
+ for (const el of paneStrip.querySelectorAll('.pane-tab, .pane-pair')) {
13140
+ el.classList.toggle('drop-before', el === action?.el && !action.after);
13141
+ el.classList.toggle('drop-after', el === action?.el && action.after);
13142
+ }
13143
+ }
13144
+
13145
+ async function movePaneItem({ sid, fixed, movable, from, to, after }) {
13146
+ const items = movable.filter((it) => it !== from);
13147
+ items.splice(items.indexOf(to) + (after ? 1 : 0), 0, from);
13148
+ const order = [fixed, ...items].flatMap((it) => it.ids).filter((id) => id !== 'board');
13149
+ const res = await paneRequest('PATCH', apiPath`/api/panes/${sid}`, { order });
13150
+ if (res.error) showToast(res.error, 'error');
13151
+ else applyPaneLayout(sid, res.layout);
13152
+ }
13153
+
13046
13154
  paneStrip.addEventListener('dragend', endPaneDrag);
13047
13155
  paneStrip.addEventListener('dragover', (e) => {
13048
- if (!draggedPair()) return;
13049
- e.preventDefault();
13050
- paneStrip.classList.add('drop-hot');
13156
+ const action = paneDropAction(e);
13157
+ markPaneDrop(action);
13158
+ if (action) e.preventDefault();
13051
13159
  });
13052
13160
  paneStrip.addEventListener('dragleave', (e) => {
13053
- if (!paneStrip.contains(e.relatedTarget)) paneStrip.classList.remove('drop-hot');
13161
+ if (!paneStrip.contains(e.relatedTarget)) markPaneDrop(null);
13054
13162
  });
13055
13163
  paneStrip.addEventListener('drop', (e) => {
13056
- const pair = draggedPair();
13057
- if (!pair) return;
13164
+ const action = paneDropAction(e);
13165
+ if (!action) return;
13058
13166
  e.preventDefault();
13059
- const sid = paneSessionId();
13060
- const shown = !!splitSideOf(pair, getActivePaneId(sid));
13167
+ const { sid, pair } = action;
13168
+ const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
13061
13169
  const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
13062
13170
  endPaneDrag();
13063
- unpairPanes(sid, pair, keep);
13171
+ if (action.kind === 'move') movePaneItem(action);
13172
+ else unpairPanes(sid, pair, keep);
13064
13173
  });
13065
13174
  paneDrop.addEventListener('dragover', (e) => {
13066
13175
  if (!paneDragId) return;
@@ -13177,8 +13286,7 @@ panePop.addEventListener('keydown', (e) => {
13177
13286
  // leaving the session view or switching sessions drops only the socket; coming back
13178
13287
  // reattaches and the server replays the screen.
13179
13288
  const TERMINAL_TOKEN_KEY = 'terminal-token';
13180
- // Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
13181
- const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
13289
+ const TERMINAL_FOCUS_KEY = 'terminal-focus';
13182
13290
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
13183
13291
  const TERMINAL_MODES_KEY = 'terminal-sessions';
13184
13292
  const TERMINAL_FONT_KEY = 'terminal-font-size';
@@ -13231,17 +13339,11 @@ function readTerminalToken() {
13231
13339
  history.replaceState(null, '', location.pathname + location.search);
13232
13340
  return m[1];
13233
13341
  }
13234
- try {
13235
- return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
13236
- } catch (_) {
13237
- return null;
13238
- }
13342
+ return tabStore.getItem(TERMINAL_TOKEN_KEY);
13239
13343
  }
13240
13344
 
13241
13345
  function storeTerminalToken(token) {
13242
- try {
13243
- sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
13244
- } catch (_) {}
13346
+ tabStore.setItem(TERMINAL_TOKEN_KEY, token);
13245
13347
  }
13246
13348
 
13247
13349
  let tokenRefresh = null;
@@ -13275,9 +13377,7 @@ function setTerminalMode(sessionId, on) {
13275
13377
  const modes = terminalModes();
13276
13378
  if (on) modes.add(sessionId);
13277
13379
  else modes.delete(sessionId);
13278
- try {
13279
- store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
13280
- } catch (_) {}
13380
+ store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
13281
13381
  }
13282
13382
 
13283
13383
  function wantsTerminal() {
@@ -13340,19 +13440,14 @@ function terminalShortcut(e, probe = false) {
13340
13440
  }
13341
13441
 
13342
13442
  function readSwapPair() {
13343
- try {
13344
- const pair = JSON.parse(store.getItem(SWAP_KEY));
13345
- if (Array.isArray(pair) && pair.length === 2) return pair;
13346
- } catch {}
13347
- return [null, null];
13443
+ const pair = readStoredList(SWAP_KEY);
13444
+ return pair.length === 2 ? pair : [null, null];
13348
13445
  }
13349
13446
 
13350
13447
  function setSwapPair(last, previous) {
13351
13448
  lastSessionId = last;
13352
13449
  previousSessionId = previous;
13353
- try {
13354
- store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
13355
- } catch {}
13450
+ store.writeJson(SWAP_KEY, [last, previous]);
13356
13451
  }
13357
13452
 
13358
13453
  async function swapToPreviousSession() {
@@ -13420,8 +13515,10 @@ function syncTerminal() {
13420
13515
  }
13421
13516
 
13422
13517
  // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
13423
- function openSession(sessionId) {
13518
+ async function openSession(sessionId) {
13424
13519
  setSessionDismissed(sessionId, false);
13520
+ // Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
13521
+ if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
13425
13522
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
13426
13523
  if (!alreadyOpen) termState.openedByUser = sessionId;
13427
13524
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -13847,18 +13944,13 @@ window.addEventListener('focus', () => {
13847
13944
  });
13848
13945
  });
13849
13946
  window.addEventListener('pagehide', () => {
13850
- try {
13851
- if (termState.attached && termState.shown && terminalHadFocus) {
13852
- sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
13853
- } else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
13854
- } catch (_) {}
13947
+ if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
13948
+ else tabStore.removeItem(TERMINAL_FOCUS_KEY);
13855
13949
  });
13856
13950
 
13857
13951
  function restoreTerminalFocus(sessionId) {
13858
- try {
13859
- if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
13860
- sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
13861
- } catch (_) {}
13952
+ if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
13953
+ tabStore.removeItem(TERMINAL_FOCUS_KEY);
13862
13954
  }
13863
13955
 
13864
13956
  // The terminal stands in for the board, so it takes the board zone however focus arrives
@@ -13921,6 +14013,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
13921
14013
  model: spec.model,
13922
14014
  prompt: spec.prompt,
13923
14015
  edit: spec.edit,
14016
+ forkOf: spec.forkOf,
13924
14017
  }),
13925
14018
  },
13926
14019
  });
@@ -14012,7 +14105,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14012
14105
  'Resuming it here too makes both processes write to the same transcript.',
14013
14106
  [
14014
14107
  ['resume', 'Resume anyway'],
14015
- ['fork', 'Fork'],
14016
14108
  ['shell', 'Shell only'],
14017
14109
  ],
14018
14110
  );
@@ -14037,7 +14129,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14037
14129
  ]
14038
14130
  : [
14039
14131
  ['resume', 'Resume'],
14040
- ['fork', 'Fork'],
14041
14132
  ['shell', 'Shell'],
14042
14133
  ],
14043
14134
  clean ? '' : tail,
@@ -14054,14 +14145,16 @@ function endTerminalSession() {
14054
14145
  }
14055
14146
 
14056
14147
  // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
14057
- function closeTerminalSession(id = termState.sessionId) {
14148
+ // Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
14149
+ async function closeTerminalSession(id = termState.sessionId) {
14058
14150
  if (id === termState.sessionId) {
14059
14151
  if (terminalPaneFocused()) leaveTerminalPane();
14060
14152
  detachTerminal();
14061
14153
  }
14062
14154
  setTerminalMode(id, false);
14063
14155
  syncTerminal();
14064
- return endTerminal(id);
14156
+ await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
14157
+ dropPlaceholder(id);
14065
14158
  }
14066
14159
 
14067
14160
  function openTerminalManager() {
@@ -14094,13 +14187,9 @@ async function pollTerminalProcStats() {
14094
14187
  if (!isOnScreen()) return;
14095
14188
  let cck;
14096
14189
  let host;
14097
- try {
14098
- const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
14099
- if (!res.ok) return;
14100
- ({ cck, host, terminals: terminalProcStats } = await res.json());
14101
- } catch (_) {
14102
- return;
14103
- }
14190
+ const stats = await getJson('/api/terminals/stats', null, { cache: 'no-store' });
14191
+ if (!stats) return;
14192
+ ({ cck, host, terminals: terminalProcStats } = stats);
14104
14193
  const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
14105
14194
  document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
14106
14195
  for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
@@ -14161,35 +14250,22 @@ async function renderTerminalManager() {
14161
14250
  body.querySelectorAll('[data-end]').forEach((b) => {
14162
14251
  b.onclick = async () => {
14163
14252
  b.disabled = true;
14164
- await endTerminal(b.dataset.end);
14253
+ await closeTerminalSession(b.dataset.end);
14165
14254
  renderTerminalManager();
14166
14255
  };
14167
14256
  });
14168
14257
  }
14169
14258
 
14170
14259
  async function terminalFetch(url, method, body) {
14171
- const send = () =>
14172
- fetch(url, {
14173
- method,
14174
- headers: {
14175
- 'X-Terminal-Token': terminalToken || '',
14176
- ...(body && { 'Content-Type': 'application/json' }),
14177
- },
14178
- ...(body && { body: JSON.stringify(body) }),
14179
- });
14260
+ const send = () => api(url, { method, body, headers: { 'X-Terminal-Token': terminalToken || '' } });
14180
14261
  const res = await send();
14181
14262
  return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
14182
14263
  }
14183
14264
 
14184
- async function endTerminal(id) {
14185
- await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
14186
- dropPlaceholder(id);
14187
- }
14188
-
14189
14265
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14190
14266
  async function endAllTerminals() {
14191
14267
  const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
14192
- await Promise.all([...buttons].map((b) => endTerminal(b.dataset.end)));
14268
+ await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
14193
14269
  renderTerminalManager();
14194
14270
  }
14195
14271
 
@@ -14220,13 +14296,18 @@ const PLACEHOLDER_HINTS = {
14220
14296
  edit: 'press Enter in the terminal to start',
14221
14297
  };
14222
14298
 
14299
+ // The parent may be outside the loaded list, so its short id stands in for its name.
14300
+ function forkLabel(parentId) {
14301
+ return `Fork of ${sessionDisplayName(sessions.find((s) => s.id === parentId) || { id: parentId })}`;
14302
+ }
14303
+
14223
14304
  function placeholderSession(id, spec) {
14224
14305
  return {
14225
14306
  id,
14226
14307
  placeholder: true,
14227
14308
  mode: spec.mode,
14228
14309
  edit: !!spec.edit,
14229
- name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14310
+ name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14230
14311
  dispatchGroup: spec.group || undefined,
14231
14312
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
14232
14313
  project: spec.cwd,
@@ -14250,23 +14331,21 @@ function mergePlaceholders(list) {
14250
14331
  }
14251
14332
 
14252
14333
  async function loadDispatches() {
14253
- try {
14254
- const res = await fetch('/api/dispatch', { cache: 'no-store' });
14255
- const { running = [] } = await res.json();
14256
- dispatchSpecs.clear();
14257
- for (const r of running) {
14258
- if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
14259
- dispatchSpecs.set(r.session, {
14260
- mode: 'dispatch',
14261
- cwd: r.cwd,
14262
- name: r.name,
14263
- worktree: r.worktree,
14264
- group: r.group,
14265
- parent: r.parent,
14266
- startedAt: r.startedAt,
14267
- });
14268
- }
14269
- } catch (_) {}
14334
+ const data = await getJson('/api/dispatch', null, { cache: 'no-store' });
14335
+ if (!data) return;
14336
+ dispatchSpecs.clear();
14337
+ for (const r of data.running || []) {
14338
+ if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
14339
+ dispatchSpecs.set(r.session, {
14340
+ mode: 'dispatch',
14341
+ cwd: r.cwd,
14342
+ name: r.name,
14343
+ worktree: r.worktree,
14344
+ group: r.group,
14345
+ parent: r.parent,
14346
+ startedAt: r.startedAt,
14347
+ });
14348
+ }
14270
14349
  }
14271
14350
 
14272
14351
  async function onDispatchUpdate() {
@@ -14325,6 +14404,7 @@ async function restorePendingSessions() {
14325
14404
  worktree: t.worktree,
14326
14405
  mode: t.mode,
14327
14406
  edit: t.edit,
14407
+ forkOf: t.forkOf,
14328
14408
  startedAt: t.startedAt,
14329
14409
  });
14330
14410
  }
@@ -14332,8 +14412,10 @@ async function restorePendingSessions() {
14332
14412
  }
14333
14413
 
14334
14414
  async function loadTerminals() {
14335
- const res = await fetch('/api/terminals', { cache: 'no-store' });
14336
- const list = (await res.json()).sessions || [];
14415
+ const res = await api('/api/terminals', { cache: 'no-store' });
14416
+ const data = await res.json();
14417
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
14418
+ const list = data.sessions || [];
14337
14419
  runningTerminals = new Set(list.map((t) => t.id));
14338
14420
  pushTerminalClaims();
14339
14421
  renderActivityChip();
@@ -14553,11 +14635,29 @@ function startNewSession() {
14553
14635
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
14554
14636
  setTerminalMode(id, true);
14555
14637
  if (v.stay) return startNewSessionInBackground(id, v);
14556
- sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14557
14638
  closeNewSession();
14639
+ openPlaceholder(id);
14640
+ }
14641
+
14642
+ function openPlaceholder(id) {
14643
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14558
14644
  fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
14559
14645
  }
14560
14646
 
14647
+ function forkableSession(id) {
14648
+ return terminalAvailable() ? sessions.find((s) => s.id === id && !s.placeholder) : undefined;
14649
+ }
14650
+
14651
+ // The fork gets its own id up front, so it runs as a new session and needs no rekey.
14652
+ function forkSession(parentId) {
14653
+ const parent = forkableSession(parentId);
14654
+ if (!parent) return;
14655
+ const id = crypto.randomUUID();
14656
+ newSpecs.set(id, { cwd: parent.project, forkOf: parentId, mode: 'new', startedAt: Date.now() });
14657
+ setTerminalMode(id, true);
14658
+ openPlaceholder(id);
14659
+ }
14660
+
14561
14661
  async function startNewSessionInBackground(id, v) {
14562
14662
  refreshPlaceholders();
14563
14663
  setNewSessionBusy(true);
@@ -14704,7 +14804,7 @@ if ('serviceWorker' in navigator) {
14704
14804
 
14705
14805
  //#region INIT
14706
14806
  loadTheme();
14707
- if (store.getItem('sessions-filtersCollapsed') === 'true') {
14807
+ if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
14708
14808
  document.getElementById('sessions-filters').classList.add('collapsed');
14709
14809
  document.getElementById('sessions-chevron').classList.add('rotated');
14710
14810
  }
@@ -14743,27 +14843,19 @@ document.addEventListener('DOMContentLoaded', () => {
14743
14843
  });
14744
14844
 
14745
14845
  loadSidebarState();
14746
- try {
14747
- const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
14748
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
14749
- cg.forEach((p) => collapsedProjectGroups.add(p));
14750
- } catch (_) {}
14846
+ for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
14751
14847
  loadSessionGroups();
14752
14848
  initSessionGroupsDnd();
14753
14849
  initSessionPicker();
14754
14850
  initProjectPicker();
14755
- try {
14756
- const af = JSON.parse(store.getItem('activityFilter') || '[]');
14757
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
14758
- af.forEach((k) => activityFilter.add(k));
14759
- } catch (_) {}
14851
+ for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
14760
14852
  initSidebarResize();
14761
14853
  applyModalZoom();
14762
14854
  loadModalFullscreen();
14763
14855
  initModalResize();
14764
14856
  loadPanelWidths();
14765
- initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
14766
- initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width', 'message-panel-width');
14857
+ initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
14858
+ initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
14767
14859
  initMessagePanelFull();
14768
14860
 
14769
14861
  const msgContentEl = document.getElementById('message-panel-content');
@@ -14876,12 +14968,7 @@ function pluginWarning(plugin) {
14876
14968
  btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
14877
14969
  btn.setAttribute('aria-label', problem);
14878
14970
  if (plugin.state !== 'disabled') {
14879
- btn.addEventListener('click', () =>
14880
- navigator.clipboard
14881
- .writeText(plugin.installCommand)
14882
- .then(() => showToast('Install command copied', 'success'))
14883
- .catch(() => showToast('Could not copy the command', 'error')),
14884
- );
14971
+ btn.addEventListener('click', () => copyText(plugin.installCommand, 'install command'));
14885
14972
  }
14886
14973
  return btn;
14887
14974
  }
@@ -14890,18 +14977,17 @@ function refreshRateLimits() {
14890
14977
  if (footerState.timer) return;
14891
14978
  footerState.timer = setTimeout(() => {
14892
14979
  footerState.timer = null;
14893
- fetch('/api/rate-limits')
14894
- .then((r) => r.json())
14895
- .then((rl) => {
14896
- scheduleRateLimitReset(rl);
14897
- const fh = rl?.five_hour?.used_percentage ?? null;
14898
- const sd = rl?.seven_day?.used_percentage ?? null;
14899
- const key = `${fh}|${sd}`;
14900
- if (key === footerState.limitsKey) return;
14901
- footerState.limitsKey = key;
14902
- renderSidebarFooter(rl);
14903
- })
14904
- .catch(() => {});
14980
+ // The route answers null when no session has reported limits, so failure needs its own value.
14981
+ getJson('/api/rate-limits', false).then((rl) => {
14982
+ if (rl === false) return;
14983
+ scheduleRateLimitReset(rl);
14984
+ const fh = rl?.five_hour?.used_percentage ?? null;
14985
+ const sd = rl?.seven_day?.used_percentage ?? null;
14986
+ const key = `${fh}|${sd}`;
14987
+ if (key === footerState.limitsKey) return;
14988
+ footerState.limitsKey = key;
14989
+ renderSidebarFooter(rl);
14990
+ });
14905
14991
  }, 1500);
14906
14992
  }
14907
14993
 
@@ -14916,15 +15002,12 @@ function scheduleRateLimitReset(rl) {
14916
15002
  const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
14917
15003
  footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
14918
15004
  }
14919
- fetch('/api/version')
14920
- .then((r) => r.json())
14921
- .then((d) => {
14922
- footerState.version = d.version;
14923
- footerState.plugin = d.plugin ?? null;
14924
- renderSidebarFooter(null);
14925
- refreshRateLimits();
14926
- })
14927
- .catch(() => {});
15005
+ getJson('/api/version', {}).then((d) => {
15006
+ footerState.version = d.version;
15007
+ footerState.plugin = d.plugin ?? null;
15008
+ renderSidebarFooter(null);
15009
+ refreshRateLimits();
15010
+ });
14928
15011
 
14929
15012
  const urlState = getUrlState();
14930
15013
  const lastView = loadLastView();
@@ -14951,12 +15034,10 @@ if (urlState.search) {
14951
15034
  document.getElementById('boot-verb').textContent = pickRandom(verbs);
14952
15035
  }
14953
15036
 
14954
- fetch('/api/config')
14955
- .then((r) => r.json())
15037
+ getJson('/api/config')
14956
15038
  .then((c) => {
14957
- appConfig = c;
15039
+ if (c) appConfig = c;
14958
15040
  })
14959
- .catch(() => {})
14960
15041
  .then(restorePendingSessions)
14961
15042
  .then(() =>
14962
15043
  fetchSessions(