claude-code-kanban 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -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') {
@@ -9684,7 +9901,8 @@ function setupEventSource() {
9684
9901
  if (!skipOffScreen()) refreshOpenPlan();
9685
9902
  }
9686
9903
 
9687
- if (data.type === 'terminals-update' && Array.isArray(data.ids)) setRunningTerminals(data.ids);
9904
+ if (data.type === 'terminals-update' && Array.isArray(data.ids))
9905
+ setRunningTerminals(data.ids, data.awaitingTrust);
9688
9906
 
9689
9907
  if (data.type === 'terminal:ended' && typeof data.id === 'string') onTerminalEnded(data.id);
9690
9908
 
@@ -10347,9 +10565,8 @@ function isSessionActive(s) {
10347
10565
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
10348
10566
  }
10349
10567
 
10350
- // An active pinned session renders outside the Pinned group until the user dismisses it.
10351
10568
  function isInPinnedGroup(s) {
10352
- return isPlacedPinned(s.id) && !isPlacedSticky(s.id) && (!isSessionActive(s) || dismissedSessionIds.has(s.id));
10569
+ return isPlacedPinned(s.id) && !isPlacedSticky(s.id);
10353
10570
  }
10354
10571
 
10355
10572
  const SESSION_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -11046,24 +11263,32 @@ function renderFilterState() {
11046
11263
  document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
11047
11264
  }
11048
11265
 
11266
+ // With the sidebar cursor on a group header, zen shows that group instead of the open session.
11049
11267
  function toggleZenMode() {
11050
- zenMode = !zenMode;
11268
+ const leavingSessionZen = zenMode;
11269
+ if (zenMode) zenMode = false;
11270
+ else if (zenGroup) zenGroup = null;
11271
+ else {
11272
+ const header = focusZone === 'sidebar' ? sessionsList.querySelector('.session-group-header.kb-selected') : null;
11273
+ zenGroup = header?.dataset.groupId || null;
11274
+ zenMode = !zenGroup;
11275
+ }
11051
11276
  store.setItem(ZEN_KEY, String(zenMode));
11052
11277
  renderZenState();
11053
- if (zenMode) closeFilterMenu();
11278
+ if (zenMode || zenGroup) closeFilterMenu();
11054
11279
  renderSessions();
11055
- // The open session may be outside the current server page/filter; refetch pins it in.
11056
- if (zenMode) fetchSessions(false);
11280
+ // The open session or the group's members may be outside the current server page/filter,
11281
+ // and leaving group zen needs the filtered page back.
11282
+ if (!leavingSessionZen) fetchSessions(false);
11057
11283
  }
11058
11284
 
11059
11285
  function renderZenState() {
11060
11286
  const btn = document.getElementById('zen-mode-btn');
11061
11287
  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)';
11288
+ const on = zenMode || !!zenGroup;
11289
+ btn.classList.toggle('active', on);
11290
+ btn.setAttribute('aria-pressed', String(on));
11291
+ btn.title = on ? 'Zen mode on — show all sessions (Ctrl+Shift+Z)' : 'Zen mode: current session only (Ctrl+Shift+Z)';
11067
11292
  }
11068
11293
 
11069
11294
  function filterBySessions(value) {
@@ -11178,46 +11403,23 @@ document.addEventListener('click', (e) => {
11178
11403
  if (sgHeader) {
11179
11404
  if (e.target.closest('.sg-name-input')) return;
11180
11405
  e.stopPropagation();
11406
+ const groupId = sgHeader.dataset.groupId;
11407
+ const group = sgGroupById(groupId);
11408
+ if (!group) return;
11181
11409
  if (e.target.closest('.sg-keep')) {
11182
- sgKeepTransient(sgHeader.dataset.transientGroup);
11410
+ sgKeep(group);
11411
+ persistSessionGroups();
11183
11412
  renderSessions();
11184
11413
  return;
11185
11414
  }
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
- });
11415
+ if (e.target.closest('.sg-rename')) {
11416
+ sgBeginRename(groupId);
11417
+ return;
11418
+ }
11419
+ if (e.target.closest('.sg-delete')) {
11420
+ sgConfirmDelete(group);
11198
11421
  return;
11199
11422
  }
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
11423
  }
11222
11424
 
11223
11425
  const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
@@ -11572,7 +11774,7 @@ function updateStickyBtnState() {
11572
11774
  const isSticky = stickySessionIds.has(_infoModalSessionId);
11573
11775
  stickyBtn.style.display = '';
11574
11776
  stickyBtn.classList.toggle('active', isSticky);
11575
- stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show at top';
11777
+ stickyBtn.title = isSticky ? 'Remove sticky pin' : 'Sticky pin — always show in the list';
11576
11778
  const svg = stickyBtn.querySelector('svg');
11577
11779
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
11578
11780
  }
@@ -13165,7 +13367,9 @@ function mountPane(sid, pane) {
13165
13367
  view.dataset.key = key;
13166
13368
  paneViews.appendChild(view);
13167
13369
  paneFrames.set(key, view);
13168
- loadPaneView(sid, pane, view);
13370
+ loadPaneView(sid, pane, view).then(() => {
13371
+ if (paneFocusWanted === key && activePaneView() === view) focusActivePane();
13372
+ });
13169
13373
  while (paneFrames.size > PANE_FRAME_LIMIT) {
13170
13374
  const [oldKey, oldView] = paneFrames.entries().next().value;
13171
13375
  paneFrames.delete(oldKey);
@@ -13226,6 +13430,10 @@ async function loadPaneView(sid, pane, view, scroll) {
13226
13430
  : ['pane-frame modal-zoomable', data.content + REVIEW_BRIDGE_TAG + restore];
13227
13431
  const frame = createPreviewFrame(cls, doc);
13228
13432
  frame.title = pane.title;
13433
+ // Focus given before the document loads stays on the frame element, out of the document's keys.
13434
+ frame.addEventListener('load', () => {
13435
+ if (document.activeElement === frame) frame.contentWindow?.focus();
13436
+ });
13229
13437
  view.appendChild(frame);
13230
13438
  attachPaneReview(view, sid, fileReviewOpts(data.path, data.kind, frame, frame));
13231
13439
  return;
@@ -13272,6 +13480,7 @@ function appendPaneDoc(view) {
13272
13480
  doc.className = 'pane-doc';
13273
13481
  const body = document.createElement('div');
13274
13482
  body.className = 'pane-doc-body rendered-md modal-zoomable';
13483
+ body.tabIndex = -1;
13275
13484
  doc.append(body, paneDocGrip(doc));
13276
13485
  view.appendChild(doc);
13277
13486
  return body;
@@ -13347,8 +13556,11 @@ async function reloadPaneView(sid, pane, view) {
13347
13556
  paneReloads.delete(key);
13348
13557
  fresh.className = view.className;
13349
13558
  fresh.style.visibility = '';
13559
+ const hadFocus = view.contains(document.activeElement);
13350
13560
  view.remove();
13351
13561
  paneFrames.set(key, fresh);
13562
+ const target = hadFocus && paneFocusTarget(fresh);
13563
+ if (target) focusPaneTarget(target);
13352
13564
  syncPaneReview();
13353
13565
  return true;
13354
13566
  }
@@ -13393,6 +13605,27 @@ function activePaneView() {
13393
13605
  return wantsTerminal() ? null : paneViews.querySelector('.pane-view.on.pane-focus');
13394
13606
  }
13395
13607
 
13608
+ // A URL page is on another origin and would keep every key from the board, so it gets no focus.
13609
+ function paneFocusTarget(view) {
13610
+ return view?.querySelector(':scope > .pane-doc > .pane-doc-body, :scope > .pane-frame:not(.pane-url-frame)');
13611
+ }
13612
+
13613
+ let paneFocusWanted = null;
13614
+
13615
+ // The key that switches panes also moves focus into the pane, so its content takes the scroll keys.
13616
+ function focusActivePane() {
13617
+ const view = activePaneView();
13618
+ const target = paneFocusTarget(view);
13619
+ paneFocusWanted = view && !view.firstElementChild ? view.dataset.key : null;
13620
+ if (target) focusPaneTarget(target);
13621
+ else if (paneViews.contains(document.activeElement)) document.activeElement.blur();
13622
+ }
13623
+
13624
+ function focusPaneTarget(target) {
13625
+ target.focus({ preventScroll: true });
13626
+ target.contentWindow?.focus();
13627
+ }
13628
+
13396
13629
  function isPaneDocOnScreen() {
13397
13630
  return !!activePaneView()?.querySelector('.modal-zoomable');
13398
13631
  }
@@ -13918,7 +14151,9 @@ paneStrip.addEventListener('click', (e) => {
13918
14151
  return;
13919
14152
  }
13920
14153
  const tab = e.target.closest('.pane-tab');
13921
- if (tab) selectPane(tab.dataset.pane);
14154
+ if (!tab) return;
14155
+ selectPane(tab.dataset.pane);
14156
+ focusActivePane();
13922
14157
  });
13923
14158
  paneStrip.addEventListener('auxclick', (e) => {
13924
14159
  const tab = e.target.closest('.pane-tab');
@@ -14288,6 +14523,7 @@ function toggleTerminal() {
14288
14523
  setTerminalMode(currentSessionId, !terminalModes().has(currentSessionId));
14289
14524
  termState.focusNext = true;
14290
14525
  syncTerminal();
14526
+ if (!wantsTerminal()) focusActivePane();
14291
14527
  }
14292
14528
 
14293
14529
  // The show card sits in the pane but is not the terminal, so Alt+` from it goes to the terminal.
@@ -14352,7 +14588,7 @@ function setSwapPair(last, previous) {
14352
14588
  async function swapToPreviousSession() {
14353
14589
  const target = currentSessionId === lastSessionId ? previousSessionId : lastSessionId;
14354
14590
  if (!target || target === currentSessionId) return;
14355
- if (terminalPaneFocused()) termState.focusNext = true;
14591
+ if (terminalPaneFocused() || isShowCardFocused()) termState.focusNext = true;
14356
14592
  await revealSession(target);
14357
14593
  }
14358
14594
 
@@ -14451,9 +14687,12 @@ function takeTerminalFocus(sessionId) {
14451
14687
  }
14452
14688
 
14453
14689
  // The terminal attaches asynchronously; by then the user may be typing elsewhere or reading a modal.
14690
+ // A session on the trust dialog has no transcript yet, so it may not be in `sessions`.
14454
14691
  function promptAwaitsUser(sessionId) {
14455
- if (!sessions.find((s) => s.id === sessionId)?.hasWaitingForUser || isAnyModalOpen()) return false;
14456
- return !document.activeElement?.matches(TEXT_FIELD_SELECTOR);
14692
+ if (isAnyModalOpen() || document.activeElement?.matches(TEXT_FIELD_SELECTOR)) return false;
14693
+ if (trustWaiting.has(sessionId)) return true;
14694
+ const session = sessions.find((s) => s.id === sessionId);
14695
+ return !!session && isWaitingSession(session);
14457
14696
  }
14458
14697
 
14459
14698
  function onTerminalShown(focus = true) {
@@ -14963,6 +15202,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
14963
15202
  name: spec.name,
14964
15203
  worktree: spec.worktree,
14965
15204
  model: spec.model,
15205
+ effort: spec.effort,
14966
15206
  prompt: spec.prompt,
14967
15207
  edit: spec.edit,
14968
15208
  forkOf: spec.forkOf,
@@ -16210,7 +16450,6 @@ function placeholderSession(id, spec) {
16210
16450
  mode: spec.mode,
16211
16451
  edit: !!spec.edit,
16212
16452
  name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
16213
- dispatchGroup: spec.group || undefined,
16214
16453
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
16215
16454
  project: spec.cwd,
16216
16455
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
@@ -16229,7 +16468,22 @@ function mergePlaceholders(list) {
16229
16468
  for (const id of [...specs.keys()]) if (ids.has(id)) specs.delete(id);
16230
16469
  }
16231
16470
  const pending = [...newSpecs, ...dispatchSpecs].sort((a, b) => b[1].startedAt - a[1].startedAt);
16232
- return [...pending.map(([id, spec]) => placeholderSession(id, spec)), ...list];
16471
+ const placeholders = pending.map(([id, spec]) => placeholderSession(id, spec));
16472
+ revealNewPlaceholders(placeholders);
16473
+ return [...placeholders, ...list];
16474
+ }
16475
+
16476
+ // Ids whose sections were expanded once; a section the user collapses again stays collapsed.
16477
+ const revealedPlaceholderIds = new Set();
16478
+
16479
+ function revealNewPlaceholders(placeholders) {
16480
+ let changed = false;
16481
+ for (const p of placeholders) {
16482
+ if (revealedPlaceholderIds.has(p.id)) continue;
16483
+ revealedPlaceholderIds.add(p.id);
16484
+ if (uncollapseFor(p)) changed = true;
16485
+ }
16486
+ if (changed) persistCollapsedGroups();
16233
16487
  }
16234
16488
 
16235
16489
  async function loadDispatches() {
@@ -16283,10 +16537,14 @@ function renderPlaceholderCard(session, depth) {
16283
16537
  const isActive = session.id === currentSessionId && viewMode === 'session';
16284
16538
  const projectHtml = renderProjectIdentity(session);
16285
16539
  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}`)}">
16540
+ <button onclick="openSession('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}${childDepthClass(depth)}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
16287
16541
  <div class="session-name">${escapeHtml(session.name)}</div>
16288
16542
  ${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>
16543
+ ${
16544
+ trustWaiting.has(session.id)
16545
+ ? `<div class="session-waiting"><span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>trust this folder?</div>`
16546
+ : `<div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>`
16547
+ }
16290
16548
  </button>
16291
16549
  `;
16292
16550
  }
@@ -16296,9 +16554,8 @@ async function restorePendingSessions() {
16296
16554
  document.getElementById('new-session-btn').hidden = !terminalAvailable();
16297
16555
  const terminals = terminalAvailable() ? loadTerminals() : null;
16298
16556
  await loadDispatches();
16299
- if (!terminals) return;
16300
16557
  try {
16301
- for (const t of await terminals) {
16558
+ for (const t of terminals ? await terminals : []) {
16302
16559
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
16303
16560
  newSpecs.set(t.id, {
16304
16561
  cwd: t.cwd,
@@ -16311,6 +16568,8 @@ async function restorePendingSessions() {
16311
16568
  });
16312
16569
  }
16313
16570
  } catch (_) {}
16571
+ // Sessions that were already running keep the collapse state saved before the reload.
16572
+ for (const id of [...newSpecs.keys(), ...dispatchSpecs.keys()]) revealedPlaceholderIds.add(id);
16314
16573
  }
16315
16574
 
16316
16575
  async function loadTerminals() {
@@ -16319,6 +16578,7 @@ async function loadTerminals() {
16319
16578
  if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
16320
16579
  const list = data.sessions || [];
16321
16580
  runningTerminals = new Set(list.map((t) => t.id));
16581
+ setTrustWaiting(list.filter((t) => t.awaitingTrust).map((t) => t.id));
16322
16582
  rememberTerminalIds(list);
16323
16583
  syncShowCard();
16324
16584
  pushTerminalClaims();
@@ -16326,8 +16586,26 @@ async function loadTerminals() {
16326
16586
  return list;
16327
16587
  }
16328
16588
 
16329
- function setRunningTerminals(ids) {
16589
+ // A dispatched session never had its terminal toggled on, so the dialog would stay out of sight.
16590
+ function setTrustWaiting(ids) {
16591
+ const before = trustWaiting;
16592
+ trustWaiting = new Set(ids);
16593
+ for (const id of trustWaiting) {
16594
+ if (before.has(id)) continue;
16595
+ setTerminalMode(id, true);
16596
+ if (id !== currentSessionId || viewMode !== 'session') continue;
16597
+ if (termState.shown && termState.attached) {
16598
+ if (promptAwaitsUser(id)) focusTerminalPane();
16599
+ } else {
16600
+ termState.openedByUser = id;
16601
+ syncTerminal();
16602
+ }
16603
+ }
16604
+ }
16605
+
16606
+ function setRunningTerminals(ids, awaitingTrust = []) {
16330
16607
  runningTerminals = new Set(ids);
16608
+ setTrustWaiting(awaitingTrust);
16331
16609
  forgetEndedTerminals();
16332
16610
  syncShowCard();
16333
16611
  pushTerminalClaims();
@@ -16351,10 +16629,12 @@ function openNewSession(folder, resume = false) {
16351
16629
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt']) document.getElementById(id).value = '';
16352
16630
  document.getElementById('ns-wt').checked = false;
16353
16631
  document.getElementById('ns-model').value = '';
16632
+ document.getElementById('ns-effort').value = '';
16354
16633
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
16355
16634
  document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
16356
16635
  setNewSessionError('');
16357
- const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
16636
+ const session = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId) : null;
16637
+ const current = session?.worktree?.repo || session?.project;
16358
16638
  setNewSessionFolder(folder || current || '');
16359
16639
  document.getElementById('new-session-modal').classList.add('visible');
16360
16640
  const input = document.getElementById('ns-folder');
@@ -16458,6 +16738,7 @@ function newSessionValues() {
16458
16738
  name: document.getElementById('ns-name').value.trim() || null,
16459
16739
  worktree: wtOn ? wtName || true : false,
16460
16740
  model: document.getElementById('ns-model').value || null,
16741
+ effort: document.getElementById('ns-effort').value || null,
16461
16742
  prompt: document.getElementById('ns-prompt').value.trim() || null,
16462
16743
  edit,
16463
16744
  stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
@@ -16666,7 +16947,7 @@ function initNewSession() {
16666
16947
  store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
16667
16948
  renderNewSessionForm();
16668
16949
  });
16669
- for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
16950
+ for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model', 'ns-effort']) {
16670
16951
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
16671
16952
  }
16672
16953
  if (SpeechRecognitionApi) {
@@ -16793,6 +17074,7 @@ loadModalFullscreen();
16793
17074
  initModalResize();
16794
17075
  loadPanelWidths();
16795
17076
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width');
17077
+ initDetailLayout();
16796
17078
  initPanelResize('message-panel', 'message-panel-resize', '--message-panel-width');
16797
17079
  initMessagePanelFull();
16798
17080