claude-code-kanban 7.6.1 → 7.8.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
@@ -4,7 +4,9 @@ const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
4
4
  // Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
5
5
  const THEME_KEY = 'theme';
6
6
  const COLOR_THEME_KEY = 'color-theme';
7
- const GLOBAL_KEYS = new Set([THEME_KEY, COLOR_THEME_KEY]);
7
+ // Shared too, so after a config-dir switch the cursor goes where it was on the board just left.
8
+ const FOCUS_ZONE_KEY = 'focus-zone';
9
+ const GLOBAL_KEYS = new Set([THEME_KEY, COLOR_THEME_KEY, FOCUS_ZONE_KEY]);
8
10
  // localStorage names are kept as they are: renaming one drops what users saved under it.
9
11
  const MESSAGE_PANEL_OPEN_KEY = 'message-panel-open';
10
12
  const MESSAGE_PANEL_FULL_KEY = 'message-panel-full';
@@ -92,6 +94,8 @@ let sessionsHasMore = false;
92
94
  // URL, so a query string would not survive a hub reload.
93
95
  const ZEN_KEY = 'zenMode';
94
96
  let zenMode = store.getItem(ZEN_KEY) === 'true';
97
+ // Group zen: the sidebar shows one group's subtree. A view of the moment, so it is not persisted.
98
+ let zenGroup = null;
95
99
  let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
96
100
  let recentProjects = new Set();
97
101
  let projectsCacheDirty = true;
@@ -107,8 +111,6 @@ const SESSION_ORDER_KEY = 'session-order';
107
111
  let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
108
112
  let sessionOrder = readStoredList(SESSION_ORDER_KEY);
109
113
  let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
110
- // Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
111
- let sgReleased = new Set();
112
114
  let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
113
115
  let searchQuery = ''; // Search query for fuzzy search
114
116
  let allTasksCache = []; // Cache all tasks for search
@@ -144,6 +146,8 @@ let taskHighlightDimmed = false;
144
146
  let focusZone = 'board'; // 'board' | 'sidebar'
145
147
  let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
146
148
  let runningTerminals = new Set();
149
+ // Embedded sessions stuck on Claude Code's folder-trust dialog. No hook runs before it, so the server reads it off the screen.
150
+ let trustWaiting = new Set();
147
151
  let selectedSessionIdx = -1;
148
152
  let selectedSessionKbId = null;
149
153
  let sessionJustSelected = false;
@@ -186,6 +190,7 @@ function setSessionDismissed(id, dismissed) {
186
190
  else dismissedSessionIds.delete(id);
187
191
  persistDismissedSessions();
188
192
  updateDismissBtnState();
193
+ if (dismissed) sgSweepTransient();
189
194
  renderSessions();
190
195
  renderActivityChip();
191
196
  return true;
@@ -256,6 +261,7 @@ function resetState() {
256
261
  sessionLimit = SESSION_PAGE;
257
262
  filterProject = FILTER_DEFAULTS.project;
258
263
  zenMode = false;
264
+ zenGroup = null;
259
265
  store.removeItem(ZEN_KEY);
260
266
  renderZenState();
261
267
  searchQuery = '';
@@ -323,21 +329,30 @@ async function getJson(path, fallback = null, opts) {
323
329
 
324
330
  async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
325
331
  try {
326
- const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
327
- if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
328
- if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
329
- const recent = filterProject === '__recent__';
332
+ // The focused session must come back whatever the filters say: renderSession and the
333
+ // info modal both look it up in `sessions` and bail when it is absent, so a session
334
+ // opened from outside the current filter would leave the view on the previous one.
335
+ // The server adds the pinned sessions itself; the ones `session open` showed are this page's.
336
+ // Group zen ignores the filters, and its session members come back through `include`.
337
+ const zenIds = zenGroup ? sgSubtreeIds(zenGroup) : new Set();
338
+ const zenMembers = sessionGroups
339
+ .filter((g) => zenIds.has(g.id))
340
+ .flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
341
+ const include = [
342
+ focusId,
343
+ revealedPlanSessionId,
344
+ revealedStorageSessionId,
345
+ ...openedStickyIds,
346
+ ...zenMembers,
347
+ ].filter(Boolean);
348
+ const recent = !zenGroup && filterProject === '__recent__';
330
349
  const res = await api('/api/sessions', {
331
350
  query: {
332
351
  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,
352
+ include: include.length ? include.join(',') : null,
338
353
  recentHours: recent ? RECENT_PROJECT_HOURS : null,
339
- project: !recent && filterProject ? filterProject : null,
340
- filter: sessionFilter === 'active' ? 'active' : null,
354
+ project: !zenGroup && !recent && filterProject ? filterProject : null,
355
+ filter: !zenGroup && sessionFilter === 'active' ? 'active' : null,
341
356
  },
342
357
  });
343
358
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -348,6 +363,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
348
363
  lastSessionsHash = sessionsHash;
349
364
  sessions = mergePlaceholders(newSessions);
350
365
  markOpenSessionRead();
366
+ sgSweepTransient();
351
367
  renderSessions();
352
368
  renderActivityChip();
353
369
  schedulePublishSession();
@@ -544,14 +560,14 @@ function toggleSection(containerId, chevronId) {
544
560
  }
545
561
 
546
562
  function isWaitingSession(s) {
547
- return !!s.hasWaitingForUser;
563
+ return !!s.hasWaitingForUser || trustWaiting.has(s.id);
548
564
  }
549
565
  function sessionBarClass(s) {
550
566
  if (isWaitingSession(s)) return 'permission-pending';
551
567
  return s.unread ? 'unread' : '';
552
568
  }
553
569
  function isActiveSession(s) {
554
- return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
570
+ return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
555
571
  }
556
572
  // hasRecentLog carries the server's registry-idle correction: an open-but-idle
557
573
  // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
@@ -765,8 +781,7 @@ async function fetchTasks(sessionId) {
765
781
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
766
782
  revealedStorageSessionId = null;
767
783
  }
768
- if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
769
- expandPinnedFor(currentSessionId);
784
+ if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
770
785
  if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
771
786
  const switched = sessionId !== currentSessionId;
772
787
  currentSessionId = sessionId;
@@ -1655,8 +1670,12 @@ function toggleToolGroup(id) {
1655
1670
 
1656
1671
  // Legacy markers (written before the gate minted kind:"plan") carry no kind
1657
1672
  // for plans — the tool name is the durable signal there.
1673
+ function isPlanAsk(kind, tool) {
1674
+ return kind === 'plan' || tool === 'ExitPlanMode';
1675
+ }
1676
+
1658
1677
  function getWaitingPill(kind, tool) {
1659
- if (kind === 'plan' || tool === 'ExitPlanMode') return 'Plan awaiting approval';
1678
+ if (isPlanAsk(kind, tool)) return 'Plan awaiting approval';
1660
1679
  if (kind === 'question') return 'Question pending';
1661
1680
  return 'Awaiting permission';
1662
1681
  }
@@ -1735,27 +1754,34 @@ function waitingDecisionButtons(cls) {
1735
1754
  const plan = currentWaiting.kind === 'plan';
1736
1755
  // Code-authored call argument, not data — hence the constant-style name the
1737
1756
  // escaping check reads as safe.
1738
- const DENY_ARG = plan ? "{behavior:'deny',message:PLAN_REJECT_MSG}" : "{behavior:'deny'}";
1739
- return `<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();respondWaiting(${DENY_ARG})">${plan ? 'Reject' : 'Deny'}</button>`;
1757
+ const DENY_ARG = plan ? "rejectWaitingPlan('inline-plan-feedback')" : "respondWaiting({behavior:'deny'})";
1758
+ const feedback = plan
1759
+ ? `<input id="inline-plan-feedback" class="msg-waiting-feedback" type="text" placeholder="feedback for reject (optional)…" value="${escapeHtml(inlinePlanFeedback)}" oninput="inlinePlanFeedback=this.value" onclick="event.stopPropagation()" onkeydown="event.stopPropagation()">`
1760
+ : '';
1761
+ return `${feedback}<button class="${escapeHtml(cls)}-allow" onclick="event.stopPropagation();respondWaiting({behavior:'allow'})">${plan ? 'Approve' : 'Allow'}</button><button class="${escapeHtml(cls)}-deny" onclick="event.stopPropagation();${DENY_ARG}">${plan ? 'Reject' : 'Deny'}</button>`;
1740
1762
  }
1741
1763
 
1764
+ let inlinePlanFeedback = '';
1765
+
1742
1766
  function renderWaitingEntry() {
1743
1767
  if (!isWaitingFresh()) return '';
1744
1768
  const tool = currentWaiting.toolName || 'unknown';
1745
1769
  const params = parseWaitingInput();
1746
- const pillText = getWaitingPill(currentWaiting.kind, tool);
1770
+ const plan = isPlanAsk(currentWaiting.kind, tool);
1747
1771
  const detail = deriveWaitingDetail(tool, params);
1748
1772
  const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
1749
1773
  const bodyHtml = renderWaitingBody(tool, params);
1750
1774
  const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
1751
- const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
1752
1775
  const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
1753
1776
  // Questions are answered in the detail modal; plans render there too.
1754
1777
  const buttons = waitingDecisionButtons('msg-waiting');
1755
1778
  const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
1756
1779
  // Lapsed = gate stopped polling — the ask is only answerable in the terminal
1757
1780
  const lapsed = !!currentWaiting.lapsed;
1758
- return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-waiting-footer"><div class="msg-time">${lapsed ? 'answer in the terminal' : 'waiting…'}</div>${actions}</div></div>${discardBtn}</div>`;
1781
+ const keyHint = lapsed ? 'answer in the terminal' : buttons ? `${sendKbd()} ${plan ? 'approve' : 'allow'}` : '';
1782
+ const hintHtml = keyHint ? `<div class="msg-time waiting-key-hint-line">${keyHint}</div>` : '';
1783
+ const footer = hintHtml || actions ? `<div class="msg-waiting-footer">${hintHtml}${actions}</div>` : '';
1784
+ return `<div class="msg-item msg-waiting${plan ? ' msg-waiting-plan' : ''}" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text"><span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}${footer}</div>${discardBtn}</div>`;
1759
1785
  }
1760
1786
 
1761
1787
  // Shared approval controls for a fresh plan ask — the waiting-detail footer and
@@ -1804,6 +1830,7 @@ async function respondWaiting(payload) {
1804
1830
  // Drop the answered/discarded ask from every surface it renders on
1805
1831
  function clearWaitingUi() {
1806
1832
  currentWaiting = null;
1833
+ inlinePlanFeedback = '';
1807
1834
  waitingAnswerDraft = {};
1808
1835
  waitingCustomDraft = {};
1809
1836
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
@@ -2123,45 +2150,95 @@ let stickySessionIds = new Set();
2123
2150
  // Pinning the currently-selected session keeps it in place until deselected (less UI movement).
2124
2151
  const deferredPinPlacement = new Set();
2125
2152
 
2126
- function loadPinnedSessions() {
2127
- return new Set(readStoredList(PINNED_SESSIONS_KEY));
2153
+ // Sessions that `session open` keeps on screen for this page only; the server never has them.
2154
+ const openedStickyIds = new Set();
2155
+
2156
+ // The server keeps the pins (`/api/session/pins`, lib/session-pins.js); the two sets are only the
2157
+ // page's copy. The localStorage lists are from before that. Each origin has its own (the hub's
2158
+ // proxied port, the standalone board), so each one merges its lists into the server's once, then
2159
+ // deletes them.
2160
+ async function syncSessionPins() {
2161
+ try {
2162
+ const body = { pinned: readStoredList(PINNED_SESSIONS_KEY), sticky: readStoredList(STICKY_SESSIONS_KEY) };
2163
+ const local = body.pinned.length || body.sticky.length;
2164
+ const res = await (local ? api('/api/session/pins/import', { method: 'POST', body }) : api('/api/session/pins'));
2165
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2166
+ const data = await res.json();
2167
+ store.removeItem(PINNED_SESSIONS_KEY);
2168
+ store.removeItem(STICKY_SESSIONS_KEY);
2169
+ applyServerPins(data.pins);
2170
+ } catch (e) {
2171
+ console.warn('[pins] server sync failed:', e.message);
2172
+ }
2128
2173
  }
2129
2174
 
2130
- function loadStickySessions() {
2131
- return new Set(readStoredList(STICKY_SESSIONS_KEY));
2175
+ // The first apply needs no fetch: `/api/sessions` adds the pinned sessions itself.
2176
+ let pinsApplied = false;
2177
+
2178
+ function applyServerPins(pins) {
2179
+ const before = new Map([...pinnedSessionIds].map((id) => [id, getSessionPinState(id)]));
2180
+ pinnedSessionIds = new Set();
2181
+ stickySessionIds = new Set(openedStickyIds);
2182
+ for (const [id, state] of Object.entries(pins || {})) {
2183
+ pinnedSessionIds.add(id);
2184
+ if (state === 'sticky') {
2185
+ stickySessionIds.add(id);
2186
+ openedStickyIds.delete(id);
2187
+ }
2188
+ }
2189
+ for (const id of deferredPinPlacement) if (!isAnyPinned(id)) deferredPinPlacement.delete(id);
2190
+ const first = !pinsApplied;
2191
+ pinsApplied = true;
2192
+ const unchanged =
2193
+ before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
2194
+ if (unchanged && !first) return;
2195
+ const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
2196
+ showPinnedSessions(added);
2132
2197
  }
2133
2198
 
2134
- function savePinnedSessions() {
2135
- store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
2136
- store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
2199
+ // A pinned session outside the current filter is only in the list once it is fetched with the pins.
2200
+ function showPinnedSessions(added) {
2201
+ const known = new Set(sessions.map((s) => s.id));
2202
+ if (added.some((id) => !known.has(id))) fetchSessions(false);
2203
+ else renderSessions();
2137
2204
  }
2138
2205
 
2139
- // Mirror pin state to server so it can be queried by the CLI. UI remains source of truth for itself.
2140
- function offloadSessionPin(sessionId) {
2141
- const state = getSessionPinState(sessionId);
2142
- api('/api/session/pin', { method: 'POST', body: { id: sessionId, state } }).catch(() => {});
2206
+ function sendPinChange(sessionId) {
2207
+ return postPins({ ids: [sessionId], state: getSessionPinState(sessionId, { saved: true }) });
2208
+ }
2209
+
2210
+ // A write names only its sessions, so the last write for a session wins. A failed save re-reads the
2211
+ // server so the page shows what is saved.
2212
+ async function postPins(body) {
2213
+ let error;
2214
+ try {
2215
+ const res = await api('/api/session/pin', { method: 'POST', body });
2216
+ if (res.ok) return;
2217
+ error = (await res.json().catch(() => null))?.error || `HTTP ${res.status}`;
2218
+ } catch (e) {
2219
+ error = e.message;
2220
+ }
2221
+ showToast(`Pin not saved (${error}); showing the saved pins`, 'error');
2222
+ await syncSessionPins();
2143
2223
  }
2144
2224
 
2145
2225
  function clearSessionPin(sessionId) {
2146
2226
  pinnedSessionIds.delete(sessionId);
2147
2227
  stickySessionIds.delete(sessionId);
2228
+ openedStickyIds.delete(sessionId);
2148
2229
  deferredPinPlacement.delete(sessionId);
2149
2230
  }
2150
2231
 
2151
2232
  function unpinSession(sessionId) {
2152
2233
  clearSessionPin(sessionId);
2153
- savePinnedSessions();
2154
- offloadSessionPin(sessionId);
2234
+ sendPinChange(sessionId);
2155
2235
  renderSessions();
2156
2236
  }
2157
2237
 
2158
2238
  function toggleSessionPin(sessionId) {
2159
2239
  if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
2160
2240
  pinnedSessionIds.add(sessionId);
2161
- if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2162
- expandPinnedFor(sessionId);
2163
- savePinnedSessions();
2164
- offloadSessionPin(sessionId);
2241
+ sendPinChange(sessionId);
2165
2242
  renderSessions();
2166
2243
  }
2167
2244
 
@@ -2173,33 +2250,32 @@ function toggleSessionSticky(sessionId) {
2173
2250
  stickySessionIds.add(sessionId);
2174
2251
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2175
2252
  }
2176
- savePinnedSessions();
2177
- offloadSessionPin(sessionId);
2253
+ sendPinChange(sessionId);
2178
2254
  renderSessions();
2179
2255
  }
2180
2256
 
2181
- function isPlacedPinned(id) {
2182
- return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
2183
- }
2184
- function isPlacedSticky(id) {
2185
- return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
2186
- }
2187
-
2257
+ // The echo of this page's own change is a no-op, so the selected session keeps its deferred place.
2188
2258
  function handleSessionPinEvent({ id, state }) {
2189
- if (!id) return;
2259
+ if (!id || getSessionPinState(id, { saved: true }) === state) return;
2190
2260
  clearSessionPin(id);
2191
2261
  if (state === 'pinned') pinnedSessionIds.add(id);
2192
2262
  if (state === 'sticky') {
2193
2263
  pinnedSessionIds.add(id);
2194
2264
  stickySessionIds.add(id);
2195
2265
  }
2196
- expandPinnedFor(id);
2197
- savePinnedSessions();
2198
- renderSessions();
2266
+ showPinnedSessions(isAnyPinned(id) ? [id] : []);
2199
2267
  }
2200
2268
 
2201
- function getSessionPinState(sessionId) {
2202
- if (stickySessionIds.has(sessionId)) return 'sticky';
2269
+ function isPlacedPinned(id) {
2270
+ return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
2271
+ }
2272
+ function isPlacedSticky(id) {
2273
+ return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
2274
+ }
2275
+
2276
+ // `saved` gives the pin the user set: a sticky that `session open` added is only this page's.
2277
+ function getSessionPinState(sessionId, { saved = false } = {}) {
2278
+ if (stickySessionIds.has(sessionId) && !(saved && openedStickyIds.has(sessionId))) return 'sticky';
2203
2279
  if (pinnedSessionIds.has(sessionId)) return 'pinned';
2204
2280
  return 'none';
2205
2281
  }
@@ -3674,12 +3750,14 @@ function getFilteredSessions() {
3674
3750
  if (filterProject) {
3675
3751
  // The open session keeps its row even when it belongs to another project, so that
3676
3752
  // revealing one by id has something to scroll to. Same exemption the active branch
3677
- // above already makes, for the same reason.
3753
+ // above already makes, for the same reason. recentProjects lags behind the session list, so a
3754
+ // session in a new folder counts as recent on its own.
3755
+ const recentCutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
3678
3756
  filteredSessions = filteredSessions.filter(
3679
3757
  (s) =>
3680
3758
  matchesProjectFilter(s.project, s.worktree?.repo) ||
3681
3759
  s.id === currentSessionId ||
3682
- (filterProject === '__recent__' && isAnyPinned(s.id)),
3760
+ (filterProject === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
3683
3761
  );
3684
3762
  }
3685
3763
 
@@ -3782,16 +3860,55 @@ function renderSessions() {
3782
3860
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3783
3861
  scheduleJustFinishedExpiry();
3784
3862
  refreshProjectList();
3863
+ syncPinPeek();
3785
3864
 
3865
+ if (zenGroup && !sgGroupById(zenGroup)) {
3866
+ zenGroup = null;
3867
+ renderZenState();
3868
+ }
3786
3869
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3787
3870
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3871
+ // Group zen renders only its subtree's buckets, so it takes every session unfiltered.
3788
3872
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3789
- const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3790
-
3791
- const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3873
+ let filteredSessions;
3874
+ if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
3875
+ else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
3876
+ else filteredSessions = applyStableSessionOrder(getFilteredSessions());
3877
+
3878
+ const outside = sidebarSearch.rows(zenMode || zenGroup ? '' : searchQuery, filteredSessions);
3879
+
3880
+ const childDepth = new Map();
3881
+ const parentOf = new Map();
3882
+ for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
3883
+ const nestChildren = (list) => {
3884
+ if (parentOf.size === 0) return list;
3885
+ const ids = new Set(list.map((s) => s.id));
3886
+ const kids = new Map();
3887
+ for (const s of list) {
3888
+ const p = parentOf.get(s.id);
3889
+ if (p && ids.has(p)) {
3890
+ if (!kids.has(p)) kids.set(p, []);
3891
+ kids.get(p).push(s);
3892
+ }
3893
+ }
3894
+ if (kids.size === 0) return list;
3895
+ const out = [];
3896
+ const seen = new Set();
3897
+ const add = (s, depth) => {
3898
+ if (seen.has(s.id)) return;
3899
+ seen.add(s.id);
3900
+ out.push(s);
3901
+ if (depth) childDepth.set(s.id, Math.min(depth, 3));
3902
+ for (const k of kids.get(s.id) || []) add(k, depth + 1);
3903
+ };
3904
+ for (const s of list) if (!ids.has(parentOf.get(s.id))) add(s, 0);
3905
+ // Sessions that are each other's parents have no top; show them as they came.
3906
+ for (const s of list) add(s, 0);
3907
+ return out;
3908
+ };
3792
3909
 
3793
3910
  const renderSessionCard = (session) => {
3794
- if (session.placeholder) return renderPlaceholderCard(session);
3911
+ if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
3795
3912
  const total = session.taskCount;
3796
3913
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3797
3914
  const isActive = session.id === currentSessionId && viewMode === 'session';
@@ -3814,12 +3931,12 @@ function renderSessions() {
3814
3931
  const padEmoji = store.getItem(_padEmojiKey(session.id));
3815
3932
  const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
3816
3933
  const tempClass =
3817
- session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3934
+ session.hasRecentLog || session.hasRecentActivity || session.inProgress || isWaitingSession(session)
3818
3935
  ? 'warm'
3819
3936
  : 'stale';
3820
3937
  // Open tasks keep an idle session warm, but the green dot is for a session that is working.
3821
3938
  const idleClass =
3822
- tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
3939
+ tempClass === 'warm' && !isWaitingSession(session) && !session.hasRecentLog && !session.hasRunningAgents
3823
3940
  ? 'idle'
3824
3941
  : '';
3825
3942
  const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
@@ -3830,7 +3947,7 @@ function renderSessions() {
3830
3947
  : '';
3831
3948
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3832
3949
  return `
3833
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}">
3950
+ <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}${childDepthClass(childDepth.get(session.id))}">
3834
3951
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3835
3952
  <div class="session-name">${escapeHtml(sessionName)}</div>
3836
3953
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3851,8 +3968,8 @@ function renderSessions() {
3851
3968
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3852
3969
  ${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
3853
3970
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3854
- ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3855
- ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3971
+ ${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3972
+ ${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3856
3973
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
3857
3974
  </span>
3858
3975
  </div>
@@ -3884,7 +4001,7 @@ function renderSessions() {
3884
4001
  )
3885
4002
  : '';
3886
4003
 
3887
- if (filteredSessions.length === 0) {
4004
+ if (filteredSessions.length === 0 && !zenGroup) {
3888
4005
  let emptyMsg = 'No sessions found';
3889
4006
  let emptyHint = 'Tasks appear when you use Claude Code';
3890
4007
 
@@ -3939,11 +4056,11 @@ function renderSessions() {
3939
4056
  const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
3940
4057
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3941
4058
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3942
- const renderGroupSessions = (sessions, pinKey) => {
3943
- if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3944
- const gIdlePinned = sessions.filter(isInPinnedGroup);
3945
- if (gIdlePinned.length === 0) return sessions.map(renderSessionCard).join('');
3946
- const gUnpinned = sessions.filter((s) => !isInPinnedGroup(s));
4059
+ const renderGroupSessions = (list, pinKey) => {
4060
+ if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
4061
+ const gPinned = nestChildren(list.filter(isInPinnedGroup));
4062
+ if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
4063
+ const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
3947
4064
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3948
4065
  return (
3949
4066
  '<div class="pinned-sub-section">' +
@@ -3955,14 +4072,14 @@ function renderSessions() {
3955
4072
  groupChevronSvg(10) +
3956
4073
  '<span class="pinned-sub-label">Pinned</span>' +
3957
4074
  '<span class="group-count">' +
3958
- gIdlePinned.length +
4075
+ gPinned.length +
3959
4076
  '</span>' +
3960
4077
  '<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">&times;</span>' +
3961
4078
  '</div>' +
3962
4079
  '<div class="pinned-sub-items' +
3963
4080
  (pinCollapsed ? ' collapsed' : '') +
3964
4081
  '">' +
3965
- gIdlePinned.map(renderSessionCard).join('') +
4082
+ gPinned.map(renderSessionCard).join('') +
3966
4083
  '</div>' +
3967
4084
  '</div>' +
3968
4085
  gUnpinned.map(renderSessionCard).join('')
@@ -4015,72 +4132,85 @@ function renderSessions() {
4015
4132
  sgRest.push(session);
4016
4133
  }
4017
4134
  }
4018
- for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
4019
4135
  // A group emptied by a filter hides its header; a group the user just created (no filters)
4020
4136
  // shows its drop-here state instead of vanishing.
4021
- const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
4137
+ const sgFiltering = !zenGroup && sgListFiltered();
4138
+
4139
+ // A group whose parent is gone renders at the top.
4140
+ const sgIds = new Set(sessionGroups.map((g) => g.id));
4141
+ const sgChildren = new Map();
4142
+ for (const g of sessionGroups) {
4143
+ const p = g.parent && sgIds.has(g.parent) ? g.parent : null;
4144
+ if (!sgChildren.has(p)) sgChildren.set(p, []);
4145
+ sgChildren.get(p).push(g);
4146
+ }
4147
+ const sgSubtree = new Map();
4148
+ const sgSubtreeOf = (group) => {
4149
+ let all = sgSubtree.get(group.id);
4150
+ if (!all) {
4151
+ all = [...(sgBuckets.get(group.id) || []), ...(sgChildren.get(group.id) || []).flatMap(sgSubtreeOf)];
4152
+ sgSubtree.set(group.id, all);
4153
+ }
4154
+ return all;
4155
+ };
4022
4156
 
4023
4157
  // flat = the "All sessions" view, which has no project sub-blocks.
4024
4158
  const sgSectionHtml = (flat) => {
4025
- const transient = [...transientGroups.values()];
4026
- if (sessionGroups.length === 0 && transient.length === 0) return '';
4159
+ if (sessionGroups.length === 0) return '';
4027
4160
  let out = '';
4028
- for (const group of [...sessionGroups, ...transient]) {
4029
- const bucket = sgBuckets.get(group.id) || [];
4030
- if (bucket.length === 0 && sgFiltering) continue;
4031
- if (!groupPinned) bucket.sort(pinSort);
4032
- const collapsed = collapsedProjectGroups.has(sgKey(group.id));
4033
- let body = '';
4034
- if (flat) {
4035
- body = bucket.map(renderSessionCard).join('');
4036
- } else if (group.transient) {
4037
- // Each session under its own project block, so a worktree session reads as its repo's.
4038
- const byProject = new Map();
4039
- for (const s of bucket) {
4040
- const key = sessionProjectKey(s) || '';
4041
- if (!byProject.has(key)) byProject.set(key, []);
4042
- byProject.get(key).push(s);
4043
- }
4044
- for (const [p, arr] of byProject) {
4045
- body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
4046
- }
4047
- } else {
4048
- const loose = [];
4049
- const byProject = new Map();
4050
- for (const s of bucket) {
4051
- const host = sgHostOf(group, s);
4052
- if (host) {
4053
- if (!byProject.has(host)) byProject.set(host, []);
4054
- byProject.get(host).push(s);
4055
- } else {
4056
- loose.push(s);
4057
- }
4058
- }
4059
- // Project members first (in the user's member order), then the individually placed
4060
- // sessions — one Pinned sub-section per group instead of one per interleaved run.
4061
- for (const m of group.members) {
4062
- if (m.type !== 'project') continue;
4063
- const arr = byProject.get(m.ref);
4064
- // A member project with nothing left in it still renders: it shows the membership, and
4065
- // it is the drop target for putting a session back under it.
4066
- if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
4067
- }
4068
- if (loose.length) {
4069
- // Individually placed sessions follow the order the user dragged them into.
4070
- const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
4071
- loose.sort((a, b) => (slot.get(`session:${a.id}`) ?? 0) - (slot.get(`session:${b.id}`) ?? 0));
4072
- // Label them, else a card sitting under the project blocks reads as stranded.
4073
- if (body) body += '<div class="sg-sub-label">Sessions</div>';
4074
- body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
4161
+ for (const group of sgChildren.get(null) || []) out += sgGroupHtml(group, flat);
4162
+ if (!out) return '';
4163
+ return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
4164
+ };
4165
+
4166
+ const sgGroupHtml = (group, flat) => {
4167
+ const bucket = sgBuckets.get(group.id) || [];
4168
+ const subtree = sgSubtreeOf(group);
4169
+ if (subtree.length === 0 && sgFiltering) return '';
4170
+ if (!groupPinned) bucket.sort(pinSort);
4171
+ const collapsed = collapsedProjectGroups.has(sgKey(group.id));
4172
+ // Placed sessions follow the order the user dragged them into; a session that is here through
4173
+ // its project takes the project's slot.
4174
+ const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
4175
+ const slotOf = (s) => slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
4176
+ const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
4177
+ let body = '';
4178
+ if (flat) {
4179
+ body = nestChildren([...bucket].sort(byMemberOrder))
4180
+ .map(renderSessionCard)
4181
+ .join('');
4182
+ } else {
4183
+ const loose = [];
4184
+ const byProject = new Map();
4185
+ for (const s of bucket) {
4186
+ const host = sgHostOf(group, s);
4187
+ if (host) {
4188
+ if (!byProject.has(host)) byProject.set(host, []);
4189
+ byProject.get(host).push(s);
4190
+ } else {
4191
+ loose.push(s);
4075
4192
  }
4076
4193
  }
4077
- if (!body) body = '<div class="sg-empty">Drop a session or a project here</div>';
4078
- const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
4079
- out += sgHeaderHtml(group, countHtml(bucket));
4080
- out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}</div>`;
4194
+ // Project members first (in the user's member order), then the individually placed
4195
+ // sessions — one Pinned sub-section per group instead of one per interleaved run.
4196
+ for (const m of group.members) {
4197
+ if (m.type !== 'project') continue;
4198
+ const arr = byProject.get(m.ref);
4199
+ // A member project with nothing left in it still renders: it shows the membership, and
4200
+ // it is the drop target for putting a session back under it.
4201
+ if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
4202
+ }
4203
+ if (loose.length) {
4204
+ loose.sort(byMemberOrder);
4205
+ // Label them, else a card sitting under the project blocks reads as stranded.
4206
+ if (body) body += '<div class="sg-sub-label">Sessions</div>';
4207
+ body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
4208
+ }
4081
4209
  }
4082
- if (!out) return '';
4083
- return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
4210
+ let childHtml = '';
4211
+ for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
4212
+ if (!body && !childHtml) body = '<div class="sg-empty">Drop a session or a project here</div>';
4213
+ return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}${childHtml}</div>`;
4084
4214
  };
4085
4215
 
4086
4216
  let html = '';
@@ -4091,8 +4221,10 @@ function renderSessions() {
4091
4221
  </div>`;
4092
4222
  }
4093
4223
 
4094
- // Group active sessions by project
4095
- if (sessionFilter === 'active') {
4224
+ if (zenGroup) {
4225
+ sessionsList.innerHTML = sgGroupHtml(sgGroupById(zenGroup), sessionFilter !== 'active');
4226
+ } else if (sessionFilter === 'active') {
4227
+ // Group active sessions by project
4096
4228
  // Auto-expand a collapsed section when newly-active work lands in it (live refresh).
4097
4229
  expandActiveGroups({ onlyNew: true });
4098
4230
  html += sgSectionHtml(false);
@@ -4131,7 +4263,7 @@ function renderSessions() {
4131
4263
  // The section header is rendered even when nothing is left under it: it is the drop target
4132
4264
  // for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
4133
4265
  // the whole project list, re-parsed on every live refresh.
4134
- const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
4266
+ const sectioned = sessionGroups.length > 0;
4135
4267
  let projectsHtml = '';
4136
4268
  if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
4137
4269
  for (const [projectPath, projectSessions] of sortedGroups) {
@@ -4160,7 +4292,7 @@ function renderSessions() {
4160
4292
 
4161
4293
  sessionsList.innerHTML = html + outsideHtml;
4162
4294
  } else {
4163
- const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
4295
+ const sectioned = sessionGroups.length > 0;
4164
4296
  const restHtml =
4165
4297
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
4166
4298
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
@@ -4283,6 +4415,30 @@ function renderOwnerBadge(task) {
4283
4415
  return `<button type="button" class="task-owner-badge task-owner-link" style="${style}" title="Open dispatched session" onclick="event.stopPropagation(); revealSession('${escAttrJs(target)}')">${ICON_DISPATCHED}${escapeHtml(task.owner)}</button>`;
4284
4416
  }
4285
4417
 
4418
+ // Card chips that a tool writing the task file adds: metadata.chips = [{label, title?, tone?}], tone one of
4419
+ // muted, warn, accent. The writer is outside cck, so the list is capped and every field is checked.
4420
+ function taskChips(task) {
4421
+ const chips = task.metadata?.chips;
4422
+ if (!Array.isArray(chips)) return [];
4423
+ return chips
4424
+ .filter((c) => typeof c?.label === 'string' && c.label.trim())
4425
+ .slice(0, 4)
4426
+ .map((c) => ({
4427
+ label: c.label.trim().slice(0, 24),
4428
+ title: typeof c.title === 'string' ? c.title.slice(0, 200) : '',
4429
+ tone: ['muted', 'warn', 'accent'].includes(c.tone) ? c.tone : 'muted',
4430
+ }));
4431
+ }
4432
+
4433
+ function renderTaskChips(chips) {
4434
+ return chips
4435
+ .map(
4436
+ (c) =>
4437
+ `<span class="task-chip task-chip-${escapeHtml(c.tone)}"${c.title ? ` title="${escapeHtml(c.title)}"` : ''}>${escapeHtml(c.label)}</span>`,
4438
+ )
4439
+ .join('');
4440
+ }
4441
+
4286
4442
  function renderTaskCard(task) {
4287
4443
  const blockers = openBlockers(task);
4288
4444
  const isBlocked = blockers.length > 0;
@@ -4290,6 +4446,9 @@ function renderTaskCard(task) {
4290
4446
  const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
4291
4447
  const statusClass = task.status.replace('_', '-');
4292
4448
  const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
4449
+ const isDim = task.metadata?.dim === true;
4450
+ const chips = taskChips(task);
4451
+ const state = [task.status.replace('_', ' '), ...chips.map((c) => c.label)].join(', ');
4293
4452
 
4294
4453
  return `
4295
4454
  <div
@@ -4301,10 +4460,11 @@ function renderTaskCard(task) {
4301
4460
  draggable="true"
4302
4461
  ondragstart="onCardDragStart(event)"
4303
4462
  ondragend="onCardDragEnd(event)"
4304
- class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''}"
4305
- aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
4463
+ class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''} ${isDim ? 'dim' : ''}"
4464
+ aria-label="${escapeHtml(task.subject)} — ${escapeHtml(state)}">
4306
4465
  <div class="task-id">
4307
4466
  <span>#${taskId}</span>
4467
+ ${renderTaskChips(chips)}
4308
4468
  ${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
4309
4469
  ${task.owner ? renderOwnerBadge(task) : ''}
4310
4470
  </div>
@@ -4588,9 +4748,10 @@ async function onColumnDrop(e) {
4588
4748
 
4589
4749
  //#region SESSION_GROUPS
4590
4750
  // User-named groups that hold whole projects and individual sessions, dragged in from the
4591
- // sidebar. They render above the project groups. Membership is a workspace preference kept in
4592
- // localStorage; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
4593
- // windows the list), so membership is never garbage-collected on load.
4751
+ // sidebar. They render above the project groups. Membership is a workspace preference kept on
4752
+ // the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
4753
+ // windows the list), so membership is never garbage-collected on load. Transient groups go only
4754
+ // on a full Active list (sgSweepTransient).
4594
4755
  const SESSION_GROUPS_KEY = 'sessionGroups';
4595
4756
  const SG_ACTION_SELECTOR =
4596
4757
  '.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
@@ -4611,89 +4772,201 @@ function pinKey(projectPath) {
4611
4772
  return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
4612
4773
  }
4613
4774
 
4614
- function loadSessionGroups() {
4615
- const saved = store.readJson(SESSION_GROUPS_KEY);
4775
+ // The server keeps the groups (`/api/groups`, lib/user-groups.js). `sessionGroups` in localStorage
4776
+ // is the copy from before that: the board imports it once and never writes it, so it stays as a
4777
+ // backup.
4778
+ const GROUPS_MIGRATED_AT = 'groupsMigratedAt';
4779
+ let sgRev = null;
4780
+ let sgSaving = null;
4781
+ let sgDirty = false;
4782
+ let sgUnsent = false;
4783
+ let sgSeenRev = 0;
4784
+
4785
+ // Until it succeeds the board shows no groups, and it tries again on the next load or SSE reconnect.
4786
+ async function syncSessionGroups() {
4787
+ const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
4788
+ const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
4789
+ const hasLocal = local?.groups?.length > 0;
4790
+ try {
4791
+ const res = hasLocal
4792
+ ? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups } })
4793
+ : await api('/api/groups');
4794
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4795
+ sgTakeServer(await res.json());
4796
+ if (!migrated) store.setItem(GROUPS_MIGRATED_AT, new Date().toISOString());
4797
+ if (sgSeenRev > sgRev) sgRefresh(sgSeenRev);
4798
+ } catch (e) {
4799
+ console.warn('[groups] server sync failed:', e.message);
4800
+ }
4801
+ }
4802
+
4803
+ // The SSE event for this board's own write often arrives before the write's response.
4804
+ async function sgRefresh(rev = Infinity) {
4805
+ await sgSaving;
4806
+ if (sgRev !== null && sgRev >= rev) return;
4807
+ const data = await getJson('/api/groups');
4808
+ if (data) sgTakeServer(data);
4809
+ }
4810
+
4811
+ function sgTakeServer(data, force = false) {
4812
+ if (!Number.isInteger(data?.rev) || (!force && sgRev !== null && data.rev <= sgRev)) return;
4813
+ sgRev = data.rev;
4814
+ sgApplyState(data);
4815
+ renderSessions();
4816
+ }
4817
+
4818
+ // An event during the first sync is kept, because the sync's read may be older than it.
4819
+ function onGroupChanged(data) {
4820
+ if (!Number.isInteger(data?.rev)) return;
4821
+ sgSeenRev = Math.max(sgSeenRev, data.rev);
4822
+ if (sgRev !== null && data.rev > sgRev) sgRefresh(data.rev);
4823
+ }
4824
+
4825
+ function sgApplyState(saved) {
4616
4826
  const list = saved?.groups;
4617
4827
  sessionGroups = (Array.isArray(list) ? list : [])
4618
4828
  .filter((g) => g && typeof g.id === 'string')
4619
4829
  .map((g) => ({
4620
4830
  id: g.id,
4621
4831
  name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4832
+ parent: typeof g.parent === 'string' ? g.parent : null,
4833
+ ...(g.transient === true ? { transient: true } : {}),
4622
4834
  members: (Array.isArray(g.members) ? g.members : [])
4623
4835
  .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4624
4836
  .map((m) => {
4625
4837
  const out = { type: m.type, ref: m.ref };
4626
4838
  if (typeof m.under === 'string' && m.under) out.under = m.under;
4627
4839
  if (m.loose === true) out.loose = true;
4840
+ if (typeof m.parent === 'string' && m.parent) out.parent = m.parent;
4628
4841
  return out;
4629
4842
  }),
4630
4843
  }));
4631
- const released = saved?.released;
4632
- sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
4633
4844
  }
4634
4845
 
4635
4846
  function persistSessionGroups() {
4636
- store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
4847
+ if (sgRev === null) {
4848
+ showToast('Groups are not loaded from the server; this change is not saved', 'error');
4849
+ return;
4850
+ }
4851
+ sgDirty = true;
4852
+ sgSaving ??= sgFlush().finally(() => {
4853
+ sgSaving = null;
4854
+ });
4855
+ }
4856
+
4857
+ // Writes run one at a time; edits made meanwhile go out in the next write. A 409 means another
4858
+ // board wrote first: its state wins and this board's unsent edit is dropped. A write that does not
4859
+ // reach the server is sent again on SSE reconnect.
4860
+ async function sgFlush() {
4861
+ while (sgDirty) {
4862
+ sgDirty = false;
4863
+ let res;
4864
+ try {
4865
+ res = await api('/api/groups', {
4866
+ method: 'PUT',
4867
+ body: { rev: sgRev, groups: sessionGroups },
4868
+ });
4869
+ } catch (e) {
4870
+ console.warn('[groups] save failed:', e.message);
4871
+ sgUnsent = true;
4872
+ showToast('Groups not saved: the server is unreachable. Retrying when it is back', 'error');
4873
+ return;
4874
+ }
4875
+ sgUnsent = false;
4876
+ const data = await res.json().catch(() => null);
4877
+ if (res.ok && Number.isInteger(data?.rev)) {
4878
+ sgRev = data.rev;
4879
+ if (!sgDirty) sgTakeCleaned(data);
4880
+ continue;
4881
+ }
4882
+ showToast(
4883
+ res.status === 409
4884
+ ? 'Groups changed in another window; showing the latest'
4885
+ : `Groups not saved (${data?.error || res.status})`,
4886
+ res.status === 409 ? undefined : 'error',
4887
+ );
4888
+ sgDirty = false;
4889
+ const fresh = res.status === 409 ? data : await getJson('/api/groups');
4890
+ if (fresh) sgTakeServer(fresh, true);
4891
+ }
4892
+ }
4893
+
4894
+ // The server drops what breaks its rules (a member in two groups, a group past the limit) and
4895
+ // trims long names; the board shows what was saved.
4896
+ function sgTakeCleaned(data) {
4897
+ if (JSON.stringify(data.groups) === JSON.stringify(sessionGroups)) return;
4898
+ sgApplyState(data);
4899
+ renderSessions();
4637
4900
  }
4638
4901
 
4639
4902
  function sgGroupById(id) {
4640
4903
  return sessionGroups.find((g) => g.id === id) || null;
4641
4904
  }
4642
4905
 
4906
+ // Matches MAX_DEPTH in lib/user-groups.js.
4907
+ const SG_MAX_DEPTH = 8;
4908
+
4909
+ function sgSubtreeIds(rootId) {
4910
+ const ids = new Set([rootId]);
4911
+ for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
4912
+ return ids;
4913
+ }
4914
+
4915
+ function sgPathOf(group) {
4916
+ const names = [];
4917
+ for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
4918
+ return names.join('/');
4919
+ }
4920
+
4643
4921
  function sgGroupOf(type, ref) {
4644
4922
  return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
4645
4923
  }
4646
4924
 
4647
- // An individually placed session wins over the placement of its project.
4648
- function sgGroupForSession(session) {
4649
- return sgUserGroupFor(session) || sgDispatchGroupFor(session);
4925
+ const sgMemberOf = (group, type, ref) => group?.members.find((m) => m.type === type && m.ref === ref);
4926
+
4927
+ function sgSessionParent(id) {
4928
+ return sgMemberOf(sgGroupOf('session', id), 'session', id)?.parent || null;
4650
4929
  }
4651
4930
 
4652
- function sgUserGroupFor(session) {
4931
+ // An individually placed session wins over the placement of its project.
4932
+ function sgGroupForSession(session) {
4653
4933
  const project = sessionProjectKey(session);
4654
4934
  return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
4655
4935
  }
4656
4936
 
4657
- // Transient groups come from the server (`dispatch start --group`) and go when their sessions
4658
- // end. They are never written to localStorage; the objects are cached only so collapse state,
4659
- // keyed by id, survives a re-render.
4660
- const transientGroups = new Map();
4661
-
4662
- function sgTransientGroup(name) {
4663
- let group = transientGroups.get(name);
4664
- if (!group) {
4665
- group = { id: `t_${name}`, name, transient: true, members: [] };
4666
- transientGroups.set(name, group);
4667
- }
4668
- return group;
4937
+ function sgListFiltered() {
4938
+ return !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
4939
+ }
4940
+
4941
+ // A transient group (`dispatch start --group` made it) goes once the Active view shows none of its
4942
+ // sessions: they ended or were dismissed. A session still starting has no card yet, so its
4943
+ // terminal or placeholder counts. Only the full Active list can tell, so other views keep them.
4944
+ function sgSweepTransient() {
4945
+ if (sgRev === null || !sessionGroups.some((g) => g.transient)) return;
4946
+ if (zenGroup || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
4947
+ const shown = new Set(getFilteredSessions().map((s) => s.id));
4948
+ const starting = (id) => !dismissedSessionIds.has(id) && (runningTerminals.has(id) || dispatchSpecs.has(id));
4949
+ const present = (m) => m.type !== 'session' || shown.has(m.ref) || starting(m.ref);
4950
+ const gone = groupTree.cascade(sessionGroups, (g) => !!g.transient && !g.members.some(present));
4951
+ if (!gone.size) return;
4952
+ let collapsed = false;
4953
+ for (const g of gone) collapsed = collapsedProjectGroups.delete(sgKey(g.id)) || collapsed;
4954
+ if (collapsed) persistCollapsedGroups();
4955
+ sessionGroups = sessionGroups.filter((g) => !gone.has(g));
4956
+ persistSessionGroups();
4669
4957
  }
4670
4958
 
4959
+ const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
4960
+
4671
4961
  function dispatchedTitle({ parent }) {
4672
4962
  const starter = parent && sessions.find((s) => s.id === parent);
4673
4963
  const by = starter ? sessionDisplayName(starter) : 'another session';
4674
4964
  return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
4675
4965
  }
4676
4966
 
4677
- // A user group with the same name takes the session in, so Keep does not split a group in two.
4678
- function sgDispatchGroupFor(session) {
4679
- if (sgReleased.has(session.id)) return null;
4680
- const name = session.dispatchGroup;
4681
- if (!name) return null;
4682
- return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4683
- }
4684
-
4685
- function sgKeepTransient(name) {
4686
- const group = sgTransientGroup(name);
4687
- const members = sessions.filter((s) => sgGroupForSession(s) === group);
4688
- const kept = sgCreateGroup(name);
4689
- for (const s of members) kept.members.push({ type: 'session', ref: s.id });
4690
- persistSessionGroups();
4691
- }
4692
-
4693
- function sgDeleteTransient(name) {
4694
- const group = sgTransientGroup(name);
4695
- for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
4696
- persistSessionGroups();
4967
+ // A group the user edits is theirs: Keep, a rename or a placement clears the flag.
4968
+ function sgKeep(group) {
4969
+ delete group.transient;
4697
4970
  }
4698
4971
 
4699
4972
  function sgDetach(type, ref) {
@@ -4714,10 +4987,13 @@ function sgHostOf(group, session) {
4714
4987
  return null;
4715
4988
  }
4716
4989
  // `opts.under` places the session under another project block of the group; `opts.loose` lifts it
4717
- // out of its project block onto the group's own level.
4990
+ // out of its project block onto the group's own level. `opts.parent` follows the server's place():
4991
+ // left out, a move inside the group keeps it; null clears it.
4718
4992
  function sgAssign(groupId, type, ref, opts = {}) {
4719
4993
  const group = sgGroupById(groupId);
4720
4994
  if (!group) return;
4995
+ sgKeep(group);
4996
+ const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
4721
4997
  sgDetach(type, ref);
4722
4998
  // Pulling a whole project in supersedes the individual placements of its sessions.
4723
4999
  if (type === 'project') {
@@ -4731,10 +5007,11 @@ function sgAssign(groupId, type, ref, opts = {}) {
4731
5007
  type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
4732
5008
  // The group already holds the session's project — a separate placement would only strand
4733
5009
  // the card at the bottom of the group, away from its project block.
4734
- if (!covered) {
5010
+ if (!covered || parent) {
4735
5011
  const member = { type, ref };
4736
5012
  if (under) member.under = under;
4737
5013
  if (loose) member.loose = true;
5014
+ if (parent) member.parent = parent;
4738
5015
  group.members.push(member);
4739
5016
  }
4740
5017
  persistSessionGroups();
@@ -4744,6 +5021,7 @@ function sgCreateGroup(name) {
4744
5021
  const group = {
4745
5022
  id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
4746
5023
  name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
5024
+ parent: null,
4747
5025
  members: [],
4748
5026
  };
4749
5027
  sessionGroups.push(group);
@@ -4752,11 +5030,27 @@ function sgCreateGroup(name) {
4752
5030
  }
4753
5031
 
4754
5032
  function sgDeleteGroup(id) {
5033
+ const parent = sgGroupById(id)?.parent ?? null;
4755
5034
  sessionGroups = sessionGroups.filter((g) => g.id !== id);
5035
+ for (const g of sessionGroups) if (g.parent === id) g.parent = parent;
4756
5036
  if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
4757
5037
  persistSessionGroups();
4758
5038
  }
4759
5039
 
5040
+ async function sgConfirmDelete(group) {
5041
+ const ok = await confirmModal({
5042
+ title: 'Delete this group?',
5043
+ message: 'Its sessions and projects go back to Projects.',
5044
+ chip: group.name,
5045
+ okLabel: 'Delete group',
5046
+ });
5047
+ if (ok) {
5048
+ sgDeleteGroup(group.id);
5049
+ renderSessions();
5050
+ }
5051
+ return ok;
5052
+ }
5053
+
4760
5054
  // Move one member of a group in front of another — the user's own order inside the group.
4761
5055
  function sgReorderMember(groupId, drag, target) {
4762
5056
  const group = sgGroupById(groupId);
@@ -4778,13 +5072,17 @@ function sgReorderGroup(dragId, targetId) {
4778
5072
  persistSessionGroups();
4779
5073
  }
4780
5074
 
4781
- // Typing a group name should surface the sessions inside it.
5075
+ // Typing a group name should surface the sessions inside it and inside its child groups.
4782
5076
  function sgSearchMatchIds(query) {
4783
5077
  const ids = new Set();
4784
5078
  if (!query) return ids;
4785
- const hits = sessionGroups.filter((g) => fuzzyMatch(g.name, query));
4786
- if (hits.length === 0) return ids;
4787
- const hitIds = new Set(hits.map((g) => g.id));
5079
+ const hits = new Set(sessionGroups.filter((g) => fuzzyMatch(g.name, query)).map((g) => g.id));
5080
+ if (hits.size === 0) return ids;
5081
+ const underHit = (g) => {
5082
+ for (let p = g, n = 0; p && n < SG_MAX_DEPTH; p = sgGroupById(p.parent), n++) if (hits.has(p.id)) return true;
5083
+ return false;
5084
+ };
5085
+ const hitIds = new Set(sessionGroups.filter(underHit).map((g) => g.id));
4788
5086
  for (const s of sessions) {
4789
5087
  const group = sgGroupForSession(s);
4790
5088
  if (group && hitIds.has(group.id)) ids.add(s.id);
@@ -4806,13 +5104,10 @@ function sgHeaderHtml(group, countHtml) {
4806
5104
  const nameHtml = editing
4807
5105
  ? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
4808
5106
  : `<span class="group-name">${escapeHtml(group.name)}</span>`;
4809
- const attrs = group.transient
4810
- ? `class="session-group-header sg-transient${collapsed ? ' collapsed' : ''}" data-transient-group="${escapeHtml(group.name)}" title="${escapeHtml(group.name)} — goes when its sessions end"`
4811
- : `class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here"`;
4812
- const actions = group.transient
4813
- ? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
4814
- <span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">&times;</span>`
4815
- : `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
5107
+ const hint = group.transient ? 'goes when its sessions leave the Active view' : 'drop sessions or projects here';
5108
+ const attrs = `class="session-group-header${group.transient ? ' sg-transient' : ''}${collapsed ? ' collapsed' : ''}${group.id === zenGroup ? ' sg-zen' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — ${hint}"`;
5109
+ const actions = `${group.transient ? '<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>' : ''}
5110
+ <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4816
5111
  <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
4817
5112
  ${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
4818
5113
  return `
@@ -4847,6 +5142,7 @@ function sgCommitRename(input, save) {
4847
5142
  sgEditingId = null;
4848
5143
  if (save && group && value && value !== group.name) {
4849
5144
  group.name = value;
5145
+ sgKeep(group);
4850
5146
  persistSessionGroups();
4851
5147
  }
4852
5148
  renderSessions();
@@ -4857,7 +5153,7 @@ function sgCommitRename(input, save) {
4857
5153
  // At most one zone is lit, so the reference is enough - dragover fires many times a second.
4858
5154
  let sgLitZone = null;
4859
5155
  let sgDragEl = null;
4860
- const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
5156
+ const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after', 'sg-insert-parent'];
4861
5157
  const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
4862
5158
 
4863
5159
  function sgClearDropTargets() {
@@ -4882,15 +5178,43 @@ function sgInsertHit(card, e, share) {
4882
5178
  return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
4883
5179
  }
4884
5180
 
5181
+ // Outside the Active filter the sidebar sorts ungrouped cards itself, so only a group's members,
5182
+ // which keep their own order, take an insert there.
4885
5183
  function sgSiblingCard(target) {
4886
- if (sgDrag?.kind !== 'session' || sessionFilter !== 'active' || !sgDragEl) return null;
5184
+ if (sgDrag?.kind !== 'session' || !sgDragEl) return null;
4887
5185
  const card = target.closest('.session-item');
4888
- return card && card !== sgDragEl && card.parentElement === sgDragEl.parentElement ? card : null;
5186
+ if (!card || card === sgDragEl || card.parentElement !== sgDragEl.parentElement) return null;
5187
+ if (sessionFilter === 'active') return card;
5188
+ const group = sgGroupOf('session', sgDrag.ref);
5189
+ return group && sgGroupOf('session', card.dataset.sessionId) === group ? card : null;
5190
+ }
5191
+
5192
+ function sgDescendsFrom(id, ancestor) {
5193
+ for (let p = sgSessionParent(id), n = 0; p && n < SG_MAX_DEPTH; p = sgSessionParent(p), n++) {
5194
+ if (p === ancestor) return true;
5195
+ }
5196
+ return false;
5197
+ }
5198
+
5199
+ // A child group's header drops into its parent group on its top edge, and wherever its own group
5200
+ // would refuse the drop. Without it a session in the child group has no way up when the parent's
5201
+ // header is off screen: the gap above is a few pixels.
5202
+ // A child group renders inside its parent's body, so that body is the parent's drop zone.
5203
+ function sgParentEdgeHit(target, e, hit) {
5204
+ const header = sgDrag?.kind === 'group' ? null : target.closest('.session-group-header[data-group-id]');
5205
+ const parentBody = header?.parentElement.closest('.session-group-sessions[data-group-id]');
5206
+ if (!parentBody) return null;
5207
+ const rect = header.getBoundingClientRect();
5208
+ if (e.clientY - rect.top > rect.height * 0.4 && hit) return null;
5209
+ const up = sgGroupDropZone(parentBody);
5210
+ return up ? { ...up, zone: header, cls: 'sg-insert-parent' } : null;
4889
5211
  }
4890
5212
 
4891
5213
  function sgDropZone(target, e) {
4892
- const sibling = sgSiblingCard(target);
4893
5214
  const hit = sgGroupDropZone(target);
5215
+ const edge = sgParentEdgeHit(target, e, hit);
5216
+ if (edge) return edge;
5217
+ const sibling = sgSiblingCard(target);
4894
5218
  // Where the middle of a card does nothing, the whole card is an insert target.
4895
5219
  return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
4896
5220
  }
@@ -4900,10 +5224,14 @@ function sgInsertSession(dragId, { card, after }) {
4900
5224
  const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
4901
5225
  if (group && sgGroupOf('session', dragId)?.id === group.id) {
4902
5226
  // Loose sessions in a named group follow the group's member order, not the sidebar order.
5227
+ // A card set next to another becomes its sibling: it takes that card's parent.
5228
+ const { parent: _old, ...moved } = sgMemberOf(group, 'session', dragId);
5229
+ const parent = sgMemberOf(group, 'session', ref)?.parent;
5230
+ if (parent && parent !== dragId && !sgDescendsFrom(parent, dragId)) moved.parent = parent;
4903
5231
  const members = insertNear(
4904
5232
  group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
4905
5233
  (m) => m.type === 'session' && m.ref === ref,
4906
- { type: 'session', ref: dragId },
5234
+ moved,
4907
5235
  after,
4908
5236
  );
4909
5237
  if (!members) return;
@@ -4949,8 +5277,7 @@ function sgGroupDropZone(target) {
4949
5277
  : null;
4950
5278
  }
4951
5279
  const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
4952
- const releasable = !!dragSession && !sgUserGroupFor(dragSession) && !!sgDispatchGroupFor(dragSession);
4953
- if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
5280
+ if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
4954
5281
  const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
4955
5282
  const fromGroupId = fromGroup?.id || null;
4956
5283
  // A drop that lands on the host the session already has changes nothing, and highlighting it
@@ -4960,8 +5287,9 @@ function sgGroupDropZone(target) {
4960
5287
  if (groupId) {
4961
5288
  if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
4962
5289
  if (fromGroupId !== groupId) return { zone, groupId };
4963
- // Already in this group: its own level is a move only out of a project block.
4964
- return fromHost ? { zone, groupId, loose: true } : null;
5290
+ // Already in this group: its own level is a move only out of a project block or from under
5291
+ // a parent session.
5292
+ return fromHost || sgSessionParent(dragSession.id) ? { zone, groupId, loose: true, parent: null } : null;
4965
5293
  }
4966
5294
  const targetSessionId = zone.dataset.sessionId;
4967
5295
  if (targetSessionId) {
@@ -4969,22 +5297,18 @@ function sgGroupDropZone(target) {
4969
5297
  const targetSession = sessions.find((s) => s.id === targetSessionId);
4970
5298
  const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
4971
5299
  const targetGroupId = targetGroup?.id || null;
4972
- // A transient group holds no members to reorder or place under; a drop there pairs instead.
4973
- if (targetGroup && !targetGroup.transient) {
4974
- // Stacking onto a card adopts that card's placement, project block included.
5300
+ if (targetGroup) {
5301
+ // Stacking onto a card nests the dragged session under it, in that card's placement, project
5302
+ // block included.
5303
+ if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
5304
+ if (sgDescendsFrom(targetSessionId, sgDrag.ref)) return null;
4975
5305
  const targetHost = sgHostOf(targetGroup, targetSession);
4976
- if (targetHost) {
4977
- return targetHost === fromHost && fromGroupId === targetGroupId
4978
- ? null
4979
- : { zone, groupId: targetGroupId, under: targetHost };
4980
- }
4981
- // Both loose in the same group: this is a reorder, and only a member has a slot.
4982
- if (fromGroupId === targetGroupId && !fromHost) {
4983
- return sgGroupOf('session', targetSessionId)?.id === targetGroupId
4984
- ? { zone, groupId: targetGroupId, reorder: { type: 'session', ref: targetSessionId } }
4985
- : null;
4986
- }
4987
- return { zone, groupId: targetGroupId, loose: true };
5306
+ return {
5307
+ zone,
5308
+ groupId: targetGroupId,
5309
+ ...(targetHost ? { under: targetHost } : { loose: true }),
5310
+ parent: targetSessionId,
5311
+ };
4988
5312
  }
4989
5313
  return { zone, pairSession: targetSessionId };
4990
5314
  }
@@ -5070,13 +5394,16 @@ function sgOnDrop(e) {
5070
5394
  if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
5071
5395
  else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
5072
5396
  else {
5073
- sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
5397
+ sgAssign(hit.groupId, drag.kind, drag.ref, {
5398
+ under: hit.under,
5399
+ loose: hit.loose,
5400
+ parent: hit.parent,
5401
+ });
5074
5402
  // A drop shows where the card landed; a move from the context menu leaves the group as it was.
5075
5403
  if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
5076
5404
  }
5077
5405
  } else {
5078
5406
  sgDetach(drag.kind, drag.ref);
5079
- if (drag.kind === 'session') sgReleased.add(drag.ref);
5080
5407
  persistSessionGroups();
5081
5408
  }
5082
5409
  sgFinishDrag();
@@ -5136,11 +5463,6 @@ function initSessionGroupsDnd() {
5136
5463
  sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
5137
5464
  sessionsList.addEventListener('touchend', sgCancelLongPress);
5138
5465
  sessionsList.addEventListener('touchmove', sgCancelLongPress, { passive: true });
5139
- window.addEventListener('storage', (e) => {
5140
- if (e.key !== SESSION_GROUPS_KEY) return;
5141
- loadSessionGroups();
5142
- renderSessions();
5143
- });
5144
5466
  }
5145
5467
  //#endregion
5146
5468
 
@@ -5218,7 +5540,7 @@ function sgOpenMenu(x, y, kind, ref) {
5218
5540
  .filter((g) => g.id !== current?.id)
5219
5541
  .map(
5220
5542
  (g) =>
5221
- `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
5543
+ `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(sgPathOf(g))}</button>`,
5222
5544
  )
5223
5545
  .join('');
5224
5546
  const menu = document.createElement('div');
@@ -5414,7 +5736,7 @@ function getNavigableItems() {
5414
5736
  }
5415
5737
  } else if (child.classList.contains('session-item')) {
5416
5738
  items.push(child);
5417
- } else if (child.classList.contains('project-group-header')) {
5739
+ } else if (child.classList.contains('project-group-header') || child.classList.contains('session-group-header')) {
5418
5740
  items.push(child);
5419
5741
  if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
5420
5742
  walkGroupContainer(getGroupSessionsContainer(child));
@@ -5494,6 +5816,10 @@ function setGroupCollapsed(header, collapsed) {
5494
5816
  if (!header) return;
5495
5817
  const projectPath = header.dataset.groupPath;
5496
5818
  if (collapsed === collapsedProjectGroups.has(projectPath)) return;
5819
+ if (collapsed && projectPath === pinPeekKey) {
5820
+ pinPeekKey = null;
5821
+ pinPeekClosedFor = currentSessionId;
5822
+ }
5497
5823
  if (collapsed) collapsedProjectGroups.add(projectPath);
5498
5824
  else collapsedProjectGroups.delete(projectPath);
5499
5825
  header.classList.toggle('collapsed', collapsed);
@@ -5503,7 +5829,7 @@ function setGroupCollapsed(header, collapsed) {
5503
5829
  }
5504
5830
 
5505
5831
  function persistCollapsedGroups() {
5506
- store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
5832
+ store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...(pinPeekKey ? [pinPeekKey] : [])]);
5507
5833
  }
5508
5834
 
5509
5835
  let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
@@ -5533,16 +5859,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5533
5859
  }
5534
5860
 
5535
5861
  // Drops every collapse key that could hide this session: its project, a named group wrapping
5536
- // the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
5537
- // it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
5862
+ // the project or the session itself and the project block it renders under there, and the section header that holds it
5863
+ // (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
5538
5864
  // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5539
5865
  // whether anything changed.
5540
5866
  function uncollapseFor(session) {
5541
5867
  const group = sgGroupForSession(session);
5542
5868
  const keys = [
5543
- sessionProjectKey(session) || '__ungrouped__',
5544
- ...pinnedCollapseKeys(session),
5545
- ...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
5869
+ ...(group
5870
+ ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS]
5871
+ : [sessionProjectKey(session) || '__ungrouped__', SECTION_PROJECTS, SECTION_SESSIONS]),
5546
5872
  ];
5547
5873
  let changed = false;
5548
5874
  for (const key of keys) {
@@ -5554,23 +5880,25 @@ function uncollapseFor(session) {
5554
5880
  function pinnedCollapseKeys(session) {
5555
5881
  if (!isInPinnedGroup(session)) return [];
5556
5882
  const group = sgGroupForSession(session);
5883
+ if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
5557
5884
  const host = sgHostOf(group, session);
5558
- return [
5559
- pinKey(sessionProjectKey(session) || '__ungrouped__'),
5560
- host && pinKey(host),
5561
- group && `__pinned_group_${group.id}__`,
5562
- ];
5885
+ return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
5563
5886
  }
5564
5887
 
5565
- // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
5566
- function expandPinnedFor(sessionId) {
5567
- const session = sessions.find((s) => s.id === sessionId);
5568
- if (!session) return;
5569
- let changed = false;
5570
- for (const key of pinnedCollapseKeys(session)) {
5571
- if (key && collapsedProjectGroups.delete(key)) changed = true;
5572
- }
5573
- if (changed) persistCollapsedGroups();
5888
+ // A Pinned sub-section the user collapsed opens while the open session is in it and closes once
5889
+ // the user leaves that session. The peek is never saved: persistCollapsedGroups writes the key back.
5890
+ let pinPeekKey = null;
5891
+ let pinPeekClosedFor = null;
5892
+
5893
+ function syncPinPeek() {
5894
+ if (pinPeekClosedFor !== currentSessionId) pinPeekClosedFor = null;
5895
+ const session = currentSessionId && !pinPeekClosedFor ? sessions.find((s) => s.id === currentSessionId) : null;
5896
+ const key = session ? (pinnedCollapseKeys(session)[0] ?? null) : null;
5897
+ const want = key && (key === pinPeekKey || collapsedProjectGroups.has(key)) ? key : null;
5898
+ if (want === pinPeekKey) return;
5899
+ if (pinPeekKey) collapsedProjectGroups.add(pinPeekKey);
5900
+ if (want) collapsedProjectGroups.delete(want);
5901
+ pinPeekKey = want;
5574
5902
  }
5575
5903
 
5576
5904
  function isGroupHeader(el) {
@@ -5636,6 +5964,7 @@ function activateSelectedSession(items) {
5636
5964
  }
5637
5965
 
5638
5966
  function setFocusZone(zone, target) {
5967
+ passTerminalZoneOn = false;
5639
5968
  clearKbSelection();
5640
5969
  clearTaskSelection();
5641
5970
 
@@ -5898,6 +6227,23 @@ async function saveTaskField(taskId, sessionId, field, value) {
5898
6227
  }
5899
6228
  }
5900
6229
 
6230
+ const DETAIL_LAYOUT_KEY = 'cck-detail-layout';
6231
+
6232
+ function setDetailExpanded(expanded) {
6233
+ detailPanel.classList.toggle('expanded', expanded);
6234
+ const btn = document.getElementById('expand-detail');
6235
+ btn.innerHTML = showIcon(expanded ? 'restore' : 'expand');
6236
+ btn.title = expanded ? 'Restore (E)' : 'Expand (E)';
6237
+ btn.setAttribute('aria-label', expanded ? 'Restore' : 'Expand');
6238
+ store.setItem(DETAIL_LAYOUT_KEY, expanded ? 'expanded' : '');
6239
+ }
6240
+
6241
+ function initDetailLayout() {
6242
+ document.getElementById('expand-detail').onclick = () =>
6243
+ setDetailExpanded(!detailPanel.classList.contains('expanded'));
6244
+ setDetailExpanded(store.getItem(DETAIL_LAYOUT_KEY) === 'expanded');
6245
+ }
6246
+
5901
6247
  function closeDetailPanel() {
5902
6248
  detailPanel.classList.remove('visible');
5903
6249
  document.getElementById('delete-task-btn').style.display = 'none';
@@ -5911,10 +6257,30 @@ function closeDetailPanel() {
5911
6257
  // Set while #confirm-modal is open; settles the open confirmModal() promise.
5912
6258
  let closeConfirmModal = null;
5913
6259
 
5914
- function confirmModal({ title, message, okLabel }) {
6260
+ // chip: the object named in one line. items: [{state, label, meta}] rows, state in busy|wait|idle (a dot) or
6261
+ // omitted (no dot); steps: plain strings shown as what will happen; warn: a line under the list.
6262
+ function confirmModal({ title, message, okLabel, chip, items, steps, warn }) {
5915
6263
  const modal = document.getElementById('confirm-modal');
5916
6264
  document.getElementById('confirm-modal-title').textContent = title;
5917
6265
  document.getElementById('confirm-modal-message').textContent = message;
6266
+ const chipEl = document.getElementById('confirm-modal-chip');
6267
+ chipEl.hidden = !chip;
6268
+ chipEl.textContent = chip || '';
6269
+ const listEl = document.getElementById('confirm-modal-list');
6270
+ const rows = [
6271
+ ...(items || []).map(
6272
+ (it) =>
6273
+ `<div class="confirm-row">${it.state ? `<span class="confirm-dot ${escapeHtml(it.state)}"></span>` : ''}<span class="confirm-row-label">${escapeHtml(it.label)}</span><span class="confirm-row-meta">${escapeHtml(it.meta || '')}</span></div>`,
6274
+ ),
6275
+ ...(steps || []).map(
6276
+ (s) => `<div class="confirm-row confirm-step"><span class="confirm-row-label">${escapeHtml(s)}</span></div>`,
6277
+ ),
6278
+ ];
6279
+ listEl.hidden = !rows.length;
6280
+ listEl.innerHTML = rows.join('');
6281
+ const warnEl = document.getElementById('confirm-modal-warn');
6282
+ warnEl.hidden = !warn;
6283
+ warnEl.textContent = warn || '';
5918
6284
  const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5919
6285
  buttons[1].textContent = okLabel;
5920
6286
  const returnFocus = document.activeElement;
@@ -5946,11 +6312,15 @@ async function deleteTask(taskId, sessionId) {
5946
6312
  const task = currentTasks.find((t) => t.id === taskId);
5947
6313
  if (!task) return;
5948
6314
  const ok = await confirmModal({
5949
- title: 'Delete Task',
5950
- message: `Delete task "${task.subject}"? This cannot be undone.`,
5951
- okLabel: 'Delete',
6315
+ title: 'Delete this task?',
6316
+ message: 'It is removed from the board. This cannot be undone.',
6317
+ chip: task.subject,
6318
+ okLabel: 'Delete task',
5952
6319
  });
5953
6320
  if (!ok) return;
6321
+ const info = taskId === selectedTaskId ? getSelectedCardInfo() : null;
6322
+ const colCards = info ? Array.from(COLUMNS[info.colIndex].el.querySelectorAll('.task-card')) : [];
6323
+ const next = colCards[info?.cardIndex + 1] || colCards[info?.cardIndex - 1];
5954
6324
 
5955
6325
  try {
5956
6326
  const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
@@ -5958,6 +6328,14 @@ async function deleteTask(taskId, sessionId) {
5958
6328
  if (res.ok) {
5959
6329
  closeDetailPanel();
5960
6330
  await refreshCurrentView();
6331
+ if (info && focusZone === 'board') {
6332
+ if (next) {
6333
+ selectTask(next.dataset.taskId, next.dataset.sessionId);
6334
+ } else {
6335
+ selectedTaskId = null;
6336
+ navigateVertical(1);
6337
+ }
6338
+ }
5961
6339
  } else {
5962
6340
  const error = await res.json().catch(() => ({}));
5963
6341
  showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
@@ -5994,6 +6372,7 @@ const SHORTCUT_TABS = [
5994
6372
  rows: [
5995
6373
  { keys: ['Enter'], label: 'Toggle task detail panel' },
5996
6374
  { keys: ['D'], label: 'Delete selected task' },
6375
+ { keys: ['E'], label: 'Expand / restore task detail panel' },
5997
6376
  { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5998
6377
  { keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
5999
6378
  { keys: ['R'], label: 'Refresh data' },
@@ -6017,7 +6396,13 @@ const SHORTCUT_TABS = [
6017
6396
  { keys: ['\\'], label: 'Toggle sidebar' },
6018
6397
  { keys: ['T'], label: 'Toggle theme' },
6019
6398
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
6020
- { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
6399
+ { keys: ['←', '→'], label: 'Storage manager: switch tab' },
6400
+ {
6401
+ keys: ['Ctrl', 'Shift', 'Z'],
6402
+ combo: true,
6403
+ label: 'Zen mode (current session, or the group under the cursor)',
6404
+ },
6405
+ { keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
6021
6406
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
6022
6407
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
6023
6408
  { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
@@ -6135,6 +6520,14 @@ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platf
6135
6520
  const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
6136
6521
  if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
6137
6522
 
6523
+ // Ctrl+Enter sends on macOS as Cmd+Enter too (the handlers accept metaKey), so the hints show ⌘ and ⌥ there.
6524
+ function sendKbd() {
6525
+ return `<kbd>${IS_MAC ? '⌘' : 'Ctrl'}</kbd>+<kbd>Enter</kbd>`;
6526
+ }
6527
+ function termKbd() {
6528
+ return `<kbd>${IS_MAC ? '⌥' : 'Alt'}</kbd>+<kbd>\`</kbd>`;
6529
+ }
6530
+
6138
6531
  // The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
6139
6532
  // macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
6140
6533
  // row shows the keys the hub reports, which the user can change; row.keys until it answers.
@@ -6624,12 +7017,17 @@ function _renderOrphanGroup(count, innerHtml) {
6624
7017
  return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
6625
7018
  }
6626
7019
 
7020
+ function _markStorageTab(tab) {
7021
+ document.querySelectorAll('.storage-tab').forEach((t) => {
7022
+ t.classList.toggle('active', t.dataset.tab === tab);
7023
+ t.setAttribute('aria-selected', String(t.dataset.tab === tab));
7024
+ });
7025
+ }
7026
+
6627
7027
  async function showStorageManager() {
6628
7028
  _storageKnownSessions = null;
6629
7029
  _updateStorageTotal();
6630
- document.querySelectorAll('.storage-tab').forEach((t) => {
6631
- t.classList.toggle('active', t.dataset.tab === 'sessions');
6632
- });
7030
+ _markStorageTab('sessions');
6633
7031
  const modal = document.getElementById('storage-modal');
6634
7032
  modal.classList.add('visible');
6635
7033
  const known = await _fetchKnownSessions().catch(() => null);
@@ -6644,9 +7042,14 @@ function closeStorageManager() {
6644
7042
 
6645
7043
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6646
7044
  function switchStorageTab(tab) {
6647
- document.querySelectorAll('.storage-tab').forEach((t) => {
6648
- t.classList.toggle('active', t.dataset.tab === tab);
6649
- });
7045
+ _markStorageTab(tab);
7046
+ _renderStorageTab();
7047
+ }
7048
+
7049
+ function stepStorageTab(step) {
7050
+ const tabs = [...document.querySelectorAll('.storage-tab')];
7051
+ const i = tabs.findIndex((t) => t.classList.contains('active'));
7052
+ _markStorageTab(tabs[(i + step + tabs.length) % tabs.length].dataset.tab);
6650
7053
  _renderStorageTab();
6651
7054
  }
6652
7055
 
@@ -6655,9 +7058,14 @@ function _renderStorageTab() {
6655
7058
  if (!modal?.classList.contains('visible')) return;
6656
7059
  const body = document.getElementById('storage-modal-body');
6657
7060
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
7061
+ const groupsClean = document.getElementById('storage-groups-clean-btn');
7062
+ groupsClean.style.display = tab === 'groups' ? '' : 'none';
7063
+ groupsClean.disabled = true;
6658
7064
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
7065
+ else if (tab === 'dismissed') body.innerHTML = _renderStorageDismissed();
6659
7066
  else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
6660
7067
  else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
7068
+ else if (tab === 'groups') _renderStorageGroups(body);
6661
7069
  }
6662
7070
 
6663
7071
  function _renderStorageSessions() {
@@ -6733,6 +7141,35 @@ function _renderStorageSessions() {
6733
7141
  return html;
6734
7142
  }
6735
7143
 
7144
+ function _renderStorageDismissed() {
7145
+ const ids = [...dismissedSessionIds.keys()];
7146
+ if (!ids.length) return '<div class="storage-empty">No dismissed sessions</div>';
7147
+ const { groups, orphans } = _groupByProject(ids);
7148
+ const renderItem = ({ id, session }) => `<div class="storage-item">
7149
+ <span class="storage-item-id">${_sessionLabel(session, id)}</span>
7150
+ <span class="storage-item-meta">dismissed ${formatDate(dismissedSessionIds.get(id))}</span>
7151
+ <div class="storage-item-actions">
7152
+ <button onclick="_storageViewSession('${escAttrJs(id)}')">View</button>
7153
+ <button onclick="_storageRestoreSession('${escAttrJs(id)}')">Restore</button>
7154
+ </div>
7155
+ </div>`;
7156
+ let html = '';
7157
+ for (const [project, items] of groups) {
7158
+ html += _renderProjectGroup(
7159
+ escapeHtml(_projectLabel(project)),
7160
+ `${items.length} session${items.length > 1 ? 's' : ''}`,
7161
+ items.map(renderItem).join(''),
7162
+ );
7163
+ }
7164
+ if (orphans.length) html += _renderOrphanGroup(orphans.length, orphans.map(renderItem).join(''));
7165
+ return html;
7166
+ }
7167
+
7168
+ function _storageRestoreSession(id) {
7169
+ setSessionDismissed(id, false);
7170
+ _renderStorageTab();
7171
+ }
7172
+
6736
7173
  async function _storageViewSession(id) {
6737
7174
  closeStorageManager();
6738
7175
  revealedStorageSessionId = id;
@@ -6741,7 +7178,7 @@ async function _storageViewSession(id) {
6741
7178
 
6742
7179
  function _storageUnpinSession(id) {
6743
7180
  clearSessionPin(id);
6744
- savePinnedSessions();
7181
+ sendPinChange(id);
6745
7182
  renderSessions();
6746
7183
  _renderStorageTab();
6747
7184
  _updateStorageTotal();
@@ -6963,6 +7400,76 @@ function _storageClearLinkedDocs(sessionId) {
6963
7400
  afterLinkedDocsChanged(sessionId);
6964
7401
  }
6965
7402
 
7403
+ async function _storageGroupsClean(group, dryRun) {
7404
+ const shown = await getJson('/api/sessions', null, { query: { filter: 'active', limit: 'all', lite: '1' } });
7405
+ if (!Array.isArray(shown)) throw new Error('could not list sessions');
7406
+ const res = await api('/api/groups/clean', {
7407
+ method: 'POST',
7408
+ body: { group, dryRun, keep: shown.map((s) => s.id), hide: [...dismissedSessionIds.keys()] },
7409
+ });
7410
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
7411
+ return res.json();
7412
+ }
7413
+
7414
+ async function _renderStorageGroups(body) {
7415
+ body.innerHTML = '<div class="storage-empty">Loading groups…</div>';
7416
+ let out;
7417
+ try {
7418
+ out = await _storageGroupsClean(null, true);
7419
+ } catch (e) {
7420
+ out = { error: e.message };
7421
+ }
7422
+ _showStorageGroups(body, out);
7423
+ }
7424
+
7425
+ function _showStorageGroups(body, out) {
7426
+ if (document.querySelector('.storage-tab.active')?.dataset.tab !== 'groups') return;
7427
+ document.getElementById('storage-groups-clean-btn').disabled = !out.groups?.some((g) => g.hidden);
7428
+ if (out.error) {
7429
+ body.innerHTML = `<div class="storage-empty">Could not load groups: ${escapeHtml(out.error)}</div>`;
7430
+ return;
7431
+ }
7432
+ if (!out.groups.length) {
7433
+ body.innerHTML = '<div class="storage-empty">No groups</div>';
7434
+ return;
7435
+ }
7436
+ const rows = out.groups
7437
+ .map((g) => {
7438
+ const badge = g.hidden ? ` <span class="storage-item-badge">${g.hidden} hidden</span>` : '';
7439
+ return `<div class="storage-group-header storage-group-row">
7440
+ <span>${escapeHtml(g.path)}${badge}</span>
7441
+ <div class="storage-item-actions">
7442
+ <span class="storage-item-meta">${g.members} member${g.members === 1 ? '' : 's'}</span>
7443
+ <button class="danger" title="Take out the hidden sessions, here and in the groups under it" onclick="_storageCleanGroup('${escAttrJs(g.id)}')">Clean</button>
7444
+ <button class="danger" onclick="_storageDeleteGroup('${escAttrJs(g.id)}')">Delete</button>
7445
+ </div>
7446
+ </div>`;
7447
+ })
7448
+ .join('');
7449
+ const hidden = out.groups.reduce((n, g) => n + g.hidden, 0);
7450
+ body.innerHTML = _renderProjectGroup('Groups', `${hidden} hidden`, rows);
7451
+ }
7452
+
7453
+ async function _storageCleanGroup(group) {
7454
+ let out;
7455
+ try {
7456
+ out = await _storageGroupsClean(group, false);
7457
+ } catch (e) {
7458
+ showToast(`Clean failed: ${e.message}`, 'error');
7459
+ return;
7460
+ }
7461
+ const n = out.dropped;
7462
+ showToast(n ? `Took ${n} hidden session${n === 1 ? '' : 's'} out of the groups` : 'No hidden sessions');
7463
+ _showStorageGroups(document.getElementById('storage-modal-body'), out);
7464
+ }
7465
+
7466
+ async function _storageDeleteGroup(id) {
7467
+ const group = sgGroupById(id);
7468
+ if (!group || !(await sgConfirmDelete(group))) return;
7469
+ await sgSaving;
7470
+ _renderStorageTab();
7471
+ }
7472
+
6966
7473
  function _findOrphanedKeys(known) {
6967
7474
  if (!known.size) return [];
6968
7475
  const orphaned = [];
@@ -6972,7 +7479,7 @@ function _findOrphanedKeys(known) {
6972
7479
  for (const key of store.keys()) {
6973
7480
  const pad = _parsePadKey(key);
6974
7481
  if (pad?.kind === 'group') {
6975
- if (!sgGroupById(pad.id)) orphaned.push(key);
7482
+ if (sgRev !== null && !sgGroupById(pad.id)) orphaned.push(key);
6976
7483
  continue;
6977
7484
  }
6978
7485
  const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
@@ -7001,13 +7508,10 @@ async function cleanupOrphanedStorage() {
7001
7508
  const orphaned = _findOrphanedKeys(known);
7002
7509
  const unpinned = new Set();
7003
7510
  for (const key of orphaned) {
7004
- if (key.startsWith('__pinned__')) {
7005
- const id = key.slice('__pinned__'.length);
7006
- pinnedSessionIds.delete(id);
7007
- unpinned.add(id);
7008
- } else if (key.startsWith('__sticky__')) {
7009
- const id = key.slice('__sticky__'.length);
7010
- stickySessionIds.delete(id);
7511
+ const pin = /^__(pinned|sticky)__/.exec(key);
7512
+ if (pin) {
7513
+ const id = key.slice(pin[0].length);
7514
+ clearSessionPin(id);
7011
7515
  unpinned.add(id);
7012
7516
  } else {
7013
7517
  store.removeItem(key);
@@ -7015,8 +7519,7 @@ async function cleanupOrphanedStorage() {
7015
7519
  if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
7016
7520
  }
7017
7521
  }
7018
- if (unpinned.size) savePinnedSessions();
7019
- for (const id of unpinned) offloadSessionPin(id);
7522
+ if (unpinned.size) postPins({ ids: [...unpinned], state: 'none' });
7020
7523
  const removed = orphaned.length;
7021
7524
 
7022
7525
  showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
@@ -7077,21 +7580,28 @@ function dismissSessionFromList(sid) {
7077
7580
  }
7078
7581
 
7079
7582
  async function confirmDismissBlockers(sid) {
7080
- const sticky = stickySessionIds.has(sid);
7583
+ const pinState = getSessionPinState(sid);
7584
+ const pinned = pinState !== 'none';
7081
7585
  const terminal = runningTerminals.has(sid);
7082
- if (!sticky && !terminal) return true;
7586
+ if (!pinned && !terminal) return true;
7083
7587
  const session = sessions.find((s) => s.id === sid);
7084
7588
  const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
7085
- const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
7086
- const [title, verb, okLabel] =
7087
- sticky && terminal
7088
- ? ['Unpin and End Terminal', 'Unpin, end the terminal', 'Unpin, end and dismiss']
7089
- : sticky
7090
- ? ['Remove Sticky Pin', 'Remove the pin', 'Unpin and dismiss']
7091
- : ['End Terminal', 'End the terminal', 'End and dismiss'];
7092
- const ok = await confirmModal({ title, message: `"${name}" has ${has}. ${verb} and dismiss the session?`, okLabel });
7589
+ const pinLabel = pinState === 'sticky' ? 'sticky pin' : 'pin';
7590
+ const has = [pinned && `a ${pinLabel}`, terminal && 'a running terminal'].filter(Boolean).join(' and ');
7591
+ const okLabel = pinned && terminal ? 'Unpin, end and dismiss' : pinned ? 'Unpin and dismiss' : 'End and dismiss';
7592
+ const steps = [
7593
+ pinned && `Remove the ${pinLabel}`,
7594
+ terminal && 'End the terminal',
7595
+ 'Hide the session from the sidebar',
7596
+ ].filter(Boolean);
7597
+ const ok = await confirmModal({
7598
+ title: `Dismiss “${name}”?`,
7599
+ message: `The session has ${has}.`,
7600
+ steps,
7601
+ okLabel,
7602
+ });
7093
7603
  if (!ok) return false;
7094
- if (sticky) unpinSession(sid);
7604
+ if (isAnyPinned(sid)) unpinSession(sid);
7095
7605
  if (terminal) closeTerminalSession(sid);
7096
7606
  return true;
7097
7607
  }
@@ -7190,19 +7700,36 @@ document.addEventListener('keydown', (e) => {
7190
7700
  } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
7191
7701
  e.preventDefault();
7192
7702
  closeHelpModal();
7703
+ } else if (
7704
+ matchKey(e, 'ArrowLeft', 'ArrowRight') &&
7705
+ [...document.querySelectorAll('.modal-overlay.visible')].at(-1)?.id === 'storage-modal'
7706
+ ) {
7707
+ e.preventDefault();
7708
+ stepStorageTab(matchKey(e, 'ArrowLeft') ? -1 : 1);
7193
7709
  } else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
7194
7710
  e.preventDefault();
7195
7711
  closeMsgDetailModal();
7196
7712
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
7197
- if (
7198
- e.key === 'Enter' &&
7199
- (e.ctrlKey || e.metaKey) &&
7200
- currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX &&
7201
- isWaitingAnswerable() &&
7202
- currentWaiting.kind !== 'question'
7203
- ) {
7713
+ const askOpen = currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX && isWaitingAnswerable();
7714
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen && currentWaiting.kind !== 'question') {
7204
7715
  e.preventDefault();
7205
7716
  respondWaiting({ behavior: 'allow' });
7717
+ } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen) {
7718
+ e.preventDefault();
7719
+ submitWaitingAnswers();
7720
+ } else if (
7721
+ askOpen &&
7722
+ currentWaiting.kind === 'question' &&
7723
+ !e.ctrlKey &&
7724
+ !e.metaKey &&
7725
+ !e.altKey &&
7726
+ /^[1-9]$/.test(e.key)
7727
+ ) {
7728
+ e.preventDefault();
7729
+ pickWaitingOptionByNumber(Number(e.key) - 1);
7730
+ } else if (askOpen && currentWaiting.kind === 'question' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7731
+ e.preventDefault();
7732
+ setWaitingTab(waitingTab + (e.key === 'ArrowRight' ? 1 : -1));
7206
7733
  } else if (matchKey(e, 'ArrowDown')) {
7207
7734
  e.preventDefault();
7208
7735
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
@@ -7234,6 +7761,20 @@ document.addEventListener('keydown', (e) => {
7234
7761
  return;
7235
7762
  }
7236
7763
 
7764
+ if (
7765
+ e.key === 'Enter' &&
7766
+ (e.ctrlKey || e.metaKey) &&
7767
+ !e.altKey &&
7768
+ !e.shiftKey &&
7769
+ isWaitingFresh() &&
7770
+ isWaitingAnswerable() &&
7771
+ currentWaiting.kind !== 'question'
7772
+ ) {
7773
+ e.preventDefault();
7774
+ respondWaiting({ behavior: 'allow' });
7775
+ return;
7776
+ }
7777
+
7237
7778
  // Global shortcuts
7238
7779
  if (matchKey(e, '\\')) {
7239
7780
  e.preventDefault();
@@ -7242,11 +7783,15 @@ document.addEventListener('keydown', (e) => {
7242
7783
  }
7243
7784
  if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
7244
7785
  e.preventDefault();
7786
+ focusActivePane();
7245
7787
  return;
7246
7788
  }
7247
7789
  const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
7248
7790
  if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
7249
- if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) e.preventDefault();
7791
+ if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) {
7792
+ e.preventDefault();
7793
+ focusActivePane();
7794
+ }
7250
7795
  return;
7251
7796
  }
7252
7797
  if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
@@ -7264,6 +7809,7 @@ document.addEventListener('keydown', (e) => {
7264
7809
  }
7265
7810
  if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
7266
7811
  e.preventDefault();
7812
+ focusActivePane();
7267
7813
  return;
7268
7814
  }
7269
7815
  if (shiftOnly(e, 'KeyL')) {
@@ -7311,8 +7857,12 @@ document.addEventListener('keydown', (e) => {
7311
7857
 
7312
7858
  // Tab toggles focus zone
7313
7859
  if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
7860
+ // A focused pane document keeps the arrow, page and space keys for scrolling.
7861
+ const inPane = paneViews.contains(e.target);
7862
+ if (inPane && e.key !== 'Tab' && e.key !== 'Escape') return;
7314
7863
  if (e.key === 'Tab') {
7315
7864
  e.preventDefault();
7865
+ if (inPane) e.target.blur();
7316
7866
  // The terminal replaces the board, so it takes the board's place in the Tab cycle.
7317
7867
  if (wantsTerminal()) {
7318
7868
  if (focusZone === 'sidebar') focusTerminalPane();
@@ -7393,6 +7943,12 @@ document.addEventListener('keydown', (e) => {
7393
7943
  deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
7394
7944
  return;
7395
7945
  }
7946
+
7947
+ if (matchKey(e, 'KeyE') && detailPanel.classList.contains('visible')) {
7948
+ e.preventDefault();
7949
+ setDetailExpanded(!detailPanel.classList.contains('expanded'));
7950
+ return;
7951
+ }
7396
7952
  }
7397
7953
 
7398
7954
  if (e.key === 'Escape') {
@@ -7739,6 +8295,31 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
7739
8295
  (document.head || document.documentElement).append(style);
7740
8296
  }
7741
8297
  const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
8298
+ const fields = (form, submitter) => {
8299
+ const out = {};
8300
+ for (const [k, v] of new FormData(form, submitter)) {
8301
+ if (typeof v !== 'string') continue;
8302
+ out[k] = k in out ? [].concat(out[k], v) : v;
8303
+ }
8304
+ return out;
8305
+ };
8306
+ const labelOf = (el, fallback) => (el?.textContent || el?.value || '').trim().slice(0, 200) || fallback;
8307
+ addEventListener('click', (e) => {
8308
+ const b = e.target.closest?.('[data-cck-action]');
8309
+ if (!b || (b.form?.matches('[data-cck-submit]') && b.type === 'submit')) return;
8310
+ e.preventDefault();
8311
+ const action = b.dataset.cckAction;
8312
+ send({ type: 'action', action, label: labelOf(b, action), data: b.form ? fields(b.form) : {} });
8313
+ });
8314
+ // The sandbox allows forms so validation runs. Every submit stops here, so a page still cannot
8315
+ // navigate its frame with a form: a show card's form-action 'none' does not stop a GET to about:srcdoc.
8316
+ addEventListener('submit', (e) => {
8317
+ e.preventDefault();
8318
+ const f = e.target.closest?.('form[data-cck-submit]');
8319
+ if (!f) return;
8320
+ const action = f.dataset.cckSubmit;
8321
+ send({ type: 'action', action, label: labelOf(e.submitter, action), data: fields(f, e.submitter) });
8322
+ });
7742
8323
  const describe = (node) => {
7743
8324
  const el = node.nodeType === 1 ? node : node.parentElement;
7744
8325
  if (!el) return {};
@@ -7922,10 +8503,11 @@ function sendBridgeClaims(frames, claims) {
7922
8503
  window.addEventListener('message', (e) => {
7923
8504
  const type =
7924
8505
  typeof e.data?.type === 'string' && e.data.type.startsWith(REVIEW_MSG) && e.data.type.slice(REVIEW_MSG.length);
7925
- if (type !== 'link' && type !== 'key' && type !== 'scroll') return;
8506
+ if (type !== 'link' && type !== 'key' && type !== 'scroll' && type !== 'action') return;
7926
8507
  const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
7927
8508
  if (!frame) return;
7928
- if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
8509
+ if (type === 'action') sendPreviewAction(frame, e.data);
8510
+ else if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
7929
8511
  else if (type === 'key') replayKey(e.data, frame);
7930
8512
  else {
7931
8513
  const url = linkUrl(e.data.href);
@@ -8138,6 +8720,24 @@ function renderReviewPanel() {
8138
8720
  .join('')}</ol>`;
8139
8721
  }
8140
8722
 
8723
+ async function toastDelivery({ delivered, markdown }, noun) {
8724
+ if (delivered === 'doorbell') return showToast('Queued for the session', 'success');
8725
+ if (delivered === 'terminal') return showToast('Pasted into the terminal: press Enter there to send', 'info');
8726
+ const copy = async () => {
8727
+ const copied = await navigator.clipboard.writeText(markdown).then(
8728
+ () => true,
8729
+ () => false,
8730
+ );
8731
+ showToast(
8732
+ copied ? `Copied the ${noun}. Paste it into the session.` : `Failed to copy the ${noun}`,
8733
+ 'info',
8734
+ { label: 'Copy again', onClick: copy },
8735
+ `Sessions get ${noun}s directly when the cck plugin runs there and board events are on.`,
8736
+ );
8737
+ };
8738
+ await copy();
8739
+ }
8740
+
8141
8741
  async function sendReview() {
8142
8742
  if (!activeReview) return;
8143
8743
  const { sessionId, source, key, onSent } = activeReview;
@@ -8150,30 +8750,49 @@ async function sendReview() {
8150
8750
  });
8151
8751
  const data = await res.json();
8152
8752
  if (!res.ok) return showToast(data.error || 'Review failed', 'error');
8153
- if (data.delivered === 'doorbell') showToast('Queued for the session', 'success');
8154
- else if (data.delivered === 'terminal') showToast('Pasted into the terminal: press Enter there to send', 'info');
8155
- else {
8156
- const { markdown } = data;
8157
- const copy = async () => {
8158
- const copied = await navigator.clipboard.writeText(markdown).then(
8159
- () => true,
8160
- () => false,
8161
- );
8162
- showToast(
8163
- copied ? 'Copied the review. Paste it into the session.' : 'Failed to copy the review',
8164
- 'info',
8165
- { label: 'Copy again', onClick: copy },
8166
- 'Sessions get reviews directly when the cck plugin runs there and board events are on.',
8167
- );
8168
- };
8169
- await copy();
8170
- }
8753
+ await toastDelivery(data, 'review');
8171
8754
  clearReviewDrafts(key);
8172
8755
  if (activeReview?.key === key) onSent?.();
8173
8756
  } catch {
8174
8757
  showToast('Failed to send the review', 'error');
8175
8758
  }
8176
8759
  }
8760
+
8761
+ // A page script can post an action without a click, so the board sends it only with transient
8762
+ // user activation, which a click in the frame gives to the board too.
8763
+ const ACTION_REPEAT_MS = 1000;
8764
+ let lastAction = null;
8765
+
8766
+ function reviewOptsOfFrame(frame) {
8767
+ return (
8768
+ [...reviewStack, reviewBase, showState.review].find((o) => o?.frame === frame) ||
8769
+ paneReviews.get(frame.parentElement) ||
8770
+ null
8771
+ );
8772
+ }
8773
+
8774
+ async function sendPreviewAction(frame, msg) {
8775
+ if (!navigator.userActivation?.isActive) return;
8776
+ const opts = reviewOptsOfFrame(frame);
8777
+ if (!opts?.sessionId) return;
8778
+ const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
8779
+ const now = Date.now();
8780
+ if (lastAction?.frame === frame && lastAction.key === key && now - lastAction.at < ACTION_REPEAT_MS) return;
8781
+ lastAction = { frame, key, at: now };
8782
+ try {
8783
+ const res = await terminalFetch(apiPath`/api/sessions/${opts.sessionId}/action`, 'POST', {
8784
+ source: opts.source,
8785
+ action: msg.action,
8786
+ label: msg.label,
8787
+ data: msg.data,
8788
+ });
8789
+ const out = await res.json();
8790
+ if (!res.ok) return showToast(out.error || 'Send failed', 'error');
8791
+ await toastDelivery(out, 'answer');
8792
+ } catch {
8793
+ showToast('Failed to send the answer', 'error');
8794
+ }
8795
+ }
8177
8796
  //#endregion
8178
8797
 
8179
8798
  //#region MARKDOWN_PREVIEW
@@ -8284,7 +8903,7 @@ function renderFrontmatterBlock(fm) {
8284
8903
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
8285
8904
  // storage, DOM or API. srcdoc has no base URL, so the server has already embedded
8286
8905
  // the document's local assets (lib/inline-assets.js); remote refs load normally.
8287
- function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups') {
8906
+ function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups allow-forms') {
8288
8907
  const frame = document.createElement('iframe');
8289
8908
  frame.className = className;
8290
8909
  frame.setAttribute('sandbox', sandbox);
@@ -8306,6 +8925,10 @@ function isAbsolutePath(p) {
8306
8925
  return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
8307
8926
  }
8308
8927
 
8928
+ function isUrlRef(s) {
8929
+ return /^[a-z][a-z0-9+.-]*:/i.test(s) || s.startsWith('//');
8930
+ }
8931
+
8309
8932
  function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8310
8933
  if (bodyEl.dataset.relLinkBound) return;
8311
8934
  bodyEl.addEventListener('click', (e) => {
@@ -8313,8 +8936,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8313
8936
  if (!a) return;
8314
8937
  const href = a.getAttribute('href');
8315
8938
  if (!href || href.startsWith('#')) return;
8316
- const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
8317
- if (isAbsoluteUrl) return;
8939
+ if (isUrlRef(href)) return;
8318
8940
  const cleanHref = href.replace(/#.*$/, '');
8319
8941
  e.preventDefault();
8320
8942
  openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
@@ -8366,10 +8988,37 @@ function renderPreviewContent(bodyEl, filePath, content, kind) {
8366
8988
  bodyEl.replaceChildren(img);
8367
8989
  } else {
8368
8990
  const { fm, body } = splitFrontmatter(content);
8369
- bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
8991
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdownFile(body, filePath);
8992
+ }
8993
+ }
8994
+
8995
+ // A relative src would resolve against the board's URL, and DOMPurify drops a drive path (`C:/x`
8996
+ // reads as a scheme), so the hook moves a local image to the image route before that check.
8997
+ function renderMarkdownFile(text, filePath) {
8998
+ if (!filePath || typeof DOMPurify === 'undefined') return renderMarkdown(text);
8999
+ DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
9000
+ if (node.nodeName === 'IMG' && data.attrName === 'src') data.attrValue = localImageUrl(data.attrValue, filePath);
9001
+ });
9002
+ try {
9003
+ return renderMarkdown(text);
9004
+ } finally {
9005
+ DOMPurify.removeHook('uponSanitizeAttribute');
8370
9006
  }
8371
9007
  }
8372
9008
 
9009
+ function localImageUrl(src, base) {
9010
+ const fileUrl = /^file:/i.test(src);
9011
+ if (src.startsWith('//') || (!fileUrl && !isAbsolutePath(src) && isUrlRef(src))) return src;
9012
+ let p = src;
9013
+ if (!fileUrl) {
9014
+ p = src.replace(/[?#].*$/, '');
9015
+ try {
9016
+ p = decodeURI(p);
9017
+ } catch {}
9018
+ }
9019
+ return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
9020
+ }
9021
+
8373
9022
  function openPreviewModal(filePath, content, kind) {
8374
9023
  currentPreviewPath = filePath;
8375
9024
  const fileName = filePath.split(/[\\/]/).pop();
@@ -8521,6 +9170,7 @@ function handleSessionOpenEvent(data) {
8521
9170
  }
8522
9171
  if (!isSessionActive(target)) {
8523
9172
  stickySessionIds.add(id);
9173
+ openedStickyIds.add(id);
8524
9174
  }
8525
9175
  setSessionDismissed(id, false);
8526
9176
  cliOpenedId = id;
@@ -9184,6 +9834,10 @@ function setupEventSource() {
9184
9834
  fetchSessions().catch(() => {});
9185
9835
  if (currentSessionId) fetchTasks(currentSessionId);
9186
9836
  refetchPaneLayout();
9837
+ if (sgRev === null) syncSessionGroups();
9838
+ else if (sgUnsent) persistSessionGroups();
9839
+ else sgRefresh();
9840
+ syncSessionPins();
9187
9841
  if (terminalAvailable())
9188
9842
  loadTerminals()
9189
9843
  .then(renderSessions)
@@ -9247,7 +9901,10 @@ function setupEventSource() {
9247
9901
  if (!skipOffScreen()) refreshOpenPlan();
9248
9902
  }
9249
9903
 
9250
- if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
9904
+ if (data.type === 'terminals-update' && Array.isArray(data.ids))
9905
+ setRunningTerminals(data.ids, data.awaitingTrust);
9906
+
9907
+ if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
9251
9908
 
9252
9909
  if (data.type === 'dispatch-update') onDispatchUpdate();
9253
9910
 
@@ -9296,6 +9953,10 @@ function setupEventSource() {
9296
9953
  handleSessionPinEvent(data);
9297
9954
  }
9298
9955
 
9956
+ if (data.type === 'group:changed') {
9957
+ onGroupChanged(data);
9958
+ }
9959
+
9299
9960
  if (data.type === 'pane:changed') {
9300
9961
  applyPaneLayout(data.sessionId, data.layout);
9301
9962
  }
@@ -9663,45 +10324,93 @@ function renderWaitingFooter(tool, params) {
9663
10324
  if (currentWaiting?.kind === 'plan') {
9664
10325
  return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
9665
10326
  }
9666
- if (currentWaiting?.kind !== 'question') return waitingDecisionButtons('waiting-btn');
10327
+ if (currentWaiting?.kind !== 'question') {
10328
+ const buttons = waitingDecisionButtons('waiting-btn');
10329
+ const hint = `<span class="waiting-footer-hint">${sendKbd()} allow · ${termKbd()} terminal</span>`;
10330
+ return buttons ? hint + buttons : '';
10331
+ }
9667
10332
  if (!isWaitingAnswerable()) return '';
9668
10333
  if (tool !== 'AskUserQuestion') return '';
9669
10334
  const questions = Array.isArray(params?.questions) ? params.questions : [];
9670
10335
  if (!questions.length) return '';
9671
10336
  const picked = questions.filter((q) => waitingAnswerFor(q)).length;
9672
10337
  const partial = picked > 0 && picked < questions.length ? ` (${picked}/${questions.length})` : '';
9673
- return `<button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Answer${partial}</button>`;
10338
+ return `<span class="waiting-footer-hint"><kbd>1</kbd>–<kbd>9</kbd> pick · ${sendKbd()} send</span><button id="waiting-answer-btn" class="waiting-btn-allow" ${picked > 0 ? '' : 'disabled'} onclick="submitWaitingAnswers()">Send answers${partial}</button>`;
9674
10339
  }
9675
10340
 
9676
10341
  function renderWaitingActions(tool, params) {
9677
10342
  if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
9678
10343
  waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
9679
10344
  if (!waitingQuestions.length) return '';
10345
+ const multiQ = waitingQuestions.length > 1;
10346
+ waitingTab = Math.max(0, Math.min(waitingTab, multiQ ? waitingQuestions.length : 0));
10347
+ const onReview = multiQ && waitingTab === waitingQuestions.length;
10348
+ const tabs = multiQ
10349
+ ? `<div class="waiting-qtabs" role="tablist">${waitingQuestions
10350
+ .map((q, idx) => {
10351
+ const done = !!waitingAnswerFor(q);
10352
+ return `<button class="waiting-qtab${idx === waitingTab ? ' active' : ''}${done ? ' done' : ''}" role="tab" onclick="setWaitingTab(${idx})"><span class="waiting-qtab-n">${idx + 1}</span>${escapeHtml(q.header || `Question ${idx + 1}`)}${done ? '<span class="waiting-qtab-check">✓</span>' : ''}</button>`;
10353
+ })
10354
+ .join(
10355
+ '',
10356
+ )}<button class="waiting-qtab${onReview ? ' active' : ''}" role="tab" onclick="setWaitingTab(${escapeHtml(String(waitingQuestions.length))})">Review</button><span class="waiting-qtabs-hint"><kbd>←</kbd><kbd>→</kbd> switch</span></div>`
10357
+ : '';
10358
+ if (onReview) {
10359
+ const rows = waitingQuestions
10360
+ .map((q) => {
10361
+ const a = waitingAnswerFor(q);
10362
+ const text = Array.isArray(a) ? a.join(', ') : a;
10363
+ return `<div class="waiting-review-row"><div class="waiting-review-q">${escapeHtml(q.header || q.question || '')}</div><div class="waiting-review-a${text ? '' : ' empty'}">${text ? escapeHtml(text) : 'Not answered'}</div></div>`;
10364
+ })
10365
+ .join('');
10366
+ return `<div class="waiting-actions">${tabs}<div class="waiting-review">${rows}</div></div>`;
10367
+ }
9680
10368
  const qs = waitingQuestions
9681
10369
  .map((q, idx) => {
10370
+ if (idx !== waitingTab && multiQ) return '';
9682
10371
  const picks = waitingAnswerDraft[q.question] || [];
9683
10372
  const opts = (q.options || [])
9684
- .map((o) => {
9685
- const label = typeof o === 'string' ? o : o?.label || '';
10373
+ .map((o, oi) => {
10374
+ const label = waitingOptionLabel(o);
9686
10375
  const desc = typeof o === 'object' ? o?.description || '' : '';
9687
10376
  const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
9688
10377
  // The option `preview` field is the TUI's side-by-side visualization
9689
10378
  // pane (mockups, code snippets, diagrams) — rendered as markdown
9690
10379
  const preview = typeof o === 'object' ? o?.preview || '' : '';
9691
10380
  const previewHtml = preview ? `<span class="waiting-option-preview">${renderMarkdown(preview)}</span>` : '';
9692
- return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</button>`;
10381
+ return `<button class="waiting-option${picks.includes(label) ? ' selected' : ''}" data-label="${escapeHtml(label)}" onclick="selectWaitingAnswer(${idx}, this.dataset.label)"><kbd class="waiting-option-key">${oi + 1}</kbd><span class="waiting-option-text"><span class="waiting-option-label">${escapeHtml(label)}</span>${descHtml}${previewHtml}</span></button>`;
9693
10382
  })
9694
10383
  .join('');
9695
10384
  const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
9696
10385
  const multi = q.multiSelect ? '<span class="waiting-question-multi">multi-select</span>' : '';
9697
- const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)">`;
9698
- return `<div class="waiting-question">${header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
10386
+ const input = `<input type="text" class="waiting-option-input" placeholder="Or type your own answer" value="${escapeHtml(waitingCustomDraft[q.question] || '')}" oninput="setWaitingCustomAnswer(${idx}, this)" onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='Enter'){event.preventDefault();submitWaitingAnswers()}">`;
10387
+ return `<div class="waiting-question">${multiQ ? '' : header}${multi}<div class="waiting-question-text">${renderMarkdown(q.question || '')}</div><div class="waiting-options">${opts}</div>${input}</div>`;
9699
10388
  })
9700
10389
  .join('');
9701
- return `<div class="waiting-actions">${qs}</div>`;
10390
+ return `<div class="waiting-actions">${tabs}${qs}</div>`;
10391
+ }
10392
+
10393
+ let waitingTab = 0;
10394
+
10395
+ function waitingOptionLabel(o) {
10396
+ return typeof o === 'string' ? o : o?.label || '';
10397
+ }
10398
+
10399
+ function setWaitingTab(i) {
10400
+ const last = waitingQuestions.length > 1 ? waitingQuestions.length : 0;
10401
+ const next = Math.max(0, Math.min(i, last));
10402
+ if (next === waitingTab) return;
10403
+ waitingTab = next;
10404
+ showWaitingDetail();
10405
+ }
10406
+
10407
+ function pickWaitingOptionByNumber(n) {
10408
+ const qi = waitingQuestions.length > 1 ? waitingTab : 0;
10409
+ const o = waitingQuestions[qi]?.options?.[n];
10410
+ if (!o) return;
10411
+ selectWaitingAnswer(qi, waitingOptionLabel(o));
9702
10412
  }
9703
10413
 
9704
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
9705
10414
  function selectWaitingAnswer(qi, label) {
9706
10415
  const q = waitingQuestions[qi];
9707
10416
  if (!q) return;
@@ -9714,6 +10423,7 @@ function selectWaitingAnswer(qi, label) {
9714
10423
  } else {
9715
10424
  waitingAnswerDraft[q.question] = [label];
9716
10425
  delete waitingCustomDraft[q.question];
10426
+ if (waitingQuestions.length > 1 && waitingTab < waitingQuestions.length) waitingTab += 1;
9717
10427
  }
9718
10428
  showWaitingDetail();
9719
10429
  }
@@ -9739,11 +10449,12 @@ function setWaitingCustomAnswer(qi, el) {
9739
10449
  const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
9740
10450
  btn.disabled = picked === 0;
9741
10451
  btn.textContent =
9742
- picked > 0 && picked < waitingQuestions.length ? `Answer (${picked}/${waitingQuestions.length})` : 'Answer';
10452
+ picked > 0 && picked < waitingQuestions.length
10453
+ ? `Send answers (${picked}/${waitingQuestions.length})`
10454
+ : 'Send answers';
9743
10455
  }
9744
10456
  }
9745
10457
 
9746
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
9747
10458
  function submitWaitingAnswers() {
9748
10459
  // Partial submits are fine — send only the questions actually answered.
9749
10460
  // multiSelect answers go as arrays of labels (the shape the TUI validates).
@@ -9756,6 +10467,38 @@ function submitWaitingAnswers() {
9756
10467
  respondWaiting({ answers });
9757
10468
  }
9758
10469
 
10470
+ function renderPermissionDiff(oldText, newText) {
10471
+ const rows = (text, sign, cls) =>
10472
+ String(text)
10473
+ .split('\n')
10474
+ .map(
10475
+ (l) =>
10476
+ `<div class="perm-diff-row ${escapeHtml(cls)}"><span class="perm-diff-sign">${sign}</span>${escapeHtml(l)}</div>`,
10477
+ )
10478
+ .join('');
10479
+ return `<div class="perm-diff">${rows(oldText, '−', 'del')}${rows(newText, '+', 'add')}</div>`;
10480
+ }
10481
+
10482
+ // The permission card shows what Claude wants to run in full, not a one-line gist:
10483
+ // the command or the diff, where it runs, and how long the ask has been waiting.
10484
+ function renderPermissionSummary(tool, params) {
10485
+ const waited = formatDuration(Math.max(0, Date.now() - new Date(currentWaiting.timestamp).getTime()));
10486
+ const head = `<div class="perm-head"><span class="perm-tool">${escapeHtml(tool)}</span><span class="perm-wait" title="How long Claude has been waiting">waiting ${escapeHtml(waited)}</span></div>`;
10487
+ let body = '';
10488
+ if (typeof params.command === 'string') {
10489
+ body = `<div class="perm-label">Command</div><pre class="perm-code">${escapeHtml(params.command)}</pre>`;
10490
+ if (params.description) body += `<div class="perm-note">${escapeHtml(params.description)}</div>`;
10491
+ } else if (typeof params.old_string === 'string' && typeof params.new_string === 'string') {
10492
+ body = `<div class="perm-label">${escapeHtml(params.file_path || 'File')}</div>${renderPermissionDiff(params.old_string, params.new_string)}`;
10493
+ } else if (typeof params.file_path === 'string') {
10494
+ body = `<div class="perm-label">File</div><pre class="perm-code">${escapeHtml(params.file_path)}</pre>`;
10495
+ } else if (typeof params.description === 'string') {
10496
+ body = `<pre class="perm-code">${escapeHtml(params.description)}</pre>`;
10497
+ }
10498
+ const cwd = currentWaiting.cwd ? `<div class="perm-cwd">in ${escapeHtml(currentWaiting.cwd)}</div>` : '';
10499
+ return `<div class="perm-card">${head}${body}${cwd}</div>`;
10500
+ }
10501
+
9759
10502
  function showWaitingDetail() {
9760
10503
  if (!isWaitingFresh()) return;
9761
10504
  currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
@@ -9770,6 +10513,7 @@ function showWaitingDetail() {
9770
10513
  waitingDraftId = currentWaiting.id || null;
9771
10514
  waitingAnswerDraft = {};
9772
10515
  waitingCustomDraft = {};
10516
+ waitingTab = 0;
9773
10517
  }
9774
10518
  const tool = currentWaiting.toolName || 'unknown';
9775
10519
  const label = getWaitingLabel(currentWaiting.kind, tool);
@@ -9791,17 +10535,9 @@ function showWaitingDetail() {
9791
10535
  // A plan ask IS the plan — render it in full so it can be reviewed and
9792
10536
  // approved right here (follow mode included) instead of a JSON dump. The
9793
10537
  // raw tool input is dropped entirely to match the saved-plan modal.
9794
- inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
10538
+ inputHtml = `<div class="detail-desc rendered-md waiting-plan">${renderMarkdown(params.plan)}</div>`;
9795
10539
  } else {
9796
- if (currentWaiting.kind !== 'question' && params) {
9797
- const gist =
9798
- typeof params.command === 'string'
9799
- ? params.command
9800
- : typeof params.description === 'string'
9801
- ? params.description
9802
- : '';
9803
- if (gist) summaryHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(gist)}</pre>`;
9804
- }
10540
+ if (currentWaiting.kind !== 'question' && params) summaryHtml = renderPermissionSummary(tool, params);
9805
10541
  inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
9806
10542
  }
9807
10543
  }
@@ -9829,9 +10565,8 @@ function isSessionActive(s) {
9829
10565
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
9830
10566
  }
9831
10567
 
9832
- // An active pinned session renders outside the Pinned group until the user dismisses it.
9833
10568
  function isInPinnedGroup(s) {
9834
- return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
10569
+ return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
9835
10570
  }
9836
10571
 
9837
10572
  const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -10102,7 +10837,6 @@ const MERMAID_VARS = {
10102
10837
  pieOuterStrokeColor: '--border',
10103
10838
  pieTitleTextColor: '--text-primary',
10104
10839
  pieLegendTextColor: '--text-primary',
10105
- fontFamily: '--mono',
10106
10840
  };
10107
10841
  // --bg-elevated sits too close to the dark page for a node, and the base theme derives near-black ER
10108
10842
  // rows from a dark primaryColor.
@@ -10111,15 +10845,39 @@ const MERMAID_MODE_VARS = {
10111
10845
  light: { primaryColor: '--bg-elevated', rowOdd: '--bg-elevated', rowEven: '--bg-elevated' },
10112
10846
  };
10113
10847
  let mermaidThemeKey = '';
10848
+ let colorProbe = null;
10849
+ const probedColors = new Map();
10850
+
10851
+ // Mermaid's color parser knows only plain hex, rgb() and hsl(), and a hub theme may set oklch() or
10852
+ // relative color syntax; its initialize throws on one, and no diagram renders.
10853
+ function mermaidColor(value) {
10854
+ if (!value || value.startsWith('#')) return value;
10855
+ let rgb = probedColors.get(value);
10856
+ if (rgb) return rgb;
10857
+ colorProbe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
10858
+ colorProbe.clearRect(0, 0, 1, 1);
10859
+ colorProbe.fillStyle = 'transparent';
10860
+ colorProbe.fillStyle = value;
10861
+ colorProbe.fillRect(0, 0, 1, 1);
10862
+ const [r, g, b, a] = colorProbe.getImageData(0, 0, 1, 1).data;
10863
+ rgb = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${+(a / 255).toFixed(3)})`;
10864
+ probedColors.set(value, rgb);
10865
+ return rgb;
10866
+ }
10114
10867
 
10115
10868
  // The base theme derives its other colors from these, and it parses colors itself, so it takes
10116
10869
  // resolved values rather than var() references.
10117
10870
  function mermaidConfig() {
10118
10871
  const css = getComputedStyle(document.body);
10119
10872
  const mode = isLightTheme() ? 'light' : 'dark';
10120
- const themeVariables = { darkMode: mode === 'dark', pieOpacity: '1', pieSectionTextColor: '#fff' };
10873
+ const themeVariables = {
10874
+ darkMode: mode === 'dark',
10875
+ pieOpacity: '1',
10876
+ pieSectionTextColor: '#fff',
10877
+ fontFamily: css.getPropertyValue('--mono').trim(),
10878
+ };
10121
10879
  for (const [k, name] of Object.entries({ ...MERMAID_VARS, ...MERMAID_MODE_VARS[mode] })) {
10122
- themeVariables[k] = css.getPropertyValue(name).trim();
10880
+ themeVariables[k] = mermaidColor(css.getPropertyValue(name).trim());
10123
10881
  }
10124
10882
  SHOW_PALETTE[mode].series.forEach((c, i) => {
10125
10883
  themeVariables[`pie${i + 1}`] = c;
@@ -10145,10 +10903,14 @@ function loadMermaid() {
10145
10903
  script.integrity = MERMAID_SCRIPT.integrity;
10146
10904
  script.crossOrigin = 'anonymous';
10147
10905
  script.onload = () => {
10148
- const config = mermaidConfig();
10149
- mermaidThemeKey = JSON.stringify(config);
10150
- mermaid.initialize(config);
10151
- resolve();
10906
+ try {
10907
+ const config = mermaidConfig();
10908
+ mermaidThemeKey = JSON.stringify(config);
10909
+ mermaid.initialize(config);
10910
+ resolve();
10911
+ } catch (err) {
10912
+ reject(err);
10913
+ }
10152
10914
  };
10153
10915
  script.onerror = () => {
10154
10916
  script.remove();
@@ -10167,24 +10929,45 @@ function queueMermaid(work) {
10167
10929
  mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
10168
10930
  }
10169
10931
 
10932
+ const MERMAID_PENDING = 'pre.mermaid:not([data-processed])';
10933
+
10170
10934
  function initMermaidBlocks(container) {
10171
10935
  const root = container || document;
10172
- if (!root.querySelector('pre.mermaid:not([data-processed])')) return;
10936
+ if (!root.querySelector(MERMAID_PENDING)) return;
10937
+ // Stamped here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
10938
+ const stampPending = () =>
10939
+ [...root.querySelectorAll(MERMAID_PENDING)].map((b) => {
10940
+ if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
10941
+ return b;
10942
+ });
10173
10943
  if (typeof mermaid === 'undefined') {
10174
10944
  loadMermaid().then(
10175
10945
  () => initMermaidBlocks(container),
10176
- (err) => console.warn(err.message),
10946
+ (err) => {
10947
+ console.warn(err.message);
10948
+ for (const b of stampPending()) showMermaidError(b, err);
10949
+ },
10177
10950
  );
10178
10951
  return;
10179
10952
  }
10180
- queueMermaid(() => {
10181
- const blocks = [...root.querySelectorAll('pre.mermaid:not([data-processed])')];
10182
- // Set here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
10183
- for (const b of blocks) if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
10184
- return blocks.length ? mermaid.run({ nodes: blocks }) : undefined;
10953
+ queueMermaid(async () => {
10954
+ // One block per run, so a bad diagram shows its own error and the others still render.
10955
+ for (const b of stampPending()) {
10956
+ try {
10957
+ await mermaid.run({ nodes: [b] });
10958
+ } catch (err) {
10959
+ showMermaidError(b, err);
10960
+ }
10961
+ }
10185
10962
  });
10186
10963
  }
10187
10964
 
10965
+ function showMermaidError(block, err) {
10966
+ block.setAttribute('data-processed', 'true');
10967
+ block.setAttribute('data-mermaid-error', '');
10968
+ block.textContent = err?.message || String(err);
10969
+ }
10970
+
10188
10971
  function reinitMermaidTheme() {
10189
10972
  if (typeof mermaid === 'undefined') return;
10190
10973
  queueMermaid(() => {
@@ -10195,6 +10978,7 @@ function reinitMermaidTheme() {
10195
10978
  mermaid.initialize(config);
10196
10979
  document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
10197
10980
  el.removeAttribute('data-processed');
10981
+ el.removeAttribute('data-mermaid-error');
10198
10982
  el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
10199
10983
  });
10200
10984
  initMermaidBlocks();
@@ -10479,24 +11263,32 @@ function renderFilterState() {
10479
11263
  document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
10480
11264
  }
10481
11265
 
11266
+ // With the sidebar cursor on a group header, zen shows that group instead of the open session.
10482
11267
  function toggleZenMode() {
10483
- zenMode = !zenMode;
11268
+ const leavingSessionZen = zenMode;
11269
+ if (zenMode) zenMode = false;
11270
+ else if (zenGroup) zenGroup = null;
11271
+ else {
11272
+ const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
11273
+ zenGroup = header?.dataset.groupId || null;
11274
+ zenMode = !zenGroup;
11275
+ }
10484
11276
  store.setItem(ZEN_KEY, String(zenMode));
10485
11277
  renderZenState();
10486
- if (zenMode) closeFilterMenu();
11278
+ if (zenMode || zenGroup) closeFilterMenu();
10487
11279
  renderSessions();
10488
- // The open session may be outside the current server page/filter; refetch pins it in.
10489
- if (zenMode) fetchSessions(false);
11280
+ // The open session or the group's members may be outside the current server page/filter,
11281
+ // and leaving group zen needs the filtered page back.
11282
+ if (!leavingSessionZen) fetchSessions(false);
10490
11283
  }
10491
11284
 
10492
11285
  function renderZenState() {
10493
11286
  const btn = document.getElementById('zen-mode-btn');
10494
11287
  if (!btn) return;
10495
- btn.classList.toggle('active', zenMode);
10496
- btn.setAttribute('aria-pressed', String(zenMode));
10497
- btn.title = zenMode
10498
- ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
10499
- : 'Zen mode: current session only (Ctrl+Shift+Z)';
11288
+ const on = zenMode || !!zenGroup;
11289
+ btn.classList.toggle('active', on);
11290
+ btn.setAttribute('aria-pressed', String(on));
11291
+ btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
10500
11292
  }
10501
11293
 
10502
11294
  function filterBySessions(value) {
@@ -10611,44 +11403,23 @@ document.addEventListener('click', (e) => {
10611
11403
  if (sgHeader) {
10612
11404
  if (e.target.closest('.sg-name-input')) return;
10613
11405
  e.stopPropagation();
11406
+ const groupId = sgHeader.dataset.groupId;
11407
+ const group = sgGroupById(groupId);
11408
+ if (!group) return;
10614
11409
  if (e.target.closest('.sg-keep')) {
10615
- sgKeepTransient(sgHeader.dataset.transientGroup);
11410
+ sgKeep(group);
11411
+ persistSessionGroups();
10616
11412
  renderSessions();
10617
11413
  return;
10618
11414
  }
10619
- if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
10620
- const name = sgHeader.dataset.transientGroup;
10621
- confirmModal({
10622
- title: 'Delete Group',
10623
- message: `Delete group “${name}”? Its sessions go back to Projects.`,
10624
- okLabel: 'Delete',
10625
- }).then((ok) => {
10626
- if (!ok) return;
10627
- sgDeleteTransient(name);
10628
- renderSessions();
10629
- });
11415
+ if (e.target.closest('.sg-rename')) {
11416
+ sgBeginRename(groupId);
11417
+ return;
11418
+ }
11419
+ if (e.target.closest('.sg-delete')) {
11420
+ sgConfirmDelete(group);
10630
11421
  return;
10631
11422
  }
10632
- const groupId = sgHeader.dataset.groupId;
10633
- const group = sgGroupById(groupId);
10634
- if (group) {
10635
- if (e.target.closest('.sg-rename')) {
10636
- sgBeginRename(groupId);
10637
- return;
10638
- }
10639
- if (e.target.closest('.sg-delete')) {
10640
- confirmModal({
10641
- title: 'Delete Group',
10642
- message: `Delete group “${group.name}”? Its sessions and projects go back to Projects.`,
10643
- okLabel: 'Delete',
10644
- }).then((ok) => {
10645
- if (!ok) return;
10646
- sgDeleteGroup(groupId);
10647
- renderSessions();
10648
- });
10649
- return;
10650
- }
10651
- } else if (!sgHeader.dataset.transientGroup) return;
10652
11423
  }
10653
11424
 
10654
11425
  const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
@@ -10914,22 +11685,22 @@ function initSidebarResize() {
10914
11685
  }
10915
11686
 
10916
11687
  function initPanelResize(panelId, handleId, cssVar) {
10917
- const panel = document.getElementById(panelId);
10918
- const handle = document.getElementById(handleId);
10919
- let startWidth;
11688
+ initResize(document.getElementById(panelId), document.getElementById(handleId), cssVar, panelWidthKey(panelId), 200);
11689
+ }
10920
11690
 
11691
+ function initResize(panel, handle, cssVar, key, min) {
11692
+ let startWidth;
10921
11693
  _initDragResize(handle, {
10922
11694
  onStart() {
10923
11695
  startWidth = panel.offsetWidth;
10924
11696
  panel.classList.add('resizing');
10925
11697
  },
10926
11698
  onMove(dx) {
10927
- const w = Math.max(200, startWidth - dx);
10928
- panel.style.setProperty(cssVar, `${w}px`);
11699
+ panel.style.setProperty(cssVar, `${Math.max(min, startWidth - dx)}px`);
10929
11700
  },
10930
11701
  onEnd() {
10931
11702
  panel.classList.remove('resizing');
10932
- store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
11703
+ store.setItem(key, panel.style.getPropertyValue(cssVar));
10933
11704
  },
10934
11705
  });
10935
11706
  }
@@ -11003,7 +11774,7 @@ function updateStickyBtnState() {
11003
11774
  const isSticky = stickySessionIds.has(_infoModalSessionId);
11004
11775
  stickyBtn.style.display = '';
11005
11776
  stickyBtn.classList.toggle('active', isSticky);
11006
- stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show at top';
11777
+ stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
11007
11778
  const svg = stickyBtn.querySelector('svg');
11008
11779
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
11009
11780
  }
@@ -12504,6 +13275,15 @@ function markPaneSide(el, side, focused) {
12504
13275
 
12505
13276
  const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
12506
13277
 
13278
+ function paneMenuLabel(pane, label) {
13279
+ const cut =
13280
+ pane.kind !== 'url' && pane.kind !== 'message' ? Math.max(label.lastIndexOf('/'), label.lastIndexOf('\\')) + 1 : 0;
13281
+ if (!cut || cut === label.length) {
13282
+ return `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`;
13283
+ }
13284
+ return `<div class="pane-menu-label pane-menu-path" title="${escapeHtml(label)}"><span class="pane-menu-dir">&lrm;${escapeHtml(label.slice(0, cut))}&lrm;</span><span class="pane-menu-name">${escapeHtml(label.slice(cut))}</span></div>`;
13285
+ }
13286
+
12507
13287
  function renderPaneTabs(sid, layout, split) {
12508
13288
  // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
12509
13289
  if (paneDragId || paneRenameId) return;
@@ -12587,7 +13367,9 @@ function mountPane(sid, pane) {
12587
13367
  view.dataset.key = key;
12588
13368
  paneViews.appendChild(view);
12589
13369
  paneFrames.set(key, view);
12590
- loadPaneView(sid, pane, view);
13370
+ loadPaneView(sid, pane, view).then(() => {
13371
+ if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
13372
+ });
12591
13373
  while (paneFrames.size > PANE_FRAME_LIMIT) {
12592
13374
  const [oldKey, oldView] = paneFrames.entries().next().value;
12593
13375
  paneFrames.delete(oldKey);
@@ -12648,6 +13430,10 @@ async function loadPaneView(sid, pane, view, scroll) {
12648
13430
  : ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
12649
13431
  const frame = createPreviewFrame(cls, doc);
12650
13432
  frame.title = pane.title;
13433
+ // Focus given before the document loads stays on the frame element, out of the document's keys.
13434
+ frame.addEventListener('load', () => {
13435
+ if (document.activeElement === frame) frame.contentWindow?.focus();
13436
+ });
12651
13437
  view.appendChild(frame);
12652
13438
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
12653
13439
  return;
@@ -12694,6 +13480,7 @@ function appendPaneDoc(view) {
12694
13480
  doc.className = 'pane-doc';
12695
13481
  const body = document.createElement('div');
12696
13482
  body.className = 'pane-doc-body rendered-md modal-zoomable';
13483
+ body.tabIndex = -1;
12697
13484
  doc.append(body, paneDocGrip(doc));
12698
13485
  view.appendChild(doc);
12699
13486
  return body;
@@ -12769,8 +13556,11 @@ async function reloadPaneView(sid, pane, view) {
12769
13556
  paneReloads.delete(key);
12770
13557
  fresh.className = view.className;
12771
13558
  fresh.style.visibility = '';
13559
+ const hadFocus = view.contains(document.activeElement);
12772
13560
  view.remove();
12773
13561
  paneFrames.set(key, fresh);
13562
+ const target = hadFocus && paneFocusTarget(fresh);
13563
+ if (target) focusPaneTarget(target);
12774
13564
  syncPaneReview();
12775
13565
  return true;
12776
13566
  }
@@ -12815,6 +13605,27 @@ function activePaneView() {
12815
13605
  return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
12816
13606
  }
12817
13607
 
13608
+ // A URL page is on another origin and would keep every key from the board, so it gets no focus.
13609
+ function paneFocusTarget(view) {
13610
+ return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
13611
+ }
13612
+
13613
+ let paneFocusWanted = null;
13614
+
13615
+ // The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
13616
+ function focusActivePane() {
13617
+ const view = activePaneView();
13618
+ const target = paneFocusTarget(view);
13619
+ paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
13620
+ if (target) focusPaneTarget(target);
13621
+ else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
13622
+ }
13623
+
13624
+ function focusPaneTarget(target) {
13625
+ target.focus({ preventScroll: true });
13626
+ target.contentWindow?.focus();
13627
+ }
13628
+
12818
13629
  function isPaneDocOnScreen() {
12819
13630
  return !!activePaneView()?.querySelector('.modal-zoomable');
12820
13631
  }
@@ -12994,16 +13805,22 @@ function paneMenuKeydown(e) {
12994
13805
  async function deleteBoardTasks(onlyCompleted) {
12995
13806
  const sid = paneSessionId();
12996
13807
  if (!sid) return;
12997
- const count = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed').length;
13808
+ const doomed = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed');
13809
+ const count = doomed.length;
12998
13810
  if (!count) {
12999
13811
  showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
13000
13812
  return;
13001
13813
  }
13002
13814
  const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
13815
+ const shown = doomed.slice(0, 3).map((t) => ({ label: t.subject, meta: t.status.replace('_', ' ') }));
13816
+ if (doomed.length > shown.length) shown.push({ label: `+ ${doomed.length - shown.length} more`, meta: '' });
13003
13817
  const ok = await confirmModal({
13004
- title: onlyCompleted ? 'Delete Completed Tasks' : 'Delete All Tasks',
13005
- message: `Delete ${noun}? This cannot be undone.`,
13006
- okLabel: 'Delete',
13818
+ title: `Delete ${noun}?`,
13819
+ message: onlyCompleted
13820
+ ? 'Every completed task in this session. This cannot be undone.'
13821
+ : 'Every task in this session. This cannot be undone.',
13822
+ items: shown,
13823
+ okLabel: `Delete ${noun}`,
13007
13824
  });
13008
13825
  if (!ok) return;
13009
13826
  const q = onlyCompleted ? '?status=completed' : '';
@@ -13079,9 +13896,12 @@ function openPaneMenu(x, y, id) {
13079
13896
  y,
13080
13897
  pane.id,
13081
13898
  'Pane actions',
13082
- `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>` +
13899
+ paneMenuLabel(pane, label) +
13083
13900
  (message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
13084
13901
  paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
13902
+ (message
13903
+ ? ''
13904
+ : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
13085
13905
  paneMenuItem('rename', 'Rename') +
13086
13906
  paneMenuItem('reload', 'Reload') +
13087
13907
  paneMenuItem('close', 'Close pane'),
@@ -13089,6 +13909,21 @@ function openPaneMenu(x, y, id) {
13089
13909
  return true;
13090
13910
  }
13091
13911
 
13912
+ function paneIsLinked(pane) {
13913
+ const key = canonicalPath(pane.target);
13914
+ return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
13915
+ }
13916
+
13917
+ function togglePaneLink(id) {
13918
+ const pane = paneById(id);
13919
+ const sid = paneSessionId();
13920
+ if (!pane || !sid) return;
13921
+ const unlink = paneIsLinked(pane);
13922
+ setSessionDocLink(sid, pane.target, unlink);
13923
+ if (unlink) forgetServerLinkedDoc(sid, pane.target);
13924
+ showToast(unlink ? 'Unlinked from session' : 'Linked to session', 'success');
13925
+ }
13926
+
13092
13927
  function showPaneMenu(x, y, id, ariaLabel, html) {
13093
13928
  closePaneMenu();
13094
13929
  sgCloseMenu();
@@ -13107,6 +13942,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
13107
13942
  const PANE_MENU_ACTIONS = {
13108
13943
  copy: copyPaneTarget,
13109
13944
  open: openPaneExternally,
13945
+ link: togglePaneLink,
13110
13946
  rename: beginPaneRename,
13111
13947
  reload: reloadPane,
13112
13948
  close: closePane,
@@ -13315,7 +14151,9 @@ paneStrip.addEventListener('click', (e) => {
13315
14151
  return;
13316
14152
  }
13317
14153
  const tab = e.target.closest('.pane-tab');
13318
- if (tab) selectPane(tab.dataset.pane);
14154
+ if (!tab) return;
14155
+ selectPane(tab.dataset.pane);
14156
+ focusActivePane();
13319
14157
  });
13320
14158
  paneStrip.addEventListener('auxclick', (e) => {
13321
14159
  const tab = e.target.closest('.pane-tab');
@@ -13579,7 +14417,6 @@ panePop.addEventListener('keydown', (e) => {
13579
14417
  // leaving the session view or switching sessions drops only the socket; coming back
13580
14418
  // reattaches and the server replays the screen.
13581
14419
  const TERMINAL_TOKEN_KEY = 'terminal-token';
13582
- const TERMINAL_FOCUS_KEY = 'terminal-focus';
13583
14420
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
13584
14421
  const TERMINAL_MODES_KEY = 'terminal-sessions';
13585
14422
  const TERMINAL_FONT_KEY = 'terminal-font-size';
@@ -13686,6 +14523,7 @@ function toggleTerminal() {
13686
14523
  setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
13687
14524
  termState.focusNext = true;
13688
14525
  syncTerminal();
14526
+ if (!wantsTerminal()) focusActivePane();
13689
14527
  }
13690
14528
 
13691
14529
  // The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
@@ -13720,6 +14558,7 @@ function terminalShortcut(e, probe = false) {
13720
14558
  if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
13721
14559
  // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
13722
14560
  if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
14561
+ if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
13723
14562
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
13724
14563
  return () => openNewSession(null, e.code === 'KeyR');
13725
14564
  }
@@ -13749,7 +14588,7 @@ function setSwapPair(last, previous) {
13749
14588
  async function swapToPreviousSession() {
13750
14589
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
13751
14590
  if (!target || target === currentSessionId) return;
13752
- if (terminalPaneFocused()) termState.focusNext = true;
14591
+ if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
13753
14592
  await revealSession(target);
13754
14593
  }
13755
14594
 
@@ -13848,9 +14687,12 @@ function takeTerminalFocus(sessionId) {
13848
14687
  }
13849
14688
 
13850
14689
  // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
14690
+ // A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
13851
14691
  function promptAwaitsUser(sessionId) {
13852
- if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
13853
- return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
14692
+ if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
14693
+ if (trustWaiting.has(sessionId)) return true;
14694
+ const session = sessions.find((s) => s.id === sessionId);
14695
+ return !!session && isWaitingSession(session);
13854
14696
  }
13855
14697
 
13856
14698
  function onTerminalShown(focus = true) {
@@ -13867,6 +14709,8 @@ function focusTerminalPane() {
13867
14709
  termFrame.el.focus();
13868
14710
  terminalFrameSend('focus');
13869
14711
  }
14712
+ // After a page load the frame may still be loading; its socket's ready takes the focus then.
14713
+ else termState.focusNext = true;
13870
14714
  }
13871
14715
 
13872
14716
  function leaveTerminalPane() {
@@ -13927,12 +14771,16 @@ function loadTerminalFrame(origin, timeoutMs) {
13927
14771
  }
13928
14772
 
13929
14773
  function sendTerminalTheme() {
13930
- if (!termFrame.inited) return;
13931
14774
  const options = terminalThemeOptions();
13932
14775
  const key = JSON.stringify(options);
13933
- if (key === termFrame.theme) return;
13934
- termFrame.theme = key;
13935
- postToTerminalFrame('theme', { options });
14776
+ for (const [frame, post] of [
14777
+ [termFrame, postToTerminalFrame],
14778
+ [kanbotState, postToKanbotFrame],
14779
+ ]) {
14780
+ if (!frame.inited || key === frame.theme) continue;
14781
+ frame.theme = key;
14782
+ post('theme', { options });
14783
+ }
13936
14784
  }
13937
14785
 
13938
14786
  // Keyed on the colors, and style is watched, because under the hub a theme change lands in two
@@ -13957,6 +14805,18 @@ window.addEventListener('message', (e) => {
13957
14805
  onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
13958
14806
  });
13959
14807
 
14808
+ function terminalInitOptions(themeOptions) {
14809
+ return {
14810
+ fontFamily:
14811
+ appConfig.terminal.fontFamily ||
14812
+ getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
14813
+ 'monospace',
14814
+ fontSize: currentTerminalFontSize(),
14815
+ scrollback: appConfig.terminal.scrollback,
14816
+ themeOptions,
14817
+ };
14818
+ }
14819
+
13960
14820
  function onTerminalFrameMessage(t, m) {
13961
14821
  const socket = termState.socket;
13962
14822
  const current = socket && m.socketId === socket.id;
@@ -13964,15 +14824,7 @@ function onTerminalFrameMessage(t, m) {
13964
14824
  // A second load is the frame reloaded (its context menu), with no terminal and no socket.
13965
14825
  const themeOptions = terminalThemeOptions();
13966
14826
  resetTerminalFrame({ reload: termFrame.reload || termFrame.inited, theme: JSON.stringify(themeOptions) });
13967
- postToTerminalFrame('init', {
13968
- fontFamily:
13969
- appConfig.terminal.fontFamily ||
13970
- getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
13971
- 'monospace',
13972
- fontSize: currentTerminalFontSize(),
13973
- scrollback: appConfig.terminal.scrollback,
13974
- themeOptions,
13975
- });
14827
+ postToTerminalFrame('init', terminalInitOptions(themeOptions));
13976
14828
  } else if (t === 'term') {
13977
14829
  termFrame.inited = true;
13978
14830
  pushTerminalClaims();
@@ -14077,17 +14929,21 @@ function pushTerminalClaims() {
14077
14929
  queueMicrotask(() => {
14078
14930
  terminalClaimsQueued = false;
14079
14931
  const bridged = bridgedFrameEls();
14080
- if (!termFrame.inited && !bridged.length) return;
14932
+ if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
14081
14933
  const claims = keyClaims();
14082
14934
  sendBridgeClaimsAll(bridged, claims);
14083
- if (!termFrame.inited) return;
14084
14935
  const sig = JSON.stringify(claims);
14085
- if (sig === termFrame.claims) return;
14086
- termFrame.claims = sig;
14087
- terminalFrameSend('claims', claims);
14936
+ sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
14937
+ sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
14088
14938
  });
14089
14939
  }
14090
14940
 
14941
+ function sendFrameClaims(frameState, claims, sig, send) {
14942
+ if (!frameState.inited || sig === frameState.claims) return;
14943
+ frameState.claims = sig;
14944
+ send('claims', claims);
14945
+ }
14946
+
14091
14947
  function terminalTheme() {
14092
14948
  const css = getComputedStyle(document.body);
14093
14949
  const v = (name) => css.getPropertyValue(name).trim();
@@ -14217,7 +15073,7 @@ function setTerminalAttached(on) {
14217
15073
 
14218
15074
  // A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
14219
15075
  function syncCloseGuard() {
14220
- const on = termState.attached && termState.shown;
15076
+ const on = (termState.attached && termState.shown) || kanbotOnScreen();
14221
15077
  if (termState.closeGuard === on) return;
14222
15078
  termState.closeGuard = on;
14223
15079
  hub.closeGuard(on);
@@ -14231,6 +15087,7 @@ window.addEventListener('beforeunload', (e) => {
14231
15087
  // The hub's palette takes focus first and clears activeElement here, so the last focus inside this
14232
15088
  // page is tracked instead; focus leaving the frame keeps it.
14233
15089
  let terminalHadFocus = false;
15090
+ let passTerminalZoneOn = false;
14234
15091
  document.addEventListener('focusin', () => {
14235
15092
  terminalHadFocus = terminalPaneFocused();
14236
15093
  });
@@ -14247,7 +15104,14 @@ document.addEventListener('focusout', (e) => {
14247
15104
  // Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
14248
15105
  // frame, so it waits a task and gives way to whatever took focus.
14249
15106
  let boardPointerDown = false;
14250
- document.addEventListener('pointerdown', () => (boardPointerDown = true), true);
15107
+ document.addEventListener(
15108
+ 'pointerdown',
15109
+ () => {
15110
+ boardPointerDown = true;
15111
+ passTerminalZoneOn = false;
15112
+ },
15113
+ true,
15114
+ );
14251
15115
  for (const type of ['pointerup', 'pointercancel']) {
14252
15116
  document.addEventListener(type, () => (boardPointerDown = false), true);
14253
15117
  }
@@ -14260,14 +15124,24 @@ window.addEventListener('focus', () => {
14260
15124
  }
14261
15125
  });
14262
15126
  });
15127
+ // A board with no session open passes the zone on, so a stop at an empty config dir keeps it. So does a
15128
+ // board whose session has no terminal for an inherited terminal zone, until the user picks a zone here.
14263
15129
  window.addEventListener('pagehide', () => {
14264
- if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
14265
- else tabStore.removeItem(TERMINAL_FOCUS_KEY);
15130
+ if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(FOCUS_ZONE_KEY, 'terminal');
15131
+ else if (passTerminalZoneOn) return;
15132
+ else if (currentSessionId || focusZone === 'sidebar') tabStore.setItem(FOCUS_ZONE_KEY, focusZone);
14266
15133
  });
14267
15134
 
14268
- function restoreTerminalFocus(sessionId) {
14269
- if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
14270
- tabStore.removeItem(TERMINAL_FOCUS_KEY);
15135
+ // While the hub shows another app this frame cannot take focus, so terminalHadFocus lets the
15136
+ // window focus handler finish the restore when the hub focuses this frame.
15137
+ function restoreTerminalFocus(zone, sessionId) {
15138
+ if (zone !== 'terminal') return;
15139
+ if (!wantsTerminalFor(sessionId)) {
15140
+ passTerminalZoneOn = true;
15141
+ return;
15142
+ }
15143
+ termState.focusNext = true;
15144
+ terminalHadFocus = true;
14271
15145
  }
14272
15146
 
14273
15147
  // The terminal stands in for the board, so it takes the board zone however focus arrives
@@ -14328,6 +15202,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
14328
15202
  name: spec.name,
14329
15203
  worktree: spec.worktree,
14330
15204
  model: spec.model,
15205
+ effort: spec.effort,
14331
15206
  prompt: spec.prompt,
14332
15207
  edit: spec.edit,
14333
15208
  forkOf: spec.forkOf,
@@ -14459,13 +15334,25 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14459
15334
  // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
14460
15335
  // Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
14461
15336
  async function closeTerminalSession(id = termState.sessionId) {
15337
+ exitTerminalMode(id);
15338
+ await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15339
+ dropPlaceholder(id);
15340
+ }
15341
+
15342
+ function exitTerminalMode(id) {
14462
15343
  if (id === termState.sessionId) {
14463
15344
  if (terminalPaneFocused()) leaveTerminalPane();
14464
15345
  detachTerminal();
14465
15346
  }
14466
15347
  setTerminalMode(id, false);
14467
15348
  syncTerminal();
14468
- await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15349
+ }
15350
+
15351
+ // An end from another tab or `dispatch end` must clear terminal mode here too, or focusing the
15352
+ // session reconnects in 'auto' and resumes claude. The mode set is shared by same-origin tabs, so the
15353
+ // tab that ended it may have cleared it already while this tab still shows its pane.
15354
+ function onTerminalEnded(id) {
15355
+ if (id === termState.sessionId || terminalModes().has(id)) exitTerminalMode(id);
14469
15356
  dropPlaceholder(id);
14470
15357
  }
14471
15358
 
@@ -14531,7 +15418,7 @@ async function renderTerminalManager() {
14531
15418
  body.innerHTML = list
14532
15419
  .map((t) => {
14533
15420
  const session = sessions.find((s) => s.id === t.id);
14534
- const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
15421
+ const name = t.kanbot ? 'Kanbot' : session ? sessionDisplayName(session) : t.id.slice(0, 8);
14535
15422
  const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
14536
15423
  const attached = t.clients ? `${t.clients} attached` : 'detached';
14537
15424
  return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
@@ -14547,8 +15434,8 @@ async function renderTerminalManager() {
14547
15434
  </div>
14548
15435
  </div>
14549
15436
  <div class="terminal-manager-actions">
14550
- <button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}">Open</button>
14551
- <button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}">End</button>
15437
+ <button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}"${t.kanbot ? ' data-kanbot' : ''}>Open</button>
15438
+ <button type="button" class="btn btn-secondary terminal-manager-end" data-end="${escapeHtml(t.id)}" data-started="${escapeHtml(String(Number(t.startedAt) || 0))}">End</button>
14552
15439
  </div>
14553
15440
  </div>`;
14554
15441
  })
@@ -14556,7 +15443,8 @@ async function renderTerminalManager() {
14556
15443
  body.querySelectorAll('[data-open]').forEach((b) => {
14557
15444
  b.onclick = () => {
14558
15445
  closeTerminalManager();
14559
- showSessionTerminal(b.dataset.open);
15446
+ if ('kanbot' in b.dataset) toggleKanbot(true);
15447
+ else showSessionTerminal(b.dataset.open);
14560
15448
  };
14561
15449
  });
14562
15450
  body.querySelectorAll('[data-end]').forEach((b) => {
@@ -14574,15 +15462,254 @@ async function terminalFetch(url, method, body) {
14574
15462
  return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
14575
15463
  }
14576
15464
 
15465
+ const TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
15466
+
14577
15467
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14578
15468
  async function endAllTerminals() {
14579
- const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
14580
- await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
15469
+ const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
15470
+ const id = b.dataset.end;
15471
+ const session = sessions.find((s) => s.id === id);
15472
+ const state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
15473
+ const started = Number(b.dataset.started);
15474
+ const meta = [
15475
+ TERMINAL_STATE_LABEL[state],
15476
+ started && `up ${formatDuration(Date.now() - started)}`,
15477
+ formatTerminalProc(terminalProcStats[id]),
15478
+ ]
15479
+ .filter(Boolean)
15480
+ .join(' · ');
15481
+ return { id, state, label: session ? sessionDisplayName(session) : id.slice(0, 8), meta };
15482
+ });
15483
+ const ids = items.map((i) => i.id);
15484
+ const notIdle = items.filter((i) => i.state !== 'idle').length;
15485
+ if (notIdle) {
15486
+ const ok = await confirmModal({
15487
+ title: `End ${ids.length} terminals?`,
15488
+ message: 'The Claude Code processes stop. Sessions stay on the board and can be resumed.',
15489
+ items,
15490
+ warn: `${notIdle} ${notIdle === 1 ? 'is' : 'are'} not idle. Work in progress is cut off.`,
15491
+ okLabel: `End ${ids.length} terminals`,
15492
+ });
15493
+ if (!ok) return;
15494
+ }
15495
+ await Promise.all(ids.map((id) => closeTerminalSession(id)));
14581
15496
  renderTerminalManager();
14582
15497
  }
14583
15498
 
14584
15499
  //#endregion
14585
15500
 
15501
+ //#region KANBOT
15502
+ // The board's own assistant (docs/kanbot.md): a claude session the server keeps hidden from the list,
15503
+ // shown in a popover through a second terminal frame on the board terminal's frame origin.
15504
+ const KANBOT_SVG =
15505
+ '<svg viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
15506
+ '<path fill="#d97757" d="M3 0h10v4h3v3h-3v3H3V7H0V4h3z"/>' +
15507
+ '<path fill="#d97757" d="M4 10h1v3H4zM6 10h1v3H6zM9 10h1v3H9zM11 10h1v3h-1z"/>' +
15508
+ '<path fill="#1f1e1d" d="M5 2h1v2H5zM10 2h1v2h-1z"/></svg>';
15509
+ const KANBOT_LAYOUT_KEY = 'cck-kanbot-layout';
15510
+ const KANBOT_WIDTH_KEY = 'cck-kanbot-width';
15511
+ const kanbotState = {
15512
+ el: null,
15513
+ frame: null,
15514
+ origin: null,
15515
+ ptyId: null,
15516
+ inited: false,
15517
+ attached: false,
15518
+ ended: false,
15519
+ socketId: 0,
15520
+ theme: null,
15521
+ claims: null,
15522
+ returnFocus: null,
15523
+ };
15524
+
15525
+ function kanbotFocused() {
15526
+ return !!kanbotState.el?.contains(document.activeElement);
15527
+ }
15528
+
15529
+ function kanbotAvailable() {
15530
+ return !!appConfig.kanbot && terminalAvailable();
15531
+ }
15532
+
15533
+ function kanbotButton() {
15534
+ const b = document.createElement('button');
15535
+ b.type = 'button';
15536
+ b.className = 'footer-kanbot';
15537
+ b.title = 'Kanbot, the board assistant';
15538
+ b.setAttribute('aria-label', b.title);
15539
+ b.innerHTML = KANBOT_SVG;
15540
+ b.onclick = toggleKanbot;
15541
+ return b;
15542
+ }
15543
+
15544
+ function ensureKanbotPopover() {
15545
+ if (kanbotState.el) return kanbotState.el;
15546
+ const el = document.createElement('div');
15547
+ el.className = 'kanbot-popover';
15548
+ el.innerHTML = `<div class="kanbot-head">${KANBOT_SVG}<strong>Kanbot</strong><span class="kanbot-status"></span><button type="button" class="kanbot-collapse" aria-label="Hide" title="Hide, Kanbot keeps running">–</button><button type="button" class="kanbot-dock">${showIcon('dock')}</button><button type="button" class="kanbot-expand"></button><button type="button" class="kanbot-close" aria-label="End" title="End the session, the next open resumes the chat">×</button></div><div class="kanbot-body"></div><div class="panel-resize-handle kanbot-resize"></div>`;
15549
+ el.querySelector('.kanbot-dock').onclick = () => setKanbotLayout(el.classList.contains('docked') ? '' : 'docked');
15550
+ el.querySelector('.kanbot-expand').onclick = () =>
15551
+ setKanbotLayout(el.classList.contains('expanded') ? '' : 'expanded');
15552
+ el.querySelector('.kanbot-collapse').onclick = () => toggleKanbot(false);
15553
+ el.querySelector('.kanbot-close').onclick = endKanbot;
15554
+ const width = store.getItem(KANBOT_WIDTH_KEY);
15555
+ if (width) el.style.setProperty('--kanbot-width', width);
15556
+ initResize(el, el.querySelector('.kanbot-resize'), '--kanbot-width', KANBOT_WIDTH_KEY, 320);
15557
+ document.body.appendChild(el);
15558
+ kanbotState.el = el;
15559
+ setKanbotLayout(store.getItem(KANBOT_LAYOUT_KEY) || '');
15560
+ return el;
15561
+ }
15562
+
15563
+ function setKanbotLayout(layout) {
15564
+ const el = kanbotState.el;
15565
+ const docked = layout === 'docked';
15566
+ const expanded = layout === 'expanded';
15567
+ el.classList.toggle('docked', docked);
15568
+ el.classList.toggle('expanded', expanded);
15569
+ const dock = el.querySelector('.kanbot-dock');
15570
+ dock.classList.toggle('active', docked);
15571
+ dock.title = docked ? 'Undock' : 'Dock to the right';
15572
+ dock.setAttribute('aria-label', dock.title);
15573
+ dock.setAttribute('aria-pressed', String(docked));
15574
+ const expand = el.querySelector('.kanbot-expand');
15575
+ expand.innerHTML = showIcon(expanded ? 'restore' : 'expand');
15576
+ expand.title = expanded ? 'Restore' : 'Expand';
15577
+ expand.setAttribute('aria-label', expand.title);
15578
+ store.setItem(KANBOT_LAYOUT_KEY, layout);
15579
+ postToKanbotFrame('focus');
15580
+ }
15581
+
15582
+ function setKanbotStatus(text) {
15583
+ kanbotState.el.querySelector('.kanbot-status').textContent = text;
15584
+ }
15585
+
15586
+ function postToKanbotFrame(t, data) {
15587
+ kanbotState.frame?.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, kanbotState.origin);
15588
+ }
15589
+
15590
+ function kanbotOnScreen() {
15591
+ return kanbotState.attached && !!kanbotState.el?.classList.contains('visible');
15592
+ }
15593
+
15594
+ function endKanbot() {
15595
+ const id = kanbotState.ptyId;
15596
+ toggleKanbot(false);
15597
+ kanbotState.ptyId = null;
15598
+ if (id) terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15599
+ }
15600
+
15601
+ function setKanbotAttached(on) {
15602
+ kanbotState.attached = on;
15603
+ syncCloseGuard();
15604
+ }
15605
+
15606
+ function toggleKanbot(force) {
15607
+ const el = ensureKanbotPopover();
15608
+ const show = typeof force === 'boolean' ? force : !el.classList.contains('visible');
15609
+ const wasShown = el.classList.contains('visible');
15610
+ el.classList.toggle('visible', show);
15611
+ if (!show) {
15612
+ const hadFocus = kanbotFocused();
15613
+ postToKanbotFrame('detach');
15614
+ setKanbotAttached(false);
15615
+ const back = kanbotState.returnFocus;
15616
+ kanbotState.returnFocus = null;
15617
+ if (hadFocus) {
15618
+ document.activeElement.blur();
15619
+ if (back === 'terminal') focusTerminalPane();
15620
+ else if (back?.isConnected) back.focus();
15621
+ }
15622
+ return;
15623
+ }
15624
+ if (!wasShown && !kanbotFocused()) {
15625
+ kanbotState.returnFocus = terminalPaneFocused() ? 'terminal' : document.activeElement;
15626
+ }
15627
+ kanbotState.frame?.focus();
15628
+ if (!kanbotState.frame) {
15629
+ const frame = document.createElement('iframe');
15630
+ frame.className = 'kanbot-frame';
15631
+ frame.title = 'Kanbot';
15632
+ frame.allow = 'clipboard-read; clipboard-write';
15633
+ kanbotState.origin = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
15634
+ frame.src = `${kanbotState.origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
15635
+ kanbotState.frame = frame;
15636
+ el.querySelector('.kanbot-body').appendChild(frame);
15637
+ return;
15638
+ }
15639
+ if (kanbotState.inited) {
15640
+ postToKanbotFrame('refresh');
15641
+ postToKanbotFrame('focus');
15642
+ if (!kanbotState.attached) openKanbot();
15643
+ }
15644
+ }
15645
+
15646
+ async function openKanbot() {
15647
+ setKanbotStatus('starting…');
15648
+ let res;
15649
+ try {
15650
+ res = await terminalFetch('/api/kanbot/start', 'POST', {});
15651
+ } catch {
15652
+ setKanbotStatus('board unreachable, retrying…');
15653
+ setTimeout(() => {
15654
+ if (kanbotState.el.classList.contains('visible') && !kanbotState.attached) openKanbot();
15655
+ }, 3000);
15656
+ return;
15657
+ }
15658
+ const data = await res.json().catch(() => ({}));
15659
+ if (!res.ok) return setKanbotStatus(data.error || `error ${res.status}`);
15660
+ kanbotState.ended = false;
15661
+ kanbotState.ptyId = data.ptyId;
15662
+ setKanbotStatus(data.sessionId ? data.sessionId.slice(0, 8) : '');
15663
+ postToKanbotFrame('open', {
15664
+ socketId: ++kanbotState.socketId,
15665
+ reset: true,
15666
+ hello: { token: terminalToken, id: data.ptyId, mode: 'auto' },
15667
+ });
15668
+ postToKanbotFrame('focus');
15669
+ }
15670
+
15671
+ window.addEventListener('message', (e) => {
15672
+ if (!kanbotState.frame || e.source !== kanbotState.frame.contentWindow || e.origin !== kanbotState.origin) return;
15673
+ const m = e.data;
15674
+ if (typeof m?.type !== 'string' || !m.type.startsWith(TERMINAL_MSG)) return;
15675
+ const t = m.type.slice(TERMINAL_MSG.length);
15676
+ if (t === 'loaded') {
15677
+ const themeOptions = terminalThemeOptions();
15678
+ kanbotState.inited = false;
15679
+ kanbotState.claims = null;
15680
+ kanbotState.theme = JSON.stringify(themeOptions);
15681
+ postToKanbotFrame('init', terminalInitOptions(themeOptions));
15682
+ return;
15683
+ }
15684
+ if (t === 'term') {
15685
+ kanbotState.inited = true;
15686
+ const claims = keyClaims();
15687
+ sendFrameClaims(kanbotState, claims, JSON.stringify(claims), postToKanbotFrame);
15688
+ // The first show created the frame after its focus call, so the frame takes focus once it is up.
15689
+ if (kanbotState.el.classList.contains('visible')) kanbotState.frame.focus();
15690
+ openKanbot();
15691
+ return;
15692
+ }
15693
+ if (t === 'key') {
15694
+ replayKey(m, document.getElementById('terminal-key-proxy'));
15695
+ return;
15696
+ }
15697
+ if (m.socketId !== kanbotState.socketId) return;
15698
+ if (t === 'ws') {
15699
+ if (m.msg?.t === 'ready') setKanbotAttached(true);
15700
+ else if (m.msg?.t === 'exit') {
15701
+ setKanbotAttached(false);
15702
+ kanbotState.ended = true;
15703
+ setKanbotStatus('ended, reopen to resume');
15704
+ } else if (m.msg?.t === 'error') setKanbotStatus(m.msg.msg || 'error');
15705
+ } else if (t === 'close') {
15706
+ setKanbotAttached(false);
15707
+ if (!kanbotState.ended && kanbotState.el.classList.contains('visible')) setTimeout(openKanbot, 1000);
15708
+ }
15709
+ });
15710
+
15711
+ //#endregion
15712
+
14586
15713
  //#region SHOW
14587
15714
  // The card the session in the embedded terminal posts to with the show tool. It belongs to the
14588
15715
  // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
@@ -14593,6 +15720,7 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
14593
15720
  const SHOW_MIN_W = 280;
14594
15721
  const SHOW_MIN_H = 120;
14595
15722
  const SHOW_INSET = 8;
15723
+ const SHOW_SANDBOX = 'allow-scripts allow-forms';
14596
15724
  const SHOW_CSP =
14597
15725
  "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:; frame-src 'none'; connect-src 'none'; form-action 'none'";
14598
15726
  // The card's token contract (the show skill lists these names) mapped to the board's theme vars.
@@ -14641,6 +15769,7 @@ const SHOW_ICONS = {
14641
15769
  uncollapse: '<path d="M6 9l6 6 6-6"/>',
14642
15770
  expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
14643
15771
  restore: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>',
15772
+ dock: '<path d="M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zM15 3v18"/>',
14644
15773
  clear: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
14645
15774
  close: '<path d="M18 6L6 18M6 6l12 12"/>',
14646
15775
  pane: '<use href="#icon-pane"/>',
@@ -14698,7 +15827,7 @@ function syncShowFocus() {
14698
15827
  // An HTML card's own keys and scroll keys reach it only inside its frame.
14699
15828
  function focusShowBody() {
14700
15829
  const frame = showState.frame;
14701
- if (frame?.isConnected) frame.focus();
15830
+ if (frame?.isConnected && frame.loaded) frame.focus();
14702
15831
  else showCardEl()?.querySelector('.show-body')?.focus();
14703
15832
  }
14704
15833
 
@@ -14721,8 +15850,12 @@ function showCardKey(e) {
14721
15850
  }
14722
15851
  const p = showState.posts[showState.idx];
14723
15852
  if (combo !== 'ctrl+d' || !p) return false;
14724
- const message = `Remove "${p.title}" from the card?`;
14725
- confirmModal({ title: 'Remove post', message, okLabel: 'Remove' }).then((ok) => {
15853
+ confirmModal({
15854
+ title: 'Remove this post?',
15855
+ message: 'It is taken off the card.',
15856
+ chip: p.title,
15857
+ okLabel: 'Remove post',
15858
+ }).then((ok) => {
14726
15859
  if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
14727
15860
  showCommand('remove');
14728
15861
  focusAfterShowRemove();
@@ -14995,11 +16128,15 @@ function setShowReview(opts) {
14995
16128
  // No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
14996
16129
  // but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
14997
16130
  function createShowFrame(title, srcdoc) {
14998
- const frame = createPreviewFrame('show-frame', srcdoc, 'allow-scripts');
16131
+ const frame = createPreviewFrame('show-frame', srcdoc, SHOW_SANDBOX);
14999
16132
  frame.title = title;
15000
- // Focus given before a document loads stays on the frame element, out of the document's keys.
16133
+ // Focus given before the first document loads stays on the frame element, out of the document's
16134
+ // keys, and the document never reports focus or blur, so the body holds focus until then.
15001
16135
  frame.addEventListener('load', () => {
15002
- if (frame === document.activeElement) frame.contentWindow?.focus();
16136
+ const waited = !frame.loaded && document.activeElement === frame.parentElement;
16137
+ frame.loaded = true;
16138
+ if (waited) frame.focus();
16139
+ else if (frame === document.activeElement) frame.contentWindow?.focus();
15003
16140
  });
15004
16141
  showState.frame = frame;
15005
16142
  showState.frameFocused = false;
@@ -15313,7 +16450,6 @@ function placeholderSession(id, spec) {
15313
16450
  mode: spec.mode,
15314
16451
  edit: !!spec.edit,
15315
16452
  name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
15316
- dispatchGroup: spec.group || undefined,
15317
16453
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
15318
16454
  project: spec.cwd,
15319
16455
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
@@ -15332,7 +16468,22 @@ function mergePlaceholders(list) {
15332
16468
  for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
15333
16469
  }
15334
16470
  const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
15335
- return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
16471
+ const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
16472
+ revealNewPlaceholders(placeholders);
16473
+ return [...placeholders, ...list];
16474
+ }
16475
+
16476
+ // Ids whose sections were expanded once; a section the user collapses again stays collapsed.
16477
+ const revealedPlaceholderIds = new Set();
16478
+
16479
+ function revealNewPlaceholders(placeholders) {
16480
+ let changed = false;
16481
+ for (const p of placeholders) {
16482
+ if (revealedPlaceholderIds.has(p.id)) continue;
16483
+ revealedPlaceholderIds.add(p.id);
16484
+ if (uncollapseFor(p)) changed = true;
16485
+ }
16486
+ if (changed) persistCollapsedGroups();
15336
16487
  }
15337
16488
 
15338
16489
  async function loadDispatches() {
@@ -15382,14 +16533,18 @@ function forgetPlaceholder(id) {
15382
16533
  return true;
15383
16534
  }
15384
16535
 
15385
- function renderPlaceholderCard(session) {
16536
+ function renderPlaceholderCard(session, depth) {
15386
16537
  const isActive = session.id === currentSessionId && viewMode === 'session';
15387
16538
  const projectHtml = renderProjectIdentity(session);
15388
16539
  return `
15389
- <button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
16540
+ <button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
15390
16541
  <div class="session-name">${escapeHtml(session.name)}</div>
15391
16542
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
15392
- <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
16543
+ ${
16544
+ trustWaiting.has(session.id)
16545
+ ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
16546
+ : `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
16547
+ }
15393
16548
  </button>
15394
16549
  `;
15395
16550
  }
@@ -15399,9 +16554,8 @@ async function restorePendingSessions() {
15399
16554
  document.getElementById('new-session-btn').hidden = !terminalAvailable();
15400
16555
  const terminals = terminalAvailable() ? loadTerminals() : null;
15401
16556
  await loadDispatches();
15402
- if (!terminals) return;
15403
16557
  try {
15404
- for (const t of await terminals) {
16558
+ for (const t of terminals ? await terminals : []) {
15405
16559
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
15406
16560
  newSpecs.set(t.id, {
15407
16561
  cwd: t.cwd,
@@ -15414,6 +16568,8 @@ async function restorePendingSessions() {
15414
16568
  });
15415
16569
  }
15416
16570
  } catch (_) {}
16571
+ // Sessions that were already running keep the collapse state saved before the reload.
16572
+ for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
15417
16573
  }
15418
16574
 
15419
16575
  async function loadTerminals() {
@@ -15422,6 +16578,7 @@ async function loadTerminals() {
15422
16578
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
15423
16579
  const list = data.sessions || [];
15424
16580
  runningTerminals = new Set(list.map((t) => t.id));
16581
+ setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
15425
16582
  rememberTerminalIds(list);
15426
16583
  syncShowCard();
15427
16584
  pushTerminalClaims();
@@ -15429,8 +16586,26 @@ async function loadTerminals() {
15429
16586
  return list;
15430
16587
  }
15431
16588
 
15432
- function setRunningTerminals(ids) {
16589
+ // A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
16590
+ function setTrustWaiting(ids) {
16591
+ const before = trustWaiting;
16592
+ trustWaiting = new Set(ids);
16593
+ for (const id of trustWaiting) {
16594
+ if (before.has(id)) continue;
16595
+ setTerminalMode(id, true);
16596
+ if (id !== currentSessionId || viewMode !== 'session') continue;
16597
+ if (termState.shown && termState.attached) {
16598
+ if (promptAwaitsUser(id)) focusTerminalPane();
16599
+ } else {
16600
+ termState.openedByUser = id;
16601
+ syncTerminal();
16602
+ }
16603
+ }
16604
+ }
16605
+
16606
+ function setRunningTerminals(ids, awaitingTrust = []) {
15433
16607
  runningTerminals = new Set(ids);
16608
+ setTrustWaiting(awaitingTrust);
15434
16609
  forgetEndedTerminals();
15435
16610
  syncShowCard();
15436
16611
  pushTerminalClaims();
@@ -15454,10 +16629,12 @@ function openNewSession(folder, resume = false) {
15454
16629
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
15455
16630
  document.getElementById('ns-wt').checked = false;
15456
16631
  document.getElementById('ns-model').value = '';
16632
+ document.getElementById('ns-effort').value = '';
15457
16633
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
15458
16634
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
15459
16635
  setNewSessionError('');
15460
- const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
16636
+ const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
16637
+ const current = session?.worktree?.repo || session?.project;
15461
16638
  setNewSessionFolder(folder || current || '');
15462
16639
  document.getElementById('new-session-modal').classList.add('visible');
15463
16640
  const input = document.getElementById('ns-folder');
@@ -15561,6 +16738,7 @@ function newSessionValues() {
15561
16738
  name: document.getElementById('ns-name').value.trim() || null,
15562
16739
  worktree: wtOn ? wtName || true : false,
15563
16740
  model: document.getElementById('ns-model').value || null,
16741
+ effort: document.getElementById('ns-effort').value || null,
15564
16742
  prompt: document.getElementById('ns-prompt').value.trim() || null,
15565
16743
  edit,
15566
16744
  stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
@@ -15769,7 +16947,7 @@ function initNewSession() {
15769
16947
  store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
15770
16948
  renderNewSessionForm();
15771
16949
  });
15772
- for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
16950
+ for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
15773
16951
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
15774
16952
  }
15775
16953
  if (SpeechRecognitionApi) {
@@ -15884,7 +17062,7 @@ document.addEventListener('DOMContentLoaded', () => {
15884
17062
 
15885
17063
  loadSidebarState();
15886
17064
  for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
15887
- loadSessionGroups();
17065
+ syncSessionGroups();
15888
17066
  initSessionGroupsDnd();
15889
17067
  initSessionPicker();
15890
17068
  initProjectPicker();
@@ -15896,6 +17074,7 @@ loadModalFullscreen();
15896
17074
  initModalResize();
15897
17075
  loadPanelWidths();
15898
17076
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
17077
+ initDetailLayout();
15899
17078
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
15900
17079
  initMessagePanelFull();
15901
17080
 
@@ -15975,13 +17154,15 @@ function makeLimitSpan(rl) {
15975
17154
  function renderSidebarFooter(rateLimits) {
15976
17155
  const el = document.getElementById('sidebar-footer');
15977
17156
  if (!el) return;
17157
+ footerState.rl = rateLimits;
15978
17158
  const fh = rateLimits?.five_hour?.used_percentage ?? null;
15979
17159
  const sd = rateLimits?.seven_day?.used_percentage ?? null;
15980
17160
  const children = [];
15981
17161
  if (footerState.version) {
15982
17162
  const v = document.createElement('span');
15983
17163
  v.className = 'footer-version';
15984
- v.textContent = `v${footerState.version}`;
17164
+ if (kanbotAvailable()) v.append(kanbotButton());
17165
+ v.append(`v${footerState.version}`);
15985
17166
  const warn = pluginWarning(footerState.plugin);
15986
17167
  if (warn) v.append(warn);
15987
17168
  children.push(v);
@@ -16061,8 +17242,7 @@ searchQuery = urlState.search || '';
16061
17242
 
16062
17243
  renderFilterState();
16063
17244
  renderZenState();
16064
- pinnedSessionIds = loadPinnedSessions();
16065
- stickySessionIds = loadStickySessions();
17245
+ syncSessionPins();
16066
17246
  setupEventSource();
16067
17247
 
16068
17248
  if (urlState.search) {
@@ -16078,6 +17258,7 @@ if (urlState.search) {
16078
17258
  getJson('/api/config')
16079
17259
  .then((c) => {
16080
17260
  if (c) appConfig = c;
17261
+ if (appConfig.kanbot && footerState.version) renderSidebarFooter(footerState.rl);
16081
17262
  })
16082
17263
  .then(restorePendingSessions)
16083
17264
  .then(() =>
@@ -16087,6 +17268,7 @@ getJson('/api/config')
16087
17268
  ),
16088
17269
  )
16089
17270
  .then(async () => {
17271
+ const focusZoneBeforeReload = tabStore.getItem(FOCUS_ZONE_KEY);
16090
17272
  if (urlState.projectView) {
16091
17273
  try {
16092
17274
  await fetchProjectView(atob(urlState.projectView));
@@ -16094,7 +17276,7 @@ getJson('/api/config')
16094
17276
  showNoSession();
16095
17277
  }
16096
17278
  } else if (urlState.session) {
16097
- restoreTerminalFocus(urlState.session);
17279
+ restoreTerminalFocus(focusZoneBeforeReload, urlState.session);
16098
17280
  await fetchTasks(urlState.session);
16099
17281
  } else {
16100
17282
  const last = loadLastView();
@@ -16105,12 +17287,13 @@ getJson('/api/config')
16105
17287
  showNoSession();
16106
17288
  }
16107
17289
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
16108
- restoreTerminalFocus(last.session);
17290
+ restoreTerminalFocus(focusZoneBeforeReload, last.session);
16109
17291
  await fetchTasks(last.session);
16110
17292
  } else {
16111
17293
  showNoSession();
16112
17294
  }
16113
17295
  }
17296
+ if (focusZoneBeforeReload === 'sidebar') setFocusZone('sidebar');
16114
17297
  if (urlState.messages && currentSessionId) {
16115
17298
  if (logAutoOpened) setMessagePanelVisible(false);
16116
17299
  toggleMessagePanel();