claude-code-kanban 7.7.0 → 7.9.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
@@ -94,6 +94,8 @@ let sessionsHasMore = false;
94
94
  // URL, so a query string would not survive a hub reload.
95
95
  const ZEN_KEY = 'zenMode';
96
96
  let zenMode = store.getItem(ZEN_KEY) === 'true';
97
+ // Group zen: the sidebar shows one group's subtree. A view of the moment, so it is not persisted.
98
+ let zenGroup = null;
97
99
  let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
98
100
  let recentProjects = new Set();
99
101
  let projectsCacheDirty = true;
@@ -109,8 +111,6 @@ const SESSION_ORDER_KEY = 'session-order';
109
111
  let stableGroupOrder = readStoredList(PROJECT_ORDER_KEY);
110
112
  let sessionOrder = readStoredList(SESSION_ORDER_KEY);
111
113
  let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
112
- // Sessions the user pulled out of their dispatch placement; the server's group map is not theirs to edit.
113
- let sgReleased = new Set();
114
114
  let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
115
115
  let searchQuery = ''; // Search query for fuzzy search
116
116
  let allTasksCache = []; // Cache all tasks for search
@@ -146,6 +146,8 @@ let taskHighlightDimmed = false;
146
146
  let focusZone = 'board'; // 'board' | 'sidebar'
147
147
  let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null, scratchAvailable: false };
148
148
  let runningTerminals = new Set();
149
+ // Embedded sessions stuck on Claude Code's folder-trust dialog. No hook runs before it, so the server reads it off the screen.
150
+ let trustWaiting = new Set();
149
151
  let selectedSessionIdx = -1;
150
152
  let selectedSessionKbId = null;
151
153
  let sessionJustSelected = false;
@@ -188,6 +190,7 @@ function setSessionDismissed(id, dismissed) {
188
190
  else dismissedSessionIds.delete(id);
189
191
  persistDismissedSessions();
190
192
  updateDismissBtnState();
193
+ if (dismissed) sgSweepTransient();
191
194
  renderSessions();
192
195
  renderActivityChip();
193
196
  return true;
@@ -258,6 +261,7 @@ function resetState() {
258
261
  sessionLimit = SESSION_PAGE;
259
262
  filterProject = FILTER_DEFAULTS.project;
260
263
  zenMode = false;
264
+ zenGroup = null;
261
265
  store.removeItem(ZEN_KEY);
262
266
  renderZenState();
263
267
  searchQuery = '';
@@ -329,15 +333,26 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
329
333
  // info modal both look it up in `sessions` and bail when it is absent, so a session
330
334
  // opened from outside the current filter would leave the view on the previous one.
331
335
  // 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);
333
- const recent = filterProject === '__recent__';
336
+ // Group zen ignores the filters, and its session members come back through `include`.
337
+ const zenIds = zenGroup ? sgSubtreeIds(zenGroup) : new Set();
338
+ const zenMembers = sessionGroups
339
+ .filter((g) => zenIds.has(g.id))
340
+ .flatMap((g) => g.members.filter((m) => m.type === 'session').map((m) => m.ref));
341
+ const include = [
342
+ focusId,
343
+ revealedPlanSessionId,
344
+ revealedStorageSessionId,
345
+ ...openedStickyIds,
346
+ ...zenMembers,
347
+ ].filter(Boolean);
348
+ const recent = !zenGroup && filterProject === '__recent__';
334
349
  const res = await api('/api/sessions', {
335
350
  query: {
336
351
  limit: sessionLimit,
337
352
  include: include.length ? include.join(',') : null,
338
353
  recentHours: recent ? RECENT_PROJECT_HOURS : null,
339
- project: !recent && filterProject ? filterProject : null,
340
- filter: sessionFilter === 'active' ? 'active' : null,
354
+ project: !zenGroup && !recent && filterProject ? filterProject : null,
355
+ filter: !zenGroup && sessionFilter === 'active' ? 'active' : null,
341
356
  },
342
357
  });
343
358
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -348,6 +363,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
348
363
  lastSessionsHash = sessionsHash;
349
364
  sessions = mergePlaceholders(newSessions);
350
365
  markOpenSessionRead();
366
+ sgSweepTransient();
351
367
  renderSessions();
352
368
  renderActivityChip();
353
369
  schedulePublishSession();
@@ -544,14 +560,14 @@ function toggleSection(containerId, chevronId) {
544
560
  }
545
561
 
546
562
  function isWaitingSession(s) {
547
- return !!s.hasWaitingForUser;
563
+ return !!s.hasWaitingForUser || trustWaiting.has(s.id);
548
564
  }
549
565
  function sessionBarClass(s) {
550
566
  if (isWaitingSession(s)) return 'permission-pending';
551
567
  return s.unread ? 'unread' : '';
552
568
  }
553
569
  function isActiveSession(s) {
554
- return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
570
+ return !isWaitingSession(s) && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
555
571
  }
556
572
  // hasRecentLog carries the server's registry-idle correction: an open-but-idle
557
573
  // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
@@ -765,8 +781,7 @@ async function fetchTasks(sessionId) {
765
781
  if (revealedStorageSessionId && sessionId !== revealedStorageSessionId) {
766
782
  revealedStorageSessionId = null;
767
783
  }
768
- if (currentSessionId && currentSessionId !== sessionId && deferredPinPlacement.delete(currentSessionId))
769
- expandPinnedFor(currentSessionId);
784
+ if (currentSessionId && currentSessionId !== sessionId) deferredPinPlacement.delete(currentSessionId);
770
785
  if (lastSessionId !== sessionId) setSwapPair(sessionId, lastSessionId);
771
786
  const switched = sessionId !== currentSessionId;
772
787
  currentSessionId = sessionId;
@@ -2178,7 +2193,6 @@ function applyServerPins(pins) {
2178
2193
  before.size === pinnedSessionIds.size && [...before].every(([id, s]) => getSessionPinState(id) === s);
2179
2194
  if (unchanged && !first) return;
2180
2195
  const added = first ? [] : [...pinnedSessionIds].filter((id) => !before.has(id));
2181
- for (const id of added) expandPinnedFor(id);
2182
2196
  showPinnedSessions(added);
2183
2197
  }
2184
2198
 
@@ -2224,8 +2238,6 @@ function unpinSession(sessionId) {
2224
2238
  function toggleSessionPin(sessionId) {
2225
2239
  if (pinnedSessionIds.has(sessionId)) return unpinSession(sessionId);
2226
2240
  pinnedSessionIds.add(sessionId);
2227
- if (sessionId === currentSessionId) deferredPinPlacement.add(sessionId);
2228
- expandPinnedFor(sessionId);
2229
2241
  sendPinChange(sessionId);
2230
2242
  renderSessions();
2231
2243
  }
@@ -2251,7 +2263,6 @@ function handleSessionPinEvent({ id, state }) {
2251
2263
  pinnedSessionIds.add(id);
2252
2264
  stickySessionIds.add(id);
2253
2265
  }
2254
- expandPinnedFor(id);
2255
2266
  showPinnedSessions(isAnyPinned(id) ? [id] : []);
2256
2267
  }
2257
2268
 
@@ -3739,12 +3750,14 @@ function getFilteredSessions() {
3739
3750
  if (filterProject) {
3740
3751
  // The open session keeps its row even when it belongs to another project, so that
3741
3752
  // revealing one by id has something to scroll to. Same exemption the active branch
3742
- // above already makes, for the same reason.
3753
+ // above already makes, for the same reason. recentProjects lags behind the session list, so a
3754
+ // session in a new folder counts as recent on its own.
3755
+ const recentCutoff = Date.now() - RECENT_PROJECT_HOURS * 60 * 60 * 1000;
3743
3756
  filteredSessions = filteredSessions.filter(
3744
3757
  (s) =>
3745
3758
  matchesProjectFilter(s.project, s.worktree?.repo) ||
3746
3759
  s.id === currentSessionId ||
3747
- (filterProject === '__recent__' && isAnyPinned(s.id)),
3760
+ (filterProject === '__recent__' && (isAnyPinned(s.id) || Date.parse(s.modifiedAt) >= recentCutoff)),
3748
3761
  );
3749
3762
  }
3750
3763
 
@@ -3847,13 +3860,22 @@ function renderSessions() {
3847
3860
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3848
3861
  scheduleJustFinishedExpiry();
3849
3862
  refreshProjectList();
3863
+ syncPinPeek();
3850
3864
 
3865
+ if (zenGroup && !sgGroupById(zenGroup)) {
3866
+ zenGroup = null;
3867
+ renderZenState();
3868
+ }
3851
3869
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
3852
3870
  // and still offers everything, or there would be no way to switch sessions without leaving zen.
3871
+ // Group zen renders only its subtree's buckets, so it takes every session unfiltered.
3853
3872
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3854
- const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3873
+ let filteredSessions;
3874
+ if (zenGroup) filteredSessions = applyStableSessionOrder(sessions);
3875
+ else if (zenMode) filteredSessions = zenSession ? [zenSession] : [];
3876
+ else filteredSessions = applyStableSessionOrder(getFilteredSessions());
3855
3877
 
3856
- const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3878
+ const outside = sidebarSearch.rows(zenMode || zenGroup ? '' : searchQuery, filteredSessions);
3857
3879
 
3858
3880
  const childDepth = new Map();
3859
3881
  const parentOf = new Map();
@@ -3909,12 +3931,12 @@ function renderSessions() {
3909
3931
  const padEmoji = store.getItem(_padEmojiKey(session.id));
3910
3932
  const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
3911
3933
  const tempClass =
3912
- session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3934
+ session.hasRecentLog || session.hasRecentActivity || session.inProgress || isWaitingSession(session)
3913
3935
  ? 'warm'
3914
3936
  : 'stale';
3915
3937
  // Open tasks keep an idle session warm, but the green dot is for a session that is working.
3916
3938
  const idleClass =
3917
- tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
3939
+ tempClass === 'warm' && !isWaitingSession(session) && !session.hasRecentLog && !session.hasRunningAgents
3918
3940
  ? 'idle'
3919
3941
  : '';
3920
3942
  const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
@@ -3946,8 +3968,8 @@ function renderSessions() {
3946
3968
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3947
3969
  ${session.dispatched ? `<span class="dispatched-badge"${session.dispatched.parent ? ` onclick="event.stopPropagation(); revealSession('${escAttrJs(session.dispatched.parent)}')"` : ''} title="${escapeHtml(dispatchedTitle(session.dispatched))}">${ICON_DISPATCHED}</span>` : ''}
3948
3970
  ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3949
- ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3950
- ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3971
+ ${isWaitingSession(session) ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3972
+ ${session.hasRunningAgents && !isWaitingSession(session) ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3951
3973
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
3952
3974
  </span>
3953
3975
  </div>
@@ -3979,7 +4001,7 @@ function renderSessions() {
3979
4001
  )
3980
4002
  : '';
3981
4003
 
3982
- if (filteredSessions.length === 0) {
4004
+ if (filteredSessions.length === 0 && !zenGroup) {
3983
4005
  let emptyMsg = 'No sessions found';
3984
4006
  let emptyHint = 'Tasks appear when you use Claude Code';
3985
4007
 
@@ -4036,8 +4058,8 @@ function renderSessions() {
4036
4058
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
4037
4059
  const renderGroupSessions = (list, pinKey) => {
4038
4060
  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('');
4061
+ const gPinned = nestChildren(list.filter(isInPinnedGroup));
4062
+ if (gPinned.length === 0) return nestChildren(list).map(renderSessionCard).join('');
4041
4063
  const gUnpinned = nestChildren(list.filter((s) => !isInPinnedGroup(s)));
4042
4064
  const pinCollapsed = collapsedProjectGroups.has(pinKey);
4043
4065
  return (
@@ -4050,14 +4072,14 @@ function renderSessions() {
4050
4072
  groupChevronSvg(10) +
4051
4073
  '<span class="pinned-sub-label">Pinned</span>' +
4052
4074
  '<span class="group-count">' +
4053
- gIdlePinned.length +
4075
+ gPinned.length +
4054
4076
  '</span>' +
4055
4077
  '<span class="pinned-ungroup-btn" title="Ungroup pinned sessions">&times;</span>' +
4056
4078
  '</div>' +
4057
4079
  '<div class="pinned-sub-items' +
4058
4080
  (pinCollapsed ? ' collapsed' : '') +
4059
4081
  '">' +
4060
- gIdlePinned.map(renderSessionCard).join('') +
4082
+ gPinned.map(renderSessionCard).join('') +
4061
4083
  '</div>' +
4062
4084
  '</div>' +
4063
4085
  gUnpinned.map(renderSessionCard).join('')
@@ -4110,10 +4132,9 @@ function renderSessions() {
4110
4132
  sgRest.push(session);
4111
4133
  }
4112
4134
  }
4113
- for (const [name, g] of transientGroups) if (!sgBuckets.has(g.id)) transientGroups.delete(name);
4114
4135
  // A group emptied by a filter hides its header; a group the user just created (no filters)
4115
4136
  // shows its drop-here state instead of vanishing.
4116
- const sgFiltering = !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
4137
+ const sgFiltering = !zenGroup && sgListFiltered();
4117
4138
 
4118
4139
  // A group whose parent is gone renders at the top.
4119
4140
  const sgIds = new Set(sessionGroups.map((g) => g.id));
@@ -4135,17 +4156,16 @@ function renderSessions() {
4135
4156
 
4136
4157
  // flat = the "All sessions" view, which has no project sub-blocks.
4137
4158
  const sgSectionHtml = (flat) => {
4138
- const transient = [...transientGroups.values()];
4139
- if (sessionGroups.length === 0 && transient.length === 0) return '';
4159
+ if (sessionGroups.length === 0) return '';
4140
4160
  let out = '';
4141
- for (const group of [...(sgChildren.get(null) || []), ...transient]) out += sgGroupHtml(group, flat);
4161
+ for (const group of sgChildren.get(null) || []) out += sgGroupHtml(group, flat);
4142
4162
  if (!out) return '';
4143
4163
  return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
4144
4164
  };
4145
4165
 
4146
4166
  const sgGroupHtml = (group, flat) => {
4147
4167
  const bucket = sgBuckets.get(group.id) || [];
4148
- const subtree = group.transient ? bucket : sgSubtreeOf(group);
4168
+ const subtree = sgSubtreeOf(group);
4149
4169
  if (subtree.length === 0 && sgFiltering) return '';
4150
4170
  if (!groupPinned) bucket.sort(pinSort);
4151
4171
  const collapsed = collapsedProjectGroups.has(sgKey(group.id));
@@ -4159,17 +4179,6 @@ function renderSessions() {
4159
4179
  body = nestChildren([...bucket].sort(byMemberOrder))
4160
4180
  .map(renderSessionCard)
4161
4181
  .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
4182
  } else {
4174
4183
  const loose = [];
4175
4184
  const byProject = new Map();
@@ -4199,10 +4208,9 @@ function renderSessions() {
4199
4208
  }
4200
4209
  }
4201
4210
  let childHtml = '';
4202
- if (!group.transient) for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
4211
+ for (const child of sgChildren.get(group.id) || []) childHtml += sgGroupHtml(child, flat);
4203
4212
  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>`;
4213
+ return `${sgHeaderHtml(group, countHtml(subtree))}<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}${childHtml}</div>`;
4206
4214
  };
4207
4215
 
4208
4216
  let html = '';
@@ -4213,8 +4221,10 @@ function renderSessions() {
4213
4221
  </div>`;
4214
4222
  }
4215
4223
 
4216
- // Group active sessions by project
4217
- if (sessionFilter === 'active') {
4224
+ if (zenGroup) {
4225
+ sessionsList.innerHTML = sgGroupHtml(sgGroupById(zenGroup), sessionFilter !== 'active');
4226
+ } else if (sessionFilter === 'active') {
4227
+ // Group active sessions by project
4218
4228
  // Auto-expand a collapsed section when newly-active work lands in it (live refresh).
4219
4229
  expandActiveGroups({ onlyNew: true });
4220
4230
  html += sgSectionHtml(false);
@@ -4253,7 +4263,7 @@ function renderSessions() {
4253
4263
  // The section header is rendered even when nothing is left under it: it is the drop target
4254
4264
  // for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
4255
4265
  // the whole project list, re-parsed on every live refresh.
4256
- const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
4266
+ const sectioned = sessionGroups.length > 0;
4257
4267
  let projectsHtml = '';
4258
4268
  if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
4259
4269
  for (const [projectPath, projectSessions] of sortedGroups) {
@@ -4282,7 +4292,7 @@ function renderSessions() {
4282
4292
 
4283
4293
  sessionsList.innerHTML = html + outsideHtml;
4284
4294
  } else {
4285
- const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
4295
+ const sectioned = sessionGroups.length > 0;
4286
4296
  const restHtml =
4287
4297
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
4288
4298
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
@@ -4405,6 +4415,30 @@ function renderOwnerBadge(task) {
4405
4415
  return `<button type="button" class="task-owner-badge task-owner-link" style="${style}" title="Open dispatched session" onclick="event.stopPropagation(); revealSession('${escAttrJs(target)}')">${ICON_DISPATCHED}${escapeHtml(task.owner)}</button>`;
4406
4416
  }
4407
4417
 
4418
+ // Card chips that a tool writing the task file adds: metadata.chips = [{label, title?, tone?}], tone one of
4419
+ // muted, warn, accent. The writer is outside cck, so the list is capped and every field is checked.
4420
+ function taskChips(task) {
4421
+ const chips = task.metadata?.chips;
4422
+ if (!Array.isArray(chips)) return [];
4423
+ return chips
4424
+ .filter((c) => typeof c?.label === 'string' && c.label.trim())
4425
+ .slice(0, 4)
4426
+ .map((c) => ({
4427
+ label: c.label.trim().slice(0, 24),
4428
+ title: typeof c.title === 'string' ? c.title.slice(0, 200) : '',
4429
+ tone: ['muted', 'warn', 'accent'].includes(c.tone) ? c.tone : 'muted',
4430
+ }));
4431
+ }
4432
+
4433
+ function renderTaskChips(chips) {
4434
+ return chips
4435
+ .map(
4436
+ (c) =>
4437
+ `<span class="task-chip task-chip-${escapeHtml(c.tone)}"${c.title ? ` title="${escapeHtml(c.title)}"` : ''}>${escapeHtml(c.label)}</span>`,
4438
+ )
4439
+ .join('');
4440
+ }
4441
+
4408
4442
  function renderTaskCard(task) {
4409
4443
  const blockers = openBlockers(task);
4410
4444
  const isBlocked = blockers.length > 0;
@@ -4412,6 +4446,9 @@ function renderTaskCard(task) {
4412
4446
  const taskId = useSlug ? `${(task._taskDir || task.sessionId || '')?.slice(0, 4)}-${task.id}` : task.id;
4413
4447
  const statusClass = task.status.replace('_', '-');
4414
4448
  const actualSessionId = task._taskDir || task.sessionId || currentSessionId || '';
4449
+ const isDim = task.metadata?.dim === true;
4450
+ const chips = taskChips(task);
4451
+ const state = [task.status.replace('_', ' '), ...chips.map((c) => c.label)].join(', ');
4415
4452
 
4416
4453
  return `
4417
4454
  <div
@@ -4423,10 +4460,11 @@ function renderTaskCard(task) {
4423
4460
  draggable="true"
4424
4461
  ondragstart="onCardDragStart(event)"
4425
4462
  ondragend="onCardDragEnd(event)"
4426
- class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''}"
4427
- aria-label="${escapeHtml(task.subject)} — ${task.status.replace('_', ' ')}">
4463
+ class="task-card ${statusClass} ${isBlocked ? 'blocked' : ''} ${isDim ? 'dim' : ''}"
4464
+ aria-label="${escapeHtml(task.subject)} — ${escapeHtml(state)}">
4428
4465
  <div class="task-id">
4429
4466
  <span>#${taskId}</span>
4467
+ ${renderTaskChips(chips)}
4430
4468
  ${isBlocked ? `<span class="task-blocked" title="Blocked">waiting on ${blockers.map((id) => `#${escapeHtml(id)}`).join(' ')}</span>` : ''}
4431
4469
  ${task.owner ? renderOwnerBadge(task) : ''}
4432
4470
  </div>
@@ -4712,7 +4750,8 @@ async function onColumnDrop(e) {
4712
4750
  // User-named groups that hold whole projects and individual sessions, dragged in from the
4713
4751
  // sidebar. They render above the project groups. Membership is a workspace preference kept on
4714
4752
  // the server; an id missing from `sessions` is NOT proof the session is gone (sessionLimit
4715
- // windows the list), so membership is never garbage-collected on load.
4753
+ // windows the list), so membership is never garbage-collected on load. Transient groups go only
4754
+ // on a full Active list (sgSweepTransient).
4716
4755
  const SESSION_GROUPS_KEY = 'sessionGroups';
4717
4756
  const SG_ACTION_SELECTOR =
4718
4757
  '.session-pin-btn, .team-info-btn, .plan-indicator, .scratchpad-badge, .bookmarks-badge, .linked-docs-badge, .project-view-btn, .project-new-btn, .header-pad-btn, .group-path-toggle, .pinned-ungroup-btn, .sg-action';
@@ -4747,10 +4786,10 @@ let sgSeenRev = 0;
4747
4786
  async function syncSessionGroups() {
4748
4787
  const migrated = !!store.getItem(GROUPS_MIGRATED_AT);
4749
4788
  const local = migrated ? null : store.readJson(SESSION_GROUPS_KEY);
4750
- const hasLocal = local?.groups?.length > 0 || local?.released?.length > 0;
4789
+ const hasLocal = local?.groups?.length > 0;
4751
4790
  try {
4752
4791
  const res = hasLocal
4753
- ? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups, released: local.released } })
4792
+ ? await api('/api/groups/import', { method: 'POST', body: { groups: local.groups } })
4754
4793
  : await api('/api/groups');
4755
4794
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
4756
4795
  sgTakeServer(await res.json());
@@ -4791,6 +4830,7 @@ function sgApplyState(saved) {
4791
4830
  id: g.id,
4792
4831
  name: typeof g.name === 'string' && g.name.trim() ? g.name : 'Group',
4793
4832
  parent: typeof g.parent === 'string' ? g.parent : null,
4833
+ ...(g.transient === true ? { transient: true } : {}),
4794
4834
  members: (Array.isArray(g.members) ? g.members : [])
4795
4835
  .filter((m) => m && (m.type === 'project' || m.type === 'session') && typeof m.ref === 'string')
4796
4836
  .map((m) => {
@@ -4801,8 +4841,6 @@ function sgApplyState(saved) {
4801
4841
  return out;
4802
4842
  }),
4803
4843
  }));
4804
- const released = saved?.released;
4805
- sgReleased = new Set(Array.isArray(released) ? released.filter((id) => typeof id === 'string') : []);
4806
4844
  }
4807
4845
 
4808
4846
  function persistSessionGroups() {
@@ -4826,7 +4864,7 @@ async function sgFlush() {
4826
4864
  try {
4827
4865
  res = await api('/api/groups', {
4828
4866
  method: 'PUT',
4829
- body: { rev: sgRev, groups: sessionGroups, released: [...sgReleased] },
4867
+ body: { rev: sgRev, groups: sessionGroups },
4830
4868
  });
4831
4869
  } catch (e) {
4832
4870
  console.warn('[groups] save failed:', e.message);
@@ -4856,8 +4894,7 @@ async function sgFlush() {
4856
4894
  // The server drops what breaks its rules (a member in two groups, a group past the limit) and
4857
4895
  // trims long names; the board shows what was saved.
4858
4896
  function sgTakeCleaned(data) {
4859
- const saved = JSON.stringify({ groups: data.groups, released: data.released });
4860
- if (saved === JSON.stringify({ groups: sessionGroups, released: [...sgReleased] })) return;
4897
+ if (JSON.stringify(data.groups) === JSON.stringify(sessionGroups)) return;
4861
4898
  sgApplyState(data);
4862
4899
  renderSessions();
4863
4900
  }
@@ -4869,6 +4906,12 @@ function sgGroupById(id) {
4869
4906
  // Matches MAX_DEPTH in lib/user-groups.js.
4870
4907
  const SG_MAX_DEPTH = 8;
4871
4908
 
4909
+ function sgSubtreeIds(rootId) {
4910
+ const ids = new Set([rootId]);
4911
+ for (const id of ids) for (const g of sessionGroups) if (g.parent === id) ids.add(g.id);
4912
+ return ids;
4913
+ }
4914
+
4872
4915
  function sgPathOf(group) {
4873
4916
  const names = [];
4874
4917
  for (let g = group; g && names.length < SG_MAX_DEPTH; g = sgGroupById(g.parent)) names.unshift(g.name);
@@ -4887,26 +4930,30 @@ function sgSessionParent(id) {
4887
4930
 
4888
4931
  // An individually placed session wins over the placement of its project.
4889
4932
  function sgGroupForSession(session) {
4890
- return sgUserGroupFor(session) || sgDispatchGroupFor(session);
4891
- }
4892
-
4893
- function sgUserGroupFor(session) {
4894
4933
  const project = sessionProjectKey(session);
4895
4934
  return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
4896
4935
  }
4897
4936
 
4898
- // Transient groups come from the server (`dispatch start --group`) and go when their sessions
4899
- // end. They are never written to localStorage; the objects are cached only so collapse state,
4900
- // keyed by id, survives a re-render.
4901
- const transientGroups = new Map();
4902
-
4903
- function sgTransientGroup(name) {
4904
- let group = transientGroups.get(name);
4905
- if (!group) {
4906
- group = { id: `t_${name}`, name, transient: true, members: [] };
4907
- transientGroups.set(name, group);
4908
- }
4909
- return group;
4937
+ function sgListFiltered() {
4938
+ return !!searchQuery || activityFilter.size > 0 || (!!filterProject && filterProject !== '__recent__');
4939
+ }
4940
+
4941
+ // A transient group (`dispatch start --group` made it) goes once the Active view shows none of its
4942
+ // sessions: they ended or were dismissed. A session still starting has no card yet, so its
4943
+ // terminal or placeholder counts. Only the full Active list can tell, so other views keep them.
4944
+ function sgSweepTransient() {
4945
+ if (sgRev === null || !sessionGroups.some((g) => g.transient)) return;
4946
+ if (zenGroup || zenMode || sessionFilter !== 'active' || sessionsHasMore || sgListFiltered()) return;
4947
+ const shown = new Set(getFilteredSessions().map((s) => s.id));
4948
+ const starting = (id) => !dismissedSessionIds.has(id) && (runningTerminals.has(id) || dispatchSpecs.has(id));
4949
+ const present = (m) => m.type !== 'session' || shown.has(m.ref) || starting(m.ref);
4950
+ const gone = groupTree.cascade(sessionGroups, (g) => !!g.transient && !g.members.some(present));
4951
+ if (!gone.size) return;
4952
+ let collapsed = false;
4953
+ for (const g of gone) collapsed = collapsedProjectGroups.delete(sgKey(g.id)) || collapsed;
4954
+ if (collapsed) persistCollapsedGroups();
4955
+ sessionGroups = sessionGroups.filter((g) => !gone.has(g));
4956
+ persistSessionGroups();
4910
4957
  }
4911
4958
 
4912
4959
  const childDepthClass = (depth) => (depth ? ` sg-child sg-depth-${depth}` : '');
@@ -4917,26 +4964,9 @@ function dispatchedTitle({ parent }) {
4917
4964
  return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
4918
4965
  }
4919
4966
 
4920
- // A user group with the same name takes the session in, so Keep does not split a group in two.
4921
- function sgDispatchGroupFor(session) {
4922
- if (sgReleased.has(session.id)) return null;
4923
- const name = session.dispatchGroup;
4924
- if (!name) return null;
4925
- return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4926
- }
4927
-
4928
- function sgKeepTransient(name) {
4929
- const group = sgTransientGroup(name);
4930
- const members = sessions.filter((s) => sgGroupForSession(s) === group);
4931
- const kept = sgCreateGroup(name);
4932
- for (const s of members) kept.members.push({ type: 'session', ref: s.id });
4933
- persistSessionGroups();
4934
- }
4935
-
4936
- function sgDeleteTransient(name) {
4937
- const group = sgTransientGroup(name);
4938
- for (const s of sessions) if (sgGroupForSession(s) === group) sgReleased.add(s.id);
4939
- persistSessionGroups();
4967
+ // A group the user edits is theirs: Keep, a rename or a placement clears the flag.
4968
+ function sgKeep(group) {
4969
+ delete group.transient;
4940
4970
  }
4941
4971
 
4942
4972
  function sgDetach(type, ref) {
@@ -4962,6 +4992,7 @@ function sgHostOf(group, session) {
4962
4992
  function sgAssign(groupId, type, ref, opts = {}) {
4963
4993
  const group = sgGroupById(groupId);
4964
4994
  if (!group) return;
4995
+ sgKeep(group);
4965
4996
  const parent = opts.parent !== undefined ? opts.parent : sgMemberOf(group, type, ref)?.parent;
4966
4997
  sgDetach(type, ref);
4967
4998
  // Pulling a whole project in supersedes the individual placements of its sessions.
@@ -5006,6 +5037,20 @@ function sgDeleteGroup(id) {
5006
5037
  persistSessionGroups();
5007
5038
  }
5008
5039
 
5040
+ async function sgConfirmDelete(group) {
5041
+ const ok = await confirmModal({
5042
+ title: 'Delete this group?',
5043
+ message: 'Its sessions and projects go back to Projects.',
5044
+ chip: group.name,
5045
+ okLabel: 'Delete group',
5046
+ });
5047
+ if (ok) {
5048
+ sgDeleteGroup(group.id);
5049
+ renderSessions();
5050
+ }
5051
+ return ok;
5052
+ }
5053
+
5009
5054
  // Move one member of a group in front of another — the user's own order inside the group.
5010
5055
  function sgReorderMember(groupId, drag, target) {
5011
5056
  const group = sgGroupById(groupId);
@@ -5059,13 +5104,10 @@ function sgHeaderHtml(group, countHtml) {
5059
5104
  const nameHtml = editing
5060
5105
  ? `<input class="sg-name-input" type="text" value="${escapeHtml(group.name)}" aria-label="Group name" spellcheck="false">`
5061
5106
  : `<span class="group-name">${escapeHtml(group.name)}</span>`;
5062
- const attrs = group.transient
5063
- ? `class="session-group-header sg-transient${collapsed ? ' collapsed' : ''}" data-transient-group="${escapeHtml(group.name)}" title="${escapeHtml(group.name)} — goes when its sessions end"`
5064
- : `class="session-group-header${collapsed ? ' collapsed' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — drop sessions or projects here"`;
5065
- const actions = group.transient
5066
- ? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
5067
- <span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">&times;</span>`
5068
- : `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
5107
+ const hint = group.transient ? 'goes when its sessions leave the Active view' : 'drop sessions or projects here';
5108
+ const attrs = `class="session-group-header${group.transient ? ' sg-transient' : ''}${collapsed ? ' collapsed' : ''}${group.id === zenGroup ? ' sg-zen' : ''}" draggable="${editing ? 'false' : 'true'}" data-group-id="${escapeHtml(group.id)}" title="${escapeHtml(group.name)} — ${hint}"`;
5109
+ const actions = `${group.transient ? '<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>' : ''}
5110
+ <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
5069
5111
  <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
5070
5112
  ${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
5071
5113
  return `
@@ -5100,6 +5142,7 @@ function sgCommitRename(input, save) {
5100
5142
  sgEditingId = null;
5101
5143
  if (save && group && value && value !== group.name) {
5102
5144
  group.name = value;
5145
+ sgKeep(group);
5103
5146
  persistSessionGroups();
5104
5147
  }
5105
5148
  renderSessions();
@@ -5234,8 +5277,7 @@ function sgGroupDropZone(target) {
5234
5277
  : null;
5235
5278
  }
5236
5279
  const dragSession = sgDrag.kind === 'session' ? sessions.find((x) => x.id === sgDrag.ref) : null;
5237
- const releasable = !!dragSession && !sgUserGroupFor(dragSession) && !!sgDispatchGroupFor(dragSession);
5238
- if (zone.classList.contains('sg-ungroup-zone')) return dragGroup || releasable ? { zone } : null;
5280
+ if (zone.classList.contains('sg-ungroup-zone')) return dragGroup ? { zone } : null;
5239
5281
  const fromGroup = dragSession ? sgGroupForSession(dragSession) : dragGroup;
5240
5282
  const fromGroupId = fromGroup?.id || null;
5241
5283
  // A drop that lands on the host the session already has changes nothing, and highlighting it
@@ -5255,8 +5297,7 @@ function sgGroupDropZone(target) {
5255
5297
  const targetSession = sessions.find((s) => s.id === targetSessionId);
5256
5298
  const targetGroup = targetSession ? sgGroupForSession(targetSession) : null;
5257
5299
  const targetGroupId = targetGroup?.id || null;
5258
- // A transient group holds no members to reorder or place under; a drop there pairs instead.
5259
- if (targetGroup && !targetGroup.transient) {
5300
+ if (targetGroup) {
5260
5301
  // Stacking onto a card nests the dragged session under it, in that card's placement, project
5261
5302
  // block included.
5262
5303
  if (sgSessionParent(sgDrag.ref) === targetSessionId && fromGroupId === targetGroupId) return null;
@@ -5363,7 +5404,6 @@ function sgOnDrop(e) {
5363
5404
  }
5364
5405
  } else {
5365
5406
  sgDetach(drag.kind, drag.ref);
5366
- if (drag.kind === 'session') sgReleased.add(drag.ref);
5367
5407
  persistSessionGroups();
5368
5408
  }
5369
5409
  sgFinishDrag();
@@ -5776,6 +5816,10 @@ function setGroupCollapsed(header, collapsed) {
5776
5816
  if (!header) return;
5777
5817
  const projectPath = header.dataset.groupPath;
5778
5818
  if (collapsed === collapsedProjectGroups.has(projectPath)) return;
5819
+ if (collapsed && projectPath === pinPeekKey) {
5820
+ pinPeekKey = null;
5821
+ pinPeekClosedFor = currentSessionId;
5822
+ }
5779
5823
  if (collapsed) collapsedProjectGroups.add(projectPath);
5780
5824
  else collapsedProjectGroups.delete(projectPath);
5781
5825
  header.classList.toggle('collapsed', collapsed);
@@ -5785,7 +5829,7 @@ function setGroupCollapsed(header, collapsed) {
5785
5829
  }
5786
5830
 
5787
5831
  function persistCollapsedGroups() {
5788
- store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups]);
5832
+ store.writeJson(COLLAPSED_GROUPS_KEY, [...collapsedProjectGroups, ...(pinPeekKey ? [pinPeekKey] : [])]);
5789
5833
  }
5790
5834
 
5791
5835
  let prevActiveSessionIds = null; // null until primed by the first onlyNew pass
@@ -5815,16 +5859,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5815
5859
  }
5816
5860
 
5817
5861
  // Drops every collapse key that could hide this session: its project, a named group wrapping
5818
- // the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
5819
- // it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
5862
+ // the project or the session itself and the project block it renders under there, and the section header that holds it
5863
+ // (a collapsed Pinned sub-section opens only for the open session, see syncPinPeek):
5820
5864
  // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5821
5865
  // whether anything changed.
5822
5866
  function uncollapseFor(session) {
5823
5867
  const group = sgGroupForSession(session);
5824
5868
  const keys = [
5825
- sessionProjectKey(session) || '__ungrouped__',
5826
- ...pinnedCollapseKeys(session),
5827
- ...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
5869
+ ...(group
5870
+ ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS]
5871
+ : [sessionProjectKey(session) || '__ungrouped__', SECTION_PROJECTS, SECTION_SESSIONS]),
5828
5872
  ];
5829
5873
  let changed = false;
5830
5874
  for (const key of keys) {
@@ -5836,23 +5880,25 @@ function uncollapseFor(session) {
5836
5880
  function pinnedCollapseKeys(session) {
5837
5881
  if (!isInPinnedGroup(session)) return [];
5838
5882
  const group = sgGroupForSession(session);
5883
+ if (!group) return [pinKey(sessionProjectKey(session) || '__ungrouped__')];
5839
5884
  const host = sgHostOf(group, session);
5840
- return [
5841
- pinKey(sessionProjectKey(session) || '__ungrouped__'),
5842
- host && pinKey(host),
5843
- group && `__pinned_group_${group.id}__`,
5844
- ];
5885
+ return [host ? pinKey(host) : `__pinned_group_${group.id}__`];
5845
5886
  }
5846
5887
 
5847
- // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
5848
- function expandPinnedFor(sessionId) {
5849
- const session = sessions.find((s) => s.id === sessionId);
5850
- if (!session) return;
5851
- let changed = false;
5852
- for (const key of pinnedCollapseKeys(session)) {
5853
- if (key && collapsedProjectGroups.delete(key)) changed = true;
5854
- }
5855
- if (changed) persistCollapsedGroups();
5888
+ // A Pinned sub-section the user collapsed opens while the open session is in it and closes once
5889
+ // the user leaves that session. The peek is never saved: persistCollapsedGroups writes the key back.
5890
+ let pinPeekKey = null;
5891
+ let pinPeekClosedFor = null;
5892
+
5893
+ function syncPinPeek() {
5894
+ if (pinPeekClosedFor !== currentSessionId) pinPeekClosedFor = null;
5895
+ const session = currentSessionId && !pinPeekClosedFor ? sessions.find((s) => s.id === currentSessionId) : null;
5896
+ const key = session ? (pinnedCollapseKeys(session)[0] ?? null) : null;
5897
+ const want = key && (key === pinPeekKey || collapsedProjectGroups.has(key)) ? key : null;
5898
+ if (want === pinPeekKey) return;
5899
+ if (pinPeekKey) collapsedProjectGroups.add(pinPeekKey);
5900
+ if (want) collapsedProjectGroups.delete(want);
5901
+ pinPeekKey = want;
5856
5902
  }
5857
5903
 
5858
5904
  function isGroupHeader(el) {
@@ -6181,6 +6227,23 @@ async function saveTaskField(taskId, sessionId, field, value) {
6181
6227
  }
6182
6228
  }
6183
6229
 
6230
+ const DETAIL_LAYOUT_KEY = 'cck-detail-layout';
6231
+
6232
+ function setDetailExpanded(expanded) {
6233
+ detailPanel.classList.toggle('expanded', expanded);
6234
+ const btn = document.getElementById('expand-detail');
6235
+ btn.innerHTML = showIcon(expanded ? 'restore' : 'expand');
6236
+ btn.title = expanded ? 'Restore (E)' : 'Expand (E)';
6237
+ btn.setAttribute('aria-label', expanded ? 'Restore' : 'Expand');
6238
+ store.setItem(DETAIL_LAYOUT_KEY, expanded ? 'expanded' : '');
6239
+ }
6240
+
6241
+ function initDetailLayout() {
6242
+ document.getElementById('expand-detail').onclick = () =>
6243
+ setDetailExpanded(!detailPanel.classList.contains('expanded'));
6244
+ setDetailExpanded(store.getItem(DETAIL_LAYOUT_KEY) === 'expanded');
6245
+ }
6246
+
6184
6247
  function closeDetailPanel() {
6185
6248
  detailPanel.classList.remove('visible');
6186
6249
  document.getElementById('delete-task-btn').style.display = 'none';
@@ -6255,6 +6318,9 @@ async function deleteTask(taskId, sessionId) {
6255
6318
  okLabel: 'Delete task',
6256
6319
  });
6257
6320
  if (!ok) return;
6321
+ const info = taskId === selectedTaskId ? getSelectedCardInfo() : null;
6322
+ const colCards = info ? Array.from(COLUMNS[info.colIndex].el.querySelectorAll('.task-card')) : [];
6323
+ const next = colCards[info?.cardIndex + 1] || colCards[info?.cardIndex - 1];
6258
6324
 
6259
6325
  try {
6260
6326
  const res = await api(apiPath`/api/tasks/${sessionId}/${taskId}`, { method: 'DELETE' });
@@ -6262,6 +6328,14 @@ async function deleteTask(taskId, sessionId) {
6262
6328
  if (res.ok) {
6263
6329
  closeDetailPanel();
6264
6330
  await refreshCurrentView();
6331
+ if (info && focusZone === 'board') {
6332
+ if (next) {
6333
+ selectTask(next.dataset.taskId, next.dataset.sessionId);
6334
+ } else {
6335
+ selectedTaskId = null;
6336
+ navigateVertical(1);
6337
+ }
6338
+ }
6265
6339
  } else {
6266
6340
  const error = await res.json().catch(() => ({}));
6267
6341
  showToast(`Failed to delete task: ${error.error || 'Unknown error'}`, 'error');
@@ -6298,6 +6372,7 @@ const SHORTCUT_TABS = [
6298
6372
  rows: [
6299
6373
  { keys: ['Enter'], label: 'Toggle task detail panel' },
6300
6374
  { keys: ['D'], label: 'Delete selected task' },
6375
+ { keys: ['E'], label: 'Expand / restore task detail panel' },
6301
6376
  { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
6302
6377
  { keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
6303
6378
  { keys: ['R'], label: 'Refresh data' },
@@ -6321,7 +6396,12 @@ const SHORTCUT_TABS = [
6321
6396
  { keys: ['\\'], label: 'Toggle sidebar' },
6322
6397
  { keys: ['T'], label: 'Toggle theme' },
6323
6398
  { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
6324
- { keys: ['Ctrl', 'Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
6399
+ { keys: ['←', '→'], label: 'Storage manager: switch tab' },
6400
+ {
6401
+ keys: ['Ctrl', 'Shift', 'Z'],
6402
+ combo: true,
6403
+ label: 'Zen mode (current session, or the group under the cursor)',
6404
+ },
6325
6405
  { keys: ['Ctrl', 'Shift', 'K'], combo: true, label: 'Show / hide Kanbot' },
6326
6406
  { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
6327
6407
  { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
@@ -6937,12 +7017,17 @@ function _renderOrphanGroup(count, innerHtml) {
6937
7017
  return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
6938
7018
  }
6939
7019
 
7020
+ function _markStorageTab(tab) {
7021
+ document.querySelectorAll('.storage-tab').forEach((t) => {
7022
+ t.classList.toggle('active', t.dataset.tab === tab);
7023
+ t.setAttribute('aria-selected', String(t.dataset.tab === tab));
7024
+ });
7025
+ }
7026
+
6940
7027
  async function showStorageManager() {
6941
7028
  _storageKnownSessions = null;
6942
7029
  _updateStorageTotal();
6943
- document.querySelectorAll('.storage-tab').forEach((t) => {
6944
- t.classList.toggle('active', t.dataset.tab === 'sessions');
6945
- });
7030
+ _markStorageTab('sessions');
6946
7031
  const modal = document.getElementById('storage-modal');
6947
7032
  modal.classList.add('visible');
6948
7033
  const known = await _fetchKnownSessions().catch(() => null);
@@ -6957,9 +7042,14 @@ function closeStorageManager() {
6957
7042
 
6958
7043
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6959
7044
  function switchStorageTab(tab) {
6960
- document.querySelectorAll('.storage-tab').forEach((t) => {
6961
- t.classList.toggle('active', t.dataset.tab === tab);
6962
- });
7045
+ _markStorageTab(tab);
7046
+ _renderStorageTab();
7047
+ }
7048
+
7049
+ function stepStorageTab(step) {
7050
+ const tabs = [...document.querySelectorAll('.storage-tab')];
7051
+ const i = tabs.findIndex((t) => t.classList.contains('active'));
7052
+ _markStorageTab(tabs[(i + step + tabs.length) % tabs.length].dataset.tab);
6963
7053
  _renderStorageTab();
6964
7054
  }
6965
7055
 
@@ -6968,9 +7058,14 @@ function _renderStorageTab() {
6968
7058
  if (!modal?.classList.contains('visible')) return;
6969
7059
  const body = document.getElementById('storage-modal-body');
6970
7060
  const tab = document.querySelector('.storage-tab.active')?.dataset.tab || 'sessions';
7061
+ const groupsClean = document.getElementById('storage-groups-clean-btn');
7062
+ groupsClean.style.display = tab === 'groups' ? '' : 'none';
7063
+ groupsClean.disabled = true;
6971
7064
  if (tab === 'sessions') body.innerHTML = _renderStorageSessions();
7065
+ else if (tab === 'dismissed') body.innerHTML = _renderStorageDismissed();
6972
7066
  else if (tab === 'scratchpads') body.innerHTML = _renderStorageScratchpads();
6973
7067
  else if (tab === 'linked-docs') body.innerHTML = _renderStorageLinkedDocs();
7068
+ else if (tab === 'groups') _renderStorageGroups(body);
6974
7069
  }
6975
7070
 
6976
7071
  function _renderStorageSessions() {
@@ -7046,6 +7141,35 @@ function _renderStorageSessions() {
7046
7141
  return html;
7047
7142
  }
7048
7143
 
7144
+ function _renderStorageDismissed() {
7145
+ const ids = [...dismissedSessionIds.keys()];
7146
+ if (!ids.length) return '<div class="storage-empty">No dismissed sessions</div>';
7147
+ const { groups, orphans } = _groupByProject(ids);
7148
+ const renderItem = ({ id, session }) => `<div class="storage-item">
7149
+ <span class="storage-item-id">${_sessionLabel(session, id)}</span>
7150
+ <span class="storage-item-meta">dismissed ${formatDate(dismissedSessionIds.get(id))}</span>
7151
+ <div class="storage-item-actions">
7152
+ <button onclick="_storageViewSession('${escAttrJs(id)}')">View</button>
7153
+ <button onclick="_storageRestoreSession('${escAttrJs(id)}')">Restore</button>
7154
+ </div>
7155
+ </div>`;
7156
+ let html = '';
7157
+ for (const [project, items] of groups) {
7158
+ html += _renderProjectGroup(
7159
+ escapeHtml(_projectLabel(project)),
7160
+ `${items.length} session${items.length > 1 ? 's' : ''}`,
7161
+ items.map(renderItem).join(''),
7162
+ );
7163
+ }
7164
+ if (orphans.length) html += _renderOrphanGroup(orphans.length, orphans.map(renderItem).join(''));
7165
+ return html;
7166
+ }
7167
+
7168
+ function _storageRestoreSession(id) {
7169
+ setSessionDismissed(id, false);
7170
+ _renderStorageTab();
7171
+ }
7172
+
7049
7173
  async function _storageViewSession(id) {
7050
7174
  closeStorageManager();
7051
7175
  revealedStorageSessionId = id;
@@ -7276,6 +7400,76 @@ function _storageClearLinkedDocs(sessionId) {
7276
7400
  afterLinkedDocsChanged(sessionId);
7277
7401
  }
7278
7402
 
7403
+ async function _storageGroupsClean(group, dryRun) {
7404
+ const shown = await getJson('/api/sessions', null, { query: { filter: 'active', limit: 'all', lite: '1' } });
7405
+ if (!Array.isArray(shown)) throw new Error('could not list sessions');
7406
+ const res = await api('/api/groups/clean', {
7407
+ method: 'POST',
7408
+ body: { group, dryRun, keep: shown.map((s) => s.id), hide: [...dismissedSessionIds.keys()] },
7409
+ });
7410
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`);
7411
+ return res.json();
7412
+ }
7413
+
7414
+ async function _renderStorageGroups(body) {
7415
+ body.innerHTML = '<div class="storage-empty">Loading groups…</div>';
7416
+ let out;
7417
+ try {
7418
+ out = await _storageGroupsClean(null, true);
7419
+ } catch (e) {
7420
+ out = { error: e.message };
7421
+ }
7422
+ _showStorageGroups(body, out);
7423
+ }
7424
+
7425
+ function _showStorageGroups(body, out) {
7426
+ if (document.querySelector('.storage-tab.active')?.dataset.tab !== 'groups') return;
7427
+ document.getElementById('storage-groups-clean-btn').disabled = !out.groups?.some((g) => g.hidden);
7428
+ if (out.error) {
7429
+ body.innerHTML = `<div class="storage-empty">Could not load groups: ${escapeHtml(out.error)}</div>`;
7430
+ return;
7431
+ }
7432
+ if (!out.groups.length) {
7433
+ body.innerHTML = '<div class="storage-empty">No groups</div>';
7434
+ return;
7435
+ }
7436
+ const rows = out.groups
7437
+ .map((g) => {
7438
+ const badge = g.hidden ? ` <span class="storage-item-badge">${g.hidden} hidden</span>` : '';
7439
+ return `<div class="storage-group-header storage-group-row">
7440
+ <span>${escapeHtml(g.path)}${badge}</span>
7441
+ <div class="storage-item-actions">
7442
+ <span class="storage-item-meta">${g.members} member${g.members === 1 ? '' : 's'}</span>
7443
+ <button class="danger" title="Take out the hidden sessions, here and in the groups under it" onclick="_storageCleanGroup('${escAttrJs(g.id)}')">Clean</button>
7444
+ <button class="danger" onclick="_storageDeleteGroup('${escAttrJs(g.id)}')">Delete</button>
7445
+ </div>
7446
+ </div>`;
7447
+ })
7448
+ .join('');
7449
+ const hidden = out.groups.reduce((n, g) => n + g.hidden, 0);
7450
+ body.innerHTML = _renderProjectGroup('Groups', `${hidden} hidden`, rows);
7451
+ }
7452
+
7453
+ async function _storageCleanGroup(group) {
7454
+ let out;
7455
+ try {
7456
+ out = await _storageGroupsClean(group, false);
7457
+ } catch (e) {
7458
+ showToast(`Clean failed: ${e.message}`, 'error');
7459
+ return;
7460
+ }
7461
+ const n = out.dropped;
7462
+ showToast(n ? `Took ${n} hidden session${n === 1 ? '' : 's'} out of the groups` : 'No hidden sessions');
7463
+ _showStorageGroups(document.getElementById('storage-modal-body'), out);
7464
+ }
7465
+
7466
+ async function _storageDeleteGroup(id) {
7467
+ const group = sgGroupById(id);
7468
+ if (!group || !(await sgConfirmDelete(group))) return;
7469
+ await sgSaving;
7470
+ _renderStorageTab();
7471
+ }
7472
+
7279
7473
  function _findOrphanedKeys(known) {
7280
7474
  if (!known.size) return [];
7281
7475
  const orphaned = [];
@@ -7386,15 +7580,17 @@ function dismissSessionFromList(sid) {
7386
7580
  }
7387
7581
 
7388
7582
  async function confirmDismissBlockers(sid) {
7389
- const sticky = stickySessionIds.has(sid);
7583
+ const pinState = getSessionPinState(sid);
7584
+ const pinned = pinState !== 'none';
7390
7585
  const terminal = runningTerminals.has(sid);
7391
- if (!sticky && !terminal) return true;
7586
+ if (!pinned && !terminal) return true;
7392
7587
  const session = sessions.find((s) => s.id === sid);
7393
7588
  const name = session ? sessionDisplayName(session) : sid.slice(0, 8);
7394
- const has = [sticky && 'a sticky pin', terminal && 'a running terminal'].filter(Boolean).join(' and ');
7395
- const okLabel = sticky && terminal ? 'Unpin, end and dismiss' : sticky ? 'Unpin and dismiss' : 'End and dismiss';
7589
+ const pinLabel = pinState === 'sticky' ? 'sticky pin' : 'pin';
7590
+ const has = [pinned && `a ${pinLabel}`, terminal && 'a running terminal'].filter(Boolean).join(' and ');
7591
+ const okLabel = pinned && terminal ? 'Unpin, end and dismiss' : pinned ? 'Unpin and dismiss' : 'End and dismiss';
7396
7592
  const steps = [
7397
- sticky && 'Remove the sticky pin',
7593
+ pinned && `Remove the ${pinLabel}`,
7398
7594
  terminal && 'End the terminal',
7399
7595
  'Hide the session from the sidebar',
7400
7596
  ].filter(Boolean);
@@ -7405,7 +7601,7 @@ async function confirmDismissBlockers(sid) {
7405
7601
  okLabel,
7406
7602
  });
7407
7603
  if (!ok) return false;
7408
- if (sticky) unpinSession(sid);
7604
+ if (isAnyPinned(sid)) unpinSession(sid);
7409
7605
  if (terminal) closeTerminalSession(sid);
7410
7606
  return true;
7411
7607
  }
@@ -7504,6 +7700,12 @@ document.addEventListener('keydown', (e) => {
7504
7700
  } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
7505
7701
  e.preventDefault();
7506
7702
  closeHelpModal();
7703
+ } else if (
7704
+ matchKey(e, 'ArrowLeft', 'ArrowRight') &&
7705
+ [...document.querySelectorAll('.modal-overlay.visible')].at(-1)?.id === 'storage-modal'
7706
+ ) {
7707
+ e.preventDefault();
7708
+ stepStorageTab(matchKey(e, 'ArrowLeft') ? -1 : 1);
7507
7709
  } else if (shiftOnly(e, 'KeyM') && document.getElementById('msg-detail-modal').classList.contains('visible')) {
7508
7710
  e.preventDefault();
7509
7711
  closeMsgDetailModal();
@@ -7581,11 +7783,15 @@ document.addEventListener('keydown', (e) => {
7581
7783
  }
7582
7784
  if (matchKey(e, '[', ']') && cyclePaneStrip(e.key === ']' ? 1 : -1)) {
7583
7785
  e.preventDefault();
7786
+ focusActivePane();
7584
7787
  return;
7585
7788
  }
7586
7789
  const altOnly = e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey;
7587
7790
  if (altOnly && (e.code === 'BracketLeft' || e.code === 'BracketRight')) {
7588
- if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) e.preventDefault();
7791
+ if (cyclePane(e.code === 'BracketRight' ? 1 : -1)) {
7792
+ e.preventDefault();
7793
+ focusActivePane();
7794
+ }
7589
7795
  return;
7590
7796
  }
7591
7797
  if (altOnly && e.code === 'KeyW' && closeFocusedPane()) {
@@ -7603,6 +7809,7 @@ document.addEventListener('keydown', (e) => {
7603
7809
  }
7604
7810
  if (plainShift && (e.key === '{' || e.key === '}') && focusPaneSide(e.key === '{' ? 'l' : 'r')) {
7605
7811
  e.preventDefault();
7812
+ focusActivePane();
7606
7813
  return;
7607
7814
  }
7608
7815
  if (shiftOnly(e, 'KeyL')) {
@@ -7650,8 +7857,12 @@ document.addEventListener('keydown', (e) => {
7650
7857
 
7651
7858
  // Tab toggles focus zone
7652
7859
  if (e.key === 'Tab' && detailPanel.contains(e.target)) return;
7860
+ // A focused pane document keeps the arrow, page and space keys for scrolling.
7861
+ const inPane = paneViews.contains(e.target);
7862
+ if (inPane && e.key !== 'Tab' && e.key !== 'Escape') return;
7653
7863
  if (e.key === 'Tab') {
7654
7864
  e.preventDefault();
7865
+ if (inPane) e.target.blur();
7655
7866
  // The terminal replaces the board, so it takes the board's place in the Tab cycle.
7656
7867
  if (wantsTerminal()) {
7657
7868
  if (focusZone === 'sidebar') focusTerminalPane();
@@ -7732,6 +7943,12 @@ document.addEventListener('keydown', (e) => {
7732
7943
  deleteTask(selectedTaskId, selectedSessionId || currentSessionId);
7733
7944
  return;
7734
7945
  }
7946
+
7947
+ if (matchKey(e, 'KeyE') && detailPanel.classList.contains('visible')) {
7948
+ e.preventDefault();
7949
+ setDetailExpanded(!detailPanel.classList.contains('expanded'));
7950
+ return;
7951
+ }
7735
7952
  }
7736
7953
 
7737
7954
  if (e.key === 'Escape') {
@@ -7851,6 +8068,9 @@ document.addEventListener('keydown', (e) => {
7851
8068
  // the frame keeps its own ranges and marks them by id. A page whose CSP forbids inline
7852
8069
  // scripts gets no comments.
7853
8070
  const reviewDrafts = new Map();
8071
+ // Text typed but not added, kept across a teardown the user did not ask for (the content
8072
+ // refreshed, a modal opened on top) until its review is unmounted.
8073
+ const reviewUnsent = new Map();
7854
8074
  const reviewHighlight = typeof Highlight === 'function' && CSS.highlights ? new Highlight() : null;
7855
8075
  const reviewHotHighlight = reviewHighlight ? new Highlight() : null;
7856
8076
  const reviewPendingHighlight = reviewHighlight ? new Highlight() : null;
@@ -7907,9 +8127,13 @@ function dropReviewEntry(panelEl) {
7907
8127
 
7908
8128
  function showTopReview() {
7909
8129
  const opts = reviewStack.at(-1) || reviewBase;
7910
- if (activeReview && activeReview.opts === opts) return;
7911
- stopReview();
7912
- if (opts) startReview(opts);
8130
+ if (!activeReview || activeReview.opts !== opts) {
8131
+ stopReview();
8132
+ if (opts) startReview(opts);
8133
+ }
8134
+ if (!reviewUnsent.size) return;
8135
+ const mounted = new Set([...reviewStack, reviewBase].filter(Boolean).map((o) => reviewKey(o.sessionId, o.source)));
8136
+ for (const key of reviewUnsent.keys()) if (!mounted.has(key)) reviewUnsent.delete(key);
7913
8137
  }
7914
8138
 
7915
8139
  function startReview(opts) {
@@ -7943,11 +8167,17 @@ function startReview(opts) {
7943
8167
  document.addEventListener('scroll', closeReviewOffer, { capture: true, signal });
7944
8168
  panelEl.addEventListener('click', onReviewPanelClick, { signal });
7945
8169
  panelEl.addEventListener('mouseover', onReviewPanelHover, { signal });
8170
+ panelEl.addEventListener('dblclick', onReviewPanelDblClick, { signal });
8171
+ panelEl.addEventListener('keydown', onReviewPanelKey, { signal });
8172
+ panelEl.addEventListener('input', onReviewPanelInput, { signal });
7946
8173
  panelEl.addEventListener('mouseleave', () => setReviewHot(null), { signal });
8174
+ if (!frame) anchorReviewItems(contentEl);
7947
8175
  renderReviewPanel();
8176
+ restoreUnsentReview();
7948
8177
  }
7949
8178
 
7950
8179
  function stopReview() {
8180
+ if (activeReview) keepUnsentReview();
7951
8181
  closeReviewPop();
7952
8182
  if (!activeReview) return;
7953
8183
  const { ctl, panelEl, hostEl } = activeReview;
@@ -8033,6 +8263,100 @@ function reviewSelection() {
8033
8263
  return { quote, range: range.cloneRange(), rect: range.getBoundingClientRect() };
8034
8264
  }
8035
8265
 
8266
+ // A re-render replaces the nodes a range points at, so a comment keeps its text and the
8267
+ // 32 chars around it, and is found again after a refresh. Port of scratchpad's cmtFindAnchor.
8268
+ function reviewAnchorOf(root, range) {
8269
+ const before = reviewTextBefore(root, range.startContainer, range.startOffset);
8270
+ const after = root.ownerDocument.createRange();
8271
+ after.selectNodeContents(root);
8272
+ after.setStart(range.endContainer, range.endOffset);
8273
+ return {
8274
+ exact: range.toString(),
8275
+ pos: before.length,
8276
+ prefix: before.slice(-32),
8277
+ suffix: after.toString().slice(0, 32),
8278
+ };
8279
+ }
8280
+
8281
+ // The best match scores the most prefix and suffix chars that match outward from the quote;
8282
+ // a tie goes to the one nearest the old offset. -1 when the quote is gone.
8283
+ function reviewQuoteAt(text, { exact, pos, prefix, suffix }) {
8284
+ let best = -1;
8285
+ let bestScore = -1;
8286
+ if (!exact) return best;
8287
+ for (let at = text.indexOf(exact); at !== -1; at = text.indexOf(exact, at + 1)) {
8288
+ let score = 0;
8289
+ for (let k = 1; k <= prefix.length && text[at - k] === prefix[prefix.length - k]; k++) score++;
8290
+ for (let k = 0; k < suffix.length && text[at + exact.length + k] === suffix[k]; k++) score++;
8291
+ if (score > bestScore || (score === bestScore && Math.abs(at - pos) < Math.abs(best - pos))) {
8292
+ best = at;
8293
+ bestScore = score;
8294
+ }
8295
+ }
8296
+ return best;
8297
+ }
8298
+
8299
+ function reviewTextIndex(root) {
8300
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
8301
+ const nodes = [];
8302
+ let text = '';
8303
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
8304
+ nodes.push({ n, start: text.length });
8305
+ text += n.nodeValue;
8306
+ }
8307
+ return { nodes, text };
8308
+ }
8309
+
8310
+ function reviewFindAnchor({ nodes, text }, anchor) {
8311
+ const at = reviewQuoteAt(text, anchor);
8312
+ if (at < 0) return null;
8313
+ const end = at + anchor.exact.length;
8314
+ const first = nodes.findLast((x) => x.start <= at);
8315
+ const last = nodes.find((x) => end <= x.start + x.n.nodeValue.length);
8316
+ const range = document.createRange();
8317
+ range.setStart(first.n, at - first.start);
8318
+ range.setEnd(last.n, end - last.start);
8319
+ return { range, at };
8320
+ }
8321
+
8322
+ function anchorReviewItems(root) {
8323
+ const items = reviewItems().filter((c) => c.anchor);
8324
+ if (!items.length) return;
8325
+ const index = reviewTextIndex(root);
8326
+ for (const c of items) {
8327
+ const found = reviewFindAnchor(index, c.anchor);
8328
+ c.range = found?.range || null;
8329
+ if (!found) continue;
8330
+ c.pos = c.anchor.pos = found.at;
8331
+ reviewHighlight?.add(c.range);
8332
+ }
8333
+ }
8334
+
8335
+ function keepUnsentReview() {
8336
+ const { key, picked, editing } = activeReview;
8337
+ const text = reviewPopMode === 'editor' ? reviewPopEl().querySelector('textarea').value : '';
8338
+ if (text.trim()) reviewUnsent.set(key, { picked, text });
8339
+ else if (editing) reviewUnsent.set(key, { editing });
8340
+ }
8341
+
8342
+ function restoreUnsentReview() {
8343
+ const u = reviewUnsent.get(activeReview.key);
8344
+ if (!u) return;
8345
+ reviewUnsent.delete(activeReview.key);
8346
+ if (u.editing) {
8347
+ if (reviewItems().includes(u.editing.c)) startReviewEdit(u.editing.c, u.editing.draft);
8348
+ return;
8349
+ }
8350
+ const { anchor } = u.picked;
8351
+ const range = anchor && reviewFindAnchor(reviewTextIndex(activeReview.contentEl), anchor)?.range;
8352
+ if (range) return openReviewEditor({ ...u.picked, range, rect: range.getBoundingClientRect() }, u.text);
8353
+ const why = anchor ? 'The text you commented on changed' : 'The page reloaded';
8354
+ showToast(`${why}, so the comment was not added`, 'info', {
8355
+ label: 'Copy',
8356
+ onClick: () => copyText(u.text, 'the comment'),
8357
+ });
8358
+ }
8359
+
8036
8360
  function onReviewFrameMessage(e) {
8037
8361
  const frameWin = activeReview?.frame?.contentWindow;
8038
8362
  const type =
@@ -8368,31 +8692,45 @@ function isReviewSendKey(e) {
8368
8692
  return !e.target.closest?.(TEXT_FIELD_SELECTOR);
8369
8693
  }
8370
8694
 
8371
- function openReviewEditor(picked) {
8695
+ function openReviewEditor(picked, text = '') {
8372
8696
  const el = reviewPopEl();
8373
8697
  reviewPopMode = 'editor';
8374
8698
  el.className = 'visible editor';
8375
- if (picked.range) reviewPendingHighlight?.add(picked.range);
8376
- else if (picked.inFrame) postToReviewFrame('pend');
8699
+ if (picked.range) {
8700
+ picked.anchor = reviewAnchorOf(activeReview.contentEl, picked.range);
8701
+ reviewPendingHighlight?.add(picked.range);
8702
+ } else if (picked.inFrame) postToReviewFrame('pend');
8703
+ activeReview.picked = picked;
8377
8704
  el.innerHTML = `<textarea rows="3" placeholder="Comment (Ctrl+Enter to add)"></textarea>
8378
8705
  <div class="review-pop-actions"><button type="button" data-act="cancel">Cancel</button><button type="button" data-act="add" class="primary">Add</button></div>`;
8379
8706
  const ta = el.querySelector('textarea');
8380
- const add = () => {
8381
- const comment = ta.value.trim();
8382
- if (!comment) return ta.focus();
8383
- addReviewComment(picked, comment);
8384
- };
8385
- ta.addEventListener('keydown', (e) => {
8386
- e.stopPropagation();
8387
- if (e.key === 'Escape') closeReviewPop();
8388
- else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) add();
8389
- });
8707
+ ta.value = text;
8708
+ const add = (comment) => addReviewComment(picked, comment);
8709
+ ta.addEventListener('keydown', (e) => onReviewEditorKey(e, add, closeReviewPop));
8390
8710
  el.querySelector('[data-act="cancel"]').addEventListener('click', closeReviewPop);
8391
- el.querySelector('[data-act="add"]').addEventListener('click', add);
8711
+ el.querySelector('[data-act="add"]').addEventListener('click', () => {
8712
+ const comment = reviewEditorText(ta);
8713
+ if (comment) add(comment);
8714
+ });
8392
8715
  placeReviewPop(el, picked.rect);
8393
8716
  ta.focus();
8394
8717
  }
8395
8718
 
8719
+ function reviewEditorText(ta) {
8720
+ const text = ta.value.trim();
8721
+ if (!text) ta.focus();
8722
+ return text;
8723
+ }
8724
+
8725
+ function onReviewEditorKey(e, save, cancel) {
8726
+ e.stopPropagation();
8727
+ if (e.key === 'Escape') cancel();
8728
+ else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
8729
+ const text = reviewEditorText(e.target);
8730
+ if (text) save(text);
8731
+ }
8732
+ }
8733
+
8396
8734
  function addReviewComment(picked, comment) {
8397
8735
  if (!activeReview) return;
8398
8736
  const { key, contentEl } = activeReview;
@@ -8413,8 +8751,8 @@ function addReviewComment(picked, comment) {
8413
8751
  heading: reviewHeadingBefore(contentEl, range.startContainer),
8414
8752
  ...activeReview.context?.(range),
8415
8753
  };
8416
- const pos = reviewTextBefore(contentEl, range.startContainer, range.startOffset).length;
8417
- item = { quote: picked.quote, comment, context, pos, range, frameId: 0 };
8754
+ const { anchor } = picked;
8755
+ item = { quote: picked.quote, comment, context, pos: anchor.pos, anchor, range, frameId: 0 };
8418
8756
  reviewHighlight?.add(range);
8419
8757
  window.getSelection()?.removeAllRanges();
8420
8758
  }
@@ -8444,6 +8782,7 @@ function removeReviewComment(c) {
8444
8782
  items.splice(i, 1);
8445
8783
  unmarkReviewItem(c);
8446
8784
  if (activeReview.hot === c) setReviewHot(null);
8785
+ if (activeReview.editing?.c === c) activeReview.editing = null;
8447
8786
  if (!items.length) reviewDrafts.delete(activeReview.key);
8448
8787
  renderReviewPanel();
8449
8788
  }
@@ -8451,6 +8790,7 @@ function removeReviewComment(c) {
8451
8790
  function clearReviewDrafts(key = activeReview?.key) {
8452
8791
  for (const c of reviewDrafts.get(key) || []) unmarkReviewItem(c);
8453
8792
  reviewDrafts.delete(key);
8793
+ if (activeReview) activeReview.editing = null;
8454
8794
  setReviewHot(null);
8455
8795
  renderReviewPanel();
8456
8796
  }
@@ -8459,16 +8799,52 @@ function onReviewPanelClick(e) {
8459
8799
  const act = e.target.closest('[data-act]')?.dataset.act;
8460
8800
  if (act === 'send') return sendReview();
8461
8801
  if (act === 'discard') return clearReviewDrafts();
8462
- const card = e.target.closest('.review-card');
8463
- const c = card && reviewItems()[Number(card.dataset.i)];
8464
- if (!c) return;
8802
+ const c = reviewCardItem(e.target);
8803
+ if (!c || e.target.closest('.review-card-edit')) return;
8465
8804
  if (e.target.closest('.review-remove')) removeReviewComment(c);
8805
+ else if (e.target.closest('.review-edit')) startReviewEdit(c);
8466
8806
  else revealReviewItem(c);
8467
8807
  }
8468
8808
 
8809
+ function reviewCardItem(el) {
8810
+ const card = el.closest('.review-card');
8811
+ return (card && reviewItems()[Number(card.dataset.i)]) || null;
8812
+ }
8813
+
8814
+ function onReviewPanelDblClick(e) {
8815
+ const c = reviewCardItem(e.target);
8816
+ if (c && e.target.closest('.review-card-comment')) startReviewEdit(c);
8817
+ }
8818
+
8819
+ // `draft` keeps the typed text when the panel renders again during the edit.
8820
+ function startReviewEdit(c, draft = c.comment) {
8821
+ activeReview.editing = { c, draft };
8822
+ renderReviewPanel();
8823
+ const ta = activeReview.panelEl.querySelector('.review-card-edit');
8824
+ ta.focus();
8825
+ ta.setSelectionRange(ta.value.length, ta.value.length);
8826
+ }
8827
+
8828
+ function endReviewEdit() {
8829
+ activeReview.editing = null;
8830
+ renderReviewPanel();
8831
+ }
8832
+
8833
+ function saveReviewEdit(comment) {
8834
+ activeReview.editing.c.comment = comment;
8835
+ endReviewEdit();
8836
+ }
8837
+
8838
+ function onReviewPanelKey(e) {
8839
+ if (e.target.matches('.review-card-edit')) onReviewEditorKey(e, saveReviewEdit, endReviewEdit);
8840
+ }
8841
+
8842
+ function onReviewPanelInput(e) {
8843
+ if (e.target.matches('.review-card-edit')) activeReview.editing.draft = e.target.value;
8844
+ }
8845
+
8469
8846
  function onReviewPanelHover(e) {
8470
- const card = e.target.closest('.review-card');
8471
- setReviewHot((card && reviewItems()[Number(card.dataset.i)]) || null);
8847
+ setReviewHot(reviewCardItem(e.target));
8472
8848
  }
8473
8849
 
8474
8850
  let reviewSendClaimed = false;
@@ -8495,10 +8871,15 @@ function renderReviewPanel() {
8495
8871
  </div>
8496
8872
  <ol class="review-panel-list">${items
8497
8873
  .map((c, i) => {
8874
+ const body =
8875
+ c === activeReview.editing?.c
8876
+ ? `<textarea class="review-card-edit" rows="3" aria-label="Edit comment" placeholder="Ctrl+Enter to save, Esc to cancel">${escapeHtml(activeReview.editing.draft)}</textarea>`
8877
+ : `<div class="review-card-comment" title="Double-click to edit">${escapeHtml(c.comment)}</div>`;
8498
8878
  return `<li class="review-card${c === activeReview.hot ? ' hot' : ''}" data-i="${i}" title="Show in the document">
8879
+ <button type="button" class="review-edit" title="Edit" aria-label="Edit">${DETAIL_EDIT_SVG}</button>
8499
8880
  <button type="button" class="review-remove" title="Remove" aria-label="Remove">×</button>
8500
- <div class="review-card-quote">${escapeHtml(c.quote.slice(0, 300))}</div>
8501
- <div class="review-card-comment">${escapeHtml(c.comment)}</div></li>`;
8881
+ <div class="review-card-quote">${c.anchor && !c.range ? '<span class="review-orphan" title="The file changed and this text is no longer in it">not found</span>' : ''}${escapeHtml(c.quote.slice(0, 300))}</div>
8882
+ ${body}</li>`;
8502
8883
  })
8503
8884
  .join('')}</ol>`;
8504
8885
  }
@@ -8581,6 +8962,8 @@ async function sendPreviewAction(frame, msg) {
8581
8962
  //#region MARKDOWN_PREVIEW
8582
8963
  const PREVIEW_STORAGE_PREFIX = 'preview-paths-';
8583
8964
  let currentPreviewPath = null;
8965
+ // The session a CLI preview was sent for, which need not be the one on screen.
8966
+ let previewSessionId = null;
8584
8967
  let previewFromTerminal = false;
8585
8968
 
8586
8969
  function readStoredList(key) {
@@ -8802,8 +9185,9 @@ function localImageUrl(src, base) {
8802
9185
  return `/api/preview/image?${new URLSearchParams({ path: p, base })}`;
8803
9186
  }
8804
9187
 
8805
- function openPreviewModal(filePath, content, kind) {
9188
+ function openPreviewModal(filePath, content, kind, sessionId) {
8806
9189
  currentPreviewPath = filePath;
9190
+ previewSessionId = sessionId;
8807
9191
  const fileName = filePath.split(/[\\/]/).pop();
8808
9192
  document.getElementById('preview-modal-title').textContent = fileName;
8809
9193
  const bodyEl = document.getElementById('preview-modal-body');
@@ -8821,7 +9205,13 @@ function openPreviewModal(filePath, content, kind) {
8821
9205
  const panelEl = document.getElementById('preview-review-panel');
8822
9206
  const opts = fileReviewOpts(filePath, kind, bodyEl, isHtml ? bodyEl.querySelector('iframe') : null);
8823
9207
  if (!opts) return unmountReview(panelEl);
8824
- mountReview({ ...opts, panelEl, hostEl: document.querySelector('#preview-modal .modal'), onSent: closePreviewModal });
9208
+ mountReview({
9209
+ ...opts,
9210
+ sessionId,
9211
+ panelEl,
9212
+ hostEl: document.querySelector('#preview-modal .modal'),
9213
+ onSent: closePreviewModal,
9214
+ });
8825
9215
  }
8826
9216
 
8827
9217
  // Null for an image, which takes no comments.
@@ -8841,35 +9231,35 @@ function sourceLineOf(bodyEl, range) {
8841
9231
  return reviewTextBefore(code, range.startContainer, range.startOffset).split('\n').length;
8842
9232
  }
8843
9233
 
8844
- function isPreviewLinkedToCurrentSession() {
8845
- if (!currentPreviewPath || !currentSessionId) return false;
9234
+ function isPreviewLinkedToSession() {
9235
+ if (!currentPreviewPath || !previewSessionId) return false;
8846
9236
  const key = canonicalPath(currentPreviewPath);
8847
- return getSessionPreviewPaths(currentSessionId).some((p) => canonicalPath(p) === key);
9237
+ return getSessionPreviewPaths(previewSessionId).some((p) => canonicalPath(p) === key);
8848
9238
  }
8849
9239
 
8850
9240
  function updatePreviewLinkBtn() {
8851
9241
  const paneBtn = document.getElementById('preview-pane-btn');
8852
- if (paneBtn) paneBtn.style.display = paneSessionId() ? '' : 'none';
9242
+ if (paneBtn) paneBtn.style.display = previewSessionId ? '' : 'none';
8853
9243
  const btn = document.getElementById('preview-link-btn');
8854
9244
  if (!btn) return;
8855
- if (!currentSessionId) {
9245
+ if (!previewSessionId) {
8856
9246
  btn.style.display = 'none';
8857
9247
  return;
8858
9248
  }
8859
9249
  btn.style.display = '';
8860
- const linked = isPreviewLinkedToCurrentSession();
8861
- btn.title = linked ? 'Unlink from current session' : 'Link to current session';
9250
+ const linked = isPreviewLinkedToSession();
9251
+ btn.title = linked ? 'Unlink from the session' : 'Link to the session';
8862
9252
  btn.style.color = linked ? 'var(--accent)' : '';
8863
9253
  }
8864
9254
 
8865
9255
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
8866
9256
  function togglePreviewSessionLink() {
8867
- if (!currentPreviewPath || !currentSessionId) {
9257
+ if (!currentPreviewPath || !previewSessionId) {
8868
9258
  showToast('Select a session first');
8869
9259
  return;
8870
9260
  }
8871
- const unlink = isPreviewLinkedToCurrentSession();
8872
- setSessionDocLink(currentSessionId, currentPreviewPath, unlink);
9261
+ const unlink = isPreviewLinkedToSession();
9262
+ setSessionDocLink(previewSessionId, currentPreviewPath, unlink);
8873
9263
  showToast(unlink ? 'Unlinked from session' : 'Linked to session');
8874
9264
  }
8875
9265
 
@@ -8896,6 +9286,7 @@ function closePreviewModal() {
8896
9286
  // Empty the body so an iframe preview is destroyed and its scripts/timers stop.
8897
9287
  document.getElementById('preview-modal-body').innerHTML = '';
8898
9288
  currentPreviewPath = null;
9289
+ previewSessionId = null;
8899
9290
  if (previewFromTerminal) focusTerminalPane();
8900
9291
  previewFromTerminal = false;
8901
9292
  }
@@ -8913,7 +9304,8 @@ function openPreviewInEditor() {
8913
9304
  // The server is the only place that knows what it can render, so a null `kind` is its
8914
9305
  // answer to "not previewable" rather than a failure: callers that have a fallback
8915
9306
  // destination pass `onUnsupported` instead of keeping a copy of the extension list here.
8916
- async function openPreviewByPath(filePath, base, onUnsupported) {
9307
+ // A link followed inside an open preview keeps the preview's session.
9308
+ async function openPreviewByPath(filePath, base, onUnsupported, sessionId = previewSessionId || currentSessionId) {
8917
9309
  if (!filePath) return;
8918
9310
  try {
8919
9311
  const r = await api('/api/preview', { query: { path: filePath, base: base || null } });
@@ -8931,7 +9323,7 @@ async function openPreviewByPath(filePath, base, onUnsupported) {
8931
9323
  else showToast('Nothing to preview in this file');
8932
9324
  return;
8933
9325
  }
8934
- openPreviewModal(data.path, data.content, data.kind);
9326
+ openPreviewModal(data.path, data.content, data.kind, sessionId);
8935
9327
  } catch {
8936
9328
  showToast('Failed to load preview', 'error');
8937
9329
  }
@@ -8970,7 +9362,7 @@ async function handlePreviewOpenEvent(data) {
8970
9362
  }
8971
9363
  }
8972
9364
  // The broadcast carries the path only — each tab fetches the document itself.
8973
- openPreviewByPath(filePath);
9365
+ openPreviewByPath(filePath, undefined, undefined, currentSessionId);
8974
9366
  }
8975
9367
 
8976
9368
  // Linked docs live in localStorage, so the CLI can only reach them through a tab:
@@ -9684,7 +10076,8 @@ function setupEventSource() {
9684
10076
  if (!skipOffScreen()) refreshOpenPlan();
9685
10077
  }
9686
10078
 
9687
- if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
10079
+ if (data.type === 'terminals-update' && Array.isArray(data.ids))
10080
+ setRunningTerminals(data.ids, data.awaitingTrust);
9688
10081
 
9689
10082
  if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
9690
10083
 
@@ -10347,9 +10740,8 @@ function isSessionActive(s) {
10347
10740
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
10348
10741
  }
10349
10742
 
10350
- // An active pinned session renders outside the Pinned group until the user dismisses it.
10351
10743
  function isInPinnedGroup(s) {
10352
- return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
10744
+ return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
10353
10745
  }
10354
10746
 
10355
10747
  const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -11046,24 +11438,32 @@ function renderFilterState() {
11046
11438
  document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
11047
11439
  }
11048
11440
 
11441
+ // With the sidebar cursor on a group header, zen shows that group instead of the open session.
11049
11442
  function toggleZenMode() {
11050
- zenMode = !zenMode;
11443
+ const leavingSessionZen = zenMode;
11444
+ if (zenMode) zenMode = false;
11445
+ else if (zenGroup) zenGroup = null;
11446
+ else {
11447
+ const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
11448
+ zenGroup = header?.dataset.groupId || null;
11449
+ zenMode = !zenGroup;
11450
+ }
11051
11451
  store.setItem(ZEN_KEY, String(zenMode));
11052
11452
  renderZenState();
11053
- if (zenMode) closeFilterMenu();
11453
+ if (zenMode || zenGroup) closeFilterMenu();
11054
11454
  renderSessions();
11055
- // The open session may be outside the current server page/filter; refetch pins it in.
11056
- if (zenMode) fetchSessions(false);
11455
+ // The open session or the group's members may be outside the current server page/filter,
11456
+ // and leaving group zen needs the filtered page back.
11457
+ if (!leavingSessionZen) fetchSessions(false);
11057
11458
  }
11058
11459
 
11059
11460
  function renderZenState() {
11060
11461
  const btn = document.getElementById('zen-mode-btn');
11061
11462
  if (!btn) return;
11062
- btn.classList.toggle('active', zenMode);
11063
- btn.setAttribute('aria-pressed', String(zenMode));
11064
- btn.title = zenMode
11065
- ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)'
11066
- : 'Zen mode: current session only (Ctrl+Shift+Z)';
11463
+ const on = zenMode || !!zenGroup;
11464
+ btn.classList.toggle('active', on);
11465
+ btn.setAttribute('aria-pressed', String(on));
11466
+ btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
11067
11467
  }
11068
11468
 
11069
11469
  function filterBySessions(value) {
@@ -11178,46 +11578,23 @@ document.addEventListener('click', (e) => {
11178
11578
  if (sgHeader) {
11179
11579
  if (e.target.closest('.sg-name-input')) return;
11180
11580
  e.stopPropagation();
11581
+ const groupId = sgHeader.dataset.groupId;
11582
+ const group = sgGroupById(groupId);
11583
+ if (!group) return;
11181
11584
  if (e.target.closest('.sg-keep')) {
11182
- sgKeepTransient(sgHeader.dataset.transientGroup);
11585
+ sgKeep(group);
11586
+ persistSessionGroups();
11183
11587
  renderSessions();
11184
11588
  return;
11185
11589
  }
11186
- if (sgHeader.dataset.transientGroup && e.target.closest('.sg-delete')) {
11187
- const name = sgHeader.dataset.transientGroup;
11188
- confirmModal({
11189
- title: 'Delete this group?',
11190
- message: 'Its sessions go back to Projects.',
11191
- chip: name,
11192
- okLabel: 'Delete group',
11193
- }).then((ok) => {
11194
- if (!ok) return;
11195
- sgDeleteTransient(name);
11196
- renderSessions();
11197
- });
11590
+ if (e.target.closest('.sg-rename')) {
11591
+ sgBeginRename(groupId);
11592
+ return;
11593
+ }
11594
+ if (e.target.closest('.sg-delete')) {
11595
+ sgConfirmDelete(group);
11198
11596
  return;
11199
11597
  }
11200
- const groupId = sgHeader.dataset.groupId;
11201
- const group = sgGroupById(groupId);
11202
- if (group) {
11203
- if (e.target.closest('.sg-rename')) {
11204
- sgBeginRename(groupId);
11205
- return;
11206
- }
11207
- if (e.target.closest('.sg-delete')) {
11208
- confirmModal({
11209
- title: 'Delete this group?',
11210
- message: 'Its sessions and projects go back to Projects.',
11211
- chip: group.name,
11212
- okLabel: 'Delete group',
11213
- }).then((ok) => {
11214
- if (!ok) return;
11215
- sgDeleteGroup(groupId);
11216
- renderSessions();
11217
- });
11218
- return;
11219
- }
11220
- } else if (!sgHeader.dataset.transientGroup) return;
11221
11598
  }
11222
11599
 
11223
11600
  const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
@@ -11572,7 +11949,7 @@ function updateStickyBtnState() {
11572
11949
  const isSticky = stickySessionIds.has(_infoModalSessionId);
11573
11950
  stickyBtn.style.display = '';
11574
11951
  stickyBtn.classList.toggle('active', isSticky);
11575
- stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show at top';
11952
+ stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
11576
11953
  const svg = stickyBtn.querySelector('svg');
11577
11954
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
11578
11955
  }
@@ -13165,7 +13542,9 @@ function mountPane(sid, pane) {
13165
13542
  view.dataset.key = key;
13166
13543
  paneViews.appendChild(view);
13167
13544
  paneFrames.set(key, view);
13168
- loadPaneView(sid, pane, view);
13545
+ loadPaneView(sid, pane, view).then(() => {
13546
+ if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
13547
+ });
13169
13548
  while (paneFrames.size > PANE_FRAME_LIMIT) {
13170
13549
  const [oldKey, oldView] = paneFrames.entries().next().value;
13171
13550
  paneFrames.delete(oldKey);
@@ -13226,6 +13605,10 @@ async function loadPaneView(sid, pane, view, scroll) {
13226
13605
  : ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
13227
13606
  const frame = createPreviewFrame(cls, doc);
13228
13607
  frame.title = pane.title;
13608
+ // Focus given before the document loads stays on the frame element, out of the document's keys.
13609
+ frame.addEventListener('load', () => {
13610
+ if (document.activeElement === frame) frame.contentWindow?.focus();
13611
+ });
13229
13612
  view.appendChild(frame);
13230
13613
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
13231
13614
  return;
@@ -13272,6 +13655,7 @@ function appendPaneDoc(view) {
13272
13655
  doc.className = 'pane-doc';
13273
13656
  const body = document.createElement('div');
13274
13657
  body.className = 'pane-doc-body rendered-md modal-zoomable';
13658
+ body.tabIndex = -1;
13275
13659
  doc.append(body, paneDocGrip(doc));
13276
13660
  view.appendChild(doc);
13277
13661
  return body;
@@ -13306,7 +13690,9 @@ let paneUpdatedToastTimer = 0;
13306
13690
  async function autoReloadPaneView(sid, pane, view) {
13307
13691
  if (!(await reloadPaneView(sid, pane, view))) return;
13308
13692
  clearTimeout(paneUpdatedToastTimer);
13309
- paneUpdatedToastTimer = setTimeout(() => showToast('Pane updated', 'info'), 1000);
13693
+ paneUpdatedToastTimer = setTimeout(() => {
13694
+ if (!document.getElementById('toast').classList.contains('has-action')) showToast('Pane updated', 'info');
13695
+ }, 1000);
13310
13696
  }
13311
13697
 
13312
13698
  // Builds the new view hidden over the old one and swaps them once it has loaded and scrolled,
@@ -13347,8 +13733,11 @@ async function reloadPaneView(sid, pane, view) {
13347
13733
  paneReloads.delete(key);
13348
13734
  fresh.className = view.className;
13349
13735
  fresh.style.visibility = '';
13736
+ const hadFocus = view.contains(document.activeElement);
13350
13737
  view.remove();
13351
13738
  paneFrames.set(key, fresh);
13739
+ const target = hadFocus && paneFocusTarget(fresh);
13740
+ if (target) focusPaneTarget(target);
13352
13741
  syncPaneReview();
13353
13742
  return true;
13354
13743
  }
@@ -13393,6 +13782,27 @@ function activePaneView() {
13393
13782
  return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
13394
13783
  }
13395
13784
 
13785
+ // A URL page is on another origin and would keep every key from the board, so it gets no focus.
13786
+ function paneFocusTarget(view) {
13787
+ return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
13788
+ }
13789
+
13790
+ let paneFocusWanted = null;
13791
+
13792
+ // The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
13793
+ function focusActivePane() {
13794
+ const view = activePaneView();
13795
+ const target = paneFocusTarget(view);
13796
+ paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
13797
+ if (target) focusPaneTarget(target);
13798
+ else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
13799
+ }
13800
+
13801
+ function focusPaneTarget(target) {
13802
+ target.focus({ preventScroll: true });
13803
+ target.contentWindow?.focus();
13804
+ }
13805
+
13396
13806
  function isPaneDocOnScreen() {
13397
13807
  return !!activePaneView()?.querySelector('.modal-zoomable');
13398
13808
  }
@@ -13792,15 +14202,13 @@ function markSourceLine(bodyEl, line) {
13792
14202
  band.scrollIntoView({ block: 'center', inline: 'nearest' });
13793
14203
  }
13794
14204
 
13795
- function openFileInPane(filePath, closeModal) {
13796
- if (!filePath || !paneSessionId()) return;
13797
- closeModal();
13798
- addPane(filePath);
13799
- }
13800
-
13801
14205
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
13802
14206
  function openPreviewInPane() {
13803
- openFileInPane(currentPreviewPath, closePreviewModal);
14207
+ if (!currentPreviewPath || !previewSessionId) return;
14208
+ const sid = previewSessionId;
14209
+ const target = currentPreviewPath;
14210
+ closePreviewModal();
14211
+ addPaneToSession(sid, target, pathBasename(target));
13804
14212
  }
13805
14213
 
13806
14214
  // The plan modal also opens other sessions' plans; the pane goes to the plan's session, wherever it is.
@@ -13811,15 +14219,19 @@ function syncPlanPaneBtn() {
13811
14219
  }
13812
14220
 
13813
14221
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
13814
- async function openPlanInPane() {
14222
+ function openPlanInPane() {
13815
14223
  if (!planPaneAllowed()) return;
13816
14224
  const sid = _planSessionId;
13817
14225
  const target = _pendingPlanPath;
13818
14226
  closePlanModal();
14227
+ addPaneToSession(sid, target, 'Plan');
14228
+ }
14229
+
14230
+ async function addPaneToSession(sid, target, label) {
13819
14231
  if (sid === paneSessionId()) return addPane(target);
13820
14232
  if ((await postPane(sid, { target, base: getSessionBaseDir(sid) || undefined })) !== false) return;
13821
14233
  const s = sessions.find((x) => x.id === sid);
13822
- showToast(`Plan added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
14234
+ showToast(`${label} added to ${s ? sessionDisplayName(s) : 'its session'}`, 'success', {
13823
14235
  label: 'Open',
13824
14236
  onClick: () => openSession(sid),
13825
14237
  });
@@ -13918,7 +14330,9 @@ paneStrip.addEventListener('click', (e) => {
13918
14330
  return;
13919
14331
  }
13920
14332
  const tab = e.target.closest('.pane-tab');
13921
- if (tab) selectPane(tab.dataset.pane);
14333
+ if (!tab) return;
14334
+ selectPane(tab.dataset.pane);
14335
+ focusActivePane();
13922
14336
  });
13923
14337
  paneStrip.addEventListener('auxclick', (e) => {
13924
14338
  const tab = e.target.closest('.pane-tab');
@@ -14288,6 +14702,7 @@ function toggleTerminal() {
14288
14702
  setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
14289
14703
  termState.focusNext = true;
14290
14704
  syncTerminal();
14705
+ if (!wantsTerminal()) focusActivePane();
14291
14706
  }
14292
14707
 
14293
14708
  // The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
@@ -14352,7 +14767,7 @@ function setSwapPair(last, previous) {
14352
14767
  async function swapToPreviousSession() {
14353
14768
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
14354
14769
  if (!target || target === currentSessionId) return;
14355
- if (terminalPaneFocused()) termState.focusNext = true;
14770
+ if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
14356
14771
  await revealSession(target);
14357
14772
  }
14358
14773
 
@@ -14451,9 +14866,12 @@ function takeTerminalFocus(sessionId) {
14451
14866
  }
14452
14867
 
14453
14868
  // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
14869
+ // A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
14454
14870
  function promptAwaitsUser(sessionId) {
14455
- if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
14456
- return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
14871
+ if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
14872
+ if (trustWaiting.has(sessionId)) return true;
14873
+ const session = sessions.find((s) => s.id === sessionId);
14874
+ return !!session && isWaitingSession(session);
14457
14875
  }
14458
14876
 
14459
14877
  function onTerminalShown(focus = true) {
@@ -14963,6 +15381,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
14963
15381
  name: spec.name,
14964
15382
  worktree: spec.worktree,
14965
15383
  model: spec.model,
15384
+ effort: spec.effort,
14966
15385
  prompt: spec.prompt,
14967
15386
  edit: spec.edit,
14968
15387
  forkOf: spec.forkOf,
@@ -16210,7 +16629,6 @@ function placeholderSession(id, spec) {
16210
16629
  mode: spec.mode,
16211
16630
  edit: !!spec.edit,
16212
16631
  name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
16213
- dispatchGroup: spec.group || undefined,
16214
16632
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
16215
16633
  project: spec.cwd,
16216
16634
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
@@ -16229,7 +16647,22 @@ function mergePlaceholders(list) {
16229
16647
  for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
16230
16648
  }
16231
16649
  const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
16232
- return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
16650
+ const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
16651
+ revealNewPlaceholders(placeholders);
16652
+ return [...placeholders, ...list];
16653
+ }
16654
+
16655
+ // Ids whose sections were expanded once; a section the user collapses again stays collapsed.
16656
+ const revealedPlaceholderIds = new Set();
16657
+
16658
+ function revealNewPlaceholders(placeholders) {
16659
+ let changed = false;
16660
+ for (const p of placeholders) {
16661
+ if (revealedPlaceholderIds.has(p.id)) continue;
16662
+ revealedPlaceholderIds.add(p.id);
16663
+ if (uncollapseFor(p)) changed = true;
16664
+ }
16665
+ if (changed) persistCollapsedGroups();
16233
16666
  }
16234
16667
 
16235
16668
  async function loadDispatches() {
@@ -16283,10 +16716,14 @@ function renderPlaceholderCard(session, depth) {
16283
16716
  const isActive = session.id === currentSessionId && viewMode === 'session';
16284
16717
  const projectHtml = renderProjectIdentity(session);
16285
16718
  return `
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}`)}">
16719
+ <button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
16287
16720
  <div class="session-name">${escapeHtml(session.name)}</div>
16288
16721
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
16289
- <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
16722
+ ${
16723
+ trustWaiting.has(session.id)
16724
+ ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
16725
+ : `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
16726
+ }
16290
16727
  </button>
16291
16728
  `;
16292
16729
  }
@@ -16296,9 +16733,8 @@ async function restorePendingSessions() {
16296
16733
  document.getElementById('new-session-btn').hidden = !terminalAvailable();
16297
16734
  const terminals = terminalAvailable() ? loadTerminals() : null;
16298
16735
  await loadDispatches();
16299
- if (!terminals) return;
16300
16736
  try {
16301
- for (const t of await terminals) {
16737
+ for (const t of terminals ? await terminals : []) {
16302
16738
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
16303
16739
  newSpecs.set(t.id, {
16304
16740
  cwd: t.cwd,
@@ -16311,6 +16747,8 @@ async function restorePendingSessions() {
16311
16747
  });
16312
16748
  }
16313
16749
  } catch (_) {}
16750
+ // Sessions that were already running keep the collapse state saved before the reload.
16751
+ for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
16314
16752
  }
16315
16753
 
16316
16754
  async function loadTerminals() {
@@ -16319,6 +16757,7 @@ async function loadTerminals() {
16319
16757
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
16320
16758
  const list = data.sessions || [];
16321
16759
  runningTerminals = new Set(list.map((t) => t.id));
16760
+ setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
16322
16761
  rememberTerminalIds(list);
16323
16762
  syncShowCard();
16324
16763
  pushTerminalClaims();
@@ -16326,8 +16765,26 @@ async function loadTerminals() {
16326
16765
  return list;
16327
16766
  }
16328
16767
 
16329
- function setRunningTerminals(ids) {
16768
+ // A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
16769
+ function setTrustWaiting(ids) {
16770
+ const before = trustWaiting;
16771
+ trustWaiting = new Set(ids);
16772
+ for (const id of trustWaiting) {
16773
+ if (before.has(id)) continue;
16774
+ setTerminalMode(id, true);
16775
+ if (id !== currentSessionId || viewMode !== 'session') continue;
16776
+ if (termState.shown && termState.attached) {
16777
+ if (promptAwaitsUser(id)) focusTerminalPane();
16778
+ } else {
16779
+ termState.openedByUser = id;
16780
+ syncTerminal();
16781
+ }
16782
+ }
16783
+ }
16784
+
16785
+ function setRunningTerminals(ids, awaitingTrust = []) {
16330
16786
  runningTerminals = new Set(ids);
16787
+ setTrustWaiting(awaitingTrust);
16331
16788
  forgetEndedTerminals();
16332
16789
  syncShowCard();
16333
16790
  pushTerminalClaims();
@@ -16351,10 +16808,12 @@ function openNewSession(folder, resume = false) {
16351
16808
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
16352
16809
  document.getElementById('ns-wt').checked = false;
16353
16810
  document.getElementById('ns-model').value = '';
16811
+ document.getElementById('ns-effort').value = '';
16354
16812
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
16355
16813
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
16356
16814
  setNewSessionError('');
16357
- const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
16815
+ const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
16816
+ const current = session?.worktree?.repo || session?.project;
16358
16817
  setNewSessionFolder(folder || current || '');
16359
16818
  document.getElementById('new-session-modal').classList.add('visible');
16360
16819
  const input = document.getElementById('ns-folder');
@@ -16458,6 +16917,7 @@ function newSessionValues() {
16458
16917
  name: document.getElementById('ns-name').value.trim() || null,
16459
16918
  worktree: wtOn ? wtName || true : false,
16460
16919
  model: document.getElementById('ns-model').value || null,
16920
+ effort: document.getElementById('ns-effort').value || null,
16461
16921
  prompt: document.getElementById('ns-prompt').value.trim() || null,
16462
16922
  edit,
16463
16923
  stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
@@ -16666,7 +17126,7 @@ function initNewSession() {
16666
17126
  store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
16667
17127
  renderNewSessionForm();
16668
17128
  });
16669
- for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
17129
+ for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
16670
17130
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
16671
17131
  }
16672
17132
  if (SpeechRecognitionApi) {
@@ -16793,6 +17253,7 @@ loadModalFullscreen();
16793
17253
  initModalResize();
16794
17254
  loadPanelWidths();
16795
17255
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
17256
+ initDetailLayout();
16796
17257
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
16797
17258
  initMessagePanelFull();
16798
17259