claude-code-kanban 7.4.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.
@@ -118,28 +164,19 @@ const dismissedSessionIds = loadDismissedSessions();
118
164
  let dismissSaveWarned = false;
119
165
 
120
166
  function loadDismissedSessions() {
121
- try {
122
- const saved = Object.entries(JSON.parse(store.getItem(DISMISSED_KEY) || '{}'));
123
- return new Map(saved.filter(([, at]) => isDismissFresh(at)));
124
- } catch {
125
- return new Map();
126
- }
167
+ const saved = store.readJson(DISMISSED_KEY, {});
168
+ return new Map(Object.entries(saved).filter(([, at]) => isDismissFresh(at)));
127
169
  }
128
170
 
129
171
  function persistDismissedSessions() {
130
172
  for (const [id, at] of dismissedSessionIds) if (!isDismissFresh(at)) dismissedSessionIds.delete(id);
131
- try {
132
- store.setItem(DISMISSED_KEY, JSON.stringify(Object.fromEntries(dismissedSessionIds)));
133
- } catch (e) {
134
- console.error('[dismissed-sessions]', e);
135
- // The map still works in memory, so only the next reload is affected. Say so once.
136
- if (!dismissSaveWarned) {
137
- dismissSaveWarned = true;
138
- showToast(
139
- 'Browser storage is full: dismissed sessions come back on reload. Storage → Clean Orphaned frees space.',
140
- 'error',
141
- );
142
- }
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
+ );
143
180
  }
144
181
  }
145
182
 
@@ -158,7 +195,7 @@ function resetMessageScrollState() {
158
195
  msgUserScrolledUp = false;
159
196
  msgHasMore = false;
160
197
  msgLoadingMore = false;
161
- currentMessages = [];
198
+ setCurrentMessages([]);
162
199
  lastMessagesHash = '';
163
200
  const btn = document.getElementById('msg-jump-latest');
164
201
  if (btn) btn.style.display = 'none';
@@ -172,7 +209,9 @@ function getUrlState() {
172
209
  project: params.get('project'),
173
210
  owner: params.get('owner'),
174
211
  search: params.get('search'),
175
- 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',
176
215
  projectView: params.get('projectView'),
177
216
  };
178
217
  }
@@ -196,32 +235,23 @@ function updateUrl() {
196
235
 
197
236
  const LAST_VIEW_KEY = 'lastView';
198
237
  function persistLastView() {
199
- try {
200
- const data = {
201
- view: viewMode,
202
- session: currentSessionId,
203
- projectPath: viewMode === 'project' ? currentProjectPath : null,
204
- // The project filter also lives in the URL, but the hub recreates each iframe at the app's
205
- // base URL on reload, so the query string alone doesn't survive a hub refresh.
206
- project: filterProject,
207
- };
208
- store.setItem(LAST_VIEW_KEY, JSON.stringify(data));
209
- } 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
+ });
210
246
  }
211
247
  function loadLastView() {
212
- try {
213
- return JSON.parse(store.getItem(LAST_VIEW_KEY)) || null;
214
- } catch (_) {
215
- return null;
216
- }
248
+ return store.readJson(LAST_VIEW_KEY);
217
249
  }
218
250
 
219
251
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
220
252
  function resetState() {
221
253
  history.replaceState(null, '', window.location.pathname);
222
- try {
223
- store.removeItem(LAST_VIEW_KEY);
224
- } catch (_) {}
254
+ store.removeItem(LAST_VIEW_KEY);
225
255
  sessionFilter = FILTER_DEFAULTS.session;
226
256
  sessionLimit = SESSION_PAGE;
227
257
  filterProject = FILTER_DEFAULTS.project;
@@ -267,31 +297,52 @@ let lastTasksHash = '';
267
297
  //#endregion
268
298
 
269
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
+
270
324
  async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
271
325
  try {
272
326
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
273
327
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
274
328
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
275
- const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
276
- // The focused session must come back whatever the filters say: renderSession and the
277
- // info modal both look it up in `sessions` and bail when it is absent, so a session
278
- // opened from outside the current filter would leave the view on the previous one.
279
- const includeParam = focusId ? `&include=${encodeURIComponent(focusId)}` : '';
280
- const projectParam =
281
- filterProject === '__recent__'
282
- ? `&recentHours=${RECENT_PROJECT_HOURS}`
283
- : filterProject
284
- ? `&project=${encodeURIComponent(filterProject)}`
285
- : '';
286
- const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
287
- const sessionsPromise = fetch(
288
- `/api/sessions?limit=${sessionLimit}${pinnedParam}${includeParam}${projectParam}${filterParam}`,
289
- ).then((r) => {
290
- sessionsHasMore = r.headers.get('X-Has-More') === 'true';
291
- 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
+ },
292
342
  });
293
-
294
- 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();
295
346
  const sessionsHash = JSON.stringify(newSessions);
296
347
  if (sessionsHash !== lastSessionsHash) {
297
348
  lastSessionsHash = sessionsHash;
@@ -316,9 +367,7 @@ function markSessionRead(sessionId) {
316
367
  if (!session?.unread) return;
317
368
  session.unread = false;
318
369
  document.querySelector(`.session-item[data-session-id="${CSS.escape(sessionId)}"]`)?.classList.remove('unread');
319
- fetch(`/api/sessions/${encodeURIComponent(sessionId)}/read`, { method: 'POST' }).catch((e) =>
320
- console.error('[markSessionRead]', e),
321
- );
370
+ api(apiPath`/api/sessions/${sessionId}/read`, { method: 'POST' }).catch((e) => console.error('[markSessionRead]', e));
322
371
  }
323
372
 
324
373
  function markOpenSessionRead() {
@@ -326,7 +375,8 @@ function markOpenSessionRead() {
326
375
  }
327
376
 
328
377
  async function loadSearchTasks() {
329
- const newTasks = await fetch('/api/tasks/all').then((r) => r.json());
378
+ const newTasks = await getJson('/api/tasks/all');
379
+ if (!newTasks) return;
330
380
  const tasksHash = JSON.stringify(newTasks);
331
381
  if (tasksHash === lastTasksHash) return;
332
382
  lastTasksHash = tasksHash;
@@ -360,14 +410,9 @@ sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
360
410
 
361
411
  // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
362
412
  async function fetchSessionsByIds(ids) {
363
- try {
364
- const r = await fetch(`/api/sessions?limit=1&include=${ids.map(encodeURIComponent).join(',')}`);
365
- if (!r.ok) return [];
366
- const byId = new Map((await r.json()).map((s) => [s.id, s]));
367
- return ids.map((id) => byId.get(id)).filter(Boolean);
368
- } catch (_) {
369
- return [];
370
- }
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);
371
416
  }
372
417
 
373
418
  async function fetchSessionById(id) {
@@ -401,11 +446,7 @@ function sessionSearch(onFound) {
401
446
  let found = null;
402
447
  let timer = null;
403
448
  const run = async (key) => {
404
- let ids = [];
405
- try {
406
- const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
407
- if (r.ok) ids = await r.json();
408
- } catch (_) {}
449
+ const ids = await getJson('/api/sessions/search', [], { query: { q: key } });
409
450
  if (key !== current) return;
410
451
  const rows = ids.length ? await fetchSessionsByIds(ids) : [];
411
452
  if (key !== current) return;
@@ -492,13 +533,14 @@ function fuzzyMatch(text, query) {
492
533
 
493
534
  //#endregion
494
535
 
536
+ //#region SESSION_ACTIVITY
495
537
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
496
538
  function toggleSection(containerId, chevronId) {
497
539
  const container = document.getElementById(containerId);
498
540
  const chevron = document.getElementById(chevronId);
499
541
  const collapsed = container.classList.toggle('collapsed');
500
542
  chevron.classList.toggle('rotated', collapsed);
501
- store.setItem(`${containerId}Collapsed`, collapsed);
543
+ store.setItem(sectionCollapsedKey(containerId), collapsed);
502
544
  }
503
545
 
504
546
  function isWaitingSession(s) {
@@ -635,7 +677,7 @@ function setActivityFilter(kind) {
635
677
  } else {
636
678
  toggleActivityKind(kind);
637
679
  }
638
- store.setItem('activityFilter', JSON.stringify([...activityFilter]));
680
+ store.writeJson(ACTIVITY_FILTER_KEY, [...activityFilter]);
639
681
  // active/waiting only make sense with the active session filter on
640
682
  const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
641
683
  if (targetFilter !== sessionFilter) {
@@ -658,7 +700,7 @@ async function fetchTasks(sessionId) {
658
700
  openTerminal(sessionId, terminalOpenMode(sessionId));
659
701
  viewMode = 'session';
660
702
  document.getElementById('message-toggle')?.style.removeProperty('display');
661
- const res = await fetch(`/api/sessions/${sessionId}`);
703
+ const res = await api(apiPath`/api/sessions/${sessionId}`);
662
704
 
663
705
  if (!res.ok) throw new Error(`Failed to fetch tasks: ${res.status}`);
664
706
  const newTasks = await res.json();
@@ -710,14 +752,16 @@ async function fetchTasks(sessionId) {
710
752
  renderSession();
711
753
  }
712
754
  }
755
+ //#endregion
713
756
 
714
- // #region TIMINGS
757
+ //#region TIMINGS
715
758
  const WAITING_TTL_MS = 30 * 60 * 1000;
716
759
  const AGENT_LOG_MAX = 8;
717
760
  const LIVE_INDICATOR_MS = 10 * 1000;
718
761
  const JUST_NOW_MS = 60 * 1000;
719
- // #endregion
762
+ //#endregion
720
763
 
764
+ //#region AGENT_FETCHING
721
765
  function resetAgentState() {
722
766
  currentAgents = [];
723
767
  currentWaiting = null;
@@ -728,7 +772,7 @@ function resetAgentState() {
728
772
 
729
773
  async function fetchAgents(sessionId) {
730
774
  try {
731
- const res = await fetch(`/api/sessions/${sessionId}/agents`);
775
+ const res = await api(apiPath`/api/sessions/${sessionId}/agents`);
732
776
  if (!res.ok) {
733
777
  resetAgentState();
734
778
  return;
@@ -760,7 +804,7 @@ async function fetchProjectView(projectPath) {
760
804
  viewMode = 'project';
761
805
  currentProjectPath = projectPath;
762
806
  currentSessionId = null;
763
- currentMessages = [];
807
+ setCurrentMessages([]);
764
808
  lastMessagesHash = '';
765
809
  if (messagePanelOpen) toggleMessagePanel();
766
810
  document.getElementById('message-toggle')?.style.setProperty('display', 'none');
@@ -774,19 +818,8 @@ async function fetchProjectView(projectPath) {
774
818
 
775
819
  const encoded = btoa(projectPath);
776
820
  const [tasksResult, agentResults] = await Promise.all([
777
- fetch(`/api/projects/${encodeURIComponent(encoded)}/tasks`)
778
- .then((r) => r.json())
779
- .catch((e) => {
780
- console.error('[fetchProjectView] tasks:', e);
781
- return [];
782
- }),
783
- Promise.all(
784
- activeSessionIds.map((id) =>
785
- fetch(`/api/sessions/${id}/agents`)
786
- .then((r) => r.json())
787
- .catch(() => ({ agents: [] })),
788
- ),
789
- ),
821
+ getJson(apiPath`/api/projects/${encoded}/tasks`, []),
822
+ Promise.all(activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] }))),
790
823
  ]);
791
824
  currentTasks = tasksResult;
792
825
  const seen = new Set();
@@ -821,11 +854,7 @@ async function refreshProjectAgents() {
821
854
  const projectSessions = sessions.filter((s) => s.project === currentProjectPath);
822
855
  const activeSessionIds = projectSessions.filter((s) => isSessionActive(s) || isAnyPinned(s.id)).map((s) => s.id);
823
856
  const agentResults = await Promise.all(
824
- activeSessionIds.map((id) =>
825
- fetch(`/api/sessions/${id}/agents`)
826
- .then((r) => r.json())
827
- .catch(() => ({ agents: [] })),
828
- ),
857
+ activeSessionIds.map((id) => getJson(apiPath`/api/sessions/${id}/agents`, { agents: [] })),
829
858
  );
830
859
  const seen = new Set();
831
860
  currentAgents = [];
@@ -850,6 +879,7 @@ async function refreshProjectAgents() {
850
879
  lastAgentsHash = hash;
851
880
  renderAgentFooter();
852
881
  }
882
+ //#endregion
853
883
 
854
884
  //#region MESSAGE_PANEL
855
885
  function setMessagePanelVisible(open) {
@@ -879,7 +909,7 @@ function toggleMessagePanel() {
879
909
  const panel = document.getElementById('message-panel');
880
910
  messagePanelOpen = !messagePanelOpen;
881
911
  termState.panelHidden = false;
882
- store.setItem('message-panel-open', messagePanelOpen);
912
+ store.setItem(MESSAGE_PANEL_OPEN_KEY, messagePanelOpen);
883
913
  panel.classList.toggle('visible', messagePanelOpen);
884
914
  document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
885
915
  if (messagePanelOpen && currentSessionId) {
@@ -902,7 +932,7 @@ function applyMessagePanelFull(on) {
902
932
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
903
933
  function toggleMessagePanelFull() {
904
934
  const on = !document.getElementById('message-panel').classList.contains('full-width');
905
- store.setItem('message-panel-full', on);
935
+ store.setItem(MESSAGE_PANEL_FULL_KEY, on);
906
936
  applyMessagePanelFull(on);
907
937
  }
908
938
 
@@ -914,7 +944,7 @@ function initMessagePanelFull() {
914
944
  new ResizeObserver(sync).observe(sidebar);
915
945
  sync();
916
946
  }
917
- applyMessagePanelFull(store.getItem('message-panel-full') === 'true');
947
+ applyMessagePanelFull(store.getItem(MESSAGE_PANEL_FULL_KEY) === 'true');
918
948
  }
919
949
 
920
950
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
@@ -922,7 +952,7 @@ async function openSessionWithBookmarks(sessionId) {
922
952
  if (!messagePanelOpen) {
923
953
  const panel = document.getElementById('message-panel');
924
954
  messagePanelOpen = true;
925
- store.setItem('message-panel-open', 'true');
955
+ store.setItem(MESSAGE_PANEL_OPEN_KEY, 'true');
926
956
  panel.classList.add('visible');
927
957
  document.getElementById('message-toggle')?.classList.add('active');
928
958
  }
@@ -964,12 +994,12 @@ async function viewAgentLog(agentId) {
964
994
  agentLogSSE.close();
965
995
  agentLogSSE = null;
966
996
  }
967
- agentLogSSE = new EventSource(`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
997
+ agentLogSSE = new EventSource(apiPath`/api/sessions/${agentLogMode.sessionId}/agents/${resolvedId}/messages/stream`);
968
998
  agentLogSSE.addEventListener('agent-log-update', (e) => {
969
999
  if (!agentLogMode || agentLogMode.agentId !== resolvedId) return;
970
1000
  try {
971
1001
  const data = JSON.parse(e.data);
972
- currentMessages = data.messages;
1002
+ setCurrentMessages(data.messages);
973
1003
  if (messagePanelOpen) renderMessages(data.messages);
974
1004
  maybeFollowLatest();
975
1005
  } catch (_) {}
@@ -998,11 +1028,11 @@ async function fetchAgentMessages() {
998
1028
  if (!agentLogMode) return;
999
1029
  const { sessionId, agentId } = agentLogMode;
1000
1030
  try {
1001
- const res = await fetch(`/api/sessions/${sessionId}/agents/${agentId}/messages?limit=100`);
1002
- if (!res.ok || !agentLogMode || agentLogMode.agentId !== agentId) return;
1003
- const data = await res.json();
1004
- if (!agentLogMode || agentLogMode.agentId !== agentId) return;
1005
- 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);
1006
1036
  if (messagePanelOpen) renderMessages(data.messages);
1007
1037
  maybeFollowLatest();
1008
1038
  } catch (e) {
@@ -1020,10 +1050,8 @@ function openLiveLatestMessage() {
1020
1050
 
1021
1051
  const MSG_PAGE_LIMIT = 15;
1022
1052
 
1023
- async function fetchSessionMessagesPage(sessionId, { before } = {}) {
1024
- const qs = new URLSearchParams({ limit: MSG_PAGE_LIMIT });
1025
- if (before) qs.set('before', before);
1026
- 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 } });
1027
1055
  return res.ok ? res.json() : null;
1028
1056
  }
1029
1057
 
@@ -1049,18 +1077,16 @@ async function fetchMessages(sessionId) {
1049
1077
  if (hash === lastMessagesHash) return;
1050
1078
  lastMessagesHash = hash;
1051
1079
  msgHasMore = data.hasMore !== false;
1052
- currentMessages = data.messages;
1080
+ setCurrentMessages(data.messages);
1053
1081
  if (messagePanelOpen) renderMessages(data.messages);
1054
1082
  } else {
1055
1083
  if (data.messages.length && currentMessages.length) {
1056
1084
  const lastKnown = currentMessages[currentMessages.length - 1].timestamp;
1057
1085
  const newMsgs = data.messages.filter((m) => m.timestamp > lastKnown);
1058
1086
  if (newMsgs.length) {
1059
- currentMessages = [...currentMessages, ...newMsgs];
1060
- if (currentMessages.length > MSG_MAX_LOADED) {
1061
- currentMessages = currentMessages.slice(-MSG_MAX_LOADED);
1062
- msgHasMore = true;
1063
- }
1087
+ const merged = [...currentMessages, ...newMsgs];
1088
+ if (merged.length > MSG_MAX_LOADED) msgHasMore = true;
1089
+ setCurrentMessages(merged.slice(-MSG_MAX_LOADED));
1064
1090
  if (messagePanelOpen) renderMessages(currentMessages);
1065
1091
  }
1066
1092
  }
@@ -1087,10 +1113,7 @@ async function loadOlderMessages() {
1087
1113
  if (data.messages.length) {
1088
1114
  loader.remove();
1089
1115
  const prevHeight = container.scrollHeight;
1090
- currentMessages = [...data.messages, ...currentMessages];
1091
- if (currentMessages.length > MSG_MAX_LOADED) {
1092
- currentMessages = currentMessages.slice(0, MSG_MAX_LOADED);
1093
- }
1116
+ setCurrentMessages([...data.messages, ...currentMessages].slice(0, MSG_MAX_LOADED));
1094
1117
  renderMessages(currentMessages);
1095
1118
  container.scrollTop = container.scrollHeight - prevHeight;
1096
1119
  }
@@ -1675,10 +1698,9 @@ function rejectWaitingPlan(inputId) {
1675
1698
  async function respondWaiting(payload) {
1676
1699
  if (!currentSessionId || !currentWaiting?.id) return;
1677
1700
  try {
1678
- const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/respond`, {
1701
+ const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/respond`, {
1679
1702
  method: 'POST',
1680
- headers: { 'Content-Type': 'application/json' },
1681
- body: JSON.stringify({ id: currentWaiting.id, ...payload }),
1703
+ body: { id: currentWaiting.id, ...payload },
1682
1704
  });
1683
1705
  if (res.ok || res.status === 409 || res.status === 410) {
1684
1706
  clearWaitingUi();
@@ -1725,9 +1747,7 @@ function clearWaitingUi() {
1725
1747
  async function discardWaiting() {
1726
1748
  if (!currentSessionId) return;
1727
1749
  try {
1728
- const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
1729
- method: 'POST',
1730
- });
1750
+ const res = await api(apiPath`/api/sessions/${currentSessionId}/waiting/discard`, { method: 'POST' });
1731
1751
  if (res.ok) clearWaitingUi();
1732
1752
  } catch (e) {
1733
1753
  console.error('[discardWaiting]', e);
@@ -1852,15 +1872,12 @@ function getPinId(m) {
1852
1872
  }
1853
1873
 
1854
1874
  function loadPins(sessionId) {
1855
- try {
1856
- return JSON.parse(store.getItem(`pinned-messages-${sessionId}`)) || [];
1857
- } catch {
1858
- return [];
1859
- }
1875
+ return readStoredList(pinsKey(sessionId));
1860
1876
  }
1861
1877
 
1862
1878
  function savePins(sessionId, pins) {
1863
- store.setItem(`pinned-messages-${sessionId}`, JSON.stringify(pins));
1879
+ if (pins.length) store.writeJson(pinsKey(sessionId), pins);
1880
+ else store.removeItem(pinsKey(sessionId));
1864
1881
  }
1865
1882
 
1866
1883
  function isPinned(m) {
@@ -1991,6 +2008,7 @@ function showPinnedMsgDetail(pinIdx) {
1991
2008
  autoSizeModal(pinModal, body);
1992
2009
  const pinBtn = document.getElementById('msg-detail-pin-btn');
1993
2010
  if (pinBtn) pinBtn.classList.add('active');
2011
+ syncMsgDetailPaneBtn();
1994
2012
  document.getElementById('msg-detail-modal').classList.add('visible');
1995
2013
  mountReplyReview(pin, body, pinModal);
1996
2014
  }
@@ -2026,34 +2044,22 @@ let stickySessionIds = new Set();
2026
2044
  const deferredPinPlacement = new Set();
2027
2045
 
2028
2046
  function loadPinnedSessions() {
2029
- try {
2030
- return new Set(JSON.parse(store.getItem('pinned-sessions')) || []);
2031
- } catch {
2032
- return new Set();
2033
- }
2047
+ return new Set(readStoredList(PINNED_SESSIONS_KEY));
2034
2048
  }
2035
2049
 
2036
2050
  function loadStickySessions() {
2037
- try {
2038
- return new Set(JSON.parse(store.getItem('sticky-sessions')) || []);
2039
- } catch {
2040
- return new Set();
2041
- }
2051
+ return new Set(readStoredList(STICKY_SESSIONS_KEY));
2042
2052
  }
2043
2053
 
2044
2054
  function savePinnedSessions() {
2045
- store.setItem('pinned-sessions', JSON.stringify([...pinnedSessionIds]));
2046
- store.setItem('sticky-sessions', JSON.stringify([...stickySessionIds]));
2055
+ store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
2056
+ store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
2047
2057
  }
2048
2058
 
2049
2059
  // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
2050
2060
  function offloadSessionPin(sessionId) {
2051
2061
  const state = getSessionPinState(sessionId);
2052
- fetch('/api/session/pin', {
2053
- method: 'POST',
2054
- headers: { 'Content-Type': 'application/json' },
2055
- body: JSON.stringify({ id: sessionId, state }),
2056
- }).catch(() => {});
2062
+ api('/api/session/pin', { method: 'POST', body: { id: sessionId, state } }).catch(() => {});
2057
2063
  }
2058
2064
 
2059
2065
  function clearSessionPin(sessionId) {
@@ -2236,33 +2242,27 @@ function highlightSelectedMsg() {
2236
2242
  if (el) el.classList.add('selected');
2237
2243
  }
2238
2244
 
2239
- function showMsgDetail(idx) {
2240
- currentMsgDetailIdx = idx;
2241
- document.getElementById('msg-detail-waiting-footer').innerHTML = '';
2242
- msgHighlightDimmed = false;
2243
- const m = currentMessages[idx];
2244
- if (!m) return;
2245
- highlightSelectedMsg();
2246
- 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) {
2247
2253
  if (m.type === 'tool_use') {
2248
- document.getElementById('msg-detail-title').textContent = m.tool;
2249
2254
  const fullText = m.fullDetail || m.detail || '';
2250
2255
  const descHtml =
2251
2256
  m.description && m.description !== fullText
2252
2257
  ? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>`
2253
2258
  : '';
2254
2259
  let agentExtraHtml = '';
2255
- const agentBtn = document.getElementById('msg-detail-agent-btn');
2256
2260
  if (m.tool === 'Agent' && m.agentId) {
2257
2261
  const agentRespText = m.agentLastMessage ? stripAnsi(m.agentLastMessage.trim()) : null;
2258
2262
  const agentPromptText = m.agentPrompt || null;
2259
2263
  const respHtml = agentRespText ? renderMarkdown(agentRespText) : null;
2260
2264
  const promptHtml = agentPromptText ? renderMarkdown(agentPromptText) : null;
2261
2265
  agentExtraHtml += renderAgentTabs(promptHtml, respHtml, agentPromptText, agentRespText);
2262
- agentBtn.style.display = '';
2263
- agentBtn.dataset.agentId = m.agentId;
2264
- } else {
2265
- agentBtn.style.display = 'none';
2266
2266
  }
2267
2267
  const sendProto = m.tool === 'SendMessage' && m.params?.protocol;
2268
2268
  const isFindings = m.tool === 'ReportFindings';
@@ -2296,7 +2296,7 @@ function showMsgDetail(idx) {
2296
2296
  }
2297
2297
  const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
2298
2298
  const toolResultImagesHtml = renderToolResultImagesHtml(m.toolResultImageCount, m.toolUseId);
2299
- body.innerHTML =
2299
+ return (
2300
2300
  mainHtml +
2301
2301
  toolParamsHtml +
2302
2302
  answersHtml +
@@ -2304,53 +2304,94 @@ function showMsgDetail(idx) {
2304
2304
  findingsHtml +
2305
2305
  (hasAgentTabs ? '' : toolResultHtml) +
2306
2306
  toolResultImagesHtml +
2307
- agentExtraHtml;
2308
- } else if (m.type === 'teammate') {
2309
- document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
2310
- document.getElementById('msg-detail-agent-btn').style.display = 'none';
2307
+ agentExtraHtml
2308
+ );
2309
+ }
2310
+ if (m.type === 'teammate') {
2311
2311
  if (m.isProtocol) {
2312
- body.innerHTML = m.protocolData
2312
+ return m.protocolData
2313
2313
  ? renderProtocolDetail(m.protocolData)
2314
2314
  : `<div class="teammate-idle-detail"><span class="protocol-label">${escapeHtml(m.protocolLabel || m.protocolType)}</span></div>`;
2315
- } else {
2316
- const text = stripAnsi(m.fullText || m.text || '');
2317
- body.innerHTML = renderMarkdown(text);
2318
2315
  }
2319
- } else {
2320
- const rawText = stripAnsi(m.fullText || m.text || '');
2321
- const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
2322
- document.getElementById('msg-detail-title').textContent =
2323
- m.type === 'assistant' ? 'Claude' : m.agentMessage ? m.systemLabel : m.systemLabel ? 'System' : 'User';
2324
- document.getElementById('msg-detail-agent-btn').style.display = 'none';
2325
- const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
2326
- if (m.compactSummary) {
2327
- body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
2328
- } else if (cmd) {
2329
- const args = parseCommandArgs(rawText) || null;
2330
- const cleanBody = rawText
2331
- .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
2332
- .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
2333
- .trim();
2334
- let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
2335
- if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
2336
- body.innerHTML = cmdHtml + userExtras;
2337
- } else if (rawText) {
2338
- body.innerHTML = renderMarkdown(rawText) + userExtras;
2339
- } else {
2340
- body.innerHTML = userExtras || '<em>No content</em>';
2341
- }
2342
- }
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;
2343
2386
  const overlay = document.getElementById('msg-detail-modal');
2344
2387
  const modal = overlay.querySelector('.modal');
2345
2388
  autoSizeModal(modal, body);
2346
2389
  setMsgDetailFollow(msgDetailFollowLatest);
2347
2390
 
2348
- const meta = [formatDate(m.timestamp)];
2349
- if (m.model) meta.unshift(m.model);
2350
- meta.push(`${idx + 1} of ${currentMessages.length}`);
2351
- document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
2391
+ setMsgDetailMeta(m, position);
2352
2392
  currentPinDetailId = null;
2353
2393
  updateMsgDetailPinState();
2394
+ syncMsgDetailPaneBtn();
2354
2395
  overlay.classList.add('visible');
2355
2396
  mountReplyReview(m, body, modal);
2356
2397
  }
@@ -2450,12 +2491,12 @@ function _applyModalFullscreen(modalId, on) {
2450
2491
  function toggleModalFullscreen(modalId) {
2451
2492
  const on = !_modalEl(modalId).classList.contains('fullscreen');
2452
2493
  _applyModalFullscreen(modalId, on);
2453
- store.setItem(`modal-fullscreen-${modalId}`, String(on));
2494
+ store.setItem(modalFullscreenKey(modalId), String(on));
2454
2495
  }
2455
2496
 
2456
2497
  function loadModalFullscreen() {
2457
2498
  _forEachFullscreenModal((modalId) => {
2458
- if (store.getItem(`modal-fullscreen-${modalId}`) === 'true') _applyModalFullscreen(modalId, true);
2499
+ if (store.getItem(modalFullscreenKey(modalId)) === 'true') _applyModalFullscreen(modalId, true);
2459
2500
  });
2460
2501
  }
2461
2502
 
@@ -2639,6 +2680,16 @@ function sendMessageAddress(peerName) {
2639
2680
  return `SendMessage(${peerName})`;
2640
2681
  }
2641
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
+
2642
2693
  async function copyWithFeedback(text, btn) {
2643
2694
  if (btn.dataset.copying) return;
2644
2695
  try {
@@ -2652,6 +2703,7 @@ async function copyWithFeedback(text, btn) {
2652
2703
  }, 1500);
2653
2704
  } catch (e) {
2654
2705
  console.error('Failed to copy:', e);
2706
+ showToast('Failed to copy', 'error');
2655
2707
  }
2656
2708
  }
2657
2709
 
@@ -2767,25 +2819,25 @@ function renderAnswerPayloadHtml(answerPayload) {
2767
2819
  })
2768
2820
  .join('');
2769
2821
  return `<div style="margin-top:6px">
2770
- <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>
2771
2823
  <ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
2772
2824
  </div>`;
2773
2825
  })
2774
2826
  .join('');
2775
- return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
2776
- <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>
2777
2829
  ${rows}
2778
2830
  </div>`;
2779
2831
  }
2780
2832
 
2781
2833
  //#region FINDINGS
2782
2834
  const FINDING_VERDICT_COLORS = {
2783
- CONFIRMED: 'var(--danger, #ef5350)',
2784
- PLAUSIBLE: 'var(--warning, #f0b429)',
2835
+ CONFIRMED: 'var(--danger)',
2836
+ PLAUSIBLE: 'var(--warning)',
2785
2837
  };
2786
2838
  const FINDING_OUTCOME_COLORS = {
2787
- fixed: 'var(--success, #3ecf8e)',
2788
- no_change_needed: 'var(--info, #60a5fa)',
2839
+ fixed: 'var(--success)',
2840
+ no_change_needed: 'var(--info)',
2789
2841
  skipped: 'var(--text-muted)',
2790
2842
  };
2791
2843
  function findingBadge(label, color) {
@@ -2878,7 +2930,7 @@ function renderToolParamsHtml(params) {
2878
2930
  blocks.push({ k, display, full });
2879
2931
  } else {
2880
2932
  badges.push(
2881
- `<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>`,
2882
2934
  );
2883
2935
  }
2884
2936
  }
@@ -2893,20 +2945,20 @@ function renderToolParamsHtml(params) {
2893
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>`;
2894
2946
  }
2895
2947
  for (const { k, pretty } of jsonBlocks) {
2896
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2897
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(k)}</div>
2898
- <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>
2899
2951
  </div>`;
2900
2952
  }
2901
2953
  if (params.old_string || params.new_string) {
2902
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
2954
+ html += `<div class="tool-detail-section">`;
2903
2955
  if (params.old_string) {
2904
- html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">old_string</div>
2905
- <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>`;
2906
2958
  }
2907
2959
  if (params.new_string) {
2908
- html += `<div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px;margin-top:6px">new_string</div>
2909
- <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>`;
2910
2962
  }
2911
2963
  html += `</div>`;
2912
2964
  }
@@ -2916,29 +2968,32 @@ function renderToolParamsHtml(params) {
2916
2968
  // small writes render as before.
2917
2969
  const fullContent = params.contentFull || params.content;
2918
2970
  const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
2919
- const truncContent = isTruncated
2920
- ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}${params.content.length > CONTENT_TRUNCATE_MAX ? '\n... (truncated)' : ''}`
2921
- : 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) : '');
2922
2976
  let writeMoreBtn = '',
2923
2977
  fullBlock = '';
2924
2978
  if (isTruncated) {
2925
- const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(fullContent), {
2979
+ const toggle = makeExpandToggle(truncHtml, escapeHtml(fullContent), {
2926
2980
  fontSize: '0.75rem',
2927
2981
  maxHeight: '500px',
2928
2982
  tinted: true,
2983
+ highlightLang: lang,
2929
2984
  });
2930
2985
  writeMoreBtn = ` ${toggle.btn}`;
2931
2986
  fullBlock = toggle.full;
2932
2987
  }
2933
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2934
- <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">content${writeMoreBtn}</div>
2935
- <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>
2936
2991
  ${fullBlock}
2937
2992
  </div>`;
2938
2993
  }
2939
2994
  if (params.plan) {
2940
- html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2941
- <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>
2942
2997
  <div class="markdown-body">${renderMarkdown(params.plan)}</div>
2943
2998
  </div>`;
2944
2999
  }
@@ -2971,20 +3026,22 @@ function toolOutputHtml(text) {
2971
3026
  return sanitizeOutputHtml(text);
2972
3027
  }
2973
3028
 
3029
+ // Strings and comments share one pass, so the apostrophe in "# don't" does not open a string.
2974
3030
  function highlightBash(escaped) {
2975
3031
  return escaped
2976
- .replace(/^(\s*)(#.*)$/gm, '$1<span style="color:#6a9955">$2</span>')
2977
- .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
+ )
2978
3035
  .replace(
2979
3036
  /\b(if|then|else|elif|fi|for|do|done|while|until|case|esac|function|return|in|select)\b/g,
2980
- '<span style="color:#c586c0">$1</span>',
3037
+ '<span class="hljs-keyword">$1</span>',
2981
3038
  )
2982
3039
  .replace(
2983
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,
2984
- '<span style="color:#569cd6">$1</span>',
3041
+ '<span class="hljs-built_in">$1</span>',
2985
3042
  )
2986
- .replace(/(\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*)/g, '<span style="color:#9cdcfe">$1</span>')
2987
- .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>');
2988
3045
  }
2989
3046
 
2990
3047
  const TINTED_PRE_CLASS = 'msg-detail-pre msg-detail-pre-tinted';
@@ -2992,6 +3049,12 @@ let _expandIdCounter = 0;
2992
3049
  function _applyExpandToggle(btn, fullEl) {
2993
3050
  const truncEl = btn.parentElement.nextElementSibling;
2994
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
+ }
2995
3058
  fullEl.style.display = expand ? 'block' : 'none';
2996
3059
  if (truncEl) truncEl.style.display = expand ? 'none' : 'block';
2997
3060
  btn.textContent = expand ? 'Show less' : 'Show more';
@@ -3012,7 +3075,8 @@ function makeExpandToggle(_truncatedHtml, fullHtml, opts = {}) {
3012
3075
  const cls = opts.tinted ? TINTED_PRE_CLASS : 'msg-detail-pre';
3013
3076
  const btn = `<button data-expand-id="${id}" onclick="_toggleExpand(this)" class="expand-toggle-btn" style="font-size:${fontSize}">Show more</button>`;
3014
3077
  const mhStyle = maxHeight ? `max-height:${maxHeight};` : '';
3015
- 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>`;
3016
3080
  return { btn, full };
3017
3081
  }
3018
3082
 
@@ -3051,8 +3115,8 @@ function renderToolResultHtml(toolResult, isTruncated, fullResult, toolUseId) {
3051
3115
  } else if (isTruncated) {
3052
3116
  truncLabel = '<span style="color:var(--text-muted);font-size:0.8rem;margin-left:6px">(truncated)</span>';
3053
3117
  }
3054
- return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
3055
- <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>
3056
3120
  <pre class="msg-detail-pre" style="overflow:auto">${escaped}</pre>
3057
3121
  ${fullBlock}
3058
3122
  </div>`;
@@ -3078,9 +3142,7 @@ async function _toggleToolResultExpand(btn) {
3078
3142
  btn.disabled = true;
3079
3143
  btn.textContent = 'Loading…';
3080
3144
  try {
3081
- const r = await fetch(
3082
- `/api/sessions/${encodeURIComponent(currentSessionId)}/tool-result/${encodeURIComponent(btn.dataset.toolUseId)}`,
3083
- );
3145
+ const r = await api(apiPath`/api/sessions/${currentSessionId}/tool-result/${btn.dataset.toolUseId}`);
3084
3146
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
3085
3147
  const { content } = await r.json();
3086
3148
  f.innerHTML = toolOutputHtml(content);
@@ -3088,7 +3150,7 @@ async function _toggleToolResultExpand(btn) {
3088
3150
  } catch (_e) {
3089
3151
  btn.textContent = 'Show more';
3090
3152
  btn.disabled = false;
3091
- showToast('Failed to load full output');
3153
+ showToast('Failed to load full output', 'error');
3092
3154
  return;
3093
3155
  }
3094
3156
  btn.disabled = false;
@@ -3121,14 +3183,10 @@ async function copyMsgToClipboard(btn) {
3121
3183
 
3122
3184
  async function postAndToast(url, body, label) {
3123
3185
  try {
3124
- const r = await fetch(url, {
3125
- method: 'POST',
3126
- headers: { 'Content-Type': 'application/json' },
3127
- body: JSON.stringify(body),
3128
- });
3129
- 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');
3130
3188
  } catch (_e) {
3131
- showToast(`Failed to open ${label}`);
3189
+ showToast(`Failed to open ${label}`, 'error');
3132
3190
  }
3133
3191
  }
3134
3192
 
@@ -3341,7 +3399,7 @@ function togglePinFromAgentModal() {
3341
3399
  async function dismissAgent(agentId) {
3342
3400
  if (!currentSessionId || !agentId) return;
3343
3401
  try {
3344
- 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' });
3345
3403
  if (res.ok) {
3346
3404
  currentWaiting = null;
3347
3405
  fetchAgents(currentSessionId);
@@ -3427,14 +3485,6 @@ function showAgentModal(agentId) {
3427
3485
  const dismissBtn = document.getElementById('agent-modal-dismiss-btn');
3428
3486
  dismissBtn.style.display = agent.status === 'active' || agent.status === 'idle' ? '' : 'none';
3429
3487
  modal.classList.add('visible');
3430
- const keyHandler = (e) => {
3431
- if (e.key === 'Escape') {
3432
- e.preventDefault();
3433
- closeAgentModal();
3434
- document.removeEventListener('keydown', keyHandler);
3435
- }
3436
- };
3437
- document.addEventListener('keydown', keyHandler);
3438
3488
  }
3439
3489
 
3440
3490
  function closeAgentModal() {
@@ -3607,9 +3657,7 @@ function getFilteredSessions() {
3607
3657
 
3608
3658
  function saveOrder(key, prev, next) {
3609
3659
  if (next.length === prev.length && next.every((id, i) => id === prev[i])) return prev;
3610
- try {
3611
- store.setItem(key, JSON.stringify(next));
3612
- } catch (_) {}
3660
+ store.writeJson(key, next);
3613
3661
  return next;
3614
3662
  }
3615
3663
 
@@ -3804,7 +3852,7 @@ function renderSessions() {
3804
3852
  return;
3805
3853
  }
3806
3854
 
3807
- const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3855
+ const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
3808
3856
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3809
3857
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3810
3858
  const renderGroupSessions = (sessions, pinKey) => {
@@ -4351,11 +4399,7 @@ function startAddTask(tile) {
4351
4399
  input.disabled = true;
4352
4400
  const sessionId = currentSessionId;
4353
4401
  try {
4354
- const res = await fetch(`/api/tasks/${sessionId}`, {
4355
- method: 'POST',
4356
- headers: { 'Content-Type': 'application/json' },
4357
- body: JSON.stringify({ subject }),
4358
- });
4402
+ const res = await api(apiPath`/api/tasks/${sessionId}`, { method: 'POST', body: { subject } });
4359
4403
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
4360
4404
  // The response carries the finished task, and the watcher will resend it within the
4361
4405
  // SSE debounce anyway -- so show it now rather than paying a session refetch for it.
@@ -4447,11 +4491,7 @@ async function onColumnDrop(e) {
4447
4491
  task.status = newStatus;
4448
4492
  renderKanban();
4449
4493
  try {
4450
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
4451
- method: 'PUT',
4452
- headers: { 'Content-Type': 'application/json' },
4453
- body: JSON.stringify({ status: newStatus }),
4454
- });
4494
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { status: newStatus } });
4455
4495
  if (res.ok) return;
4456
4496
  } catch (_) {}
4457
4497
  if (task.status === newStatus) {
@@ -4488,34 +4528,28 @@ function pinKey(projectPath) {
4488
4528
  }
4489
4529
 
4490
4530
  function loadSessionGroups() {
4491
- try {
4492
- const list = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.groups;
4493
- sessionGroups = (Array.isArray(list) ? list : [])
4494
- .filter((g) => g && typeof g.id === 'string')
4495
- .map((g) => ({
4496
- id: g.id,
4497
- name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4498
- members: (Array.isArray(g.members) ? g.members : [])
4499
- .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4500
- .map((m) => {
4501
- const out = { type: m.type, ref: m.ref };
4502
- if (typeof m.under === 'string' && m.under) out.under = m.under;
4503
- if (m.loose === true) out.loose = true;
4504
- return out;
4505
- }),
4506
- }));
4507
- const released = JSON.parse(store.getItem(SESSION_GROUPS_KEY) || 'null')?.released;
4508
- sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
4509
- } catch (_) {
4510
- sessionGroups = [];
4511
- sgReleased = new Set();
4512
- }
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') : []);
4513
4549
  }
4514
4550
 
4515
4551
  function persistSessionGroups() {
4516
- try {
4517
- store.setItem(SESSION_GROUPS_KEY, JSON.stringify({ version: 1, groups: sessionGroups, released: [...sgReleased] }));
4518
- } catch (_) {}
4552
+ store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
4519
4553
  }
4520
4554
 
4521
4555
  function sgGroupById(id) {
@@ -5030,10 +5064,13 @@ function initSessionGroupsDnd() {
5030
5064
  // Non-drag path: right-click or long-press a session / project header.
5031
5065
  let sgLongPressTimer = null;
5032
5066
 
5033
- function sgCloseMenu() {
5067
+ function sgCloseMenu(restore) {
5034
5068
  document.removeEventListener('keydown', sgMenuKeydown, true);
5035
5069
  const menu = document.getElementById('sg-menu');
5036
- if (menu) menu.remove();
5070
+ if (menu) {
5071
+ menu.remove();
5072
+ takeMenuFocus(restore);
5073
+ }
5037
5074
  }
5038
5075
 
5039
5076
  // Run in the capture phase: the global handler would otherwise read these keys as sidebar shortcuts.
@@ -5047,11 +5084,25 @@ function menuKeydown(e, items, close) {
5047
5084
  else if (matchKey(e, 'ArrowUp')) next = i < 0 ? items.length - 1 : (i - 1 + items.length) % items.length;
5048
5085
  else if (e.key === 'Home') next = 0;
5049
5086
  else if (e.key === 'End') next = items.length - 1;
5050
- else if (e.key === 'Escape' || e.key === 'Tab') close();
5087
+ else if (e.key === 'Escape' || e.key === 'Tab') close(true);
5051
5088
  if (next !== null) items[next]?.focus();
5052
5089
  }
5053
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
+
5054
5104
  function placeMenu(menu, x, y) {
5105
+ menuReturnFocus = document.activeElement;
5055
5106
  document.body.appendChild(menu);
5056
5107
  const rect = menu.getBoundingClientRect();
5057
5108
  menu.style.left = `${Math.min(x, window.innerWidth - rect.width - 8)}px`;
@@ -5135,7 +5186,7 @@ function sgOnTouchStart(e) {
5135
5186
  document.addEventListener('click', (e) => {
5136
5187
  const item = e.target.closest('.sg-menu-item');
5137
5188
  if (!item) {
5138
- if (!e.target.closest('.sg-menu')) sgCloseMenu();
5189
+ if (!e.target.closest('.sg-menu')) sgCloseMenu(true);
5139
5190
  return;
5140
5191
  }
5141
5192
  e.stopPropagation();
@@ -5359,9 +5410,7 @@ function setGroupCollapsed(header, collapsed) {
5359
5410
  }
5360
5411
 
5361
5412
  function persistCollapsedGroups() {
5362
- try {
5363
- store.setItem('collapsedGroups', JSON.stringify([...collapsedProjectGroups]));
5364
- } catch (_) {}
5413
+ store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
5365
5414
  }
5366
5415
 
5367
5416
  let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
@@ -5548,42 +5597,6 @@ sessionsList.addEventListener('click', (e) => {
5548
5597
  if (focusZone === 'sidebar' || (!taskInUse && !terminalPaneFocused())) setFocusZone('sidebar', el);
5549
5598
  });
5550
5599
 
5551
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5552
- function getAvailableTasksOptions(currentTaskId = null) {
5553
- const pending = currentTasks.filter((t) => t.status === 'pending' && t.id !== currentTaskId);
5554
- const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && t.id !== currentTaskId);
5555
- const completed = currentTasks.filter((t) => t.status === 'completed' && t.id !== currentTaskId);
5556
-
5557
- // Build options grouped by status
5558
- let options = '';
5559
-
5560
- if (pending.length > 0) {
5561
- options += '<optgroup label="Pending">';
5562
- pending.forEach((t, _idx) => {
5563
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5564
- });
5565
- options += '</optgroup>';
5566
- }
5567
-
5568
- if (inProgress.length > 0) {
5569
- options += '<optgroup label="In Progress">';
5570
- inProgress.forEach((t, _idx) => {
5571
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5572
- });
5573
- options += '</optgroup>';
5574
- }
5575
-
5576
- if (completed.length > 0) {
5577
- options += '<optgroup label="Completed">';
5578
- completed.forEach((t, _idx) => {
5579
- options += `<option value="${escapeHtml(t.id)}">#${t.id} - ${escapeHtml(t.subject)}</option>`;
5580
- });
5581
- options += '</optgroup>';
5582
- }
5583
-
5584
- return options;
5585
- }
5586
-
5587
5600
  //#endregion
5588
5601
 
5589
5602
  //#region TASK_DETAIL
@@ -5594,15 +5607,9 @@ async function showTaskDetail(taskId, sessionId = null) {
5594
5607
 
5595
5608
  // If task not found in currentTasks, fetch it from the session
5596
5609
  if (!task && sessionId && sessionId !== 'undefined') {
5597
- try {
5598
- const res = await fetch(`/api/sessions/${sessionId}`);
5599
- const tasks = await res.json();
5600
- task = tasks.find((t) => t.id === taskId);
5601
- if (!task) return;
5602
- } catch (error) {
5603
- console.error('Failed to fetch task:', error);
5604
- return;
5605
- }
5610
+ const tasks = await getJson(apiPath`/api/sessions/${sessionId}`, []);
5611
+ task = tasks.find((t) => t.id === taskId);
5612
+ if (!task) return;
5606
5613
  }
5607
5614
 
5608
5615
  if (!task) return;
@@ -5782,11 +5789,7 @@ function editDescription(descEl, task, sessionId) {
5782
5789
 
5783
5790
  async function saveTaskField(taskId, sessionId, field, value) {
5784
5791
  try {
5785
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
5786
- method: 'PUT',
5787
- headers: { 'Content-Type': 'application/json' },
5788
- body: JSON.stringify({ [field]: value }),
5789
- });
5792
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'PUT', body: { [field]: value } });
5790
5793
 
5791
5794
  if (res.ok) {
5792
5795
  lastCurrentTasksHash = null;
@@ -5805,47 +5808,6 @@ function closeDetailPanel() {
5805
5808
  taskHighlightDimmed = false;
5806
5809
  }
5807
5810
 
5808
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5809
- function showBlockedTaskModal(task) {
5810
- const messageDiv = document.getElementById('blocked-task-message');
5811
-
5812
- const blockedByList = task.blockedBy
5813
- .map((id) => {
5814
- const blockingTask = currentTasks.find((t) => t.id === id);
5815
- if (blockingTask) {
5816
- return `<li><strong>#${blockingTask.id}</strong> - ${escapeHtml(blockingTask.subject)}</li>`;
5817
- }
5818
- return `<li><strong>#${id}</strong></li>`;
5819
- })
5820
- .join('');
5821
-
5822
- messageDiv.innerHTML = `
5823
- <p style="margin-bottom: 12px;">Task <strong>#${task.id}</strong> - ${escapeHtml(task.subject)} is currently blocked by:</p>
5824
- <ul style="margin: 0 0 16px 20px; padding: 0;">${blockedByList}</ul>
5825
- <p style="margin: 0; color: var(--text-secondary); font-size: 13px;">
5826
- Please resolve these dependencies before moving this task to <strong>In Progress</strong>.
5827
- </p>
5828
- `;
5829
-
5830
- const modal = document.getElementById('blocked-task-modal');
5831
- modal.classList.add('visible');
5832
-
5833
- // Handle ESC key
5834
- const keyHandler = (e) => {
5835
- if (e.key === 'Escape') {
5836
- e.preventDefault();
5837
- closeBlockedTaskModal();
5838
- document.removeEventListener('keydown', keyHandler);
5839
- }
5840
- };
5841
- document.addEventListener('keydown', keyHandler);
5842
- }
5843
-
5844
- function closeBlockedTaskModal() {
5845
- const modal = document.getElementById('blocked-task-modal');
5846
- modal.classList.remove('visible');
5847
- }
5848
-
5849
5811
  //#endregion
5850
5812
 
5851
5813
  //#region DELETE_TASK
@@ -5858,6 +5820,7 @@ function confirmModal({ title, message, okLabel }) {
5858
5820
  document.getElementById('confirm-modal-message').textContent = message;
5859
5821
  const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5860
5822
  buttons[1].textContent = okLabel;
5823
+ const returnFocus = document.activeElement;
5861
5824
  modal.classList.add('visible');
5862
5825
  buttons[1].focus();
5863
5826
 
@@ -5876,6 +5839,7 @@ function confirmModal({ title, message, okLabel }) {
5876
5839
  modal.classList.remove('visible');
5877
5840
  document.removeEventListener('keydown', onKey);
5878
5841
  closeConfirmModal = null;
5842
+ if (!ok) restoreFocusTo(returnFocus, modal);
5879
5843
  resolve(ok);
5880
5844
  };
5881
5845
  });
@@ -5892,20 +5856,18 @@ async function deleteTask(taskId, sessionId) {
5892
5856
  if (!ok) return;
5893
5857
 
5894
5858
  try {
5895
- const res = await fetch(`/api/tasks/${sessionId}/${taskId}`, {
5896
- method: 'DELETE',
5897
- });
5859
+ const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
5898
5860
 
5899
5861
  if (res.ok) {
5900
5862
  closeDetailPanel();
5901
5863
  await refreshCurrentView();
5902
5864
  } else {
5903
- const error = await res.json();
5904
- 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');
5905
5867
  }
5906
5868
  } catch (error) {
5907
5869
  console.error('Failed to delete task:', error);
5908
- alert('Failed to delete task');
5870
+ showToast('Failed to delete task', 'error');
5909
5871
  }
5910
5872
  }
5911
5873
 
@@ -5942,14 +5904,20 @@ const SHORTCUT_TABS = [
5942
5904
  ],
5943
5905
  },
5944
5906
  {
5945
- title: 'View',
5907
+ title: 'Tabs',
5946
5908
  rows: [
5947
- { keys: ['\\'], label: 'Toggle sidebar' },
5948
5909
  { keys: ['[', ']'], label: 'Previous / next tab (a split counts as one)' },
5949
5910
  { keys: ['Alt', '[ / ]'], combo: true, label: 'Change the tab in the focused side of a split' },
5950
5911
  { keys: ['|'], label: 'Split with the tab before it / unsplit (or drag a tab onto a side)' },
5951
5912
  { keys: ['{', '}'], label: 'Focus left / right side of a split' },
5952
- { 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' },
5953
5921
  { keys: ['T'], label: 'Toggle theme' },
5954
5922
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5955
5923
  { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
@@ -5959,13 +5927,6 @@ const SHORTCUT_TABS = [
5959
5927
  { keys: ['?'], label: 'Show this help' },
5960
5928
  ],
5961
5929
  },
5962
- {
5963
- title: 'Copy',
5964
- rows: [
5965
- { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5966
- { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Session name (SendMessage address)' },
5967
- ],
5968
- },
5969
5930
  ],
5970
5931
  },
5971
5932
  {
@@ -5980,7 +5941,7 @@ const SHORTCUT_TABS = [
5980
5941
  { keys: ['I'], label: 'Session info' },
5981
5942
  { keys: ['.'], label: 'Pin / unpin' },
5982
5943
  { keys: ['>'], label: 'Toggle sticky' },
5983
- { 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)' },
5984
5945
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
5985
5946
  { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5986
5947
  { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
@@ -5994,6 +5955,8 @@ const SHORTCUT_TABS = [
5994
5955
  { keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
5995
5956
  { keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
5996
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)' },
5997
5960
  ],
5998
5961
  },
5999
5962
  ],
@@ -6006,7 +5969,7 @@ const SHORTCUT_TABS = [
6006
5969
  rows: [
6007
5970
  { keys: ['Ctrl', '`'], combo: true, label: 'Show / hide terminal' },
6008
5971
  { keys: ['Alt', '`'], combo: true, label: 'Focus terminal / page' },
6009
- { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End and close terminal' },
5972
+ { keys: ['Alt', 'Shift', '`'], combo: true, label: 'End terminal' },
6010
5973
  { keys: ['Ctrl', 'Shift', '`'], combo: true, label: 'All terminals' },
6011
5974
  { keys: ['Ctrl', '+/−/0'], combo: true, label: 'Terminal text size' },
6012
5975
  ],
@@ -6020,9 +5983,9 @@ const SHORTCUT_TABS = [
6020
5983
  {
6021
5984
  title: 'Hub',
6022
5985
  rows: [
6023
- { keys: ['M'], label: 'Jump to marketplace' },
6024
- { keys: ['$'], label: 'Jump to cost' },
6025
- { 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' },
6026
5989
  {
6027
5990
  keys: ['Ctrl', 'Alt', '←/→'],
6028
5991
  action: ['hub.prevApp', 'hub.nextApp'],
@@ -6104,8 +6067,14 @@ function buildHelpShortcuts(groups) {
6104
6067
  let helpTab = 0;
6105
6068
  const helpFilter = document.getElementById('help-filter');
6106
6069
 
6107
- // Hub keys do nothing outside Claude Code Hub, so the standalone board drops that tab.
6108
- 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);
6109
6078
 
6110
6079
  // The matching rows of every tab, in their groups.
6111
6080
  function filteredShortcutGroups(query) {
@@ -6113,7 +6082,7 @@ function filteredShortcutGroups(query) {
6113
6082
  .flatMap((t) => t.groups)
6114
6083
  .map((g) => ({
6115
6084
  title: g.title,
6116
- 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)),
6117
6086
  }))
6118
6087
  .filter((g) => g.rows.length);
6119
6088
  }
@@ -6494,7 +6463,7 @@ function _updateStorageTotal() {
6494
6463
  let _storageKnownSessions = null;
6495
6464
 
6496
6465
  async function _fetchKnownSessions() {
6497
- const res = await fetch('/api/sessions/known');
6466
+ const res = await api('/api/sessions/known');
6498
6467
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
6499
6468
  const known = new Map((await res.json()).map((s) => [s.id, s]));
6500
6469
  for (const s of sessions) known.set(s.id, s);
@@ -6587,12 +6556,10 @@ function _renderStorageSessions() {
6587
6556
 
6588
6557
  const msgMap = new Map();
6589
6558
  for (const key of store.keys()) {
6590
- if (!key.startsWith('pinned-messages-')) continue;
6591
- const sid = key.slice('pinned-messages-'.length);
6592
- try {
6593
- const pins = JSON.parse(store.getItem(key)) || [];
6594
- if (pins.length) msgMap.set(sid, { pins, key });
6595
- } 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 });
6596
6563
  }
6597
6564
 
6598
6565
  const allIds = [...new Set([...pinnedIds, ...msgMap.keys()])];
@@ -6672,7 +6639,7 @@ function _storageUnpinSession(id) {
6672
6639
  }
6673
6640
 
6674
6641
  function _storageClearSessionPins(sessionId) {
6675
- store.removeItem(`pinned-messages-${sessionId}`);
6642
+ store.removeItem(pinsKey(sessionId));
6676
6643
  if (currentSessionId === sessionId) {
6677
6644
  currentPins = [];
6678
6645
  const el = document.getElementById('message-panel-pinned');
@@ -6683,20 +6650,16 @@ function _storageClearSessionPins(sessionId) {
6683
6650
  }
6684
6651
 
6685
6652
  function _storageUnpinMessage(sessionId, pinId) {
6686
- const key = `pinned-messages-${sessionId}`;
6687
- try {
6688
- const pins = JSON.parse(store.getItem(key)) || [];
6689
- const idx = pins.findIndex((p) => p.id === pinId);
6690
- if (idx < 0) return;
6691
- pins.splice(idx, 1);
6692
- if (pins.length) store.setItem(key, JSON.stringify(pins));
6693
- else store.removeItem(key);
6694
- if (currentSessionId === sessionId) {
6695
- currentPins = pins;
6696
- const el = document.getElementById('message-panel-pinned');
6697
- if (el) el.innerHTML = renderPinnedSection();
6698
- }
6699
- } 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
+ }
6700
6663
  _renderStorageTab();
6701
6664
  _updateStorageTotal();
6702
6665
  }
@@ -6796,19 +6759,14 @@ function _storagePreviewScratchpad(key) {
6796
6759
 
6797
6760
  function _storagePreviewPin(sessionId, pinId) {
6798
6761
  closeStorageManager();
6799
- const key = `pinned-messages-${sessionId}`;
6800
- try {
6801
- const pins = JSON.parse(store.getItem(key)) || [];
6802
- const pin = pins.find((p) => p.id === pinId);
6803
- if (!pin) return;
6804
- document.getElementById('msg-detail-pin-btn').style.display = 'none';
6805
- currentMsgDetailIdx = null;
6806
- currentPinDetailId = null;
6807
- _renderPinToDetail(pin);
6808
- document.getElementById('msg-detail-modal').classList.add('visible');
6809
- } catch (e) {
6810
- console.error('_storagePreviewPin error:', e);
6811
- }
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');
6812
6770
  }
6813
6771
 
6814
6772
  function _storageDeleteScratchpad(key) {
@@ -6901,7 +6859,7 @@ function _findOrphanedKeys(known) {
6901
6859
  const orphaned = [];
6902
6860
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
6903
6861
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
6904
- 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];
6905
6863
  for (const key of store.keys()) {
6906
6864
  const pad = _parsePadKey(key);
6907
6865
  if (pad?.kind === 'group') {
@@ -6928,23 +6886,28 @@ async function cleanupOrphanedStorage() {
6928
6886
  try {
6929
6887
  known = await _fetchKnownSessions();
6930
6888
  } catch {
6931
- showToast('Could not list sessions — nothing was cleaned');
6889
+ showToast('Failed to list sessions — nothing was cleaned', 'error');
6932
6890
  return;
6933
6891
  }
6934
6892
  const orphaned = _findOrphanedKeys(known);
6935
- let pinsChanged = false;
6893
+ const unpinned = new Set();
6936
6894
  for (const key of orphaned) {
6937
6895
  if (key.startsWith('__pinned__')) {
6938
- pinnedSessionIds.delete(key.slice('__pinned__'.length));
6939
- pinsChanged = true;
6896
+ const id = key.slice('__pinned__'.length);
6897
+ pinnedSessionIds.delete(id);
6898
+ unpinned.add(id);
6940
6899
  } else if (key.startsWith('__sticky__')) {
6941
- stickySessionIds.delete(key.slice('__sticky__'.length));
6942
- pinsChanged = true;
6900
+ const id = key.slice('__sticky__'.length);
6901
+ stickySessionIds.delete(id);
6902
+ unpinned.add(id);
6943
6903
  } else {
6944
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));
6945
6907
  }
6946
6908
  }
6947
- if (pinsChanged) savePinnedSessions();
6909
+ if (unpinned.size) savePinnedSessions();
6910
+ for (const id of unpinned) offloadSessionPin(id);
6948
6911
  const removed = orphaned.length;
6949
6912
 
6950
6913
  showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
@@ -6961,6 +6924,10 @@ function matchKey(e, ...keys) {
6961
6924
  return keys.some((k) => e.key === k || e.code === k);
6962
6925
  }
6963
6926
 
6927
+ function shiftOnly(e, code) {
6928
+ return e.code === code && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey;
6929
+ }
6930
+
6964
6931
  function dismissSessionFromList(sid) {
6965
6932
  const prevIdx = selectedSessionIdx;
6966
6933
  if (!setSessionDismissed(sid, true)) return;
@@ -7009,10 +6976,10 @@ async function confirmDismissBlockers(sid) {
7009
6976
  const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
7010
6977
  const [title, verb, okLabel] =
7011
6978
  sticky && terminal
7012
- ? ['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']
7013
6980
  : sticky
7014
6981
  ? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
7015
- : ['Close Terminal', 'Close the terminal', 'Close and dismiss'];
6982
+ : ['End Terminal', 'End the terminal', 'End and dismiss'];
7016
6983
  const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
7017
6984
  if (!ok) return false;
7018
6985
  if (sticky) unpinSession(sid);
@@ -7110,11 +7077,7 @@ document.addEventListener('keydown', (e) => {
7110
7077
  } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
7111
7078
  e.preventDefault();
7112
7079
  closeHelpModal();
7113
- } else if (
7114
- e.code === 'KeyM' &&
7115
- e.shiftKey &&
7116
- document.getElementById('msg-detail-modal').classList.contains('visible')
7117
- ) {
7080
+ } else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
7118
7081
  e.preventDefault();
7119
7082
  closeMsgDetailModal();
7120
7083
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
@@ -7177,6 +7140,10 @@ document.addEventListener('keydown', (e) => {
7177
7140
  e.preventDefault();
7178
7141
  return;
7179
7142
  }
7143
+ if (isMenuKey(e) && ((focusZone === 'sidebar' && sgOpenMenuForKbSelection()) || openFocusedPaneMenu())) {
7144
+ e.preventDefault();
7145
+ return;
7146
+ }
7180
7147
  const plainShift = !e.ctrlKey && !e.altKey && !e.metaKey;
7181
7148
  if (plainShift && e.key === '|' && togglePaneSplit()) {
7182
7149
  e.preventDefault();
@@ -7186,17 +7153,17 @@ document.addEventListener('keydown', (e) => {
7186
7153
  e.preventDefault();
7187
7154
  return;
7188
7155
  }
7189
- if (e.code === 'KeyL' && e.shiftKey) {
7156
+ if (shiftOnly(e, 'KeyL')) {
7190
7157
  e.preventDefault();
7191
7158
  toggleMessagePanel();
7192
7159
  return;
7193
7160
  }
7194
- if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
7161
+ if (shiftOnly(e, 'KeyP')) {
7195
7162
  e.preventDefault();
7196
7163
  openProjectPicker();
7197
7164
  return;
7198
7165
  }
7199
- if (e.code === 'KeyM' && e.shiftKey) {
7166
+ if (shiftOnly(e, 'KeyM')) {
7200
7167
  e.preventDefault();
7201
7168
  const msgDetailModal = document.getElementById('msg-detail-modal');
7202
7169
  if (msgDetailModal.classList.contains('visible')) {
@@ -7210,12 +7177,12 @@ document.addEventListener('keydown', (e) => {
7210
7177
  }
7211
7178
  return;
7212
7179
  }
7213
- if (e.code === 'KeyS' && e.shiftKey) {
7180
+ if (shiftOnly(e, 'KeyS')) {
7214
7181
  e.preventDefault();
7215
7182
  showStorageManager();
7216
7183
  return;
7217
7184
  }
7218
- if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
7185
+ if (shiftOnly(e, 'KeyZ')) {
7219
7186
  e.preventDefault();
7220
7187
  toggleZenMode();
7221
7188
  return;
@@ -7271,11 +7238,6 @@ document.addEventListener('keydown', (e) => {
7271
7238
  activateSelectedSession();
7272
7239
  return;
7273
7240
  }
7274
- const menuKey = e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey);
7275
- if (menuKey && sgOpenMenuForKbSelection()) {
7276
- e.preventDefault();
7277
- return;
7278
- }
7279
7241
  }
7280
7242
 
7281
7243
  // Board navigation
@@ -7340,17 +7302,17 @@ document.addEventListener('keydown', (e) => {
7340
7302
  const cursorEl = focusZone === 'sidebar' ? sessionsList.querySelector('.kb-selected') : null;
7341
7303
  const contextSid =
7342
7304
  focusZone === 'sidebar' ? cursorEl?.dataset.sessionId || currentSessionId : selectedSessionId || currentSessionId;
7343
- if (matchKey(e, 'KeyP') && !e.shiftKey) {
7305
+ if (matchKey(e, 'KeyP')) {
7344
7306
  e.preventDefault();
7345
7307
  if (contextSid) openPlanForSession(contextSid);
7346
7308
  return;
7347
7309
  }
7348
- if (matchKey(e, 'KeyI') && !e.shiftKey) {
7310
+ if (matchKey(e, 'KeyI')) {
7349
7311
  e.preventDefault();
7350
7312
  if (contextSid) showSessionInfoModal(contextSid);
7351
7313
  return;
7352
7314
  }
7353
- if (matchKey(e, 'KeyN') && !e.shiftKey) {
7315
+ if (matchKey(e, 'KeyN')) {
7354
7316
  e.preventDefault();
7355
7317
  const cursorKey = _scratchpadKeyOf(cursorEl);
7356
7318
  if (cursorKey) showScratchpad(cursorKey);
@@ -7368,13 +7330,13 @@ document.addEventListener('keydown', (e) => {
7368
7330
  openMarketplace(mSession?.project);
7369
7331
  return;
7370
7332
  }
7371
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'm') {
7333
+ if (ClaudeHub.comboOf(e) === 'ctrl+m') {
7372
7334
  e.preventDefault();
7373
7335
  const mSession = contextSid ? sessions.find((s) => s.id === contextSid) : null;
7374
7336
  openMemory(mSession?.project);
7375
7337
  return;
7376
7338
  }
7377
- if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.key === 'd') {
7339
+ if (ClaudeHub.comboOf(e) === 'ctrl+d') {
7378
7340
  e.preventDefault();
7379
7341
  if (!contextSid) return;
7380
7342
  dismissSession(contextSid);
@@ -7394,10 +7356,7 @@ document.addEventListener('keydown', (e) => {
7394
7356
  showToast('No live session name: the session is not running');
7395
7357
  return;
7396
7358
  }
7397
- navigator.clipboard
7398
- .writeText(text)
7399
- .then(() => showToast(`Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`, 'success'))
7400
- .catch(() => showToast(`Failed to copy ${label}`));
7359
+ copyText(text, label, `Copied ${label}: ${e.ctrlKey ? text : text.slice(0, 8)}`);
7401
7360
  return;
7402
7361
  }
7403
7362
  if (matchKey(e, 'KeyR')) {
@@ -8034,7 +7993,7 @@ async function sendReview() {
8034
7993
  const items = reviewItems();
8035
7994
  if (!items.length) return;
8036
7995
  try {
8037
- const res = await terminalFetch(`/api/sessions/${encodeURIComponent(sessionId)}/review`, 'POST', {
7996
+ const res = await terminalFetch(apiPath`/api/sessions/${sessionId}/review`, 'POST', {
8038
7997
  source,
8039
7998
  comments: items.map(({ quote, comment, context }) => ({ quote, comment, ...context })),
8040
7999
  });
@@ -8044,14 +8003,19 @@ async function sendReview() {
8044
8003
  else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
8045
8004
  else {
8046
8005
  const { markdown } = data;
8047
- 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
+ };
8048
8018
  await copy();
8049
- showToast(
8050
- 'Review copied. Paste it into the session.',
8051
- 'info',
8052
- { label: 'Copy again', onClick: copy },
8053
- 'Run /claude-code-kanban:follow there to send reviews directly.',
8054
- );
8055
8019
  }
8056
8020
  clearReviewDrafts(key);
8057
8021
  if (activeReview?.key === key) onSent?.();
@@ -8066,12 +8030,8 @@ const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
8066
8030
  let currentPreviewPath = null;
8067
8031
 
8068
8032
  function readStoredList(key) {
8069
- try {
8070
- const arr = JSON.parse(store.getItem(key) || '[]');
8071
- return Array.isArray(arr) ? arr : [];
8072
- } catch {
8073
- return [];
8074
- }
8033
+ const arr = store.readJson(key);
8034
+ return Array.isArray(arr) ? arr : [];
8075
8035
  }
8076
8036
 
8077
8037
  function getSessionPreviewPaths(sessionId) {
@@ -8105,7 +8065,7 @@ function removeSessionPreviewPath(sessionId, filePath) {
8105
8065
  }
8106
8066
 
8107
8067
  function saveSessionPreviewPaths(sessionId, paths) {
8108
- 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));
8109
8069
  else store.removeItem(PREVIEW_STORAGE_PREFIX + sessionId);
8110
8070
  }
8111
8071
 
@@ -8122,14 +8082,7 @@ function setSessionDocLink(sessionId, filePath, unlink) {
8122
8082
 
8123
8083
  // Links sent while no tab was open reach the browser only through the server copy.
8124
8084
  async function mergeServerLinkedDocs() {
8125
- let bySession;
8126
- try {
8127
- const res = await fetch('/api/document/links');
8128
- if (!res.ok) return;
8129
- bySession = await res.json();
8130
- } catch {
8131
- return;
8132
- }
8085
+ const bySession = await getJson('/api/document/links', {});
8133
8086
  for (const [sessionId, paths] of Object.entries(bySession || {})) {
8134
8087
  const local = getSessionPreviewPaths(sessionId);
8135
8088
  const known = new Set(local.map(canonicalPath));
@@ -8141,8 +8094,9 @@ async function mergeServerLinkedDocs() {
8141
8094
  }
8142
8095
 
8143
8096
  function forgetServerLinkedDoc(sessionId, filePath) {
8144
- const q = filePath ? `?path=${encodeURIComponent(filePath)}` : '';
8145
- 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
+ );
8146
8100
  }
8147
8101
 
8148
8102
  function afterLinkedDocsChanged(sessionId) {
@@ -8223,13 +8177,24 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8223
8177
  // backtracking on the main thread, and .jsonl/.log files in a scratchpad reach megabytes.
8224
8178
  const HLJS_MAX_CHARS = 256 * 1024;
8225
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
+
8226
8193
  function renderSourcePreview(filePath, content) {
8227
- const ext = (filePath.split('.').pop() || '').toLowerCase();
8228
- if (typeof hljs !== 'undefined' && content.length <= HLJS_MAX_CHARS && hljs.getLanguage(ext)) {
8229
- try {
8230
- const value = hljs.highlight(content, { language: ext, ignoreIllegals: true }).value;
8231
- return `<pre class="preview-source"><code class="hljs language-${escapeHtml(ext)}">${value}</code></pre>`;
8232
- } 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>`;
8233
8198
  }
8234
8199
  return `<pre class="preview-source"><code class="hljs">${escapeHtml(content)}</code></pre>`;
8235
8200
  }
@@ -8304,7 +8269,7 @@ function updatePreviewLinkBtn() {
8304
8269
  btn.style.display = '';
8305
8270
  const linked = isPreviewLinkedToCurrentSession();
8306
8271
  btn.title = linked ? 'Unlink from current session' : 'Link to current session';
8307
- btn.style.color = linked ? 'var(--accent, #5b9a6b)' : '';
8272
+ btn.style.color = linked ? 'var(--accent)' : '';
8308
8273
  }
8309
8274
 
8310
8275
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -8359,16 +8324,14 @@ function openPreviewInEditor() {
8359
8324
  async function openPreviewByPath(filePath, base, onUnsupported) {
8360
8325
  if (!filePath) return;
8361
8326
  try {
8362
- const qs = new URLSearchParams({ path: filePath });
8363
- if (base) qs.set('base', base);
8364
- const r = await fetch(`/api/preview?${qs}`);
8327
+ const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
8365
8328
  if (!r.ok) {
8366
- showToast('Preview file unavailable');
8329
+ showToast('Preview file unavailable', 'error');
8367
8330
  return;
8368
8331
  }
8369
8332
  const data = await r.json();
8370
8333
  if (data.exists === false) {
8371
- showToast('File not found');
8334
+ showToast('File not found', 'error');
8372
8335
  return;
8373
8336
  }
8374
8337
  if (data.kind === null) {
@@ -8378,7 +8341,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
8378
8341
  }
8379
8342
  openPreviewModal(data.path, data.content, data.kind);
8380
8343
  } catch {
8381
- showToast('Failed to load preview');
8344
+ showToast('Failed to load preview', 'error');
8382
8345
  }
8383
8346
  }
8384
8347
 
@@ -8450,12 +8413,8 @@ async function loadPreviewKinds(sessionId, paths) {
8450
8413
  try {
8451
8414
  await Promise.all(
8452
8415
  pending.map(async (p) => {
8453
- let kind = null;
8454
- try {
8455
- const r = await fetch(`/api/file/resolve?path=${encodeURIComponent(p)}`);
8456
- if (r.ok) kind = (await r.json()).kind;
8457
- } catch (_) {}
8458
- previewKindByPath.set(p, kind);
8416
+ const data = await getJson('/api/file/resolve', {}, { query: { path: p } });
8417
+ previewKindByPath.set(p, data.kind ?? null);
8459
8418
  }),
8460
8419
  );
8461
8420
  } finally {
@@ -8475,21 +8434,18 @@ function getOfferedPadPaths(sessionId) {
8475
8434
  }
8476
8435
 
8477
8436
  function setOfferedPadPaths(sessionId, paths) {
8478
- store.setItem(PAD_LINKED_PREFIX + sessionId, JSON.stringify(paths));
8437
+ store.writeJson(PAD_LINKED_PREFIX + sessionId, paths);
8479
8438
  }
8480
8439
 
8481
8440
  async function loadSessionPads(sessionId) {
8482
8441
  if (!sessionId || padsFetched.has(sessionId)) return;
8483
8442
  padsFetched.add(sessionId);
8484
- let pads;
8485
- try {
8486
- const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/pads`);
8487
- if (!r.ok) throw new Error(r.status);
8488
- pads = (await r.json()).pads || [];
8489
- } catch (_) {
8443
+ const data = await getJson(apiPath`/api/sessions/${sessionId}/pads`);
8444
+ if (!data) {
8490
8445
  padsFetched.delete(sessionId);
8491
8446
  return;
8492
8447
  }
8448
+ const pads = data.pads || [];
8493
8449
  const offered = new Set(getOfferedPadPaths(sessionId).map(canonicalPath));
8494
8450
  const fresh = pads.map((p) => p.path).filter((p) => !offered.has(canonicalPath(p)));
8495
8451
  if (!fresh.length) return;
@@ -8652,15 +8608,10 @@ function makeSectionLoader({ attr, endpoint, pick, innerHtml, ttlMs = 0 }) {
8652
8608
  const cached = store.get(sessionId);
8653
8609
  if (cached && Date.now() - cached.at < ttlMs) return;
8654
8610
  inFlight.add(sessionId);
8655
- try {
8656
- const res = await fetch(`/api/sessions/${sessionId}/${endpoint}`);
8657
- if (!res.ok) return;
8658
- store.set(sessionId, { at: Date.now(), data: pick(await res.json()) });
8659
- } catch (_) {
8660
- return;
8661
- } finally {
8662
- inFlight.delete(sessionId);
8663
- }
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) });
8664
8615
  paintSectionSlots(attr, sessionId, innerHtml(sessionId));
8665
8616
  },
8666
8617
  };
@@ -8910,13 +8861,9 @@ async function toggleScratchFolder(sessionId, dirPath) {
8910
8861
  }
8911
8862
  scratchFolders.set(dirPath, { status: 'loading', children: [] });
8912
8863
  scratchFilesSection.repaint(sessionId);
8913
- let entry = { status: 'error', children: [] };
8914
- try {
8915
- const res = await fetch(`/api/sessions/${sessionId}/scratchpad-files?path=${encodeURIComponent(dirPath)}`);
8916
- if (res.ok) entry = { status: 'ready', children: (await res.json()).files || [] };
8917
- } catch (_) {
8918
- // Reads "Failed to load"; closing and reopening the folder retries.
8919
- }
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: [] };
8920
8867
  // The user may have closed the folder while the request was in flight.
8921
8868
  if (scratchFolders.has(dirPath)) {
8922
8869
  scratchFolders.set(dirPath, entry);
@@ -9017,10 +8964,7 @@ async function linkFileByPath(sessionId, raw, slot) {
9017
8964
  }
9018
8965
  } else {
9019
8966
  try {
9020
- const qs = new URLSearchParams({ path: value });
9021
- const base = getSessionBaseDir(sessionId);
9022
- if (base) qs.set('base', base);
9023
- const r = await fetch(`/api/file/resolve?${qs}`);
8967
+ const r = await api('/api/file/resolve', { query: { path: value, base: getSessionBaseDir(sessionId) || null } });
9024
8968
  const data = await r.json().catch(() => ({}));
9025
8969
  if (!r.ok || !data.exists) {
9026
8970
  fail(data.error || 'File not found');
@@ -9247,7 +9191,12 @@ function setupEventSource() {
9247
9191
  connect();
9248
9192
  }
9249
9193
 
9250
- 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
+ };
9251
9200
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
9252
9201
  const MODEL_THRESHOLDS = [
9253
9202
  { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
@@ -9707,6 +9656,7 @@ function showWaitingDetail() {
9707
9656
  document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
9708
9657
  currentPinDetailId = null;
9709
9658
  updateMsgDetailPinState();
9659
+ syncMsgDetailPaneBtn();
9710
9660
  overlay.classList.add('visible');
9711
9661
  }
9712
9662
 
@@ -9964,10 +9914,7 @@ function renderMarkdown(text) {
9964
9914
  }
9965
9915
 
9966
9916
  function isLightTheme() {
9967
- const saved = store.getItem('theme');
9968
- return (
9969
- document.body.classList.contains('light') || (!saved && window.matchMedia('(prefers-color-scheme: light)').matches)
9970
- );
9917
+ return document.body.classList.contains('light');
9971
9918
  }
9972
9919
 
9973
9920
  function getMermaidTheme() {
@@ -10073,28 +10020,13 @@ async function copyAgentTabActive(groupId, btn) {
10073
10020
  copyAgentTab(key, btn);
10074
10021
  }
10075
10022
 
10076
- const ownerColors = [
10077
- { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' }, // blue
10078
- { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' }, // purple
10079
- { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' }, // teal
10080
- { bg: 'rgba(220, 80, 30, 0.14)', color: '#c04a1a' }, // red-orange
10081
- { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' }, // amber
10082
- { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' }, // pink
10083
- { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' }, // green
10084
- { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' }, // indigo
10085
- ];
10086
- const namedColorMap = {
10087
- red: { bg: 'rgba(239, 68, 68, 0.14)', color: '#dc2626' },
10088
- blue: { bg: 'rgba(37, 99, 235, 0.14)', color: '#1d5bbf' },
10089
- green: { bg: 'rgba(22, 163, 74, 0.14)', color: '#15803d' },
10090
- purple: { bg: 'rgba(168, 85, 247, 0.14)', color: '#7c3aed' },
10091
- orange: { bg: 'rgba(234, 88, 12, 0.14)', color: '#c2410c' },
10092
- pink: { bg: 'rgba(219, 39, 119, 0.14)', color: '#b5246a' },
10093
- yellow: { bg: 'rgba(202, 138, 4, 0.14)', color: '#92700c' },
10094
- teal: { bg: 'rgba(14, 165, 133, 0.14)', color: '#0d7d65' },
10095
- indigo: { bg: 'rgba(99, 102, 241, 0.14)', color: '#4f46e5' },
10096
- cyan: { bg: 'rgba(6, 182, 212, 0.14)', color: '#0891b2' },
10097
- };
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)]));
10098
10030
  const ownerColorCache = {};
10099
10031
  const teamColorMap = {};
10100
10032
  function isInternalTask(task) {
@@ -10406,7 +10338,7 @@ document.addEventListener('click', (e) => {
10406
10338
  if (breadcrumb) {
10407
10339
  e.stopPropagation();
10408
10340
  const path = breadcrumb.dataset.fullPath;
10409
- if (path) navigator.clipboard.writeText(path).catch(() => {});
10341
+ if (path) copyText(path, 'project path');
10410
10342
  return;
10411
10343
  }
10412
10344
 
@@ -10427,13 +10359,13 @@ document.addEventListener('click', (e) => {
10427
10359
 
10428
10360
  if (e.target.closest('.pinned-ungroup-btn')) {
10429
10361
  e.stopPropagation();
10430
- store.setItem('groupPinnedSessions', 'false');
10362
+ store.setItem(GROUP_PINNED_KEY, 'false');
10431
10363
  renderSessions();
10432
10364
  return;
10433
10365
  }
10434
10366
 
10435
10367
  if (e.target.closest('.pinned-regroup-banner')) {
10436
- store.setItem('groupPinnedSessions', 'true');
10368
+ store.setItem(GROUP_PINNED_KEY, 'true');
10437
10369
  renderSessions();
10438
10370
  return;
10439
10371
  }
@@ -10449,10 +10381,15 @@ document.addEventListener('click', (e) => {
10449
10381
  }
10450
10382
  if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
10451
10383
  const name = sgHeader.dataset.transientGroup;
10452
- 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;
10453
10390
  sgDeleteTransient(name);
10454
10391
  renderSessions();
10455
- }
10392
+ });
10456
10393
  return;
10457
10394
  }
10458
10395
  const groupId = sgHeader.dataset.groupId;
@@ -10463,10 +10400,15 @@ document.addEventListener('click', (e) => {
10463
10400
  return;
10464
10401
  }
10465
10402
  if (e.target.closest('.sg-delete')) {
10466
- 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;
10467
10409
  sgDeleteGroup(groupId);
10468
10410
  renderSessions();
10469
- }
10411
+ });
10470
10412
  return;
10471
10413
  }
10472
10414
  } else if (!sgHeader.dataset.transientGroup) return;
@@ -10511,15 +10453,9 @@ let projectsCache = null;
10511
10453
 
10512
10454
  async function loadProjects() {
10513
10455
  if (!projectsCacheDirty && projectsCache) return projectsCache;
10514
- let projects;
10515
- try {
10516
- const res = await fetch('/api/projects');
10517
- projects = await res.json();
10518
- } catch (_e) {
10519
- projects = [...new Set(sessions.map((s) => s.project).filter(Boolean))]
10520
- .sort()
10521
- .map((p) => ({ path: p, modifiedAt: null }));
10522
- }
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 }));
10523
10459
  projectsCache = projects;
10524
10460
  projectsCacheDirty = false;
10525
10461
  return projects;
@@ -10558,52 +10494,36 @@ function updateThemeColor(isLight) {
10558
10494
  //#endregion
10559
10495
 
10560
10496
  //#region THEME
10561
- function toggleTheme() {
10562
- const isCurrentlyLight = document.body.classList.contains('light');
10563
- if (isCurrentlyLight) {
10564
- document.body.classList.remove('light');
10565
- document.body.classList.add('dark-forced');
10566
- store.setItem('theme', 'dark');
10567
- } else {
10568
- document.body.classList.add('light');
10569
- document.body.classList.remove('dark-forced');
10570
- store.setItem('theme', 'light');
10571
- }
10572
- updateThemeIcon();
10573
- updateThemeColor(!isCurrentlyLight);
10574
- syncHljsTheme();
10575
- reinitMermaidTheme();
10576
- }
10577
-
10578
- function syncHljsTheme() {
10579
- 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);
10580
10503
  const dark$ = document.getElementById('hljs-theme-dark');
10581
10504
  const light$ = document.getElementById('hljs-theme-light');
10582
10505
  if (dark$) dark$.disabled = light;
10583
10506
  if (light$) light$.disabled = !light;
10507
+ reinitMermaidTheme();
10584
10508
  }
10585
10509
 
10586
- function updateThemeIcon() {
10587
- const light = isLightTheme();
10588
- document.getElementById('theme-icon-dark').style.display = light ? 'none' : 'block';
10589
- 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);
10590
10514
  }
10591
10515
 
10592
10516
  function loadTheme() {
10593
- const saved = store.getItem('theme');
10594
- if (saved === 'light') {
10595
- document.body.classList.add('light');
10596
- document.body.classList.remove('dark-forced');
10597
- } else if (saved === 'dark') {
10598
- document.body.classList.remove('light');
10599
- document.body.classList.add('dark-forced');
10600
- }
10601
- // If no saved preference, system prefers-color-scheme CSS handles it
10602
- updateThemeIcon();
10603
- updateThemeColor(document.body.classList.contains('light'));
10604
- 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
+ });
10605
10525
  buildThemeMenu();
10606
- const colorTheme = store.getItem('color-theme');
10526
+ const colorTheme = store.getItem(COLOR_THEME_KEY);
10607
10527
  if (colorTheme) document.body.dataset.colorTheme = colorTheme;
10608
10528
  syncColorThemeSelect(colorTheme || 'ember');
10609
10529
  }
@@ -10661,10 +10581,10 @@ function syncColorThemeSelect(id) {
10661
10581
  function setColorTheme(id) {
10662
10582
  if (!id || id === 'ember') {
10663
10583
  delete document.body.dataset.colorTheme;
10664
- store.removeItem('color-theme');
10584
+ store.removeItem(COLOR_THEME_KEY);
10665
10585
  } else {
10666
10586
  document.body.dataset.colorTheme = id;
10667
- store.setItem('color-theme', id);
10587
+ store.setItem(COLOR_THEME_KEY, id);
10668
10588
  }
10669
10589
  syncColorThemeSelect(id);
10670
10590
  }
@@ -10676,13 +10596,13 @@ function expandSidebar() {
10676
10596
  const sidebar = document.querySelector('.sidebar');
10677
10597
  if (!sidebar.classList.contains('collapsed')) return;
10678
10598
  sidebar.classList.remove('collapsed');
10679
- store.setItem('sidebar-collapsed', false);
10599
+ store.setItem(SIDEBAR_COLLAPSED_KEY, false);
10680
10600
  }
10681
10601
 
10682
10602
  function toggleSidebar() {
10683
10603
  const sidebar = document.querySelector('.sidebar');
10684
10604
  const collapsed = sidebar.classList.toggle('collapsed');
10685
- store.setItem('sidebar-collapsed', collapsed);
10605
+ store.setItem(SIDEBAR_COLLAPSED_KEY, collapsed);
10686
10606
  if (collapsed) {
10687
10607
  sidebar.style.width = '';
10688
10608
  if (focusZone === 'sidebar') setFocusZone('board');
@@ -10694,10 +10614,10 @@ function toggleSidebar() {
10694
10614
 
10695
10615
  function loadSidebarState() {
10696
10616
  const sidebar = document.querySelector('.sidebar');
10697
- if (store.getItem('sidebar-collapsed') === 'true') {
10617
+ if (store.getItem(SIDEBAR_COLLAPSED_KEY) === 'true') {
10698
10618
  sidebar.classList.add('collapsed');
10699
10619
  }
10700
- const w = store.getItem('sidebar-width');
10620
+ const w = store.getItem(SIDEBAR_WIDTH_KEY);
10701
10621
  if (w) {
10702
10622
  sidebar.style.setProperty('--sidebar-width', w);
10703
10623
  }
@@ -10752,12 +10672,12 @@ function initSidebarResize() {
10752
10672
  },
10753
10673
  onEnd() {
10754
10674
  sidebar.classList.remove('resizing');
10755
- store.setItem('sidebar-width', sidebar.style.getPropertyValue('--sidebar-width'));
10675
+ store.setItem(SIDEBAR_WIDTH_KEY, sidebar.style.getPropertyValue('--sidebar-width'));
10756
10676
  },
10757
10677
  });
10758
10678
  }
10759
10679
 
10760
- function initPanelResize(panelId, handleId, cssVar, storageKey) {
10680
+ function initPanelResize(panelId, handleId, cssVar) {
10761
10681
  const panel = document.getElementById(panelId);
10762
10682
  const handle = document.getElementById(handleId);
10763
10683
  let startWidth;
@@ -10773,7 +10693,7 @@ function initPanelResize(panelId, handleId, cssVar, storageKey) {
10773
10693
  },
10774
10694
  onEnd() {
10775
10695
  panel.classList.remove('resizing');
10776
- store.setItem(storageKey, panel.style.getPropertyValue(cssVar));
10696
+ store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
10777
10697
  },
10778
10698
  });
10779
10699
  }
@@ -10783,7 +10703,7 @@ function loadPanelWidths() {
10783
10703
  ['detail-panel', '--detail-panel-width'],
10784
10704
  ['message-panel', '--message-panel-width'],
10785
10705
  ].forEach(([id, cssVar]) => {
10786
- const w = store.getItem(`${id}-width`);
10706
+ const w = store.getItem(panelWidthKey(id));
10787
10707
  if (w) document.getElementById(id).style.setProperty(cssVar, w);
10788
10708
  });
10789
10709
  }
@@ -10815,29 +10735,18 @@ async function showSessionInfoModal(sessionId) {
10815
10735
  };
10816
10736
 
10817
10737
  const teamPromise = session.isTeam
10818
- ? fetch(`/api/teams/${session.teamName || sessionId}`)
10819
- .then((r) => (r.ok ? r.json() : null))
10820
- .catch(() => null)
10738
+ ? getJson(apiPath`/api/teams/${session.teamName || sessionId}`)
10821
10739
  : Promise.resolve(null);
10822
10740
 
10823
- const planPromise = fetch(`/api/sessions/${sessionId}/plan`)
10824
- .then((r) => (r.ok ? r.json() : null))
10825
- .catch(() => null)
10826
- .then((data) => {
10827
- if (data?.content) _pendingPlanPath = data.path || null;
10828
- return data?.content || null;
10829
- });
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
+ });
10830
10745
 
10831
10746
  const tasksPromise =
10832
- cachedTasks.length > 0
10833
- ? Promise.resolve(cachedTasks)
10834
- : fetch(`/api/sessions/${sessionId}`)
10835
- .then((r) => (r.ok ? r.json() : []))
10836
- .catch(() => []);
10747
+ cachedTasks.length > 0 ? Promise.resolve(cachedTasks) : getJson(apiPath`/api/sessions/${sessionId}`, []);
10837
10748
 
10838
- const parentPromise = fetch(`/api/sessions/${sessionId}/parent`)
10839
- .then((r) => (r.ok ? r.json() : null))
10840
- .catch(() => null);
10749
+ const parentPromise = getJson(apiPath`/api/sessions/${sessionId}/parent`);
10841
10750
 
10842
10751
  const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
10843
10752
  teamPromise,
@@ -10983,13 +10892,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
10983
10892
  _pendingPlanContent = planContent;
10984
10893
  const titleMatch = planContent.match(/^#\s+(.+)$/m);
10985
10894
  const planTitle = titleMatch ? titleMatch[1].trim() : null;
10986
- 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)'">
10987
- <span style="font-size: 14px;">📋</span>
10988
- <div style="flex: 1; min-width: 0;">
10989
- <div style="font-size: 11px; font-weight: 500; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px;">Plan</div>
10990
- ${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>` : ''}
10991
10900
  </div>
10992
- <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>
10993
10902
  </div>`;
10994
10903
  }
10995
10904
 
@@ -11053,7 +10962,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
11053
10962
  // Bind to the section, not bodyEl: bodyEl outlives the modal, so listeners on it
11054
10963
  // would stack up once per open and fire the click handler N times.
11055
10964
  bindLinkedDocsHandlers(bodyEl.querySelector('.linked-docs-section'), session.id);
11056
- const alreadyVisible = modal.classList.contains('visible');
11057
10965
  _infoModalSessionId = session.id;
11058
10966
  updateStickyBtnState();
11059
10967
  updateDismissBtnState();
@@ -11065,21 +10973,6 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
11065
10973
  if (mkBtn) mkBtn.style.display = proj && hub.can('project.plugins') ? '' : 'none';
11066
10974
  if (memBtn) memBtn.style.display = proj && hub.can('project.memory') ? '' : 'none';
11067
10975
  modal.classList.add('visible');
11068
-
11069
- if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
11070
-
11071
- const keyHandler = (e) => {
11072
- if (e.key === 'Escape') {
11073
- // An inline editor inside the modal owns Escape first — it reverts itself.
11074
- if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
11075
- if (document.getElementById('plan-modal').classList.contains('visible')) return;
11076
- if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
11077
- e.preventDefault();
11078
- closeTeamModal();
11079
- document.removeEventListener('keydown', keyHandler);
11080
- }
11081
- };
11082
- document.addEventListener('keydown', keyHandler);
11083
10976
  }
11084
10977
 
11085
10978
  function closeTeamModal() {
@@ -11464,30 +11357,23 @@ function initProjectPicker() {
11464
11357
  //#region PLAN
11465
11358
  function refreshOpenPlan() {
11466
11359
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
11467
- fetch(`/api/sessions/${_planSessionId}/plan`)
11468
- .then((r) => (r.ok ? r.json() : null))
11469
- .then((data) => {
11470
- if (data?.content && (data.content !== _pendingPlanContent || data.path !== _pendingPlanPath)) {
11471
- _pendingPlanContent = data.content;
11472
- _pendingPlanPath = data.path || null;
11473
- document.getElementById('plan-modal-body').innerHTML = renderMarkdown(_pendingPlanContent);
11474
- mountPlanReview();
11475
- }
11476
- })
11477
- .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
+ });
11478
11369
  }
11479
11370
 
11480
11371
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
11481
11372
  function showLoopModal(sessionId) {
11482
11373
  const body = document.getElementById('loop-modal-body');
11483
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11374
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11484
11375
  document.getElementById('loop-modal').classList.add('visible');
11485
- fetch(`/api/sessions/${sessionId}/loop`)
11486
- .then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
11487
- .catch(() => ({ wakeups: [], crons: [] }))
11488
- .then((data) => {
11489
- renderLoopModalBody(data);
11490
- });
11376
+ getJson(apiPath`/api/sessions/${sessionId}/loop`, { wakeups: [], crons: [] }).then(renderLoopModalBody);
11491
11377
  }
11492
11378
 
11493
11379
  function fmtLoopDelay(s) {
@@ -11554,8 +11440,7 @@ function renderLoopModalBody(data) {
11554
11440
  const wakeups = data.wakeups || [];
11555
11441
  const crons = data.crons || [];
11556
11442
  if (!wakeups.length && !crons.length) {
11557
- body.innerHTML =
11558
- '<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>';
11559
11444
  return;
11560
11445
  }
11561
11446
  const section = (title, items, kind) =>
@@ -11604,23 +11489,19 @@ function showWorkflowModal(sessionId) {
11604
11489
  _workflowSessionId = sessionId;
11605
11490
  setWorkflowHeader('Workflows', null);
11606
11491
  const body = document.getElementById('workflow-modal-body');
11607
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11492
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11608
11493
  document.getElementById('workflow-modal').classList.add('visible');
11609
- fetch(`/api/sessions/${sessionId}/workflows`)
11610
- .then((r) => (r.ok ? r.json() : { workflows: [] }))
11611
- .catch(() => ({ workflows: [] }))
11612
- .then((data) => {
11613
- _workflowList = data.workflows || [];
11614
- if (!_workflowList.length) {
11615
- setWorkflowHeader('Workflows', null);
11616
- body.innerHTML =
11617
- '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
11618
- } else if (_workflowList.length === 1) {
11619
- showWorkflowRun(_workflowList[0].id);
11620
- } else {
11621
- renderWorkflowPicker(_workflowList);
11622
- }
11623
- });
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
+ });
11624
11505
  }
11625
11506
 
11626
11507
  function setWorkflowHeader(name, wfId) {
@@ -11658,17 +11539,14 @@ function showWorkflowRun(wfId) {
11658
11539
  const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
11659
11540
  setWorkflowHeader(name, wfId);
11660
11541
  const body = document.getElementById('workflow-modal-body');
11661
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11662
- fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/run`)
11663
- .then((r) => (r.ok ? r.json() : null))
11664
- .catch(() => null)
11665
- .then((run) => {
11666
- if (!run) {
11667
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load run state.</div>';
11668
- return;
11669
- }
11670
- renderWorkflowRun(run);
11671
- });
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
+ });
11672
11550
  }
11673
11551
 
11674
11552
  function fmtTokens(t) {
@@ -11752,27 +11630,24 @@ function renderWorkflowRun(run) {
11752
11630
 
11753
11631
  function loadWorkflowCode(wfId, codeEl) {
11754
11632
  if (!codeEl) return;
11755
- codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
11756
- fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}`)
11757
- .then((r) => (r.ok ? r.json() : null))
11758
- .catch(() => null)
11759
- .then((data) => {
11760
- if (!data?.content) {
11761
- codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Failed to load script.</div>';
11762
- return;
11763
- }
11764
- let highlighted;
11765
- if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
11766
- highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
11767
- } else {
11768
- highlighted = escapeHtml(data.content);
11769
- }
11770
- codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
11771
- });
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
+ });
11772
11647
  }
11773
11648
 
11774
11649
  function openWorkflowInEditor(wfId) {
11775
- postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
11650
+ postAndToast(apiPath`/api/sessions/${_workflowSessionId}/workflows/${wfId}/open`, {}, 'in editor');
11776
11651
  }
11777
11652
 
11778
11653
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -11788,17 +11663,14 @@ function closeWorkflowModal() {
11788
11663
  }
11789
11664
 
11790
11665
  function openPlanForSession(sid) {
11791
- fetch(`/api/sessions/${sid}/plan`)
11792
- .then((r) => (r.ok ? r.json() : null))
11793
- .catch(() => null)
11794
- .then((data) => {
11795
- if (data?.content) {
11796
- _pendingPlanContent = data.content;
11797
- _pendingPlanPath = data.path || null;
11798
- _planSessionId = sid;
11799
- openPlanModal();
11800
- }
11801
- });
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
+ });
11802
11674
  }
11803
11675
 
11804
11676
  function openPlanModal() {
@@ -11814,6 +11686,7 @@ function openPlanModal() {
11814
11686
  controls.innerHTML = planApprovalControlsHtml('plan-modal-reject-feedback');
11815
11687
  document.querySelector('#plan-modal .modal-footer').prepend(controls);
11816
11688
  }
11689
+ syncPlanPaneBtn();
11817
11690
  document.getElementById('plan-modal').classList.add('visible');
11818
11691
  mountPlanReview();
11819
11692
  const keyHandler = (e) => {
@@ -11848,7 +11721,7 @@ function closePlanModal() {
11848
11721
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
11849
11722
  function openPlanInEditor() {
11850
11723
  if (!_planSessionId) return;
11851
- postAndToast(`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
11724
+ postAndToast(apiPath`/api/sessions/${_planSessionId}/plan/open`, {}, 'in editor');
11852
11725
  }
11853
11726
 
11854
11727
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -11895,22 +11768,19 @@ let _toolStatsData = null;
11895
11768
  function showToolStatsModal(sessionId) {
11896
11769
  if (!sessionId) return;
11897
11770
  const body = document.getElementById('tool-stats-modal-body');
11898
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
11771
+ body.innerHTML = '<div class="modal-status">Loading…</div>';
11899
11772
  document.getElementById('tool-stats-modal').classList.add('visible');
11900
11773
 
11901
- fetch(`/api/sessions/${sessionId}/tool-stats`)
11902
- .then((r) => (r.ok ? r.json() : null))
11903
- .catch(() => null)
11904
- .then((data) => {
11905
- if (!data) {
11906
- body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load tool statistics.</div>';
11907
- return;
11908
- }
11909
- _toolStatsSortCol = 'count';
11910
- _toolStatsSortDir = 'desc';
11911
- _toolStatsData = data;
11912
- body.innerHTML = renderToolStatsBody(data);
11913
- });
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
+ });
11914
11784
  }
11915
11785
 
11916
11786
  function renderToolStatsBody(data) {
@@ -11927,7 +11797,7 @@ function renderToolStatsBody(data) {
11927
11797
  if (!tools?.length) {
11928
11798
  return (
11929
11799
  summary +
11930
- '<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>'
11931
11801
  );
11932
11802
  }
11933
11803
 
@@ -12074,10 +11944,7 @@ function paneOwnOrigin(pane) {
12074
11944
 
12075
11945
  async function probePaneFraming(sid, pane) {
12076
11946
  const q = new URLSearchParams({ ancestors: paneAncestors().join(',') });
12077
- const { frameable } = await paneRequest(
12078
- 'GET',
12079
- `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(pane.id)}/framing?${q}`,
12080
- );
11947
+ const { frameable } = await paneRequest('GET', `${apiPath`/api/panes/${sid}/${pane.id}/framing`}?${q}`);
12081
11948
  if (frameable === false) paneRefused.add(pane.target);
12082
11949
  return frameable;
12083
11950
  }
@@ -12101,10 +11968,8 @@ function applyPaneLayout(sid, layout) {
12101
11968
  }
12102
11969
 
12103
11970
  async function fetchPaneLayout(sid) {
12104
- try {
12105
- const r = await fetch(`/api/panes/${encodeURIComponent(sid)}`);
12106
- if (r.ok) applyPaneLayout(sid, await r.json());
12107
- } catch {}
11971
+ const layout = await getJson(apiPath`/api/panes/${sid}`);
11972
+ if (layout) applyPaneLayout(sid, layout);
12108
11973
  }
12109
11974
 
12110
11975
  function refetchPaneLayout() {
@@ -12114,11 +11979,7 @@ function refetchPaneLayout() {
12114
11979
 
12115
11980
  async function paneRequest(method, path, body) {
12116
11981
  try {
12117
- const r = await fetch(path, {
12118
- method,
12119
- headers: body ? { 'Content-Type': 'application/json' } : undefined,
12120
- body: body ? JSON.stringify(body) : undefined,
12121
- });
11982
+ const r = await api(path, { method, body });
12122
11983
  const data = await r.json().catch(() => ({}));
12123
11984
  return r.ok ? data : { error: data.error || `${method} failed (${r.status})`, code: data.code };
12124
11985
  } catch {
@@ -12130,11 +11991,8 @@ async function paneRequest(method, path, body) {
12130
11991
  const PANE_STATE_CAP = 20;
12131
11992
 
12132
11993
  function loadPaneState(key) {
12133
- try {
12134
- const map = JSON.parse(sessionStorage.getItem(key));
12135
- if (map && typeof map === 'object') return map;
12136
- } catch {}
12137
- return {};
11994
+ const map = tabStore.readJson(key);
11995
+ return map && typeof map === 'object' ? map : {};
12138
11996
  }
12139
11997
 
12140
11998
  // Puts `sid` last, so the cap drops the least recent session. A null value removes it.
@@ -12142,14 +12000,12 @@ function savePaneState(key, map, sid, value) {
12142
12000
  delete map[sid];
12143
12001
  if (value != null) map[sid] = value;
12144
12002
  const capped = Object.fromEntries(Object.entries(map).slice(-PANE_STATE_CAP));
12145
- try {
12146
- sessionStorage.setItem(key, JSON.stringify(capped));
12147
- } catch {}
12003
+ tabStore.writeJson(key, capped);
12148
12004
  return capped;
12149
12005
  }
12150
12006
 
12151
- // `cck.paneActive`: {[sessionId]: paneId}.
12152
- const PANE_ACTIVE_KEY = 'cck.paneActive';
12007
+ // {[sessionId]: paneId}.
12008
+ const PANE_ACTIVE_KEY = 'pane-active';
12153
12009
  let paneActive = loadPaneState(PANE_ACTIVE_KEY);
12154
12010
 
12155
12011
  function getActivePaneId(sid) {
@@ -12162,10 +12018,10 @@ function setActivePaneId(sid, id) {
12162
12018
  notePairFocus(sid, id);
12163
12019
  }
12164
12020
 
12165
- // `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
12166
12022
  // last. A tab is in one pair at most, and the pair that holds the active tab is the split on
12167
12023
  // screen. Clicks never change a pair; only the split keys and drags do.
12168
- const PANE_PAIRS_KEY = 'cck.panePairs';
12024
+ const PANE_PAIRS_KEY = 'pane-pairs';
12169
12025
  const PANE_SPLIT_RATIO_KEY = 'pane-split-ratio';
12170
12026
  let panePairs = loadPaneState(PANE_PAIRS_KEY);
12171
12027
  const clampSplitRatio = (r) => Math.min(0.85, Math.max(0.15, r));
@@ -12302,6 +12158,8 @@ const PANE_ICONS = {
12302
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>',
12303
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>',
12304
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>',
12305
12163
  warning:
12306
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>',
12307
12165
  };
@@ -12407,9 +12265,11 @@ function markPaneSide(el, side, focused) {
12407
12265
  el.classList.toggle('pane-focus', focused);
12408
12266
  }
12409
12267
 
12268
+ const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
12269
+
12410
12270
  function renderPaneTabs(sid, layout, split) {
12411
- // A rebuild mid-drag removes the dragged tab, and then no dragend fires.
12412
- 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;
12413
12273
  const active = getActivePaneId(sid);
12414
12274
  const state = (id) => (active === id ? ' on' : split && splitSideOf(split, id) ? ' shown' : '');
12415
12275
  const tab = (id, cls, inner, tip) =>
@@ -12430,8 +12290,8 @@ function renderPaneTabs(sid, layout, split) {
12430
12290
  return tab(
12431
12291
  p.id,
12432
12292
  '',
12433
- `${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>`,
12434
- 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),
12435
12295
  );
12436
12296
  };
12437
12297
  const itemHtml = ({ ids, pair }) =>
@@ -12471,7 +12331,8 @@ function markPaneFrameFocus() {
12471
12331
  }
12472
12332
 
12473
12333
  function paneCardHtml(title, text, button) {
12474
- 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>`;
12475
12336
  }
12476
12337
 
12477
12338
  // Returns the key of the view to show.
@@ -12534,11 +12395,11 @@ async function loadPaneView(sid, pane, view, scroll) {
12534
12395
  }
12535
12396
  return;
12536
12397
  }
12537
- let data = null;
12538
- try {
12539
- const r = await fetch(`/api/preview?${new URLSearchParams({ path: pane.target })}`);
12540
- if (r.ok) data = await r.json();
12541
- } 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 } });
12542
12403
  if (data?.kind === 'html') {
12543
12404
  // The frame's origin is opaque, so the page restores its own scroll on load.
12544
12405
  const restore = scroll
@@ -12551,14 +12412,9 @@ async function loadPaneView(sid, pane, view, scroll) {
12551
12412
  return;
12552
12413
  }
12553
12414
  if (data?.kind) {
12554
- const doc = document.createElement('div');
12555
- doc.className = 'pane-doc';
12556
- doc.dataset.path = data.path;
12557
- const body = document.createElement('div');
12558
- body.className = 'pane-doc-body rendered-md modal-zoomable';
12415
+ const body = appendPaneDoc(view);
12416
+ body.parentElement.dataset.path = data.path;
12559
12417
  renderPreviewContent(body, data.path, data.content, data.kind);
12560
- doc.append(body, paneDocGrip(doc));
12561
- view.appendChild(doc);
12562
12418
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, body, null));
12563
12419
  return;
12564
12420
  }
@@ -12571,6 +12427,49 @@ async function loadPaneView(sid, pane, view, scroll) {
12571
12427
  view.insertAdjacentHTML('beforeend', paneCardHtml(title, text, button));
12572
12428
  }
12573
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
+
12574
12473
  function onPaneFileChanged(filePath) {
12575
12474
  for (const [key, view] of paneFrames) {
12576
12475
  const [sid, paneId] = key.split('/');
@@ -12727,9 +12626,25 @@ function cyclePaneStrip(step) {
12727
12626
  return true;
12728
12627
  }
12729
12628
 
12730
- 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() {
12731
12634
  const sid = paneSessionId();
12732
- 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();
12733
12648
  if (!id || id === 'board') return false;
12734
12649
  closePane(id);
12735
12650
  return true;
@@ -12751,7 +12666,7 @@ async function closePane(paneId) {
12751
12666
  }
12752
12667
  unmountPane(sid, paneId);
12753
12668
  syncPanes();
12754
- const res = await paneRequest('DELETE', `/api/panes/${encodeURIComponent(sid)}/${encodeURIComponent(paneId)}`);
12669
+ const res = await paneRequest('DELETE', apiPath`/api/panes/${sid}/${paneId}`);
12755
12670
  if (res.layout) applyPaneLayout(sid, res.layout);
12756
12671
  else if (res.code === 'no_pane') fetchPaneLayout(sid);
12757
12672
  else showToast(res.error, 'error');
@@ -12781,21 +12696,39 @@ function reloadPane(id) {
12781
12696
  function openPaneExternally(id) {
12782
12697
  const pane = paneById(id);
12783
12698
  if (!pane) return;
12699
+ if (pane.kind === 'message') {
12700
+ openPaneMessageDetail(pane);
12701
+ return;
12702
+ }
12784
12703
  openLinkedDoc(pane.target, getSessionBaseDir(paneSessionId()));
12785
12704
  }
12786
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
+
12787
12719
  function copyPaneTarget(id) {
12788
12720
  const pane = paneById(id);
12789
12721
  if (!pane) return;
12790
- navigator.clipboard.writeText(pane.target).then(
12791
- () => showToast(pane.kind === 'url' ? 'URL copied' : 'Path copied', 'success'),
12792
- () => showToast('Copy failed', 'error'),
12793
- );
12722
+ copyText(pane.target, pane.kind === 'url' ? 'URL' : 'path');
12794
12723
  }
12795
12724
 
12796
- function closePaneMenu() {
12725
+ function closePaneMenu(restore) {
12797
12726
  document.removeEventListener('keydown', paneMenuKeydown, true);
12798
- document.getElementById('pane-menu')?.remove();
12727
+ const menu = document.getElementById('pane-menu');
12728
+ if (menu) {
12729
+ menu.remove();
12730
+ takeMenuFocus(restore);
12731
+ }
12799
12732
  }
12800
12733
 
12801
12734
  function paneMenuKeydown(e) {
@@ -12820,7 +12753,7 @@ async function deleteBoardTasks(onlyCompleted) {
12820
12753
  });
12821
12754
  if (!ok) return;
12822
12755
  const q = onlyCompleted ? '?status=completed' : '';
12823
- const res = await paneRequest('DELETE', `/api/tasks/${encodeURIComponent(sid)}${q}`);
12756
+ const res = await paneRequest('DELETE', `${apiPath`/api/tasks/${sid}`}${q}`);
12824
12757
  if (res.error) {
12825
12758
  showToast(`Failed to delete tasks: ${res.error}`, 'error');
12826
12759
  return;
@@ -12830,6 +12763,42 @@ async function deleteBoardTasks(onlyCompleted) {
12830
12763
  await refreshCurrentView();
12831
12764
  }
12832
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
+
12833
12802
  const paneMenuItem = (act, label) =>
12834
12803
  `<button class="pane-menu-item" role="menuitem" data-pane-act="${escapeHtml(act)}">${label}</button>`;
12835
12804
 
@@ -12844,22 +12813,26 @@ function openPaneMenu(x, y, id) {
12844
12813
  paneMenuItem('deleteCompleted', 'Delete completed tasks') +
12845
12814
  paneMenuItem('deleteAll', 'Delete all tasks'),
12846
12815
  );
12847
- return;
12816
+ return true;
12848
12817
  }
12849
12818
  const pane = paneById(id);
12850
- if (!pane) return;
12819
+ if (!pane) return false;
12851
12820
  const url = pane.kind === 'url';
12821
+ const message = pane.kind === 'message';
12822
+ const label = paneLabel(pane);
12852
12823
  showPaneMenu(
12853
12824
  x,
12854
12825
  y,
12855
12826
  pane.id,
12856
12827
  'Pane actions',
12857
- `<div class="pane-menu-label" title="${escapeHtml(pane.target)}">${escapeHtml(pane.target)}</div>` +
12858
- paneMenuItem('copy', url ? 'Copy URL' : 'Copy path') +
12859
- 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') +
12860
12832
  paneMenuItem('reload', 'Reload') +
12861
12833
  paneMenuItem('close', 'Close pane'),
12862
12834
  );
12835
+ return true;
12863
12836
  }
12864
12837
 
12865
12838
  function showPaneMenu(x, y, id, ariaLabel, html) {
@@ -12880,6 +12853,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
12880
12853
  const PANE_MENU_ACTIONS = {
12881
12854
  copy: copyPaneTarget,
12882
12855
  open: openPaneExternally,
12856
+ rename: beginPaneRename,
12883
12857
  reload: reloadPane,
12884
12858
  close: closePane,
12885
12859
  deleteCompleted: () => deleteBoardTasks(true),
@@ -12902,32 +12876,81 @@ async function addPane(raw) {
12902
12876
  const t = sid && paneTargetFrom(raw);
12903
12877
  if (!t) return;
12904
12878
  if (t.error) {
12905
- showToast(t.error);
12879
+ showToast(t.error, 'error');
12906
12880
  return;
12907
12881
  }
12908
- const res = await paneRequest('POST', `/api/panes/${encodeURIComponent(sid)}`, {
12909
- target: t.target,
12910
- base: getSessionBaseDir(sid) || undefined,
12911
- });
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);
12912
12890
  if (!res.pane) {
12913
12891
  showToast(res.error, 'error');
12914
- return;
12892
+ return null;
12915
12893
  }
12916
12894
  setActivePaneId(sid, res.pane.id);
12917
12895
  applyPaneLayout(sid, res.layout);
12918
- if (sid !== paneSessionId()) return;
12919
- closePanePop();
12920
- syncPanes();
12896
+ return sid === paneSessionId();
12921
12897
  }
12922
12898
 
12923
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
12924
- function openPreviewInPane() {
12925
- const filePath = currentPreviewPath;
12899
+ function openFileInPane(filePath, closeModal) {
12926
12900
  if (!filePath || !paneSessionId()) return;
12927
- closePreviewModal();
12901
+ closeModal();
12928
12902
  addPane(filePath);
12929
12903
  }
12930
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
+
12931
12954
  let panePopMatches = [];
12932
12955
  // -1 means Enter adds the typed value.
12933
12956
  let panePopIdx = -1;
@@ -12980,7 +13003,19 @@ function closePanePop() {
12980
13003
  panePop.classList.remove('visible');
12981
13004
  }
12982
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
+ });
12983
13017
  paneStrip.addEventListener('click', (e) => {
13018
+ if (e.target.closest('.pane-title-input')) return;
12984
13019
  const close = e.target.closest('[data-close]');
12985
13020
  if (close) {
12986
13021
  closePane(close.dataset.close);
@@ -13004,7 +13039,8 @@ document.addEventListener('click', (e) => {
13004
13039
  if (!menu) return;
13005
13040
  const act = e.target.closest('.pane-menu-item')?.dataset.paneAct;
13006
13041
  if (act) PANE_MENU_ACTIONS[act](menu.dataset.pane);
13007
- if (act || !menu.contains(e.target)) closePaneMenu();
13042
+ if (act) closePaneMenu();
13043
+ else if (!menu.contains(e.target)) closePaneMenu(true);
13008
13044
  });
13009
13045
  function focusSideOf(el) {
13010
13046
  if (el?.classList.contains('side-l')) focusPaneSide('l');
@@ -13060,18 +13096,13 @@ paneSplitBox.addEventListener(
13060
13096
  function endPaneDrag() {
13061
13097
  if (!paneDragId) return;
13062
13098
  paneDragId = null;
13099
+ markPaneDrop(null);
13063
13100
  paneSplitBox.classList.remove('dropping');
13064
- paneStrip.classList.remove('drop-hot');
13065
13101
  for (const el of paneDrop.children) el.classList.remove('hot');
13066
13102
  paneStrip.querySelector('.pane-tab.dragging')?.classList.remove('dragging');
13067
13103
  syncPanes();
13068
13104
  }
13069
13105
 
13070
- function draggedPair() {
13071
- const sid = paneSessionId();
13072
- return (sid && paneDragId && pairOf(sid, paneDragId)) || null;
13073
- }
13074
-
13075
13106
  paneStrip.addEventListener('dragstart', (e) => {
13076
13107
  const tab = e.target.closest('.pane-tab');
13077
13108
  if (!tab || !paneSessionId() || wantsTerminal()) return;
@@ -13081,24 +13112,64 @@ paneStrip.addEventListener('dragstart', (e) => {
13081
13112
  tab.classList.add('dragging');
13082
13113
  paneSplitBox.classList.add('dropping');
13083
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
+
13084
13154
  paneStrip.addEventListener('dragend', endPaneDrag);
13085
13155
  paneStrip.addEventListener('dragover', (e) => {
13086
- if (!draggedPair()) return;
13087
- e.preventDefault();
13088
- paneStrip.classList.add('drop-hot');
13156
+ const action = paneDropAction(e);
13157
+ markPaneDrop(action);
13158
+ if (action) e.preventDefault();
13089
13159
  });
13090
13160
  paneStrip.addEventListener('dragleave', (e) => {
13091
- if (!paneStrip.contains(e.relatedTarget)) paneStrip.classList.remove('drop-hot');
13161
+ if (!paneStrip.contains(e.relatedTarget)) markPaneDrop(null);
13092
13162
  });
13093
13163
  paneStrip.addEventListener('drop', (e) => {
13094
- const pair = draggedPair();
13095
- if (!pair) return;
13164
+ const action = paneDropAction(e);
13165
+ if (!action) return;
13096
13166
  e.preventDefault();
13097
- const sid = paneSessionId();
13098
- const shown = !!splitSideOf(pair, getActivePaneId(sid));
13167
+ const { sid, pair } = action;
13168
+ const shown = pair && !!splitSideOf(pair, getActivePaneId(sid));
13099
13169
  const keep = shown ? pair[otherSide(splitSideOf(pair, paneDragId))] : null;
13100
13170
  endPaneDrag();
13101
- unpairPanes(sid, pair, keep);
13171
+ if (action.kind === 'move') movePaneItem(action);
13172
+ else unpairPanes(sid, pair, keep);
13102
13173
  });
13103
13174
  paneDrop.addEventListener('dragover', (e) => {
13104
13175
  if (!paneDragId) return;
@@ -13215,8 +13286,7 @@ panePop.addEventListener('keydown', (e) => {
13215
13286
  // leaving the session view or switching sessions drops only the socket; coming back
13216
13287
  // reattaches and the server replays the screen.
13217
13288
  const TERMINAL_TOKEN_KEY = 'terminal-token';
13218
- // Every config dir's board is served on the hub's one kanban origin, so sessionStorage is shared.
13219
- const TERMINAL_FOCUS_KEY = nsKey('terminal-focus');
13289
+ const TERMINAL_FOCUS_KEY = 'terminal-focus';
13220
13290
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
13221
13291
  const TERMINAL_MODES_KEY = 'terminal-sessions';
13222
13292
  const TERMINAL_FONT_KEY = 'terminal-font-size';
@@ -13269,17 +13339,11 @@ function readTerminalToken() {
13269
13339
  history.replaceState(null, '', location.pathname + location.search);
13270
13340
  return m[1];
13271
13341
  }
13272
- try {
13273
- return sessionStorage.getItem(TERMINAL_TOKEN_KEY);
13274
- } catch (_) {
13275
- return null;
13276
- }
13342
+ return tabStore.getItem(TERMINAL_TOKEN_KEY);
13277
13343
  }
13278
13344
 
13279
13345
  function storeTerminalToken(token) {
13280
- try {
13281
- sessionStorage.setItem(TERMINAL_TOKEN_KEY, token);
13282
- } catch (_) {}
13346
+ tabStore.setItem(TERMINAL_TOKEN_KEY, token);
13283
13347
  }
13284
13348
 
13285
13349
  let tokenRefresh = null;
@@ -13313,9 +13377,7 @@ function setTerminalMode(sessionId, on) {
13313
13377
  const modes = terminalModes();
13314
13378
  if (on) modes.add(sessionId);
13315
13379
  else modes.delete(sessionId);
13316
- try {
13317
- store.setItem(TERMINAL_MODES_KEY, JSON.stringify([...modes].slice(-50)));
13318
- } catch (_) {}
13380
+ store.writeJson(TERMINAL_MODES_KEY, [...modes].slice(-50));
13319
13381
  }
13320
13382
 
13321
13383
  function wantsTerminal() {
@@ -13378,19 +13440,14 @@ function terminalShortcut(e, probe = false) {
13378
13440
  }
13379
13441
 
13380
13442
  function readSwapPair() {
13381
- try {
13382
- const pair = JSON.parse(store.getItem(SWAP_KEY));
13383
- if (Array.isArray(pair) && pair.length === 2) return pair;
13384
- } catch {}
13385
- return [null, null];
13443
+ const pair = readStoredList(SWAP_KEY);
13444
+ return pair.length === 2 ? pair : [null, null];
13386
13445
  }
13387
13446
 
13388
13447
  function setSwapPair(last, previous) {
13389
13448
  lastSessionId = last;
13390
13449
  previousSessionId = previous;
13391
- try {
13392
- store.setItem(SWAP_KEY, JSON.stringify([last, previous]));
13393
- } catch {}
13450
+ store.writeJson(SWAP_KEY, [last, previous]);
13394
13451
  }
13395
13452
 
13396
13453
  async function swapToPreviousSession() {
@@ -13887,18 +13944,13 @@ window.addEventListener('focus', () => {
13887
13944
  });
13888
13945
  });
13889
13946
  window.addEventListener('pagehide', () => {
13890
- try {
13891
- if (termState.attached && termState.shown && terminalHadFocus) {
13892
- sessionStorage.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
13893
- } else sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
13894
- } catch (_) {}
13947
+ if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
13948
+ else tabStore.removeItem(TERMINAL_FOCUS_KEY);
13895
13949
  });
13896
13950
 
13897
13951
  function restoreTerminalFocus(sessionId) {
13898
- try {
13899
- if (sessionStorage.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
13900
- sessionStorage.removeItem(TERMINAL_FOCUS_KEY);
13901
- } catch (_) {}
13952
+ if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
13953
+ tabStore.removeItem(TERMINAL_FOCUS_KEY);
13902
13954
  }
13903
13955
 
13904
13956
  // The terminal stands in for the board, so it takes the board zone however focus arrives
@@ -14093,14 +14145,16 @@ function endTerminalSession() {
14093
14145
  }
14094
14146
 
14095
14147
  // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
14096
- 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) {
14097
14150
  if (id === termState.sessionId) {
14098
14151
  if (terminalPaneFocused()) leaveTerminalPane();
14099
14152
  detachTerminal();
14100
14153
  }
14101
14154
  setTerminalMode(id, false);
14102
14155
  syncTerminal();
14103
- return endTerminal(id);
14156
+ await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
14157
+ dropPlaceholder(id);
14104
14158
  }
14105
14159
 
14106
14160
  function openTerminalManager() {
@@ -14133,13 +14187,9 @@ async function pollTerminalProcStats() {
14133
14187
  if (!isOnScreen()) return;
14134
14188
  let cck;
14135
14189
  let host;
14136
- try {
14137
- const res = await fetch('/api/terminals/stats', { cache: 'no-store' });
14138
- if (!res.ok) return;
14139
- ({ cck, host, terminals: terminalProcStats } = await res.json());
14140
- } catch (_) {
14141
- return;
14142
- }
14190
+ const stats = await getJson('/api/terminals/stats', null, { cache: 'no-store' });
14191
+ if (!stats) return;
14192
+ ({ cck, host, terminals: terminalProcStats } = stats);
14143
14193
  const parts = [cck && `cck ${formatTerminalProc(cck)}`, host && `terminal host ${formatTerminalProc(host)}`];
14144
14194
  document.getElementById('terminal-manager-stats').textContent = parts.filter(Boolean).join(' · ');
14145
14195
  for (const el of document.querySelectorAll('#terminal-manager-body [data-proc]')) {
@@ -14200,35 +14250,22 @@ async function renderTerminalManager() {
14200
14250
  body.querySelectorAll('[data-end]').forEach((b) => {
14201
14251
  b.onclick = async () => {
14202
14252
  b.disabled = true;
14203
- await endTerminal(b.dataset.end);
14253
+ await closeTerminalSession(b.dataset.end);
14204
14254
  renderTerminalManager();
14205
14255
  };
14206
14256
  });
14207
14257
  }
14208
14258
 
14209
14259
  async function terminalFetch(url, method, body) {
14210
- const send = () =>
14211
- fetch(url, {
14212
- method,
14213
- headers: {
14214
- 'X-Terminal-Token': terminalToken || '',
14215
- ...(body && { 'Content-Type': 'application/json' }),
14216
- },
14217
- ...(body && { body: JSON.stringify(body) }),
14218
- });
14260
+ const send = () => api(url, { method, body, headers: { 'X-Terminal-Token': terminalToken || '' } });
14219
14261
  const res = await send();
14220
14262
  return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
14221
14263
  }
14222
14264
 
14223
- async function endTerminal(id) {
14224
- await terminalFetch(`/api/terminals/${encodeURIComponent(id)}`, 'DELETE').catch(() => {});
14225
- dropPlaceholder(id);
14226
- }
14227
-
14228
14265
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14229
14266
  async function endAllTerminals() {
14230
14267
  const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
14231
- await Promise.all([...buttons].map((b) => endTerminal(b.dataset.end)));
14268
+ await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
14232
14269
  renderTerminalManager();
14233
14270
  }
14234
14271
 
@@ -14294,23 +14331,21 @@ function mergePlaceholders(list) {
14294
14331
  }
14295
14332
 
14296
14333
  async function loadDispatches() {
14297
- try {
14298
- const res = await fetch('/api/dispatch', { cache: 'no-store' });
14299
- const { running = [] } = await res.json();
14300
- dispatchSpecs.clear();
14301
- for (const r of running) {
14302
- if (!r.session || sessions.some((s) => s.id === r.session && !s.placeholder)) continue;
14303
- dispatchSpecs.set(r.session, {
14304
- mode: 'dispatch',
14305
- cwd: r.cwd,
14306
- name: r.name,
14307
- worktree: r.worktree,
14308
- group: r.group,
14309
- parent: r.parent,
14310
- startedAt: r.startedAt,
14311
- });
14312
- }
14313
- } 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
+ }
14314
14349
  }
14315
14350
 
14316
14351
  async function onDispatchUpdate() {
@@ -14377,8 +14412,10 @@ async function restorePendingSessions() {
14377
14412
  }
14378
14413
 
14379
14414
  async function loadTerminals() {
14380
- const res = await fetch('/api/terminals', { cache: 'no-store' });
14381
- 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 || [];
14382
14419
  runningTerminals = new Set(list.map((t) => t.id));
14383
14420
  pushTerminalClaims();
14384
14421
  renderActivityChip();
@@ -14767,7 +14804,7 @@ if ('serviceWorker' in navigator) {
14767
14804
 
14768
14805
  //#region INIT
14769
14806
  loadTheme();
14770
- if (store.getItem('sessions-filtersCollapsed') === 'true') {
14807
+ if (store.getItem(sectionCollapsedKey('sessions-filters')) === 'true') {
14771
14808
  document.getElementById('sessions-filters').classList.add('collapsed');
14772
14809
  document.getElementById('sessions-chevron').classList.add('rotated');
14773
14810
  }
@@ -14806,27 +14843,19 @@ document.addEventListener('DOMContentLoaded', () => {
14806
14843
  });
14807
14844
 
14808
14845
  loadSidebarState();
14809
- try {
14810
- const cg = JSON.parse(store.getItem('collapsedGroups') || '[]');
14811
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
14812
- cg.forEach((p) => collapsedProjectGroups.add(p));
14813
- } catch (_) {}
14846
+ for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
14814
14847
  loadSessionGroups();
14815
14848
  initSessionGroupsDnd();
14816
14849
  initSessionPicker();
14817
14850
  initProjectPicker();
14818
- try {
14819
- const af = JSON.parse(store.getItem('activityFilter') || '[]');
14820
- // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
14821
- af.forEach((k) => activityFilter.add(k));
14822
- } catch (_) {}
14851
+ for (const k of readStoredList(ACTIVITY_FILTER_KEY)) activityFilter.add(k);
14823
14852
  initSidebarResize();
14824
14853
  applyModalZoom();
14825
14854
  loadModalFullscreen();
14826
14855
  initModalResize();
14827
14856
  loadPanelWidths();
14828
- initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
14829
- 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');
14830
14859
  initMessagePanelFull();
14831
14860
 
14832
14861
  const msgContentEl = document.getElementById('message-panel-content');
@@ -14939,12 +14968,7 @@ function pluginWarning(plugin) {
14939
14968
  btn.title = `${problem} (${plugin.configDir}).\n${fix}`;
14940
14969
  btn.setAttribute('aria-label', problem);
14941
14970
  if (plugin.state !== 'disabled') {
14942
- btn.addEventListener('click', () =>
14943
- navigator.clipboard
14944
- .writeText(plugin.installCommand)
14945
- .then(() => showToast('Install command copied', 'success'))
14946
- .catch(() => showToast('Could not copy the command', 'error')),
14947
- );
14971
+ btn.addEventListener('click', () => copyText(plugin.installCommand, 'install command'));
14948
14972
  }
14949
14973
  return btn;
14950
14974
  }
@@ -14953,18 +14977,17 @@ function refreshRateLimits() {
14953
14977
  if (footerState.timer) return;
14954
14978
  footerState.timer = setTimeout(() => {
14955
14979
  footerState.timer = null;
14956
- fetch('/api/rate-limits')
14957
- .then((r) => r.json())
14958
- .then((rl) => {
14959
- scheduleRateLimitReset(rl);
14960
- const fh = rl?.five_hour?.used_percentage ?? null;
14961
- const sd = rl?.seven_day?.used_percentage ?? null;
14962
- const key = `${fh}|${sd}`;
14963
- if (key === footerState.limitsKey) return;
14964
- footerState.limitsKey = key;
14965
- renderSidebarFooter(rl);
14966
- })
14967
- .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
+ });
14968
14991
  }, 1500);
14969
14992
  }
14970
14993
 
@@ -14979,15 +15002,12 @@ function scheduleRateLimitReset(rl) {
14979
15002
  const ms = Math.min(...resets) * 1000 - Date.now() + 1000;
14980
15003
  footerState.resetTimer = setTimeout(refreshRateLimits, Math.min(Math.max(ms, 0), 2 ** 31 - 1));
14981
15004
  }
14982
- fetch('/api/version')
14983
- .then((r) => r.json())
14984
- .then((d) => {
14985
- footerState.version = d.version;
14986
- footerState.plugin = d.plugin ?? null;
14987
- renderSidebarFooter(null);
14988
- refreshRateLimits();
14989
- })
14990
- .catch(() => {});
15005
+ getJson('/api/version', {}).then((d) => {
15006
+ footerState.version = d.version;
15007
+ footerState.plugin = d.plugin ?? null;
15008
+ renderSidebarFooter(null);
15009
+ refreshRateLimits();
15010
+ });
14991
15011
 
14992
15012
  const urlState = getUrlState();
14993
15013
  const lastView = loadLastView();
@@ -15014,12 +15034,10 @@ if (urlState.search) {
15014
15034
  document.getElementById('boot-verb').textContent = pickRandom(verbs);
15015
15035
  }
15016
15036
 
15017
- fetch('/api/config')
15018
- .then((r) => r.json())
15037
+ getJson('/api/config')
15019
15038
  .then((c) => {
15020
- appConfig = c;
15039
+ if (c) appConfig = c;
15021
15040
  })
15022
- .catch(() => {})
15023
15041
  .then(restorePendingSessions)
15024
15042
  .then(() =>
15025
15043
  fetchSessions(