claude-code-kanban 7.6.1 → 7.7.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';
@@ -323,18 +325,16 @@ async function getJson(path, fallback = null, opts) {
323
325
 
324
326
  async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
325
327
  try {
326
- const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
327
- if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
328
- if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
328
+ // The focused session must come back whatever the filters say: renderSession and the
329
+ // info modal both look it up in `sessions` and bail when it is absent, so a session
330
+ // opened from outside the current filter would leave the view on the previous one.
331
+ // The server adds the pinned sessions itself; the ones `session open` showed are this page's.
332
+ const include = [focusId, revealedPlanSessionId, revealedStorageSessionId, ...openedStickyIds].filter(Boolean);
329
333
  const recent = filterProject === '__recent__';
330
334
  const res = await api('/api/sessions', {
331
335
  query: {
332
336
  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,
337
+ include: include.length ? include.join(',') : null,
338
338
  recentHours: recent ? RECENT_PROJECT_HOURS : null,
339
339
  project: !recent && filterProject ? filterProject : null,
340
340
  filter: sessionFilter === 'active' ? 'active' : null,
@@ -1655,8 +1655,12 @@ function toggleToolGroup(id) {
1655
1655
 
1656
1656
  // Legacy markers (written before the gate minted kind:"plan") carry no kind
1657
1657
  // for plans — the tool name is the durable signal there.
1658
+ function isPlanAsk(kind, tool) {
1659
+ return kind === 'plan' || tool === 'ExitPlanMode';
1660
+ }
1661
+
1658
1662
  function getWaitingPill(kind, tool) {
1659
- if (kind === 'plan' || tool === 'ExitPlanMode') return 'Plan awaiting approval';
1663
+ if (isPlanAsk(kind, tool)) return 'Plan awaiting approval';
1660
1664
  if (kind === 'question') return 'Question pending';
1661
1665
  return 'Awaiting permission';
1662
1666
  }
@@ -1735,27 +1739,34 @@ function waitingDecisionButtons(cls) {
1735
1739
  const plan = currentWaiting.kind === 'plan';
1736
1740
  // Code-authored call argument, not data — hence the constant-style name the
1737
1741
  // 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>`;
1742
+ const DENY_ARG = plan ? "rejectWaitingPlan('inline-plan-feedback')" : "respondWaiting({behavior:'deny'})";
1743
+ const feedback = plan
1744
+ ? `<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()">`
1745
+ : '';
1746
+ 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
1747
  }
1741
1748
 
1749
+ let inlinePlanFeedback = '';
1750
+
1742
1751
  function renderWaitingEntry() {
1743
1752
  if (!isWaitingFresh()) return '';
1744
1753
  const tool = currentWaiting.toolName || 'unknown';
1745
1754
  const params = parseWaitingInput();
1746
- const pillText = getWaitingPill(currentWaiting.kind, tool);
1755
+ const plan = isPlanAsk(currentWaiting.kind, tool);
1747
1756
  const detail = deriveWaitingDetail(tool, params);
1748
1757
  const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
1749
1758
  const bodyHtml = renderWaitingBody(tool, params);
1750
1759
  const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
1751
- const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
1752
1760
  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
1761
  // Questions are answered in the detail modal; plans render there too.
1754
1762
  const buttons = waitingDecisionButtons('msg-waiting');
1755
1763
  const actions = buttons ? `<span class="msg-waiting-actions">${buttons}</span>` : '';
1756
1764
  // Lapsed = gate stopped polling — the ask is only answerable in the terminal
1757
1765
  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>`;
1766
+ const keyHint = lapsed ? 'answer in the terminal' : buttons ? `${sendKbd()} ${plan ? 'approve' : 'allow'}` : '';
1767
+ const hintHtml = keyHint ? `<div class="msg-time waiting-key-hint-line">${keyHint}</div>` : '';
1768
+ const footer = hintHtml || actions ? `<div class="msg-waiting-footer">${hintHtml}${actions}</div>` : '';
1769
+ 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
1770
  }
1760
1771
 
1761
1772
  // Shared approval controls for a fresh plan ask — the waiting-detail footer and
@@ -1804,6 +1815,7 @@ async function respondWaiting(payload) {
1804
1815
  // Drop the answered/discarded ask from every surface it renders on
1805
1816
  function clearWaitingUi() {
1806
1817
  currentWaiting = null;
1818
+ inlinePlanFeedback = '';
1807
1819
  waitingAnswerDraft = {};
1808
1820
  waitingCustomDraft = {};
1809
1821
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
@@ -2123,35 +2135,89 @@ let stickySessionIds = new Set();
2123
2135
  // Pinning the currently-selected session keeps it in place until deselected (less UI movement).
2124
2136
  const deferredPinPlacement = new Set();
2125
2137
 
2126
- function loadPinnedSessions() {
2127
- return new Set(readStoredList(PINNED_SESSIONS_KEY));
2138
+ // Sessions that `session open` keeps on screen for this page only; the server never has them.
2139
+ const openedStickyIds = new Set();
2140
+
2141
+ // The server keeps the pins (`/api/session/pins`, lib/session-pins.js); the two sets are only the
2142
+ // page's copy. The localStorage lists are from before that. Each origin has its own (the hub's
2143
+ // proxied port, the standalone board), so each one merges its lists into the server's once, then
2144
+ // deletes them.
2145
+ async function syncSessionPins() {
2146
+ try {
2147
+ const body = { pinned: readStoredList(PINNED_SESSIONS_KEY), sticky: readStoredList(STICKY_SESSIONS_KEY) };
2148
+ const local = body.pinned.length || body.sticky.length;
2149
+ const res = await (local ? api('/api/session/pins/import', { method: 'POST', body }) : api('/api/session/pins'));
2150
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2151
+ const data = await res.json();
2152
+ store.removeItem(PINNED_SESSIONS_KEY);
2153
+ store.removeItem(STICKY_SESSIONS_KEY);
2154
+ applyServerPins(data.pins);
2155
+ } catch (e) {
2156
+ console.warn('[pins] server sync failed:', e.message);
2157
+ }
2128
2158
  }
2129
2159
 
2130
- function loadStickySessions() {
2131
- return new Set(readStoredList(STICKY_SESSIONS_KEY));
2160
+ // The first apply needs no fetch: `/api/sessions` adds the pinned sessions itself.
2161
+ let pinsApplied = false;
2162
+
2163
+ function applyServerPins(pins) {
2164
+ const before = new Map([...pinnedSessionIds].map((id) => [id, getSessionPinState(id)]));
2165
+ pinnedSessionIds = new Set();
2166
+ stickySessionIds = new Set(openedStickyIds);
2167
+ for (const [id, state] of Object.entries(pins || {})) {
2168
+ pinnedSessionIds.add(id);
2169
+ if (state === 'sticky') {
2170
+ stickySessionIds.add(id);
2171
+ openedStickyIds.delete(id);
2172
+ }
2173
+ }
2174
+ for (const id of deferredPinPlacement) if (!isAnyPinned(id)) deferredPinPlacement.delete(id);
2175
+ const first = !pinsApplied;
2176
+ pinsApplied = true;
2177
+ const unchanged =
2178
+ before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
2179
+ if (unchanged && !first) return;
2180
+ const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
2181
+ for (const id of added) expandPinnedFor(id);
2182
+ showPinnedSessions(added);
2183
+ }
2184
+
2185
+ // A pinned session outside the current filter is only in the list once it is fetched with the pins.
2186
+ function showPinnedSessions(added) {
2187
+ const known = new Set(sessions.map((s) => s.id));
2188
+ if (added.some((id) => !known.has(id))) fetchSessions(false);
2189
+ else renderSessions();
2132
2190
  }
2133
2191
 
2134
- function savePinnedSessions() {
2135
- store.writeJson(PINNED_SESSIONS_KEY, [...pinnedSessionIds]);
2136
- store.writeJson(STICKY_SESSIONS_KEY, [...stickySessionIds]);
2192
+ function sendPinChange(sessionId) {
2193
+ return postPins({ ids: [sessionId], state: getSessionPinState(sessionId, { saved: true }) });
2137
2194
  }
2138
2195
 
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(() => {});
2196
+ // A write names only its sessions, so the last write for a session wins. A failed save re-reads the
2197
+ // server so the page shows what is saved.
2198
+ async function postPins(body) {
2199
+ let error;
2200
+ try {
2201
+ const res = await api('/api/session/pin', { method: 'POST', body });
2202
+ if (res.ok) return;
2203
+ error = (await res.json().catch(() => null))?.error || `HTTP ${res.status}`;
2204
+ } catch (e) {
2205
+ error = e.message;
2206
+ }
2207
+ showToast(`Pin not saved (${error}); showing the saved pins`, 'error');
2208
+ await syncSessionPins();
2143
2209
  }
2144
2210
 
2145
2211
  function clearSessionPin(sessionId) {
2146
2212
  pinnedSessionIds.delete(sessionId);
2147
2213
  stickySessionIds.delete(sessionId);
2214
+ openedStickyIds.delete(sessionId);
2148
2215
  deferredPinPlacement.delete(sessionId);
2149
2216
  }
2150
2217
 
2151
2218
  function unpinSession(sessionId) {
2152
2219
  clearSessionPin(sessionId);
2153
- savePinnedSessions();
2154
- offloadSessionPin(sessionId);
2220
+ sendPinChange(sessionId);
2155
2221
  renderSessions();
2156
2222
  }
2157
2223
 
@@ -2160,8 +2226,7 @@ function toggleSessionPin(sessionId) {
2160
2226
  pinnedSessionIds.add(sessionId);
2161
2227
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2162
2228
  expandPinnedFor(sessionId);
2163
- savePinnedSessions();
2164
- offloadSessionPin(sessionId);
2229
+ sendPinChange(sessionId);
2165
2230
  renderSessions();
2166
2231
  }
2167
2232
 
@@ -2173,20 +2238,13 @@ function toggleSessionSticky(sessionId) {
2173
2238
  stickySessionIds.add(sessionId);
2174
2239
  if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2175
2240
  }
2176
- savePinnedSessions();
2177
- offloadSessionPin(sessionId);
2241
+ sendPinChange(sessionId);
2178
2242
  renderSessions();
2179
2243
  }
2180
2244
 
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
-
2245
+ // The echo of this page's own change is a no-op, so the selected session keeps its deferred place.
2188
2246
  function handleSessionPinEvent({ id, state }) {
2189
- if (!id) return;
2247
+ if (!id || getSessionPinState(id, { saved: true }) === state) return;
2190
2248
  clearSessionPin(id);
2191
2249
  if (state === 'pinned') pinnedSessionIds.add(id);
2192
2250
  if (state === 'sticky') {
@@ -2194,12 +2252,19 @@ function handleSessionPinEvent({ id, state }) {
2194
2252
  stickySessionIds.add(id);
2195
2253
  }
2196
2254
  expandPinnedFor(id);
2197
- savePinnedSessions();
2198
- renderSessions();
2255
+ showPinnedSessions(isAnyPinned(id) ? [id] : []);
2256
+ }
2257
+
2258
+ function isPlacedPinned(id) {
2259
+ return pinnedSessionIds.has(id) && !deferredPinPlacement.has(id);
2260
+ }
2261
+ function isPlacedSticky(id) {
2262
+ return stickySessionIds.has(id) && !deferredPinPlacement.has(id);
2199
2263
  }
2200
2264
 
2201
- function getSessionPinState(sessionId) {
2202
- if (stickySessionIds.has(sessionId)) return 'sticky';
2265
+ // `saved` gives the pin the user set: a sticky that `session open` added is only this page's.
2266
+ function getSessionPinState(sessionId, { saved = false } = {}) {
2267
+ if (stickySessionIds.has(sessionId) && !(saved && openedStickyIds.has(sessionId))) return 'sticky';
2203
2268
  if (pinnedSessionIds.has(sessionId)) return 'pinned';
2204
2269
  return 'none';
2205
2270
  }
@@ -3790,8 +3855,38 @@ function renderSessions() {
3790
3855
 
3791
3856
  const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3792
3857
 
3858
+ const childDepth = new Map();
3859
+ const parentOf = new Map();
3860
+ for (const g of sessionGroups) for (const m of g.members) if (m.parent) parentOf.set(m.ref, m.parent);
3861
+ const nestChildren = (list) => {
3862
+ if (parentOf.size === 0) return list;
3863
+ const ids = new Set(list.map((s) => s.id));
3864
+ const kids = new Map();
3865
+ for (const s of list) {
3866
+ const p = parentOf.get(s.id);
3867
+ if (p && ids.has(p)) {
3868
+ if (!kids.has(p)) kids.set(p, []);
3869
+ kids.get(p).push(s);
3870
+ }
3871
+ }
3872
+ if (kids.size === 0) return list;
3873
+ const out = [];
3874
+ const seen = new Set();
3875
+ const add = (s, depth) => {
3876
+ if (seen.has(s.id)) return;
3877
+ seen.add(s.id);
3878
+ out.push(s);
3879
+ if (depth) childDepth.set(s.id, Math.min(depth, 3));
3880
+ for (const k of kids.get(s.id) || []) add(k, depth + 1);
3881
+ };
3882
+ for (const s of list) if (!ids.has(parentOf.get(s.id))) add(s, 0);
3883
+ // Sessions that are each other's parents have no top; show them as they came.
3884
+ for (const s of list) add(s, 0);
3885
+ return out;
3886
+ };
3887
+
3793
3888
  const renderSessionCard = (session) => {
3794
- if (session.placeholder) return renderPlaceholderCard(session);
3889
+ if (session.placeholder) return renderPlaceholderCard(session, childDepth.get(session.id));
3795
3890
  const total = session.taskCount;
3796
3891
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3797
3892
  const isActive = session.id === currentSessionId && viewMode === 'session';
@@ -3830,7 +3925,7 @@ function renderSessions() {
3830
3925
  : '';
3831
3926
  const metricsHtml = progressHtml + (showCtx ? renderContextBar(session) : '');
3832
3927
  return `
3833
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${sessionBarClass(session)} ${tempClass} ${idleClass} ${justFinishedClass}">
3928
+ <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
3929
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3835
3930
  <div class="session-name">${escapeHtml(sessionName)}</div>
3836
3931
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3939,11 +4034,11 @@ function renderSessions() {
3939
4034
  const groupPinned = store.getItem(GROUP_PINNED_KEY) !== 'false';
3940
4035
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3941
4036
  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));
4037
+ const renderGroupSessions = (list, pinKey) => {
4038
+ if (!groupPinned || pinnedSessionIds.size === 0) return nestChildren(list).map(renderSessionCard).join('');
4039
+ const gIdlePinned = nestChildren(list.filter(isInPinnedGroup));
4040
+ if (gIdlePinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
4041
+ const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
3947
4042
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
3948
4043
  return (
3949
4044
  '<div class="pinned-sub-section">' +
@@ -4020,67 +4115,94 @@ function renderSessions() {
4020
4115
  // shows its drop-here state instead of vanishing.
4021
4116
  const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
4022
4117
 
4118
+ // A group whose parent is gone renders at the top.
4119
+ const sgIds = new Set(sessionGroups.map((g) => g.id));
4120
+ const sgChildren = new Map();
4121
+ for (const g of sessionGroups) {
4122
+ const p = g.parent && sgIds.has(g.parent) ? g.parent : null;
4123
+ if (!sgChildren.has(p)) sgChildren.set(p, []);
4124
+ sgChildren.get(p).push(g);
4125
+ }
4126
+ const sgSubtree = new Map();
4127
+ const sgSubtreeOf = (group) => {
4128
+ let all = sgSubtree.get(group.id);
4129
+ if (!all) {
4130
+ all = [...(sgBuckets.get(group.id) || []), ...(sgChildren.get(group.id) || []).flatMap(sgSubtreeOf)];
4131
+ sgSubtree.set(group.id, all);
4132
+ }
4133
+ return all;
4134
+ };
4135
+
4023
4136
  // flat = the "All sessions" view, which has no project sub-blocks.
4024
4137
  const sgSectionHtml = (flat) => {
4025
4138
  const transient = [...transientGroups.values()];
4026
4139
  if (sessionGroups.length === 0 && transient.length === 0) return '';
4027
4140
  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}__`);
4141
+ for (const group of [...(sgChildren.get(null) || []), ...transient]) out += sgGroupHtml(group, flat);
4142
+ if (!out) return '';
4143
+ return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
4144
+ };
4145
+
4146
+ const sgGroupHtml = (group, flat) => {
4147
+ const bucket = sgBuckets.get(group.id) || [];
4148
+ const subtree = group.transient ? bucket : sgSubtreeOf(group);
4149
+ if (subtree.length === 0 && sgFiltering) return '';
4150
+ if (!groupPinned) bucket.sort(pinSort);
4151
+ const collapsed = collapsedProjectGroups.has(sgKey(group.id));
4152
+ // Placed sessions follow the order the user dragged them into; a session that is here through
4153
+ // its project takes the project's slot.
4154
+ const slot = new Map(group.members.map((m, i) => [`${m.type}:${m.ref}`, i]));
4155
+ const slotOf = (s) => slot.get(`session:${s.id}`) ?? slot.get(`project:${sessionProjectKey(s)}`) ?? 0;
4156
+ const byMemberOrder = (a, b) => (groupPinned ? 0 : pinSort(a, b)) || slotOf(a) - slotOf(b);
4157
+ let body = '';
4158
+ if (flat) {
4159
+ body = nestChildren([...bucket].sort(byMemberOrder))
4160
+ .map(renderSessionCard)
4161
+ .join('');
4162
+ } else if (group.transient) {
4163
+ // Each session under its own project block, so a worktree session reads as its repo's.
4164
+ const byProject = new Map();
4165
+ for (const s of bucket) {
4166
+ const key = sessionProjectKey(s) || '';
4167
+ if (!byProject.has(key)) byProject.set(key, []);
4168
+ byProject.get(key).push(s);
4169
+ }
4170
+ for (const [p, arr] of byProject) {
4171
+ body += p ? projectBlock(p, arr, true) : renderGroupSessions(arr, `__pinned_group_${group.id}__`);
4172
+ }
4173
+ } else {
4174
+ const loose = [];
4175
+ const byProject = new Map();
4176
+ for (const s of bucket) {
4177
+ const host = sgHostOf(group, s);
4178
+ if (host) {
4179
+ if (!byProject.has(host)) byProject.set(host, []);
4180
+ byProject.get(host).push(s);
4181
+ } else {
4182
+ loose.push(s);
4075
4183
  }
4076
4184
  }
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>`;
4185
+ // Project members first (in the user's member order), then the individually placed
4186
+ // sessions — one Pinned sub-section per group instead of one per interleaved run.
4187
+ for (const m of group.members) {
4188
+ if (m.type !== 'project') continue;
4189
+ const arr = byProject.get(m.ref);
4190
+ // A member project with nothing left in it still renders: it shows the membership, and
4191
+ // it is the drop target for putting a session back under it.
4192
+ if (arr?.length || !sgFiltering) body += projectBlock(m.ref, arr || [], true);
4193
+ }
4194
+ if (loose.length) {
4195
+ loose.sort(byMemberOrder);
4196
+ // Label them, else a card sitting under the project blocks reads as stranded.
4197
+ if (body) body += '<div class="sg-sub-label">Sessions</div>';
4198
+ body += renderGroupSessions(loose, `__pinned_group_${group.id}__`);
4199
+ }
4081
4200
  }
4082
- if (!out) return '';
4083
- return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
4201
+ let childHtml = '';
4202
+ if (!group.transient) for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
4203
+ if (!body && !childHtml) body = '<div class="sg-empty">Drop a session or a project here</div>';
4204
+ const idAttr = group.transient ? '' : ` data-group-id="${escapeHtml(group.id)}"`;
4205
+ return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}"${idAttr}>${body}${childHtml}</div>`;
4084
4206
  };
4085
4207
 
4086
4208
  let html = '';
@@ -4588,8 +4710,8 @@ async function onColumnDrop(e) {
4588
4710
 
4589
4711
  //#region SESSION_GROUPS
4590
4712
  // 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
4713
+ // sidebar. They render above the project groups. Membership is a workspace preference kept on
4714
+ // the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
4593
4715
  // windows the list), so membership is never garbage-collected on load.
4594
4716
  const SESSION_GROUPS_KEY = 'sessionGroups';
4595
4717
  const SG_ACTION_SELECTOR =
@@ -4611,20 +4733,71 @@ function pinKey(projectPath) {
4611
4733
  return projectPath === '__ungrouped__' ? '__pinned___ungrouped__' : `__pinned_${projectPath}__`;
4612
4734
  }
4613
4735
 
4614
- function loadSessionGroups() {
4615
- const saved = store.readJson(SESSION_GROUPS_KEY);
4736
+ // The server keeps the groups (`/api/groups`, lib/user-groups.js). `sessionGroups` in localStorage
4737
+ // is the copy from before that: the board imports it once and never writes it, so it stays as a
4738
+ // backup.
4739
+ const GROUPS_MIGRATED_AT = 'groupsMigratedAt';
4740
+ let sgRev = null;
4741
+ let sgSaving = null;
4742
+ let sgDirty = false;
4743
+ let sgUnsent = false;
4744
+ let sgSeenRev = 0;
4745
+
4746
+ // Until it succeeds the board shows no groups, and it tries again on the next load or SSE reconnect.
4747
+ async function syncSessionGroups() {
4748
+ const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
4749
+ const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
4750
+ const hasLocal = local?.groups?.length > 0 || local?.released?.length > 0;
4751
+ try {
4752
+ const res = hasLocal
4753
+ ? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups, released: local.released } })
4754
+ : await api('/api/groups');
4755
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
4756
+ sgTakeServer(await res.json());
4757
+ if (!migrated) store.setItem(GROUPS_MIGRATED_AT, new Date().toISOString());
4758
+ if (sgSeenRev > sgRev) sgRefresh(sgSeenRev);
4759
+ } catch (e) {
4760
+ console.warn('[groups] server sync failed:', e.message);
4761
+ }
4762
+ }
4763
+
4764
+ // The SSE event for this board's own write often arrives before the write's response.
4765
+ async function sgRefresh(rev = Infinity) {
4766
+ await sgSaving;
4767
+ if (sgRev !== null && sgRev >= rev) return;
4768
+ const data = await getJson('/api/groups');
4769
+ if (data) sgTakeServer(data);
4770
+ }
4771
+
4772
+ function sgTakeServer(data, force = false) {
4773
+ if (!Number.isInteger(data?.rev) || (!force && sgRev !== null && data.rev <= sgRev)) return;
4774
+ sgRev = data.rev;
4775
+ sgApplyState(data);
4776
+ renderSessions();
4777
+ }
4778
+
4779
+ // An event during the first sync is kept, because the sync's read may be older than it.
4780
+ function onGroupChanged(data) {
4781
+ if (!Number.isInteger(data?.rev)) return;
4782
+ sgSeenRev = Math.max(sgSeenRev, data.rev);
4783
+ if (sgRev !== null && data.rev > sgRev) sgRefresh(data.rev);
4784
+ }
4785
+
4786
+ function sgApplyState(saved) {
4616
4787
  const list = saved?.groups;
4617
4788
  sessionGroups = (Array.isArray(list) ? list : [])
4618
4789
  .filter((g) => g && typeof g.id === 'string')
4619
4790
  .map((g) => ({
4620
4791
  id: g.id,
4621
4792
  name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4793
+ parent: typeof g.parent === 'string' ? g.parent : null,
4622
4794
  members: (Array.isArray(g.members) ? g.members : [])
4623
4795
  .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4624
4796
  .map((m) => {
4625
4797
  const out = { type: m.type, ref: m.ref };
4626
4798
  if (typeof m.under === 'string' && m.under) out.under = m.under;
4627
4799
  if (m.loose === true) out.loose = true;
4800
+ if (typeof m.parent === 'string' && m.parent) out.parent = m.parent;
4628
4801
  return out;
4629
4802
  }),
4630
4803
  }));
@@ -4633,17 +4806,85 @@ function loadSessionGroups() {
4633
4806
  }
4634
4807
 
4635
4808
  function persistSessionGroups() {
4636
- store.writeJson(SESSION_GROUPS_KEY, { version: 1, groups: sessionGroups, released: [...sgReleased] });
4809
+ if (sgRev === null) {
4810
+ showToast('Groups are not loaded from the server; this change is not saved', 'error');
4811
+ return;
4812
+ }
4813
+ sgDirty = true;
4814
+ sgSaving ??= sgFlush().finally(() => {
4815
+ sgSaving = null;
4816
+ });
4817
+ }
4818
+
4819
+ // Writes run one at a time; edits made meanwhile go out in the next write. A 409 means another
4820
+ // board wrote first: its state wins and this board's unsent edit is dropped. A write that does not
4821
+ // reach the server is sent again on SSE reconnect.
4822
+ async function sgFlush() {
4823
+ while (sgDirty) {
4824
+ sgDirty = false;
4825
+ let res;
4826
+ try {
4827
+ res = await api('/api/groups', {
4828
+ method: 'PUT',
4829
+ body: { rev: sgRev, groups: sessionGroups, released: [...sgReleased] },
4830
+ });
4831
+ } catch (e) {
4832
+ console.warn('[groups] save failed:', e.message);
4833
+ sgUnsent = true;
4834
+ showToast('Groups not saved: the server is unreachable. Retrying when it is back', 'error');
4835
+ return;
4836
+ }
4837
+ sgUnsent = false;
4838
+ const data = await res.json().catch(() => null);
4839
+ if (res.ok && Number.isInteger(data?.rev)) {
4840
+ sgRev = data.rev;
4841
+ if (!sgDirty) sgTakeCleaned(data);
4842
+ continue;
4843
+ }
4844
+ showToast(
4845
+ res.status === 409
4846
+ ? 'Groups changed in another window; showing the latest'
4847
+ : `Groups not saved (${data?.error || res.status})`,
4848
+ res.status === 409 ? undefined : 'error',
4849
+ );
4850
+ sgDirty = false;
4851
+ const fresh = res.status === 409 ? data : await getJson('/api/groups');
4852
+ if (fresh) sgTakeServer(fresh, true);
4853
+ }
4854
+ }
4855
+
4856
+ // The server drops what breaks its rules (a member in two groups, a group past the limit) and
4857
+ // trims long names; the board shows what was saved.
4858
+ function sgTakeCleaned(data) {
4859
+ const saved = JSON.stringify({ groups: data.groups, released: data.released });
4860
+ if (saved === JSON.stringify({ groups: sessionGroups, released: [...sgReleased] })) return;
4861
+ sgApplyState(data);
4862
+ renderSessions();
4637
4863
  }
4638
4864
 
4639
4865
  function sgGroupById(id) {
4640
4866
  return sessionGroups.find((g) => g.id === id) || null;
4641
4867
  }
4642
4868
 
4869
+ // Matches MAX_DEPTH in lib/user-groups.js.
4870
+ const SG_MAX_DEPTH = 8;
4871
+
4872
+ function sgPathOf(group) {
4873
+ const names = [];
4874
+ for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
4875
+ return names.join('/');
4876
+ }
4877
+
4643
4878
  function sgGroupOf(type, ref) {
4644
4879
  return sessionGroups.find((g) => g.members.some((m) => m.type === type && m.ref === ref)) || null;
4645
4880
  }
4646
4881
 
4882
+ const sgMemberOf = (group, type, ref) => group?.members.find((m) => m.type === type && m.ref === ref);
4883
+
4884
+ function sgSessionParent(id) {
4885
+ return sgMemberOf(sgGroupOf('session', id), 'session', id)?.parent || null;
4886
+ }
4887
+
4647
4888
  // An individually placed session wins over the placement of its project.
4648
4889
  function sgGroupForSession(session) {
4649
4890
  return sgUserGroupFor(session) || sgDispatchGroupFor(session);
@@ -4668,6 +4909,8 @@ function sgTransientGroup(name) {
4668
4909
  return group;
4669
4910
  }
4670
4911
 
4912
+ const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
4913
+
4671
4914
  function dispatchedTitle({ parent }) {
4672
4915
  const starter = parent && sessions.find((s) => s.id === parent);
4673
4916
  const by = starter ? sessionDisplayName(starter) : 'another session';
@@ -4714,10 +4957,12 @@ function sgHostOf(group, session) {
4714
4957
  return null;
4715
4958
  }
4716
4959
  // `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.
4960
+ // out of its project block onto the group's own level. `opts.parent` follows the server's place():
4961
+ // left out, a move inside the group keeps it; null clears it.
4718
4962
  function sgAssign(groupId, type, ref, opts = {}) {
4719
4963
  const group = sgGroupById(groupId);
4720
4964
  if (!group) return;
4965
+ const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
4721
4966
  sgDetach(type, ref);
4722
4967
  // Pulling a whole project in supersedes the individual placements of its sessions.
4723
4968
  if (type === 'project') {
@@ -4731,10 +4976,11 @@ function sgAssign(groupId, type, ref, opts = {}) {
4731
4976
  type === 'session' && !under && !loose && group.members.some((m) => m.type === 'project' && m.ref === own);
4732
4977
  // The group already holds the session's project — a separate placement would only strand
4733
4978
  // the card at the bottom of the group, away from its project block.
4734
- if (!covered) {
4979
+ if (!covered || parent) {
4735
4980
  const member = { type, ref };
4736
4981
  if (under) member.under = under;
4737
4982
  if (loose) member.loose = true;
4983
+ if (parent) member.parent = parent;
4738
4984
  group.members.push(member);
4739
4985
  }
4740
4986
  persistSessionGroups();
@@ -4744,6 +4990,7 @@ function sgCreateGroup(name) {
4744
4990
  const group = {
4745
4991
  id: `g_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
4746
4992
  name: (name || '').trim() || `Group ${sessionGroups.length + 1}`,
4993
+ parent: null,
4747
4994
  members: [],
4748
4995
  };
4749
4996
  sessionGroups.push(group);
@@ -4752,7 +4999,9 @@ function sgCreateGroup(name) {
4752
4999
  }
4753
5000
 
4754
5001
  function sgDeleteGroup(id) {
5002
+ const parent = sgGroupById(id)?.parent ?? null;
4755
5003
  sessionGroups = sessionGroups.filter((g) => g.id !== id);
5004
+ for (const g of sessionGroups) if (g.parent === id) g.parent = parent;
4756
5005
  if (collapsedProjectGroups.delete(sgKey(id))) persistCollapsedGroups();
4757
5006
  persistSessionGroups();
4758
5007
  }
@@ -4778,13 +5027,17 @@ function sgReorderGroup(dragId, targetId) {
4778
5027
  persistSessionGroups();
4779
5028
  }
4780
5029
 
4781
- // Typing a group name should surface the sessions inside it.
5030
+ // Typing a group name should surface the sessions inside it and inside its child groups.
4782
5031
  function sgSearchMatchIds(query) {
4783
5032
  const ids = new Set();
4784
5033
  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));
5034
+ const hits = new Set(sessionGroups.filter((g) => fuzzyMatch(g.name, query)).map((g) => g.id));
5035
+ if (hits.size === 0) return ids;
5036
+ const underHit = (g) => {
5037
+ for (let p = g, n = 0; p && n < SG_MAX_DEPTH; p = sgGroupById(p.parent), n++) if (hits.has(p.id)) return true;
5038
+ return false;
5039
+ };
5040
+ const hitIds = new Set(sessionGroups.filter(underHit).map((g) => g.id));
4788
5041
  for (const s of sessions) {
4789
5042
  const group = sgGroupForSession(s);
4790
5043
  if (group && hitIds.has(group.id)) ids.add(s.id);
@@ -4857,7 +5110,7 @@ function sgCommitRename(input, save) {
4857
5110
  // At most one zone is lit, so the reference is enough - dragover fires many times a second.
4858
5111
  let sgLitZone = null;
4859
5112
  let sgDragEl = null;
4860
- const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after'];
5113
+ const SG_LIT_CLASSES = ['sg-drop-over', 'sg-insert-before', 'sg-insert-after', 'sg-insert-parent'];
4861
5114
  const SG_LIT_SELECTOR = SG_LIT_CLASSES.map((c) => `.${c}`).join(', ');
4862
5115
 
4863
5116
  function sgClearDropTargets() {
@@ -4882,15 +5135,43 @@ function sgInsertHit(card, e, share) {
4882
5135
  return { zone: card, cls: after ? 'sg-insert-after' : 'sg-insert-before', insert: { card, after } };
4883
5136
  }
4884
5137
 
5138
+ // Outside the Active filter the sidebar sorts ungrouped cards itself, so only a group's members,
5139
+ // which keep their own order, take an insert there.
4885
5140
  function sgSiblingCard(target) {
4886
- if (sgDrag?.kind !== 'session' || sessionFilter !== 'active' || !sgDragEl) return null;
5141
+ if (sgDrag?.kind !== 'session' || !sgDragEl) return null;
4887
5142
  const card = target.closest('.session-item');
4888
- return card && card !== sgDragEl && card.parentElement === sgDragEl.parentElement ? card : null;
5143
+ if (!card || card === sgDragEl || card.parentElement !== sgDragEl.parentElement) return null;
5144
+ if (sessionFilter === 'active') return card;
5145
+ const group = sgGroupOf('session', sgDrag.ref);
5146
+ return group && sgGroupOf('session', card.dataset.sessionId) === group ? card : null;
5147
+ }
5148
+
5149
+ function sgDescendsFrom(id, ancestor) {
5150
+ for (let p = sgSessionParent(id), n = 0; p && n < SG_MAX_DEPTH; p = sgSessionParent(p), n++) {
5151
+ if (p === ancestor) return true;
5152
+ }
5153
+ return false;
5154
+ }
5155
+
5156
+ // A child group's header drops into its parent group on its top edge, and wherever its own group
5157
+ // would refuse the drop. Without it a session in the child group has no way up when the parent's
5158
+ // header is off screen: the gap above is a few pixels.
5159
+ // A child group renders inside its parent's body, so that body is the parent's drop zone.
5160
+ function sgParentEdgeHit(target, e, hit) {
5161
+ const header = sgDrag?.kind === 'group' ? null : target.closest('.session-group-header[data-group-id]');
5162
+ const parentBody = header?.parentElement.closest('.session-group-sessions[data-group-id]');
5163
+ if (!parentBody) return null;
5164
+ const rect = header.getBoundingClientRect();
5165
+ if (e.clientY - rect.top > rect.height * 0.4 && hit) return null;
5166
+ const up = sgGroupDropZone(parentBody);
5167
+ return up ? { ...up, zone: header, cls: 'sg-insert-parent' } : null;
4889
5168
  }
4890
5169
 
4891
5170
  function sgDropZone(target, e) {
4892
- const sibling = sgSiblingCard(target);
4893
5171
  const hit = sgGroupDropZone(target);
5172
+ const edge = sgParentEdgeHit(target, e, hit);
5173
+ if (edge) return edge;
5174
+ const sibling = sgSiblingCard(target);
4894
5175
  // Where the middle of a card does nothing, the whole card is an insert target.
4895
5176
  return (sibling && sgInsertHit(sibling, e, hit ? 0.25 : 0.5)) || hit;
4896
5177
  }
@@ -4900,10 +5181,14 @@ function sgInsertSession(dragId, { card, after }) {
4900
5181
  const group = card.closest('.project-group-sessions') ? null : sgGroupOf('session', ref);
4901
5182
  if (group && sgGroupOf('session', dragId)?.id === group.id) {
4902
5183
  // Loose sessions in a named group follow the group's member order, not the sidebar order.
5184
+ // A card set next to another becomes its sibling: it takes that card's parent.
5185
+ const { parent: _old, ...moved } = sgMemberOf(group, 'session', dragId);
5186
+ const parent = sgMemberOf(group, 'session', ref)?.parent;
5187
+ if (parent && parent !== dragId && !sgDescendsFrom(parent, dragId)) moved.parent = parent;
4903
5188
  const members = insertNear(
4904
5189
  group.members.filter((m) => !(m.type === 'session' && m.ref === dragId)),
4905
5190
  (m) => m.type === 'session' && m.ref === ref,
4906
- { type: 'session', ref: dragId },
5191
+ moved,
4907
5192
  after,
4908
5193
  );
4909
5194
  if (!members) return;
@@ -4960,8 +5245,9 @@ function sgGroupDropZone(target) {
4960
5245
  if (groupId) {
4961
5246
  if (!dragSession) return dragGroup?.id === groupId ? null : { zone, groupId };
4962
5247
  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;
5248
+ // Already in this group: its own level is a move only out of a project block or from under
5249
+ // a parent session.
5250
+ return fromHost || sgSessionParent(dragSession.id) ? { zone, groupId, loose: true, parent: null } : null;
4965
5251
  }
4966
5252
  const targetSessionId = zone.dataset.sessionId;
4967
5253
  if (targetSessionId) {
@@ -4971,20 +5257,17 @@ function sgGroupDropZone(target) {
4971
5257
  const targetGroupId = targetGroup?.id || null;
4972
5258
  // A transient group holds no members to reorder or place under; a drop there pairs instead.
4973
5259
  if (targetGroup && !targetGroup.transient) {
4974
- // Stacking onto a card adopts that card's placement, project block included.
5260
+ // Stacking onto a card nests the dragged session under it, in that card's placement, project
5261
+ // block included.
5262
+ if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
5263
+ if (sgDescendsFrom(targetSessionId, sgDrag.ref)) return null;
4975
5264
  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 };
5265
+ return {
5266
+ zone,
5267
+ groupId: targetGroupId,
5268
+ ...(targetHost ? { under: targetHost } : { loose: true }),
5269
+ parent: targetSessionId,
5270
+ };
4988
5271
  }
4989
5272
  return { zone, pairSession: targetSessionId };
4990
5273
  }
@@ -5070,7 +5353,11 @@ function sgOnDrop(e) {
5070
5353
  if (hit.reorder) sgReorderMember(hit.groupId, drag, hit.reorder);
5071
5354
  else if (drag.kind === 'group') sgReorderGroup(drag.ref, hit.groupId);
5072
5355
  else {
5073
- sgAssign(hit.groupId, drag.kind, drag.ref, { under: hit.under, loose: hit.loose });
5356
+ sgAssign(hit.groupId, drag.kind, drag.ref, {
5357
+ under: hit.under,
5358
+ loose: hit.loose,
5359
+ parent: hit.parent,
5360
+ });
5074
5361
  // A drop shows where the card landed; a move from the context menu leaves the group as it was.
5075
5362
  if (collapsedProjectGroups.delete(sgKey(hit.groupId))) persistCollapsedGroups();
5076
5363
  }
@@ -5136,11 +5423,6 @@ function initSessionGroupsDnd() {
5136
5423
  sessionsList.addEventListener('touchstart', sgOnTouchStart, { passive: true });
5137
5424
  sessionsList.addEventListener('touchend', sgCancelLongPress);
5138
5425
  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
5426
  }
5145
5427
  //#endregion
5146
5428
 
@@ -5218,7 +5500,7 @@ function sgOpenMenu(x, y, kind, ref) {
5218
5500
  .filter((g) => g.id !== current?.id)
5219
5501
  .map(
5220
5502
  (g) =>
5221
- `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(g.name)}</button>`,
5503
+ `<button class="sg-menu-item" role="menuitem" data-sg-move="${escapeHtml(g.id)}">${escapeHtml(sgPathOf(g))}</button>`,
5222
5504
  )
5223
5505
  .join('');
5224
5506
  const menu = document.createElement('div');
@@ -5414,7 +5696,7 @@ function getNavigableItems() {
5414
5696
  }
5415
5697
  } else if (child.classList.contains('session-item')) {
5416
5698
  items.push(child);
5417
- } else if (child.classList.contains('project-group-header')) {
5699
+ } else if (child.classList.contains('project-group-header') || child.classList.contains('session-group-header')) {
5418
5700
  items.push(child);
5419
5701
  if (!collapsedProjectGroups.has(child.dataset.groupPath)) {
5420
5702
  walkGroupContainer(getGroupSessionsContainer(child));
@@ -5636,6 +5918,7 @@ function activateSelectedSession(items) {
5636
5918
  }
5637
5919
 
5638
5920
  function setFocusZone(zone, target) {
5921
+ passTerminalZoneOn = false;
5639
5922
  clearKbSelection();
5640
5923
  clearTaskSelection();
5641
5924
 
@@ -5911,10 +6194,30 @@ function closeDetailPanel() {
5911
6194
  // Set while #confirm-modal is open; settles the open confirmModal() promise.
5912
6195
  let closeConfirmModal = null;
5913
6196
 
5914
- function confirmModal({ title, message, okLabel }) {
6197
+ // chip: the object named in one line. items: [{state, label, meta}] rows, state in busy|wait|idle (a dot) or
6198
+ // omitted (no dot); steps: plain strings shown as what will happen; warn: a line under the list.
6199
+ function confirmModal({ title, message, okLabel, chip, items, steps, warn }) {
5915
6200
  const modal = document.getElementById('confirm-modal');
5916
6201
  document.getElementById('confirm-modal-title').textContent = title;
5917
6202
  document.getElementById('confirm-modal-message').textContent = message;
6203
+ const chipEl = document.getElementById('confirm-modal-chip');
6204
+ chipEl.hidden = !chip;
6205
+ chipEl.textContent = chip || '';
6206
+ const listEl = document.getElementById('confirm-modal-list');
6207
+ const rows = [
6208
+ ...(items || []).map(
6209
+ (it) =>
6210
+ `<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>`,
6211
+ ),
6212
+ ...(steps || []).map(
6213
+ (s) => `<div class="confirm-row confirm-step"><span class="confirm-row-label">${escapeHtml(s)}</span></div>`,
6214
+ ),
6215
+ ];
6216
+ listEl.hidden = !rows.length;
6217
+ listEl.innerHTML = rows.join('');
6218
+ const warnEl = document.getElementById('confirm-modal-warn');
6219
+ warnEl.hidden = !warn;
6220
+ warnEl.textContent = warn || '';
5918
6221
  const buttons = [document.getElementById('confirm-cancel-btn'), document.getElementById('confirm-ok-btn')];
5919
6222
  buttons[1].textContent = okLabel;
5920
6223
  const returnFocus = document.activeElement;
@@ -5946,9 +6249,10 @@ async function deleteTask(taskId, sessionId) {
5946
6249
  const task = currentTasks.find((t) => t.id === taskId);
5947
6250
  if (!task) return;
5948
6251
  const ok = await confirmModal({
5949
- title: 'Delete Task',
5950
- message: `Delete task "${task.subject}"? This cannot be undone.`,
5951
- okLabel: 'Delete',
6252
+ title: 'Delete this task?',
6253
+ message: 'It is removed from the board. This cannot be undone.',
6254
+ chip: task.subject,
6255
+ okLabel: 'Delete task',
5952
6256
  });
5953
6257
  if (!ok) return;
5954
6258
 
@@ -6018,6 +6322,7 @@ const SHORTCUT_TABS = [
6018
6322
  { keys: ['T'], label: 'Toggle theme' },
6019
6323
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
6020
6324
  { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
6325
+ { keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
6021
6326
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
6022
6327
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
6023
6328
  { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
@@ -6135,6 +6440,14 @@ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platf
6135
6440
  const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
6136
6441
  if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
6137
6442
 
6443
+ // Ctrl+Enter sends on macOS as Cmd+Enter too (the handlers accept metaKey), so the hints show ⌘ and ⌥ there.
6444
+ function sendKbd() {
6445
+ return `<kbd>${IS_MAC ? '⌘' : 'Ctrl'}</kbd>+<kbd>Enter</kbd>`;
6446
+ }
6447
+ function termKbd() {
6448
+ return `<kbd>${IS_MAC ? '⌥' : 'Alt'}</kbd>+<kbd>\`</kbd>`;
6449
+ }
6450
+
6138
6451
  // The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
6139
6452
  // macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
6140
6453
  // row shows the keys the hub reports, which the user can change; row.keys until it answers.
@@ -6741,7 +7054,7 @@ async function _storageViewSession(id) {
6741
7054
 
6742
7055
  function _storageUnpinSession(id) {
6743
7056
  clearSessionPin(id);
6744
- savePinnedSessions();
7057
+ sendPinChange(id);
6745
7058
  renderSessions();
6746
7059
  _renderStorageTab();
6747
7060
  _updateStorageTotal();
@@ -6972,7 +7285,7 @@ function _findOrphanedKeys(known) {
6972
7285
  for (const key of store.keys()) {
6973
7286
  const pad = _parsePadKey(key);
6974
7287
  if (pad?.kind === 'group') {
6975
- if (!sgGroupById(pad.id)) orphaned.push(key);
7288
+ if (sgRev !== null && !sgGroupById(pad.id)) orphaned.push(key);
6976
7289
  continue;
6977
7290
  }
6978
7291
  const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
@@ -7001,13 +7314,10 @@ async function cleanupOrphanedStorage() {
7001
7314
  const orphaned = _findOrphanedKeys(known);
7002
7315
  const unpinned = new Set();
7003
7316
  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);
7317
+ const pin = /^__(pinned|sticky)__/.exec(key);
7318
+ if (pin) {
7319
+ const id = key.slice(pin[0].length);
7320
+ clearSessionPin(id);
7011
7321
  unpinned.add(id);
7012
7322
  } else {
7013
7323
  store.removeItem(key);
@@ -7015,8 +7325,7 @@ async function cleanupOrphanedStorage() {
7015
7325
  if (key.startsWith(PREVIEW_STORAGE_PREFIX)) forgetServerLinkedDoc(key.slice(PREVIEW_STORAGE_PREFIX.length));
7016
7326
  }
7017
7327
  }
7018
- if (unpinned.size) savePinnedSessions();
7019
- for (const id of unpinned) offloadSessionPin(id);
7328
+ if (unpinned.size) postPins({ ids: [...unpinned], state: 'none' });
7020
7329
  const removed = orphaned.length;
7021
7330
 
7022
7331
  showToast(removed ? `Cleaned ${removed} orphaned item${removed > 1 ? 's' : ''}` : 'No orphaned items found');
@@ -7083,13 +7392,18 @@ async function confirmDismissBlockers(sid) {
7083
7392
  const session = sessions.find((s) => s.id === sid);
7084
7393
  const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
7085
7394
  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 });
7395
+ const okLabel = sticky && terminal ? 'Unpin, end and dismiss' : sticky ? 'Unpin and dismiss' : 'End and dismiss';
7396
+ const steps = [
7397
+ sticky && 'Remove the sticky pin',
7398
+ terminal && 'End the terminal',
7399
+ 'Hide the session from the sidebar',
7400
+ ].filter(Boolean);
7401
+ const ok = await confirmModal({
7402
+ title: `Dismiss “${name}”?`,
7403
+ message: `The session has ${has}.`,
7404
+ steps,
7405
+ okLabel,
7406
+ });
7093
7407
  if (!ok) return false;
7094
7408
  if (sticky) unpinSession(sid);
7095
7409
  if (terminal) closeTerminalSession(sid);
@@ -7194,15 +7508,26 @@ document.addEventListener('keydown', (e) => {
7194
7508
  e.preventDefault();
7195
7509
  closeMsgDetailModal();
7196
7510
  } 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
- ) {
7511
+ const askOpen = currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX && isWaitingAnswerable();
7512
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen && currentWaiting.kind !== 'question') {
7204
7513
  e.preventDefault();
7205
7514
  respondWaiting({ behavior: 'allow' });
7515
+ } else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && askOpen) {
7516
+ e.preventDefault();
7517
+ submitWaitingAnswers();
7518
+ } else if (
7519
+ askOpen &&
7520
+ currentWaiting.kind === 'question' &&
7521
+ !e.ctrlKey &&
7522
+ !e.metaKey &&
7523
+ !e.altKey &&
7524
+ /^[1-9]$/.test(e.key)
7525
+ ) {
7526
+ e.preventDefault();
7527
+ pickWaitingOptionByNumber(Number(e.key) - 1);
7528
+ } else if (askOpen && currentWaiting.kind === 'question' && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
7529
+ e.preventDefault();
7530
+ setWaitingTab(waitingTab + (e.key === 'ArrowRight' ? 1 : -1));
7206
7531
  } else if (matchKey(e, 'ArrowDown')) {
7207
7532
  e.preventDefault();
7208
7533
  if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
@@ -7234,6 +7559,20 @@ document.addEventListener('keydown', (e) => {
7234
7559
  return;
7235
7560
  }
7236
7561
 
7562
+ if (
7563
+ e.key === 'Enter' &&
7564
+ (e.ctrlKey || e.metaKey) &&
7565
+ !e.altKey &&
7566
+ !e.shiftKey &&
7567
+ isWaitingFresh() &&
7568
+ isWaitingAnswerable() &&
7569
+ currentWaiting.kind !== 'question'
7570
+ ) {
7571
+ e.preventDefault();
7572
+ respondWaiting({ behavior: 'allow' });
7573
+ return;
7574
+ }
7575
+
7237
7576
  // Global shortcuts
7238
7577
  if (matchKey(e, '\\')) {
7239
7578
  e.preventDefault();
@@ -7739,6 +8078,31 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
7739
8078
  (document.head || document.documentElement).append(style);
7740
8079
  }
7741
8080
  const send = (msg) => parent.postMessage({ ...msg, type: P + msg.type }, '*');
8081
+ const fields = (form, submitter) => {
8082
+ const out = {};
8083
+ for (const [k, v] of new FormData(form, submitter)) {
8084
+ if (typeof v !== 'string') continue;
8085
+ out[k] = k in out ? [].concat(out[k], v) : v;
8086
+ }
8087
+ return out;
8088
+ };
8089
+ const labelOf = (el, fallback) => (el?.textContent || el?.value || '').trim().slice(0, 200) || fallback;
8090
+ addEventListener('click', (e) => {
8091
+ const b = e.target.closest?.('[data-cck-action]');
8092
+ if (!b || (b.form?.matches('[data-cck-submit]') && b.type === 'submit')) return;
8093
+ e.preventDefault();
8094
+ const action = b.dataset.cckAction;
8095
+ send({ type: 'action', action, label: labelOf(b, action), data: b.form ? fields(b.form) : {} });
8096
+ });
8097
+ // The sandbox allows forms so validation runs. Every submit stops here, so a page still cannot
8098
+ // navigate its frame with a form: a show card's form-action 'none' does not stop a GET to about:srcdoc.
8099
+ addEventListener('submit', (e) => {
8100
+ e.preventDefault();
8101
+ const f = e.target.closest?.('form[data-cck-submit]');
8102
+ if (!f) return;
8103
+ const action = f.dataset.cckSubmit;
8104
+ send({ type: 'action', action, label: labelOf(e.submitter, action), data: fields(f, e.submitter) });
8105
+ });
7742
8106
  const describe = (node) => {
7743
8107
  const el = node.nodeType === 1 ? node : node.parentElement;
7744
8108
  if (!el) return {};
@@ -7922,10 +8286,11 @@ function sendBridgeClaims(frames, claims) {
7922
8286
  window.addEventListener('message', (e) => {
7923
8287
  const type =
7924
8288
  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;
8289
+ if (type !== 'link' && type !== 'key' && type !== 'scroll' && type !== 'action') return;
7926
8290
  const frame = bridgedFrameEls().find((f) => f.contentWindow === e.source);
7927
8291
  if (!frame) return;
7928
- if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
8292
+ if (type === 'action') sendPreviewAction(frame, e.data);
8293
+ else if (type === 'scroll') paneFrameScroll.set(frame, { x: e.data.x, y: e.data.y });
7929
8294
  else if (type === 'key') replayKey(e.data, frame);
7930
8295
  else {
7931
8296
  const url = linkUrl(e.data.href);
@@ -8138,6 +8503,24 @@ function renderReviewPanel() {
8138
8503
  .join('')}</ol>`;
8139
8504
  }
8140
8505
 
8506
+ async function toastDelivery({ delivered, markdown }, noun) {
8507
+ if (delivered === 'doorbell') return showToast('Queued for the session', 'success');
8508
+ if (delivered === 'terminal') return showToast('Pasted into the terminal: press Enter there to send', 'info');
8509
+ const copy = async () => {
8510
+ const copied = await navigator.clipboard.writeText(markdown).then(
8511
+ () => true,
8512
+ () => false,
8513
+ );
8514
+ showToast(
8515
+ copied ? `Copied the ${noun}. Paste it into the session.` : `Failed to copy the ${noun}`,
8516
+ 'info',
8517
+ { label: 'Copy again', onClick: copy },
8518
+ `Sessions get ${noun}s directly when the cck plugin runs there and board events are on.`,
8519
+ );
8520
+ };
8521
+ await copy();
8522
+ }
8523
+
8141
8524
  async function sendReview() {
8142
8525
  if (!activeReview) return;
8143
8526
  const { sessionId, source, key, onSent } = activeReview;
@@ -8150,30 +8533,49 @@ async function sendReview() {
8150
8533
  });
8151
8534
  const data = await res.json();
8152
8535
  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
- }
8536
+ await toastDelivery(data, 'review');
8171
8537
  clearReviewDrafts(key);
8172
8538
  if (activeReview?.key === key) onSent?.();
8173
8539
  } catch {
8174
8540
  showToast('Failed to send the review', 'error');
8175
8541
  }
8176
8542
  }
8543
+
8544
+ // A page script can post an action without a click, so the board sends it only with transient
8545
+ // user activation, which a click in the frame gives to the board too.
8546
+ const ACTION_REPEAT_MS = 1000;
8547
+ let lastAction = null;
8548
+
8549
+ function reviewOptsOfFrame(frame) {
8550
+ return (
8551
+ [...reviewStack, reviewBase, showState.review].find((o) => o?.frame === frame) ||
8552
+ paneReviews.get(frame.parentElement) ||
8553
+ null
8554
+ );
8555
+ }
8556
+
8557
+ async function sendPreviewAction(frame, msg) {
8558
+ if (!navigator.userActivation?.isActive) return;
8559
+ const opts = reviewOptsOfFrame(frame);
8560
+ if (!opts?.sessionId) return;
8561
+ const key = `${msg.action}\n${JSON.stringify(msg.data)}`;
8562
+ const now = Date.now();
8563
+ if (lastAction?.frame === frame && lastAction.key === key && now - lastAction.at < ACTION_REPEAT_MS) return;
8564
+ lastAction = { frame, key, at: now };
8565
+ try {
8566
+ const res = await terminalFetch(apiPath`/api/sessions/${opts.sessionId}/action`, 'POST', {
8567
+ source: opts.source,
8568
+ action: msg.action,
8569
+ label: msg.label,
8570
+ data: msg.data,
8571
+ });
8572
+ const out = await res.json();
8573
+ if (!res.ok) return showToast(out.error || 'Send failed', 'error');
8574
+ await toastDelivery(out, 'answer');
8575
+ } catch {
8576
+ showToast('Failed to send the answer', 'error');
8577
+ }
8578
+ }
8177
8579
  //#endregion
8178
8580
 
8179
8581
  //#region MARKDOWN_PREVIEW
@@ -8284,7 +8686,7 @@ function renderFrontmatterBlock(fm) {
8284
8686
  // allow-same-origin puts it on an opaque origin, so it cannot touch this app's
8285
8687
  // storage, DOM or API. srcdoc has no base URL, so the server has already embedded
8286
8688
  // the document's local assets (lib/inline-assets.js); remote refs load normally.
8287
- function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups') {
8689
+ function createPreviewFrame(className, srcdoc, sandbox = 'allow-scripts allow-popups allow-forms') {
8288
8690
  const frame = document.createElement('iframe');
8289
8691
  frame.className = className;
8290
8692
  frame.setAttribute('sandbox', sandbox);
@@ -8306,6 +8708,10 @@ function isAbsolutePath(p) {
8306
8708
  return /^([a-zA-Z]:[\\/]|[\\/])/.test(p);
8307
8709
  }
8308
8710
 
8711
+ function isUrlRef(s) {
8712
+ return /^[a-z][a-z0-9+.-]*:/i.test(s) || s.startsWith('//');
8713
+ }
8714
+
8309
8715
  function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8310
8716
  if (bodyEl.dataset.relLinkBound) return;
8311
8717
  bodyEl.addEventListener('click', (e) => {
@@ -8313,8 +8719,7 @@ function bindPreviewRelativeLinks(bodyEl, baseOf = () => currentPreviewPath) {
8313
8719
  if (!a) return;
8314
8720
  const href = a.getAttribute('href');
8315
8721
  if (!href || href.startsWith('#')) return;
8316
- const isAbsoluteUrl = /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
8317
- if (isAbsoluteUrl) return;
8722
+ if (isUrlRef(href)) return;
8318
8723
  const cleanHref = href.replace(/#.*$/, '');
8319
8724
  e.preventDefault();
8320
8725
  openPreviewByPath(cleanHref, isAbsolutePath(href) ? undefined : baseOf(a), openFileInEditor);
@@ -8366,10 +8771,37 @@ function renderPreviewContent(bodyEl, filePath, content, kind) {
8366
8771
  bodyEl.replaceChildren(img);
8367
8772
  } else {
8368
8773
  const { fm, body } = splitFrontmatter(content);
8369
- bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdown(body);
8774
+ bodyEl.innerHTML = (fm ? renderFrontmatterBlock(fm) : '') + renderMarkdownFile(body, filePath);
8370
8775
  }
8371
8776
  }
8372
8777
 
8778
+ // A relative src would resolve against the board's URL, and DOMPurify drops a drive path (`C:/x`
8779
+ // reads as a scheme), so the hook moves a local image to the image route before that check.
8780
+ function renderMarkdownFile(text, filePath) {
8781
+ if (!filePath || typeof DOMPurify === 'undefined') return renderMarkdown(text);
8782
+ DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
8783
+ if (node.nodeName === 'IMG' && data.attrName === 'src') data.attrValue = localImageUrl(data.attrValue, filePath);
8784
+ });
8785
+ try {
8786
+ return renderMarkdown(text);
8787
+ } finally {
8788
+ DOMPurify.removeHook('uponSanitizeAttribute');
8789
+ }
8790
+ }
8791
+
8792
+ function localImageUrl(src, base) {
8793
+ const fileUrl = /^file:/i.test(src);
8794
+ if (src.startsWith('//') || (!fileUrl && !isAbsolutePath(src) && isUrlRef(src))) return src;
8795
+ let p = src;
8796
+ if (!fileUrl) {
8797
+ p = src.replace(/[?#].*$/, '');
8798
+ try {
8799
+ p = decodeURI(p);
8800
+ } catch {}
8801
+ }
8802
+ return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
8803
+ }
8804
+
8373
8805
  function openPreviewModal(filePath, content, kind) {
8374
8806
  currentPreviewPath = filePath;
8375
8807
  const fileName = filePath.split(/[\\/]/).pop();
@@ -8521,6 +8953,7 @@ function handleSessionOpenEvent(data) {
8521
8953
  }
8522
8954
  if (!isSessionActive(target)) {
8523
8955
  stickySessionIds.add(id);
8956
+ openedStickyIds.add(id);
8524
8957
  }
8525
8958
  setSessionDismissed(id, false);
8526
8959
  cliOpenedId = id;
@@ -9184,6 +9617,10 @@ function setupEventSource() {
9184
9617
  fetchSessions().catch(() => {});
9185
9618
  if (currentSessionId) fetchTasks(currentSessionId);
9186
9619
  refetchPaneLayout();
9620
+ if (sgRev === null) syncSessionGroups();
9621
+ else if (sgUnsent) persistSessionGroups();
9622
+ else sgRefresh();
9623
+ syncSessionPins();
9187
9624
  if (terminalAvailable())
9188
9625
  loadTerminals()
9189
9626
  .then(renderSessions)
@@ -9249,6 +9686,8 @@ function setupEventSource() {
9249
9686
 
9250
9687
  if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
9251
9688
 
9689
+ if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
9690
+
9252
9691
  if (data.type === 'dispatch-update') onDispatchUpdate();
9253
9692
 
9254
9693
  if (data.type === 'show:posted') onShowPosted(data);
@@ -9296,6 +9735,10 @@ function setupEventSource() {
9296
9735
  handleSessionPinEvent(data);
9297
9736
  }
9298
9737
 
9738
+ if (data.type === 'group:changed') {
9739
+ onGroupChanged(data);
9740
+ }
9741
+
9299
9742
  if (data.type === 'pane:changed') {
9300
9743
  applyPaneLayout(data.sessionId, data.layout);
9301
9744
  }
@@ -9663,45 +10106,93 @@ function renderWaitingFooter(tool, params) {
9663
10106
  if (currentWaiting?.kind === 'plan') {
9664
10107
  return isWaitingAnswerable() ? planApprovalControlsHtml('plan-reject-feedback') : '';
9665
10108
  }
9666
- if (currentWaiting?.kind !== 'question') return waitingDecisionButtons('waiting-btn');
10109
+ if (currentWaiting?.kind !== 'question') {
10110
+ const buttons = waitingDecisionButtons('waiting-btn');
10111
+ const hint = `<span class="waiting-footer-hint">${sendKbd()} allow · ${termKbd()} terminal</span>`;
10112
+ return buttons ? hint + buttons : '';
10113
+ }
9667
10114
  if (!isWaitingAnswerable()) return '';
9668
10115
  if (tool !== 'AskUserQuestion') return '';
9669
10116
  const questions = Array.isArray(params?.questions) ? params.questions : [];
9670
10117
  if (!questions.length) return '';
9671
10118
  const picked = questions.filter((q) => waitingAnswerFor(q)).length;
9672
10119
  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>`;
10120
+ 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
10121
  }
9675
10122
 
9676
10123
  function renderWaitingActions(tool, params) {
9677
10124
  if (!isWaitingAnswerable() || currentWaiting.kind !== 'question' || tool !== 'AskUserQuestion') return '';
9678
10125
  waitingQuestions = Array.isArray(params?.questions) ? params.questions : [];
9679
10126
  if (!waitingQuestions.length) return '';
10127
+ const multiQ = waitingQuestions.length > 1;
10128
+ waitingTab = Math.max(0, Math.min(waitingTab, multiQ ? waitingQuestions.length : 0));
10129
+ const onReview = multiQ && waitingTab === waitingQuestions.length;
10130
+ const tabs = multiQ
10131
+ ? `<div class="waiting-qtabs" role="tablist">${waitingQuestions
10132
+ .map((q, idx) => {
10133
+ const done = !!waitingAnswerFor(q);
10134
+ 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>`;
10135
+ })
10136
+ .join(
10137
+ '',
10138
+ )}<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>`
10139
+ : '';
10140
+ if (onReview) {
10141
+ const rows = waitingQuestions
10142
+ .map((q) => {
10143
+ const a = waitingAnswerFor(q);
10144
+ const text = Array.isArray(a) ? a.join(', ') : a;
10145
+ 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>`;
10146
+ })
10147
+ .join('');
10148
+ return `<div class="waiting-actions">${tabs}<div class="waiting-review">${rows}</div></div>`;
10149
+ }
9680
10150
  const qs = waitingQuestions
9681
10151
  .map((q, idx) => {
10152
+ if (idx !== waitingTab && multiQ) return '';
9682
10153
  const picks = waitingAnswerDraft[q.question] || [];
9683
10154
  const opts = (q.options || [])
9684
- .map((o) => {
9685
- const label = typeof o === 'string' ? o : o?.label || '';
10155
+ .map((o, oi) => {
10156
+ const label = waitingOptionLabel(o);
9686
10157
  const desc = typeof o === 'object' ? o?.description || '' : '';
9687
10158
  const descHtml = desc ? `<span class="waiting-option-desc">${renderMarkdown(desc)}</span>` : '';
9688
10159
  // The option `preview` field is the TUI's side-by-side visualization
9689
10160
  // pane (mockups, code snippets, diagrams) — rendered as markdown
9690
10161
  const preview = typeof o === 'object' ? o?.preview || '' : '';
9691
10162
  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>`;
10163
+ 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
10164
  })
9694
10165
  .join('');
9695
10166
  const header = q.header ? `<span class="waiting-question-header">${escapeHtml(q.header)}</span>` : '';
9696
10167
  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>`;
10168
+ 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()}">`;
10169
+ 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
10170
  })
9700
10171
  .join('');
9701
- return `<div class="waiting-actions">${qs}</div>`;
10172
+ return `<div class="waiting-actions">${tabs}${qs}</div>`;
10173
+ }
10174
+
10175
+ let waitingTab = 0;
10176
+
10177
+ function waitingOptionLabel(o) {
10178
+ return typeof o === 'string' ? o : o?.label || '';
10179
+ }
10180
+
10181
+ function setWaitingTab(i) {
10182
+ const last = waitingQuestions.length > 1 ? waitingQuestions.length : 0;
10183
+ const next = Math.max(0, Math.min(i, last));
10184
+ if (next === waitingTab) return;
10185
+ waitingTab = next;
10186
+ showWaitingDetail();
10187
+ }
10188
+
10189
+ function pickWaitingOptionByNumber(n) {
10190
+ const qi = waitingQuestions.length > 1 ? waitingTab : 0;
10191
+ const o = waitingQuestions[qi]?.options?.[n];
10192
+ if (!o) return;
10193
+ selectWaitingAnswer(qi, waitingOptionLabel(o));
9702
10194
  }
9703
10195
 
9704
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
9705
10196
  function selectWaitingAnswer(qi, label) {
9706
10197
  const q = waitingQuestions[qi];
9707
10198
  if (!q) return;
@@ -9714,6 +10205,7 @@ function selectWaitingAnswer(qi, label) {
9714
10205
  } else {
9715
10206
  waitingAnswerDraft[q.question] = [label];
9716
10207
  delete waitingCustomDraft[q.question];
10208
+ if (waitingQuestions.length > 1 && waitingTab < waitingQuestions.length) waitingTab += 1;
9717
10209
  }
9718
10210
  showWaitingDetail();
9719
10211
  }
@@ -9739,11 +10231,12 @@ function setWaitingCustomAnswer(qi, el) {
9739
10231
  const picked = waitingQuestions.filter((qq) => waitingAnswerFor(qq)).length;
9740
10232
  btn.disabled = picked === 0;
9741
10233
  btn.textContent =
9742
- picked > 0 && picked < waitingQuestions.length ? `Answer (${picked}/${waitingQuestions.length})` : 'Answer';
10234
+ picked > 0 && picked < waitingQuestions.length
10235
+ ? `Send answers (${picked}/${waitingQuestions.length})`
10236
+ : 'Send answers';
9743
10237
  }
9744
10238
  }
9745
10239
 
9746
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
9747
10240
  function submitWaitingAnswers() {
9748
10241
  // Partial submits are fine — send only the questions actually answered.
9749
10242
  // multiSelect answers go as arrays of labels (the shape the TUI validates).
@@ -9756,6 +10249,38 @@ function submitWaitingAnswers() {
9756
10249
  respondWaiting({ answers });
9757
10250
  }
9758
10251
 
10252
+ function renderPermissionDiff(oldText, newText) {
10253
+ const rows = (text, sign, cls) =>
10254
+ String(text)
10255
+ .split('\n')
10256
+ .map(
10257
+ (l) =>
10258
+ `<div class="perm-diff-row ${escapeHtml(cls)}"><span class="perm-diff-sign">${sign}</span>${escapeHtml(l)}</div>`,
10259
+ )
10260
+ .join('');
10261
+ return `<div class="perm-diff">${rows(oldText, '−', 'del')}${rows(newText, '+', 'add')}</div>`;
10262
+ }
10263
+
10264
+ // The permission card shows what Claude wants to run in full, not a one-line gist:
10265
+ // the command or the diff, where it runs, and how long the ask has been waiting.
10266
+ function renderPermissionSummary(tool, params) {
10267
+ const waited = formatDuration(Math.max(0, Date.now() - new Date(currentWaiting.timestamp).getTime()));
10268
+ 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>`;
10269
+ let body = '';
10270
+ if (typeof params.command === 'string') {
10271
+ body = `<div class="perm-label">Command</div><pre class="perm-code">${escapeHtml(params.command)}</pre>`;
10272
+ if (params.description) body += `<div class="perm-note">${escapeHtml(params.description)}</div>`;
10273
+ } else if (typeof params.old_string === 'string' && typeof params.new_string === 'string') {
10274
+ body = `<div class="perm-label">${escapeHtml(params.file_path || 'File')}</div>${renderPermissionDiff(params.old_string, params.new_string)}`;
10275
+ } else if (typeof params.file_path === 'string') {
10276
+ body = `<div class="perm-label">File</div><pre class="perm-code">${escapeHtml(params.file_path)}</pre>`;
10277
+ } else if (typeof params.description === 'string') {
10278
+ body = `<pre class="perm-code">${escapeHtml(params.description)}</pre>`;
10279
+ }
10280
+ const cwd = currentWaiting.cwd ? `<div class="perm-cwd">in ${escapeHtml(currentWaiting.cwd)}</div>` : '';
10281
+ return `<div class="perm-card">${head}${body}${cwd}</div>`;
10282
+ }
10283
+
9759
10284
  function showWaitingDetail() {
9760
10285
  if (!isWaitingFresh()) return;
9761
10286
  currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
@@ -9770,6 +10295,7 @@ function showWaitingDetail() {
9770
10295
  waitingDraftId = currentWaiting.id || null;
9771
10296
  waitingAnswerDraft = {};
9772
10297
  waitingCustomDraft = {};
10298
+ waitingTab = 0;
9773
10299
  }
9774
10300
  const tool = currentWaiting.toolName || 'unknown';
9775
10301
  const label = getWaitingLabel(currentWaiting.kind, tool);
@@ -9791,17 +10317,9 @@ function showWaitingDetail() {
9791
10317
  // A plan ask IS the plan — render it in full so it can be reviewed and
9792
10318
  // approved right here (follow mode included) instead of a JSON dump. The
9793
10319
  // raw tool input is dropped entirely to match the saved-plan modal.
9794
- inputHtml = `<div class="detail-desc rendered-md">${renderMarkdown(params.plan)}</div>`;
10320
+ inputHtml = `<div class="detail-desc rendered-md waiting-plan">${renderMarkdown(params.plan)}</div>`;
9795
10321
  } 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
- }
10322
+ if (currentWaiting.kind !== 'question' && params) summaryHtml = renderPermissionSummary(tool, params);
9805
10323
  inputHtml = `${summaryHtml}<details class="waiting-raw-input"><summary>Raw tool input</summary>${inputHtml}</details>`;
9806
10324
  }
9807
10325
  }
@@ -10102,7 +10620,6 @@ const MERMAID_VARS = {
10102
10620
  pieOuterStrokeColor: '--border',
10103
10621
  pieTitleTextColor: '--text-primary',
10104
10622
  pieLegendTextColor: '--text-primary',
10105
- fontFamily: '--mono',
10106
10623
  };
10107
10624
  // --bg-elevated sits too close to the dark page for a node, and the base theme derives near-black ER
10108
10625
  // rows from a dark primaryColor.
@@ -10111,15 +10628,39 @@ const MERMAID_MODE_VARS = {
10111
10628
  light: { primaryColor: '--bg-elevated', rowOdd: '--bg-elevated', rowEven: '--bg-elevated' },
10112
10629
  };
10113
10630
  let mermaidThemeKey = '';
10631
+ let colorProbe = null;
10632
+ const probedColors = new Map();
10633
+
10634
+ // Mermaid's color parser knows only plain hex, rgb() and hsl(), and a hub theme may set oklch() or
10635
+ // relative color syntax; its initialize throws on one, and no diagram renders.
10636
+ function mermaidColor(value) {
10637
+ if (!value || value.startsWith('#')) return value;
10638
+ let rgb = probedColors.get(value);
10639
+ if (rgb) return rgb;
10640
+ colorProbe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
10641
+ colorProbe.clearRect(0, 0, 1, 1);
10642
+ colorProbe.fillStyle = 'transparent';
10643
+ colorProbe.fillStyle = value;
10644
+ colorProbe.fillRect(0, 0, 1, 1);
10645
+ const [r, g, b, a] = colorProbe.getImageData(0, 0, 1, 1).data;
10646
+ rgb = a === 255 ? `rgb(${r}, ${g}, ${b})` : `rgba(${r}, ${g}, ${b}, ${+(a / 255).toFixed(3)})`;
10647
+ probedColors.set(value, rgb);
10648
+ return rgb;
10649
+ }
10114
10650
 
10115
10651
  // The base theme derives its other colors from these, and it parses colors itself, so it takes
10116
10652
  // resolved values rather than var() references.
10117
10653
  function mermaidConfig() {
10118
10654
  const css = getComputedStyle(document.body);
10119
10655
  const mode = isLightTheme() ? 'light' : 'dark';
10120
- const themeVariables = { darkMode: mode === 'dark', pieOpacity: '1', pieSectionTextColor: '#fff' };
10656
+ const themeVariables = {
10657
+ darkMode: mode === 'dark',
10658
+ pieOpacity: '1',
10659
+ pieSectionTextColor: '#fff',
10660
+ fontFamily: css.getPropertyValue('--mono').trim(),
10661
+ };
10121
10662
  for (const [k, name] of Object.entries({ ...MERMAID_VARS, ...MERMAID_MODE_VARS[mode] })) {
10122
- themeVariables[k] = css.getPropertyValue(name).trim();
10663
+ themeVariables[k] = mermaidColor(css.getPropertyValue(name).trim());
10123
10664
  }
10124
10665
  SHOW_PALETTE[mode].series.forEach((c, i) => {
10125
10666
  themeVariables[`pie${i + 1}`] = c;
@@ -10145,10 +10686,14 @@ function loadMermaid() {
10145
10686
  script.integrity = MERMAID_SCRIPT.integrity;
10146
10687
  script.crossOrigin = 'anonymous';
10147
10688
  script.onload = () => {
10148
- const config = mermaidConfig();
10149
- mermaidThemeKey = JSON.stringify(config);
10150
- mermaid.initialize(config);
10151
- resolve();
10689
+ try {
10690
+ const config = mermaidConfig();
10691
+ mermaidThemeKey = JSON.stringify(config);
10692
+ mermaid.initialize(config);
10693
+ resolve();
10694
+ } catch (err) {
10695
+ reject(err);
10696
+ }
10152
10697
  };
10153
10698
  script.onerror = () => {
10154
10699
  script.remove();
@@ -10167,24 +10712,45 @@ function queueMermaid(work) {
10167
10712
  mermaidQueue = mermaidQueue.then(work).catch((err) => console.warn(err.message));
10168
10713
  }
10169
10714
 
10715
+ const MERMAID_PENDING = 'pre.mermaid:not([data-processed])';
10716
+
10170
10717
  function initMermaidBlocks(container) {
10171
10718
  const root = container || document;
10172
- if (!root.querySelector('pre.mermaid:not([data-processed])')) return;
10719
+ if (!root.querySelector(MERMAID_PENDING)) return;
10720
+ // Stamped here, after DOMPurify: it drops an attribute whose value holds `-->`, as most diagrams do.
10721
+ const stampPending = () =>
10722
+ [...root.querySelectorAll(MERMAID_PENDING)].map((b) => {
10723
+ if (!b.hasAttribute('data-original')) b.setAttribute('data-original', b.textContent);
10724
+ return b;
10725
+ });
10173
10726
  if (typeof mermaid === 'undefined') {
10174
10727
  loadMermaid().then(
10175
10728
  () => initMermaidBlocks(container),
10176
- (err) => console.warn(err.message),
10729
+ (err) => {
10730
+ console.warn(err.message);
10731
+ for (const b of stampPending()) showMermaidError(b, err);
10732
+ },
10177
10733
  );
10178
10734
  return;
10179
10735
  }
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;
10736
+ queueMermaid(async () => {
10737
+ // One block per run, so a bad diagram shows its own error and the others still render.
10738
+ for (const b of stampPending()) {
10739
+ try {
10740
+ await mermaid.run({ nodes: [b] });
10741
+ } catch (err) {
10742
+ showMermaidError(b, err);
10743
+ }
10744
+ }
10185
10745
  });
10186
10746
  }
10187
10747
 
10748
+ function showMermaidError(block, err) {
10749
+ block.setAttribute('data-processed', 'true');
10750
+ block.setAttribute('data-mermaid-error', '');
10751
+ block.textContent = err?.message || String(err);
10752
+ }
10753
+
10188
10754
  function reinitMermaidTheme() {
10189
10755
  if (typeof mermaid === 'undefined') return;
10190
10756
  queueMermaid(() => {
@@ -10195,6 +10761,7 @@ function reinitMermaidTheme() {
10195
10761
  mermaid.initialize(config);
10196
10762
  document.querySelectorAll('pre.mermaid[data-processed]').forEach((el) => {
10197
10763
  el.removeAttribute('data-processed');
10764
+ el.removeAttribute('data-mermaid-error');
10198
10765
  el.innerHTML = escapeHtml(el.getAttribute('data-original') || '');
10199
10766
  });
10200
10767
  initMermaidBlocks();
@@ -10619,9 +11186,10 @@ document.addEventListener('click', (e) => {
10619
11186
  if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
10620
11187
  const name = sgHeader.dataset.transientGroup;
10621
11188
  confirmModal({
10622
- title: 'Delete Group',
10623
- message: `Delete group “${name}”? Its sessions go back to Projects.`,
10624
- okLabel: 'Delete',
11189
+ title: 'Delete this group?',
11190
+ message: 'Its sessions go back to Projects.',
11191
+ chip: name,
11192
+ okLabel: 'Delete group',
10625
11193
  }).then((ok) => {
10626
11194
  if (!ok) return;
10627
11195
  sgDeleteTransient(name);
@@ -10638,9 +11206,10 @@ document.addEventListener('click', (e) => {
10638
11206
  }
10639
11207
  if (e.target.closest('.sg-delete')) {
10640
11208
  confirmModal({
10641
- title: 'Delete Group',
10642
- message: `Delete group “${group.name}”? Its sessions and projects go back to Projects.`,
10643
- okLabel: 'Delete',
11209
+ title: 'Delete this group?',
11210
+ message: 'Its sessions and projects go back to Projects.',
11211
+ chip: group.name,
11212
+ okLabel: 'Delete group',
10644
11213
  }).then((ok) => {
10645
11214
  if (!ok) return;
10646
11215
  sgDeleteGroup(groupId);
@@ -10914,22 +11483,22 @@ function initSidebarResize() {
10914
11483
  }
10915
11484
 
10916
11485
  function initPanelResize(panelId, handleId, cssVar) {
10917
- const panel = document.getElementById(panelId);
10918
- const handle = document.getElementById(handleId);
10919
- let startWidth;
11486
+ initResize(document.getElementById(panelId), document.getElementById(handleId), cssVar, panelWidthKey(panelId), 200);
11487
+ }
10920
11488
 
11489
+ function initResize(panel, handle, cssVar, key, min) {
11490
+ let startWidth;
10921
11491
  _initDragResize(handle, {
10922
11492
  onStart() {
10923
11493
  startWidth = panel.offsetWidth;
10924
11494
  panel.classList.add('resizing');
10925
11495
  },
10926
11496
  onMove(dx) {
10927
- const w = Math.max(200, startWidth - dx);
10928
- panel.style.setProperty(cssVar, `${w}px`);
11497
+ panel.style.setProperty(cssVar, `${Math.max(min, startWidth - dx)}px`);
10929
11498
  },
10930
11499
  onEnd() {
10931
11500
  panel.classList.remove('resizing');
10932
- store.setItem(panelWidthKey(panelId), panel.style.getPropertyValue(cssVar));
11501
+ store.setItem(key, panel.style.getPropertyValue(cssVar));
10933
11502
  },
10934
11503
  });
10935
11504
  }
@@ -12504,6 +13073,15 @@ function markPaneSide(el, side, focused) {
12504
13073
 
12505
13074
  const paneLabel = (p) => (p.kind === 'message' ? p.title : p.target);
12506
13075
 
13076
+ function paneMenuLabel(pane, label) {
13077
+ const cut =
13078
+ pane.kind !== 'url' && pane.kind !== 'message' ? Math.max(label.lastIndexOf('/'), label.lastIndexOf('\\')) + 1 : 0;
13079
+ if (!cut || cut === label.length) {
13080
+ return `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>`;
13081
+ }
13082
+ 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>`;
13083
+ }
13084
+
12507
13085
  function renderPaneTabs(sid, layout, split) {
12508
13086
  // A rebuild mid-drag removes the dragged tab, and then no dragend fires. Mid-rename it drops the input.
12509
13087
  if (paneDragId || paneRenameId) return;
@@ -12994,16 +13572,22 @@ function paneMenuKeydown(e) {
12994
13572
  async function deleteBoardTasks(onlyCompleted) {
12995
13573
  const sid = paneSessionId();
12996
13574
  if (!sid) return;
12997
- const count = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed').length;
13575
+ const doomed = currentTasks.filter((t) => !onlyCompleted || t.status === 'completed');
13576
+ const count = doomed.length;
12998
13577
  if (!count) {
12999
13578
  showToast(onlyCompleted ? 'No completed tasks' : 'No tasks', 'info');
13000
13579
  return;
13001
13580
  }
13002
13581
  const noun = `${count} ${onlyCompleted ? 'completed ' : ''}task${count === 1 ? '' : 's'}`;
13582
+ const shown = doomed.slice(0, 3).map((t) => ({ label: t.subject, meta: t.status.replace('_', ' ') }));
13583
+ if (doomed.length > shown.length) shown.push({ label: `+ ${doomed.length - shown.length} more`, meta: '' });
13003
13584
  const ok = await confirmModal({
13004
- title: onlyCompleted ? 'Delete Completed Tasks' : 'Delete All Tasks',
13005
- message: `Delete ${noun}? This cannot be undone.`,
13006
- okLabel: 'Delete',
13585
+ title: `Delete ${noun}?`,
13586
+ message: onlyCompleted
13587
+ ? 'Every completed task in this session. This cannot be undone.'
13588
+ : 'Every task in this session. This cannot be undone.',
13589
+ items: shown,
13590
+ okLabel: `Delete ${noun}`,
13007
13591
  });
13008
13592
  if (!ok) return;
13009
13593
  const q = onlyCompleted ? '?status=completed' : '';
@@ -13079,9 +13663,12 @@ function openPaneMenu(x, y, id) {
13079
13663
  y,
13080
13664
  pane.id,
13081
13665
  'Pane actions',
13082
- `<div class="pane-menu-label" title="${escapeHtml(label)}">${escapeHtml(label)}</div>` +
13666
+ paneMenuLabel(pane, label) +
13083
13667
  (message ? '' : paneMenuItem('copy', url ? 'Copy URL' : 'Copy path')) +
13084
13668
  paneMenuItem('open', url ? 'Open in new tab' : message ? 'Open in message dialog' : 'Open in preview') +
13669
+ (message
13670
+ ? ''
13671
+ : paneMenuItem('link', paneIsLinked(pane) ? 'Remove from linked documents' : 'Add to linked documents')) +
13085
13672
  paneMenuItem('rename', 'Rename') +
13086
13673
  paneMenuItem('reload', 'Reload') +
13087
13674
  paneMenuItem('close', 'Close pane'),
@@ -13089,6 +13676,21 @@ function openPaneMenu(x, y, id) {
13089
13676
  return true;
13090
13677
  }
13091
13678
 
13679
+ function paneIsLinked(pane) {
13680
+ const key = canonicalPath(pane.target);
13681
+ return getSessionPreviewPaths(paneSessionId()).some((p) => canonicalPath(p) === key);
13682
+ }
13683
+
13684
+ function togglePaneLink(id) {
13685
+ const pane = paneById(id);
13686
+ const sid = paneSessionId();
13687
+ if (!pane || !sid) return;
13688
+ const unlink = paneIsLinked(pane);
13689
+ setSessionDocLink(sid, pane.target, unlink);
13690
+ if (unlink) forgetServerLinkedDoc(sid, pane.target);
13691
+ showToast(unlink ? 'Unlinked from session' : 'Linked to session', 'success');
13692
+ }
13693
+
13092
13694
  function showPaneMenu(x, y, id, ariaLabel, html) {
13093
13695
  closePaneMenu();
13094
13696
  sgCloseMenu();
@@ -13107,6 +13709,7 @@ function showPaneMenu(x, y, id, ariaLabel, html) {
13107
13709
  const PANE_MENU_ACTIONS = {
13108
13710
  copy: copyPaneTarget,
13109
13711
  open: openPaneExternally,
13712
+ link: togglePaneLink,
13110
13713
  rename: beginPaneRename,
13111
13714
  reload: reloadPane,
13112
13715
  close: closePane,
@@ -13579,7 +14182,6 @@ panePop.addEventListener('keydown', (e) => {
13579
14182
  // leaving the session view or switching sessions drops only the socket; coming back
13580
14183
  // reattaches and the server replays the screen.
13581
14184
  const TERMINAL_TOKEN_KEY = 'terminal-token';
13582
- const TERMINAL_FOCUS_KEY = 'terminal-focus';
13583
14185
  const TERMINAL_TOKEN_RE = /^[0-9a-f]{64}$/;
13584
14186
  const TERMINAL_MODES_KEY = 'terminal-sessions';
13585
14187
  const TERMINAL_FONT_KEY = 'terminal-font-size';
@@ -13720,6 +14322,7 @@ function terminalShortcut(e, probe = false) {
13720
14322
  if (ctrlShift && e.code === 'KeyP') return toggleSessionPicker;
13721
14323
  // Ctrl+Shift+Z is redo in a text field, so only the terminal's own textarea gives it up.
13722
14324
  if (ctrlShift && e.code === 'KeyZ') return inPageField(e) ? null : toggleZenMode;
14325
+ if (ctrlShift && e.code === 'KeyK') return kanbotAvailable() ? () => toggleKanbot() : null;
13723
14326
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
13724
14327
  return () => openNewSession(null, e.code === 'KeyR');
13725
14328
  }
@@ -13867,6 +14470,8 @@ function focusTerminalPane() {
13867
14470
  termFrame.el.focus();
13868
14471
  terminalFrameSend('focus');
13869
14472
  }
14473
+ // After a page load the frame may still be loading; its socket's ready takes the focus then.
14474
+ else termState.focusNext = true;
13870
14475
  }
13871
14476
 
13872
14477
  function leaveTerminalPane() {
@@ -13927,12 +14532,16 @@ function loadTerminalFrame(origin, timeoutMs) {
13927
14532
  }
13928
14533
 
13929
14534
  function sendTerminalTheme() {
13930
- if (!termFrame.inited) return;
13931
14535
  const options = terminalThemeOptions();
13932
14536
  const key = JSON.stringify(options);
13933
- if (key === termFrame.theme) return;
13934
- termFrame.theme = key;
13935
- postToTerminalFrame('theme', { options });
14537
+ for (const [frame, post] of [
14538
+ [termFrame, postToTerminalFrame],
14539
+ [kanbotState, postToKanbotFrame],
14540
+ ]) {
14541
+ if (!frame.inited || key === frame.theme) continue;
14542
+ frame.theme = key;
14543
+ post('theme', { options });
14544
+ }
13936
14545
  }
13937
14546
 
13938
14547
  // Keyed on the colors, and style is watched, because under the hub a theme change lands in two
@@ -13957,6 +14566,18 @@ window.addEventListener('message', (e) => {
13957
14566
  onTerminalFrameMessage(m.type.slice(TERMINAL_MSG.length), m);
13958
14567
  });
13959
14568
 
14569
+ function terminalInitOptions(themeOptions) {
14570
+ return {
14571
+ fontFamily:
14572
+ appConfig.terminal.fontFamily ||
14573
+ getComputedStyle(document.body).getPropertyValue('--font-mono').trim() ||
14574
+ 'monospace',
14575
+ fontSize: currentTerminalFontSize(),
14576
+ scrollback: appConfig.terminal.scrollback,
14577
+ themeOptions,
14578
+ };
14579
+ }
14580
+
13960
14581
  function onTerminalFrameMessage(t, m) {
13961
14582
  const socket = termState.socket;
13962
14583
  const current = socket && m.socketId === socket.id;
@@ -13964,15 +14585,7 @@ function onTerminalFrameMessage(t, m) {
13964
14585
  // A second load is the frame reloaded (its context menu), with no terminal and no socket.
13965
14586
  const themeOptions = terminalThemeOptions();
13966
14587
  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
- });
14588
+ postToTerminalFrame('init', terminalInitOptions(themeOptions));
13976
14589
  } else if (t === 'term') {
13977
14590
  termFrame.inited = true;
13978
14591
  pushTerminalClaims();
@@ -14077,17 +14690,21 @@ function pushTerminalClaims() {
14077
14690
  queueMicrotask(() => {
14078
14691
  terminalClaimsQueued = false;
14079
14692
  const bridged = bridgedFrameEls();
14080
- if (!termFrame.inited && !bridged.length) return;
14693
+ if (!termFrame.inited && !kanbotState.inited && !bridged.length) return;
14081
14694
  const claims = keyClaims();
14082
14695
  sendBridgeClaimsAll(bridged, claims);
14083
- if (!termFrame.inited) return;
14084
14696
  const sig = JSON.stringify(claims);
14085
- if (sig === termFrame.claims) return;
14086
- termFrame.claims = sig;
14087
- terminalFrameSend('claims', claims);
14697
+ sendFrameClaims(kanbotState, claims, sig, postToKanbotFrame);
14698
+ sendFrameClaims(termFrame, claims, sig, terminalFrameSend);
14088
14699
  });
14089
14700
  }
14090
14701
 
14702
+ function sendFrameClaims(frameState, claims, sig, send) {
14703
+ if (!frameState.inited || sig === frameState.claims) return;
14704
+ frameState.claims = sig;
14705
+ send('claims', claims);
14706
+ }
14707
+
14091
14708
  function terminalTheme() {
14092
14709
  const css = getComputedStyle(document.body);
14093
14710
  const v = (name) => css.getPropertyValue(name).trim();
@@ -14217,7 +14834,7 @@ function setTerminalAttached(on) {
14217
14834
 
14218
14835
  // A hidden pane stays attached, but Ctrl+W can only be meant for a terminal on screen.
14219
14836
  function syncCloseGuard() {
14220
- const on = termState.attached && termState.shown;
14837
+ const on = (termState.attached && termState.shown) || kanbotOnScreen();
14221
14838
  if (termState.closeGuard === on) return;
14222
14839
  termState.closeGuard = on;
14223
14840
  hub.closeGuard(on);
@@ -14231,6 +14848,7 @@ window.addEventListener('beforeunload', (e) => {
14231
14848
  // The hub's palette takes focus first and clears activeElement here, so the last focus inside this
14232
14849
  // page is tracked instead; focus leaving the frame keeps it.
14233
14850
  let terminalHadFocus = false;
14851
+ let passTerminalZoneOn = false;
14234
14852
  document.addEventListener('focusin', () => {
14235
14853
  terminalHadFocus = terminalPaneFocused();
14236
14854
  });
@@ -14247,7 +14865,14 @@ document.addEventListener('focusout', (e) => {
14247
14865
  // Chrome fires this before focusin when a click or a focus() call in this page takes focus from the
14248
14866
  // frame, so it waits a task and gives way to whatever took focus.
14249
14867
  let boardPointerDown = false;
14250
- document.addEventListener('pointerdown', () => (boardPointerDown = true), true);
14868
+ document.addEventListener(
14869
+ 'pointerdown',
14870
+ () => {
14871
+ boardPointerDown = true;
14872
+ passTerminalZoneOn = false;
14873
+ },
14874
+ true,
14875
+ );
14251
14876
  for (const type of ['pointerup', 'pointercancel']) {
14252
14877
  document.addEventListener(type, () => (boardPointerDown = false), true);
14253
14878
  }
@@ -14260,14 +14885,24 @@ window.addEventListener('focus', () => {
14260
14885
  }
14261
14886
  });
14262
14887
  });
14888
+ // A board with no session open passes the zone on, so a stop at an empty config dir keeps it. So does a
14889
+ // board whose session has no terminal for an inherited terminal zone, until the user picks a zone here.
14263
14890
  window.addEventListener('pagehide', () => {
14264
- if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(TERMINAL_FOCUS_KEY, currentSessionId);
14265
- else tabStore.removeItem(TERMINAL_FOCUS_KEY);
14891
+ if (termState.attached && termState.shown && terminalHadFocus) tabStore.setItem(FOCUS_ZONE_KEY, 'terminal');
14892
+ else if (passTerminalZoneOn) return;
14893
+ else if (currentSessionId || focusZone === 'sidebar') tabStore.setItem(FOCUS_ZONE_KEY, focusZone);
14266
14894
  });
14267
14895
 
14268
- function restoreTerminalFocus(sessionId) {
14269
- if (tabStore.getItem(TERMINAL_FOCUS_KEY) === sessionId) termState.focusNext = true;
14270
- tabStore.removeItem(TERMINAL_FOCUS_KEY);
14896
+ // While the hub shows another app this frame cannot take focus, so terminalHadFocus lets the
14897
+ // window focus handler finish the restore when the hub focuses this frame.
14898
+ function restoreTerminalFocus(zone, sessionId) {
14899
+ if (zone !== 'terminal') return;
14900
+ if (!wantsTerminalFor(sessionId)) {
14901
+ passTerminalZoneOn = true;
14902
+ return;
14903
+ }
14904
+ termState.focusNext = true;
14905
+ terminalHadFocus = true;
14271
14906
  }
14272
14907
 
14273
14908
  // The terminal stands in for the board, so it takes the board zone however focus arrives
@@ -14459,13 +15094,25 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14459
15094
  // Detaching first means no exit message reaches the pane, so no Resume prompt shows before the board.
14460
15095
  // Terminal mode must go too: left on, opening the session again reconnects in 'auto' and resumes claude.
14461
15096
  async function closeTerminalSession(id = termState.sessionId) {
15097
+ exitTerminalMode(id);
15098
+ await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15099
+ dropPlaceholder(id);
15100
+ }
15101
+
15102
+ function exitTerminalMode(id) {
14462
15103
  if (id === termState.sessionId) {
14463
15104
  if (terminalPaneFocused()) leaveTerminalPane();
14464
15105
  detachTerminal();
14465
15106
  }
14466
15107
  setTerminalMode(id, false);
14467
15108
  syncTerminal();
14468
- await terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15109
+ }
15110
+
15111
+ // An end from another tab or `dispatch end` must clear terminal mode here too, or focusing the
15112
+ // session reconnects in 'auto' and resumes claude. The mode set is shared by same-origin tabs, so the
15113
+ // tab that ended it may have cleared it already while this tab still shows its pane.
15114
+ function onTerminalEnded(id) {
15115
+ if (id === termState.sessionId || terminalModes().has(id)) exitTerminalMode(id);
14469
15116
  dropPlaceholder(id);
14470
15117
  }
14471
15118
 
@@ -14531,7 +15178,7 @@ async function renderTerminalManager() {
14531
15178
  body.innerHTML = list
14532
15179
  .map((t) => {
14533
15180
  const session = sessions.find((s) => s.id === t.id);
14534
- const name = session ? sessionDisplayName(session) : t.id.slice(0, 8);
15181
+ const name = t.kanbot ? 'Kanbot' : session ? sessionDisplayName(session) : t.id.slice(0, 8);
14535
15182
  const here = t.id === termState.sessionId ? '<span class="terminal-manager-here">this tab</span>' : '';
14536
15183
  const attached = t.clients ? `${t.clients} attached` : 'detached';
14537
15184
  return `<div class="terminal-manager-row${t.clients ? ' attached' : ''}">
@@ -14547,8 +15194,8 @@ async function renderTerminalManager() {
14547
15194
  </div>
14548
15195
  </div>
14549
15196
  <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>
15197
+ <button type="button" class="btn btn-secondary" data-open="${escapeHtml(t.id)}"${t.kanbot ? ' data-kanbot' : ''}>Open</button>
15198
+ <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
15199
  </div>
14553
15200
  </div>`;
14554
15201
  })
@@ -14556,7 +15203,8 @@ async function renderTerminalManager() {
14556
15203
  body.querySelectorAll('[data-open]').forEach((b) => {
14557
15204
  b.onclick = () => {
14558
15205
  closeTerminalManager();
14559
- showSessionTerminal(b.dataset.open);
15206
+ if ('kanbot' in b.dataset) toggleKanbot(true);
15207
+ else showSessionTerminal(b.dataset.open);
14560
15208
  };
14561
15209
  });
14562
15210
  body.querySelectorAll('[data-end]').forEach((b) => {
@@ -14574,15 +15222,254 @@ async function terminalFetch(url, method, body) {
14574
15222
  return res.status === 401 && (await refreshTerminalToken()) ? send() : res;
14575
15223
  }
14576
15224
 
15225
+ const TERMINAL_STATE_LABEL = { busy: 'working', wait: 'waiting', idle: 'idle' };
15226
+
14577
15227
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
14578
15228
  async function endAllTerminals() {
14579
- const buttons = document.querySelectorAll('#terminal-manager-body [data-end]');
14580
- await Promise.all([...buttons].map((b) => closeTerminalSession(b.dataset.end)));
15229
+ const items = [...document.querySelectorAll('#terminal-manager-body [data-end]')].map((b) => {
15230
+ const id = b.dataset.end;
15231
+ const session = sessions.find((s) => s.id === id);
15232
+ const state = !session ? 'idle' : isWaitingSession(session) ? 'wait' : isActiveSession(session) ? 'busy' : 'idle';
15233
+ const started = Number(b.dataset.started);
15234
+ const meta = [
15235
+ TERMINAL_STATE_LABEL[state],
15236
+ started && `up ${formatDuration(Date.now() - started)}`,
15237
+ formatTerminalProc(terminalProcStats[id]),
15238
+ ]
15239
+ .filter(Boolean)
15240
+ .join(' · ');
15241
+ return { id, state, label: session ? sessionDisplayName(session) : id.slice(0, 8), meta };
15242
+ });
15243
+ const ids = items.map((i) => i.id);
15244
+ const notIdle = items.filter((i) => i.state !== 'idle').length;
15245
+ if (notIdle) {
15246
+ const ok = await confirmModal({
15247
+ title: `End ${ids.length} terminals?`,
15248
+ message: 'The Claude Code processes stop. Sessions stay on the board and can be resumed.',
15249
+ items,
15250
+ warn: `${notIdle} ${notIdle === 1 ? 'is' : 'are'} not idle. Work in progress is cut off.`,
15251
+ okLabel: `End ${ids.length} terminals`,
15252
+ });
15253
+ if (!ok) return;
15254
+ }
15255
+ await Promise.all(ids.map((id) => closeTerminalSession(id)));
14581
15256
  renderTerminalManager();
14582
15257
  }
14583
15258
 
14584
15259
  //#endregion
14585
15260
 
15261
+ //#region KANBOT
15262
+ // The board's own assistant (docs/kanbot.md): a claude session the server keeps hidden from the list,
15263
+ // shown in a popover through a second terminal frame on the board terminal's frame origin.
15264
+ const KANBOT_SVG =
15265
+ '<svg viewBox="0 0 16 13" width="16" height="13" shape-rendering="crispEdges" aria-hidden="true">' +
15266
+ '<path fill="#d97757" d="M3 0h10v4h3v3h-3v3H3V7H0V4h3z"/>' +
15267
+ '<path fill="#d97757" d="M4 10h1v3H4zM6 10h1v3H6zM9 10h1v3H9zM11 10h1v3h-1z"/>' +
15268
+ '<path fill="#1f1e1d" d="M5 2h1v2H5zM10 2h1v2h-1z"/></svg>';
15269
+ const KANBOT_LAYOUT_KEY = 'cck-kanbot-layout';
15270
+ const KANBOT_WIDTH_KEY = 'cck-kanbot-width';
15271
+ const kanbotState = {
15272
+ el: null,
15273
+ frame: null,
15274
+ origin: null,
15275
+ ptyId: null,
15276
+ inited: false,
15277
+ attached: false,
15278
+ ended: false,
15279
+ socketId: 0,
15280
+ theme: null,
15281
+ claims: null,
15282
+ returnFocus: null,
15283
+ };
15284
+
15285
+ function kanbotFocused() {
15286
+ return !!kanbotState.el?.contains(document.activeElement);
15287
+ }
15288
+
15289
+ function kanbotAvailable() {
15290
+ return !!appConfig.kanbot && terminalAvailable();
15291
+ }
15292
+
15293
+ function kanbotButton() {
15294
+ const b = document.createElement('button');
15295
+ b.type = 'button';
15296
+ b.className = 'footer-kanbot';
15297
+ b.title = 'Kanbot, the board assistant';
15298
+ b.setAttribute('aria-label', b.title);
15299
+ b.innerHTML = KANBOT_SVG;
15300
+ b.onclick = toggleKanbot;
15301
+ return b;
15302
+ }
15303
+
15304
+ function ensureKanbotPopover() {
15305
+ if (kanbotState.el) return kanbotState.el;
15306
+ const el = document.createElement('div');
15307
+ el.className = 'kanbot-popover';
15308
+ 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>`;
15309
+ el.querySelector('.kanbot-dock').onclick = () => setKanbotLayout(el.classList.contains('docked') ? '' : 'docked');
15310
+ el.querySelector('.kanbot-expand').onclick = () =>
15311
+ setKanbotLayout(el.classList.contains('expanded') ? '' : 'expanded');
15312
+ el.querySelector('.kanbot-collapse').onclick = () => toggleKanbot(false);
15313
+ el.querySelector('.kanbot-close').onclick = endKanbot;
15314
+ const width = store.getItem(KANBOT_WIDTH_KEY);
15315
+ if (width) el.style.setProperty('--kanbot-width', width);
15316
+ initResize(el, el.querySelector('.kanbot-resize'), '--kanbot-width', KANBOT_WIDTH_KEY, 320);
15317
+ document.body.appendChild(el);
15318
+ kanbotState.el = el;
15319
+ setKanbotLayout(store.getItem(KANBOT_LAYOUT_KEY) || '');
15320
+ return el;
15321
+ }
15322
+
15323
+ function setKanbotLayout(layout) {
15324
+ const el = kanbotState.el;
15325
+ const docked = layout === 'docked';
15326
+ const expanded = layout === 'expanded';
15327
+ el.classList.toggle('docked', docked);
15328
+ el.classList.toggle('expanded', expanded);
15329
+ const dock = el.querySelector('.kanbot-dock');
15330
+ dock.classList.toggle('active', docked);
15331
+ dock.title = docked ? 'Undock' : 'Dock to the right';
15332
+ dock.setAttribute('aria-label', dock.title);
15333
+ dock.setAttribute('aria-pressed', String(docked));
15334
+ const expand = el.querySelector('.kanbot-expand');
15335
+ expand.innerHTML = showIcon(expanded ? 'restore' : 'expand');
15336
+ expand.title = expanded ? 'Restore' : 'Expand';
15337
+ expand.setAttribute('aria-label', expand.title);
15338
+ store.setItem(KANBOT_LAYOUT_KEY, layout);
15339
+ postToKanbotFrame('focus');
15340
+ }
15341
+
15342
+ function setKanbotStatus(text) {
15343
+ kanbotState.el.querySelector('.kanbot-status').textContent = text;
15344
+ }
15345
+
15346
+ function postToKanbotFrame(t, data) {
15347
+ kanbotState.frame?.contentWindow?.postMessage({ ...data, type: TERMINAL_MSG + t }, kanbotState.origin);
15348
+ }
15349
+
15350
+ function kanbotOnScreen() {
15351
+ return kanbotState.attached && !!kanbotState.el?.classList.contains('visible');
15352
+ }
15353
+
15354
+ function endKanbot() {
15355
+ const id = kanbotState.ptyId;
15356
+ toggleKanbot(false);
15357
+ kanbotState.ptyId = null;
15358
+ if (id) terminalFetch(apiPath`/api/terminals/${id}`, 'DELETE').catch(() => {});
15359
+ }
15360
+
15361
+ function setKanbotAttached(on) {
15362
+ kanbotState.attached = on;
15363
+ syncCloseGuard();
15364
+ }
15365
+
15366
+ function toggleKanbot(force) {
15367
+ const el = ensureKanbotPopover();
15368
+ const show = typeof force === 'boolean' ? force : !el.classList.contains('visible');
15369
+ const wasShown = el.classList.contains('visible');
15370
+ el.classList.toggle('visible', show);
15371
+ if (!show) {
15372
+ const hadFocus = kanbotFocused();
15373
+ postToKanbotFrame('detach');
15374
+ setKanbotAttached(false);
15375
+ const back = kanbotState.returnFocus;
15376
+ kanbotState.returnFocus = null;
15377
+ if (hadFocus) {
15378
+ document.activeElement.blur();
15379
+ if (back === 'terminal') focusTerminalPane();
15380
+ else if (back?.isConnected) back.focus();
15381
+ }
15382
+ return;
15383
+ }
15384
+ if (!wasShown && !kanbotFocused()) {
15385
+ kanbotState.returnFocus = terminalPaneFocused() ? 'terminal' : document.activeElement;
15386
+ }
15387
+ kanbotState.frame?.focus();
15388
+ if (!kanbotState.frame) {
15389
+ const frame = document.createElement('iframe');
15390
+ frame.className = 'kanbot-frame';
15391
+ frame.title = 'Kanbot';
15392
+ frame.allow = 'clipboard-read; clipboard-write';
15393
+ kanbotState.origin = termFrame.sameOrigin ? location.origin : terminalFrameOrigin();
15394
+ frame.src = `${kanbotState.origin}/terminal.html#p=${encodeURIComponent(location.origin)}`;
15395
+ kanbotState.frame = frame;
15396
+ el.querySelector('.kanbot-body').appendChild(frame);
15397
+ return;
15398
+ }
15399
+ if (kanbotState.inited) {
15400
+ postToKanbotFrame('refresh');
15401
+ postToKanbotFrame('focus');
15402
+ if (!kanbotState.attached) openKanbot();
15403
+ }
15404
+ }
15405
+
15406
+ async function openKanbot() {
15407
+ setKanbotStatus('starting…');
15408
+ let res;
15409
+ try {
15410
+ res = await terminalFetch('/api/kanbot/start', 'POST', {});
15411
+ } catch {
15412
+ setKanbotStatus('board unreachable, retrying…');
15413
+ setTimeout(() => {
15414
+ if (kanbotState.el.classList.contains('visible') && !kanbotState.attached) openKanbot();
15415
+ }, 3000);
15416
+ return;
15417
+ }
15418
+ const data = await res.json().catch(() => ({}));
15419
+ if (!res.ok) return setKanbotStatus(data.error || `error ${res.status}`);
15420
+ kanbotState.ended = false;
15421
+ kanbotState.ptyId = data.ptyId;
15422
+ setKanbotStatus(data.sessionId ? data.sessionId.slice(0, 8) : '');
15423
+ postToKanbotFrame('open', {
15424
+ socketId: ++kanbotState.socketId,
15425
+ reset: true,
15426
+ hello: { token: terminalToken, id: data.ptyId, mode: 'auto' },
15427
+ });
15428
+ postToKanbotFrame('focus');
15429
+ }
15430
+
15431
+ window.addEventListener('message', (e) => {
15432
+ if (!kanbotState.frame || e.source !== kanbotState.frame.contentWindow || e.origin !== kanbotState.origin) return;
15433
+ const m = e.data;
15434
+ if (typeof m?.type !== 'string' || !m.type.startsWith(TERMINAL_MSG)) return;
15435
+ const t = m.type.slice(TERMINAL_MSG.length);
15436
+ if (t === 'loaded') {
15437
+ const themeOptions = terminalThemeOptions();
15438
+ kanbotState.inited = false;
15439
+ kanbotState.claims = null;
15440
+ kanbotState.theme = JSON.stringify(themeOptions);
15441
+ postToKanbotFrame('init', terminalInitOptions(themeOptions));
15442
+ return;
15443
+ }
15444
+ if (t === 'term') {
15445
+ kanbotState.inited = true;
15446
+ const claims = keyClaims();
15447
+ sendFrameClaims(kanbotState, claims, JSON.stringify(claims), postToKanbotFrame);
15448
+ // The first show created the frame after its focus call, so the frame takes focus once it is up.
15449
+ if (kanbotState.el.classList.contains('visible')) kanbotState.frame.focus();
15450
+ openKanbot();
15451
+ return;
15452
+ }
15453
+ if (t === 'key') {
15454
+ replayKey(m, document.getElementById('terminal-key-proxy'));
15455
+ return;
15456
+ }
15457
+ if (m.socketId !== kanbotState.socketId) return;
15458
+ if (t === 'ws') {
15459
+ if (m.msg?.t === 'ready') setKanbotAttached(true);
15460
+ else if (m.msg?.t === 'exit') {
15461
+ setKanbotAttached(false);
15462
+ kanbotState.ended = true;
15463
+ setKanbotStatus('ended, reopen to resume');
15464
+ } else if (m.msg?.t === 'error') setKanbotStatus(m.msg.msg || 'error');
15465
+ } else if (t === 'close') {
15466
+ setKanbotAttached(false);
15467
+ if (!kanbotState.ended && kanbotState.el.classList.contains('visible')) setTimeout(openKanbot, 1000);
15468
+ }
15469
+ });
15470
+
15471
+ //#endregion
15472
+
14586
15473
  //#region SHOW
14587
15474
  // The card the session in the embedded terminal posts to with the show tool. It belongs to the
14588
15475
  // terminal, not the session: /clear and the --resume picker change the session id, the terminal id
@@ -14593,6 +15480,7 @@ const SHOW_VIEW_KEY = 'cck-show-view-v1';
14593
15480
  const SHOW_MIN_W = 280;
14594
15481
  const SHOW_MIN_H = 120;
14595
15482
  const SHOW_INSET = 8;
15483
+ const SHOW_SANDBOX = 'allow-scripts allow-forms';
14596
15484
  const SHOW_CSP =
14597
15485
  "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
15486
  // The card's token contract (the show skill lists these names) mapped to the board's theme vars.
@@ -14641,6 +15529,7 @@ const SHOW_ICONS = {
14641
15529
  uncollapse: '<path d="M6 9l6 6 6-6"/>',
14642
15530
  expand: '<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"/>',
14643
15531
  restore: '<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"/>',
15532
+ 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
15533
  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
15534
  close: '<path d="M18 6L6 18M6 6l12 12"/>',
14646
15535
  pane: '<use href="#icon-pane"/>',
@@ -14698,7 +15587,7 @@ function syncShowFocus() {
14698
15587
  // An HTML card's own keys and scroll keys reach it only inside its frame.
14699
15588
  function focusShowBody() {
14700
15589
  const frame = showState.frame;
14701
- if (frame?.isConnected) frame.focus();
15590
+ if (frame?.isConnected && frame.loaded) frame.focus();
14702
15591
  else showCardEl()?.querySelector('.show-body')?.focus();
14703
15592
  }
14704
15593
 
@@ -14721,8 +15610,12 @@ function showCardKey(e) {
14721
15610
  }
14722
15611
  const p = showState.posts[showState.idx];
14723
15612
  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) => {
15613
+ confirmModal({
15614
+ title: 'Remove this post?',
15615
+ message: 'It is taken off the card.',
15616
+ chip: p.title,
15617
+ okLabel: 'Remove post',
15618
+ }).then((ok) => {
14726
15619
  if (!ok || showState.posts[showState.idx]?.id !== p.id) return;
14727
15620
  showCommand('remove');
14728
15621
  focusAfterShowRemove();
@@ -14995,11 +15888,15 @@ function setShowReview(opts) {
14995
15888
  // No allow-popups: it opens window.open as a way out. The CSP blocks fetch and remote sub-resources,
14996
15889
  // but not navigation or WebRTC; the real guard is that only the terminal's own agent can post.
14997
15890
  function createShowFrame(title, srcdoc) {
14998
- const frame = createPreviewFrame('show-frame', srcdoc, 'allow-scripts');
15891
+ const frame = createPreviewFrame('show-frame', srcdoc, SHOW_SANDBOX);
14999
15892
  frame.title = title;
15000
- // Focus given before a document loads stays on the frame element, out of the document's keys.
15893
+ // Focus given before the first document loads stays on the frame element, out of the document's
15894
+ // keys, and the document never reports focus or blur, so the body holds focus until then.
15001
15895
  frame.addEventListener('load', () => {
15002
- if (frame === document.activeElement) frame.contentWindow?.focus();
15896
+ const waited = !frame.loaded && document.activeElement === frame.parentElement;
15897
+ frame.loaded = true;
15898
+ if (waited) frame.focus();
15899
+ else if (frame === document.activeElement) frame.contentWindow?.focus();
15003
15900
  });
15004
15901
  showState.frame = frame;
15005
15902
  showState.frameFocused = false;
@@ -15382,11 +16279,11 @@ function forgetPlaceholder(id) {
15382
16279
  return true;
15383
16280
  }
15384
16281
 
15385
- function renderPlaceholderCard(session) {
16282
+ function renderPlaceholderCard(session, depth) {
15386
16283
  const isActive = session.id === currentSessionId && viewMode === 'session';
15387
16284
  const projectHtml = renderProjectIdentity(session);
15388
16285
  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}`)}">
16286
+ <button onclick="fetchTasks('${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
16287
  <div class="session-name">${escapeHtml(session.name)}</div>
15391
16288
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
15392
16289
  <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
@@ -15884,7 +16781,7 @@ document.addEventListener('DOMContentLoaded', () => {
15884
16781
 
15885
16782
  loadSidebarState();
15886
16783
  for (const p of readStoredList(COLLAPSED_GROUPS_KEY)) collapsedProjectGroups.add(p);
15887
- loadSessionGroups();
16784
+ syncSessionGroups();
15888
16785
  initSessionGroupsDnd();
15889
16786
  initSessionPicker();
15890
16787
  initProjectPicker();
@@ -15975,13 +16872,15 @@ function makeLimitSpan(rl) {
15975
16872
  function renderSidebarFooter(rateLimits) {
15976
16873
  const el = document.getElementById('sidebar-footer');
15977
16874
  if (!el) return;
16875
+ footerState.rl = rateLimits;
15978
16876
  const fh = rateLimits?.five_hour?.used_percentage ?? null;
15979
16877
  const sd = rateLimits?.seven_day?.used_percentage ?? null;
15980
16878
  const children = [];
15981
16879
  if (footerState.version) {
15982
16880
  const v = document.createElement('span');
15983
16881
  v.className = 'footer-version';
15984
- v.textContent = `v${footerState.version}`;
16882
+ if (kanbotAvailable()) v.append(kanbotButton());
16883
+ v.append(`v${footerState.version}`);
15985
16884
  const warn = pluginWarning(footerState.plugin);
15986
16885
  if (warn) v.append(warn);
15987
16886
  children.push(v);
@@ -16061,8 +16960,7 @@ searchQuery = urlState.search || '';
16061
16960
 
16062
16961
  renderFilterState();
16063
16962
  renderZenState();
16064
- pinnedSessionIds = loadPinnedSessions();
16065
- stickySessionIds = loadStickySessions();
16963
+ syncSessionPins();
16066
16964
  setupEventSource();
16067
16965
 
16068
16966
  if (urlState.search) {
@@ -16078,6 +16976,7 @@ if (urlState.search) {
16078
16976
  getJson('/api/config')
16079
16977
  .then((c) => {
16080
16978
  if (c) appConfig = c;
16979
+ if (appConfig.kanbot && footerState.version) renderSidebarFooter(footerState.rl);
16081
16980
  })
16082
16981
  .then(restorePendingSessions)
16083
16982
  .then(() =>
@@ -16087,6 +16986,7 @@ getJson('/api/config')
16087
16986
  ),
16088
16987
  )
16089
16988
  .then(async () => {
16989
+ const focusZoneBeforeReload = tabStore.getItem(FOCUS_ZONE_KEY);
16090
16990
  if (urlState.projectView) {
16091
16991
  try {
16092
16992
  await fetchProjectView(atob(urlState.projectView));
@@ -16094,7 +16994,7 @@ getJson('/api/config')
16094
16994
  showNoSession();
16095
16995
  }
16096
16996
  } else if (urlState.session) {
16097
- restoreTerminalFocus(urlState.session);
16997
+ restoreTerminalFocus(focusZoneBeforeReload, urlState.session);
16098
16998
  await fetchTasks(urlState.session);
16099
16999
  } else {
16100
17000
  const last = loadLastView();
@@ -16105,12 +17005,13 @@ getJson('/api/config')
16105
17005
  showNoSession();
16106
17006
  }
16107
17007
  } else if (last?.view === 'session' && last.session && sessions.some((s) => s.id === last.session)) {
16108
- restoreTerminalFocus(last.session);
17008
+ restoreTerminalFocus(focusZoneBeforeReload, last.session);
16109
17009
  await fetchTasks(last.session);
16110
17010
  } else {
16111
17011
  showNoSession();
16112
17012
  }
16113
17013
  }
17014
+ if (focusZoneBeforeReload === 'sidebar') setFocusZone('sidebar');
16114
17015
  if (urlState.messages && currentSessionId) {
16115
17016
  if (logAutoOpened) setMessagePanelVisible(false);
16116
17017
  toggleMessagePanel();