claude-code-kanban 4.23.1 → 4.24.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/README.md CHANGED
@@ -53,6 +53,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
53
53
  - **Agent teams** — Color-coded team members, owner filtering, member count badges
54
54
  - **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
55
55
  - **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
56
+ - **Session picker** — Jump to any session in the sidebar with `Shift+P`, filtering by name, project or branch
56
57
  - **Keyboard-first** — Press `?` for the full shortcut reference
57
58
 
58
59
  ![Session info](assets/shot-session-info.png)
package/lib/parsers.js CHANGED
@@ -160,6 +160,17 @@ function formatTaskUsage(usage) {
160
160
  return parts.length ? ' · ' + parts.join(' · ') : '';
161
161
  }
162
162
 
163
+ // Raw per-agent cost for the agent modal's chips plus the formatted one-liner for
164
+ // the inline message row. Null when the source carried no numbers.
165
+ function agentUsageEntry(subagentTokens, toolUses, durationMs) {
166
+ const usage = {};
167
+ if (subagentTokens != null) usage.tokens = subagentTokens;
168
+ if (toolUses != null) usage.tools = toolUses;
169
+ if (durationMs != null) usage.durationMs = durationMs;
170
+ if (!Object.keys(usage).length) return null;
171
+ return { usage, usageText: formatTaskUsage({ subagentTokens, toolUses, durationMs }) };
172
+ }
173
+
163
174
  function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
164
175
  if (sysLabel === '__skip__') return;
165
176
  let safeText = text || '';
@@ -1099,6 +1110,7 @@ function buildSessionDigest(jsonlPath) {
1099
1110
  const taskIdRe = /<task-id>([a-zA-Z0-9_-]+)<\/task-id>/;
1100
1111
  const nameByToolUseId = {};
1101
1112
  const descByToolUseId = {};
1113
+ const usageByToolUseId = {};
1102
1114
  for (const line of content.split('\n')) {
1103
1115
  // Terminated-teammate detection: check first since cheap substring guards
1104
1116
  if (line.includes('teammate-message') &&
@@ -1171,34 +1183,34 @@ function buildSessionDigest(jsonlPath) {
1171
1183
  } else if (line.includes('User rejected tool use') && line.includes('"tool_use_id"')) {
1172
1184
  const m = tmToolIdRe.exec(line);
1173
1185
  if (m) rejectedToolUseIds.add(m[1]);
1174
- } else if (line.includes('<task-notification>') &&
1175
- (line.includes('<status>killed</status>') || line.includes('<status>error</status>'))) {
1176
- const idMatch = taskIdRe.exec(line);
1177
- if (idMatch) killedAgentIds.add(idMatch[1]);
1186
+ } else if (line.includes('<task-notification>')) {
1187
+ // The envelope's tags survive JSON escaping, so read them off the raw line.
1188
+ // This covers every delivery shape (user message, queue-operation,
1189
+ // queued_command attachment) without enumerating them.
1190
+ if (line.includes('<status>killed</status>') || line.includes('<status>error</status>')) {
1191
+ const idMatch = taskIdRe.exec(line);
1192
+ if (idMatch) killedAgentIds.add(idMatch[1]);
1193
+ }
1194
+ if (line.includes('<subagent_tokens>')) {
1195
+ const notif = parseTaskNotification(line);
1196
+ const u = notif?.usage;
1197
+ const entry = u && notif.toolUseId ? agentUsageEntry(u.subagentTokens, u.toolUses, u.durationMs) : null;
1198
+ if (entry) usageByToolUseId[notif.toolUseId] = entry;
1199
+ }
1178
1200
  } else if (line.includes('"toolUseResult"') && line.includes('"agentId"') && line.includes('"tool_result"')) {
1179
1201
  try {
1180
1202
  const obj = JSON.parse(line);
1181
1203
  const tur = obj.toolUseResult;
1182
1204
  if (tur?.agentId) {
1183
- // Foreground agents report their cost on the completion toolUseResult
1184
- // (totalTokens/totalToolUseCount/totalDurationMs) — the same numbers a
1185
- // background agent surfaces via its <task-notification> <usage> block. Keep
1186
- // the raw numbers (for the agent modal's per-stat chips) and a formatted
1187
- // one-liner via the shared formatter (for the inline message row):
1188
- // " · 31.5k tok · 5 tools · 34s".
1189
- const usage = {};
1190
- if (tur.totalTokens != null) usage.tokens = tur.totalTokens;
1191
- if (tur.totalToolUseCount != null) usage.tools = tur.totalToolUseCount;
1192
- if (tur.totalDurationMs != null) usage.durationMs = tur.totalDurationMs;
1193
- const hasUsage = Object.keys(usage).length > 0;
1194
- const usageText = hasUsage
1195
- ? formatTaskUsage({ subagentTokens: usage.tokens, toolUses: usage.tools, durationMs: usage.durationMs })
1196
- : null;
1205
+ // Foreground agents report their cost on the completion toolUseResult;
1206
+ // background agents report the same numbers in their <task-notification>.
1207
+ const { usage, usageText } = agentUsageEntry(tur.totalTokens, tur.totalToolUseCount, tur.totalDurationMs)
1208
+ || { usage: null, usageText: null };
1197
1209
  const blocks = obj.message?.content;
1198
1210
  if (Array.isArray(blocks)) {
1199
1211
  for (const b of blocks) {
1200
1212
  if (b.type === 'tool_result' && b.tool_use_id && !map[b.tool_use_id]) {
1201
- map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage: hasUsage ? usage : null, usageText };
1213
+ map[b.tool_use_id] = { agentId: tur.agentId, prompt: tur.prompt || null, usage, usageText };
1202
1214
  }
1203
1215
  }
1204
1216
  }
@@ -1207,6 +1219,7 @@ function buildSessionDigest(jsonlPath) {
1207
1219
  }
1208
1220
  }
1209
1221
  for (const [key, entry] of Object.entries(map)) {
1222
+ if (!entry.usage && usageByToolUseId[key]) Object.assign(entry, usageByToolUseId[key]);
1210
1223
  if (nameByToolUseId[key]) entry.name = nameByToolUseId[key];
1211
1224
  if (descByToolUseId[key]) entry.description = descByToolUseId[key];
1212
1225
  // Prefer the full prompt from the assistant's tool_use input — Claude Code's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.23.1",
3
+ "version": "4.24.0",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -15,6 +15,9 @@ let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last
15
15
  let recentProjects = new Set();
16
16
  let projectsCacheDirty = true;
17
17
  const collapsedProjectGroups = new Set();
18
+ const SECTION_GROUPS = '__section_groups__';
19
+ const SECTION_PROJECTS = '__section_projects__';
20
+ const SECTION_SESSIONS = '__section_sessions__';
18
21
  let stableGroupOrder = []; // cached project path order to prevent jumping
19
22
  let sessionGroups = []; // user-named groups: [{id, name, color, members:[{type,ref}]}]
20
23
  let sgDrag = null; // in-flight sidebar drag: {kind:'session'|'project'|'group', ref}
@@ -280,6 +283,11 @@ function isWaitingSession(s) {
280
283
  function isActiveSession(s) {
281
284
  return !s.hasWaitingForUser && (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents);
282
285
  }
286
+ // hasRecentLog carries the server's registry-idle correction: an open-but-idle
287
+ // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
288
+ function isSessionLive(s) {
289
+ return !!s.hasRecentLog && Date.now() - Date.parse(s.modifiedAt) <= LIVE_INDICATOR_MS;
290
+ }
283
291
 
284
292
  const ACTIVITY_PREDICATES = {
285
293
  waiting: isWaitingSession,
@@ -2883,10 +2891,8 @@ function showAgentModal(agentId) {
2883
2891
  return `<span class="agent-chip${cls}"${style}${title}>${labelHtml}<span class="agent-chip-val">${value}</span></span>`;
2884
2892
  };
2885
2893
 
2886
- // Agent cost stats live on the launch row's message (resolved from the completion
2887
- // toolUseResult), not on the agent-activity record — so read them from there.
2888
2894
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2889
- const usageStats = agentMsg?.agentUsageStats || null;
2895
+ const usageStats = agent.usage || null;
2890
2896
  const fmtTok = fmtTokens;
2891
2897
 
2892
2898
  const chips = [];
@@ -2953,6 +2959,19 @@ function highlightSelectedAgent() {
2953
2959
  //#region RENDERING
2954
2960
  let revealedPlanSessionId = null;
2955
2961
  let revealedStorageSessionId = null;
2962
+
2963
+ // Opens a session and scrolls the sidebar to it, refetching first when the id
2964
+ // fell outside the session list the current filters asked for.
2965
+ async function revealSession(id) {
2966
+ if (!sessions.some((s) => s.id === id)) {
2967
+ lastSessionsHash = '';
2968
+ await fetchSessions();
2969
+ }
2970
+ await fetchTasks(id);
2971
+ const el = document.querySelector(`.session-item[data-session-id="${escSel(id)}"]`);
2972
+ if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2973
+ }
2974
+
2956
2975
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
2957
2976
  async function revealPlanSession(planSessionId) {
2958
2977
  if (revealedPlanSessionId === planSessionId) {
@@ -2961,13 +2980,7 @@ async function revealPlanSession(planSessionId) {
2961
2980
  return;
2962
2981
  }
2963
2982
  revealedPlanSessionId = planSessionId;
2964
- if (!sessions.some((s) => s.id === planSessionId)) {
2965
- lastSessionsHash = '';
2966
- await fetchSessions();
2967
- }
2968
- await fetchTasks(planSessionId);
2969
- const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(planSessionId)}"]`);
2970
- if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2983
+ await revealSession(planSessionId);
2971
2984
  }
2972
2985
 
2973
2986
  async function showAllTasks() {
@@ -3019,15 +3032,10 @@ function renderAllTasks() {
3019
3032
  renderKanban();
3020
3033
  }
3021
3034
 
3022
- function renderSessions() {
3023
- // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3024
- // half-typed group name, and the SSE path can fire at any moment — defer instead.
3025
- if (sgDrag || sgIsEditing()) return;
3026
- // Update project dropdown
3027
- updateProjectDropdown();
3028
-
3029
- // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
3030
- // project filter → search filter → ensure pinned/sticky sessions are always included
3035
+ // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
3036
+ // project filter → search filter → ensure pinned/sticky sessions are always included.
3037
+ // Pure over module state, so the session picker can ask for the same list without a render.
3038
+ function getFilteredSessions() {
3031
3039
  let filteredSessions = sessions;
3032
3040
  if (sessionFilter === 'active') {
3033
3041
  const activeSessionIds = new Set();
@@ -3111,6 +3119,18 @@ function renderSessions() {
3111
3119
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
3112
3120
  }
3113
3121
 
3122
+ return filteredSessions;
3123
+ }
3124
+
3125
+ function renderSessions() {
3126
+ // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3127
+ // half-typed group name, and the SSE path can fire at any moment — defer instead.
3128
+ if (sgDrag || sgIsEditing()) return;
3129
+ // Update project dropdown
3130
+ updateProjectDropdown();
3131
+
3132
+ const filteredSessions = getFilteredSessions();
3133
+
3114
3134
  if (filteredSessions.length === 0) {
3115
3135
  let emptyMsg = 'No sessions found';
3116
3136
  let emptyHint = 'Tasks appear when you use Claude Code';
@@ -3142,13 +3162,8 @@ function renderSessions() {
3142
3162
  const total = session.taskCount;
3143
3163
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3144
3164
  const isActive = session.id === currentSessionId && viewMode === 'session';
3145
- // hasRecentLog carries the server's registry-idle correction: an open-but-idle
3146
- // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
3147
- const isLive = session.hasRecentLog && Date.now() - Date.parse(session.modifiedAt) <= LIVE_INDICATOR_MS;
3148
- const rawName = session.name || session.id;
3149
- const sessionName = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(rawName)
3150
- ? rawName.slice(0, 8)
3151
- : rawName;
3165
+ const isLive = isSessionLive(session);
3166
+ const sessionName = sessionDisplayName(session);
3152
3167
  const useGrouped = sessionFilter === 'active' && session.project;
3153
3168
  const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
3154
3169
  const secondaryName = useGrouped ? null : session.project ? sessionName : null;
@@ -3214,6 +3229,15 @@ function renderSessions() {
3214
3229
  const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3215
3230
  return `<span class="group-count" title="${active} active / ${arr.length} total">${active > 0 ? `<span class="group-count-active">${active}</span><span class="group-count-sep">/</span>` : ''}${arr.length}</span>`;
3216
3231
  };
3232
+ // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3233
+ const sectionHtml = (key, text, ungroup, body) => {
3234
+ const collapsed = collapsedProjectGroups.has(key);
3235
+ const escPath = escapeHtml(key);
3236
+ const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3237
+ return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3238
+ <div class="sg-section-body${collapsed ? ' collapsed' : ''}" data-group-path="${escPath}">${body}</div>`;
3239
+ };
3240
+
3217
3241
  const renderGroupSessions = (sessions, pinKey) => {
3218
3242
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3219
3243
  const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
@@ -3300,7 +3324,7 @@ function renderSessions() {
3300
3324
  // flat = the "All sessions" view, which has no project sub-blocks.
3301
3325
  const sgSectionHtml = (flat) => {
3302
3326
  if (sessionGroups.length === 0) return '';
3303
- let out = '<div class="sg-section-label">Groups</div>';
3327
+ let out = '';
3304
3328
  for (const group of sessionGroups) {
3305
3329
  const bucket = sgBuckets.get(group.id) || [];
3306
3330
  if (bucket.length === 0 && sgFiltering) continue;
@@ -3343,7 +3367,8 @@ function renderSessions() {
3343
3367
  out += sgHeaderHtml(group, countHtml(bucket));
3344
3368
  out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
3345
3369
  }
3346
- return out;
3370
+ if (!out) return '';
3371
+ return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
3347
3372
  };
3348
3373
 
3349
3374
  let html = '';
@@ -3391,17 +3416,19 @@ function renderSessions() {
3391
3416
  stableGroupOrder = [...keptOrder, ...newPaths];
3392
3417
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3393
3418
 
3394
- // Rendered even when nothing is left under it: it is the drop target for ungrouping.
3395
- if (sessionGroups.length > 0) {
3396
- html += '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Projects</div>';
3397
- }
3398
- for (const [projectPath, projectSessions] of sortedGroups) {
3399
- html += projectBlock(projectPath, projectSessions, false);
3400
- }
3419
+ // The section header is rendered even when nothing is left under it: it is the drop target
3420
+ // for ungrouping. Its body is skipped while collapsed — nothing would be visible, and this is
3421
+ // the whole project list, re-parsed on every live refresh.
3422
+ const sectioned = sessionGroups.length > 0;
3423
+ let projectsHtml = '';
3424
+ if (!sectioned || !collapsedProjectGroups.has(SECTION_PROJECTS)) {
3425
+ for (const [projectPath, projectSessions] of sortedGroups) {
3426
+ projectsHtml += projectBlock(projectPath, projectSessions, false);
3427
+ }
3401
3428
 
3402
- if (ungrouped.length > 0 && sortedGroups.length > 0) {
3403
- const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
3404
- html += `
3429
+ if (ungrouped.length > 0 && sortedGroups.length > 0) {
3430
+ const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
3431
+ projectsHtml += `
3405
3432
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
3406
3433
  ${groupChevronSvg()}
3407
3434
  <span class="group-name">Ungrouped</span>
@@ -3411,17 +3438,20 @@ function renderSessions() {
3411
3438
  ${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
3412
3439
  </div>
3413
3440
  `;
3414
- } else {
3415
- html += ungrouped.map(renderSessionCard).join('');
3441
+ } else {
3442
+ projectsHtml += ungrouped.map(renderSessionCard).join('');
3443
+ }
3416
3444
  }
3417
3445
 
3446
+ html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml) : projectsHtml;
3447
+
3418
3448
  sessionsList.innerHTML = html;
3419
3449
  } else {
3420
- const ungroupZone =
3421
- sessionGroups.length > 0
3422
- ? '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Sessions</div>'
3423
- : '';
3424
- sessionsList.innerHTML = html + sgSectionHtml(true) + ungroupZone + sgRest.map(renderSessionCard).join('');
3450
+ const sectioned = sessionGroups.length > 0;
3451
+ const restHtml =
3452
+ sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3453
+ const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml) : restHtml;
3454
+ sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
3425
3455
  }
3426
3456
 
3427
3457
  const navItems = getNavigableItems();
@@ -4416,14 +4446,24 @@ function getKbId(el) {
4416
4446
  return el.dataset.sessionId || el.dataset.groupPath || null;
4417
4447
  }
4418
4448
 
4449
+ // Every collapsible header class and the body class it opens. Order matters only in that a
4450
+ // header carries exactly one of these.
4451
+ const COLLAPSIBLE_BODY_CLASS = {
4452
+ 'pinned-sub-header': 'pinned-sub-items',
4453
+ 'session-group-header': 'session-group-sessions',
4454
+ 'sg-section-toggle': 'sg-section-body',
4455
+ 'project-group-header': 'project-group-sessions',
4456
+ };
4457
+ const COLLAPSIBLE_HEADER_SELECTOR = Object.keys(COLLAPSIBLE_BODY_CLASS)
4458
+ .map((c) => `.${c}`)
4459
+ .join(', ');
4460
+
4419
4461
  function getGroupSessionsContainer(header) {
4420
- const cls = header.classList.contains('pinned-sub-header')
4421
- ? 'pinned-sub-items'
4422
- : header.classList.contains('session-group-header')
4423
- ? 'session-group-sessions'
4424
- : 'project-group-sessions';
4462
+ const cls = Object.keys(COLLAPSIBLE_BODY_CLASS).find((c) => header.classList.contains(c));
4463
+ if (!cls) return null;
4425
4464
  let el = header.nextElementSibling;
4426
- while (el && !el.classList.contains(cls)) el = el.nextElementSibling;
4465
+ const bodyCls = COLLAPSIBLE_BODY_CLASS[cls];
4466
+ while (el && !el.classList.contains(bodyCls)) el = el.nextElementSibling;
4427
4467
  return el;
4428
4468
  }
4429
4469
 
@@ -4450,16 +4490,27 @@ function getNavigableItems() {
4450
4490
  }
4451
4491
  }
4452
4492
  };
4453
- for (const el of sessionsList.children) {
4454
- if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
4455
- items.push(el);
4456
- if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
4457
- walkGroupContainer(getGroupSessionsContainer(el));
4493
+ // A section label sits next to its body div rather than wrapping it, so the top level walks
4494
+ // into the body to reach the blocks inside.
4495
+ const walkTopLevel = (children) => {
4496
+ for (const el of children) {
4497
+ if (
4498
+ el.classList.contains('project-group-header') ||
4499
+ el.classList.contains('session-group-header') ||
4500
+ el.classList.contains('sg-section-toggle')
4501
+ ) {
4502
+ items.push(el);
4503
+ if (!el.classList.contains('sg-section-toggle') && !collapsedProjectGroups.has(el.dataset.groupPath)) {
4504
+ walkGroupContainer(getGroupSessionsContainer(el));
4505
+ }
4506
+ } else if (el.classList.contains('session-item')) {
4507
+ items.push(el);
4508
+ } else if (el.classList.contains('sg-section-body') && !el.classList.contains('collapsed')) {
4509
+ walkTopLevel(el.children);
4458
4510
  }
4459
- } else if (el.classList.contains('session-item')) {
4460
- items.push(el);
4461
4511
  }
4462
- }
4512
+ };
4513
+ walkTopLevel(sessionsList.children);
4463
4514
  return items;
4464
4515
  }
4465
4516
 
@@ -4543,6 +4594,10 @@ function expandActiveGroups({ onlyNew = false } = {}) {
4543
4594
  // A named group wrapping that project (or the session itself) would keep it hidden.
4544
4595
  const group = sgGroupForSession(s);
4545
4596
  if (group && collapsedProjectGroups.delete(sgKey(group.id))) changed = true;
4597
+ // Which section holds it depends on the view, so open every one rather than re-deriving it.
4598
+ for (const key of [SECTION_GROUPS, SECTION_PROJECTS, SECTION_SESSIONS]) {
4599
+ if (collapsedProjectGroups.delete(key)) changed = true;
4600
+ }
4546
4601
  }
4547
4602
  prevActiveSessionIds = activeIds;
4548
4603
  if (changed) persistCollapsedGroups();
@@ -4550,11 +4605,7 @@ function expandActiveGroups({ onlyNew = false } = {}) {
4550
4605
  }
4551
4606
 
4552
4607
  function isGroupHeader(el) {
4553
- return (
4554
- el.classList.contains('project-group-header') ||
4555
- el.classList.contains('pinned-sub-header') ||
4556
- el.classList.contains('session-group-header')
4557
- );
4608
+ return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
4558
4609
  }
4559
4610
 
4560
4611
  function findParentHeader(el) {
@@ -5032,19 +5083,122 @@ async function confirmDelete() {
5032
5083
  //#endregion
5033
5084
 
5034
5085
  //#region HELP
5086
+ // Each entry pairs a left and a right group onto the same grid rows, so their
5087
+ // headings sit level and the shorter group just leaves empty rows.
5088
+ // `combo` joins the keys with a plus (a chord) instead of listing them as
5089
+ // alternatives; `hub` marks rows that only work inside Claude Code Hub.
5090
+ const SHORTCUT_PAIRS = [
5091
+ [
5092
+ {
5093
+ title: 'Navigate',
5094
+ rows: [
5095
+ { keys: ['J', '↓'], label: 'Next item' },
5096
+ { keys: ['K', '↑'], label: 'Previous item' },
5097
+ { keys: ['H', '←'], label: 'Left column / collapse group' },
5098
+ { keys: ['L', '→'], label: 'Right column / expand group' },
5099
+ { keys: ['Tab'], label: 'Switch sidebar ↔ board' },
5100
+ { keys: ['Enter', 'Space'], label: 'Open selected item' },
5101
+ ],
5102
+ },
5103
+ {
5104
+ title: 'Session',
5105
+ rows: [
5106
+ { keys: ['P'], label: 'Open plan' },
5107
+ { keys: ['Shift', 'P'], combo: true, label: 'Session picker' },
5108
+ { keys: ['I'], label: 'Session info' },
5109
+ { keys: ['.'], label: 'Pin / unpin' },
5110
+ { keys: ['>'], label: 'Toggle sticky' },
5111
+ { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session' },
5112
+ { keys: ['Shift', 'L'], combo: true, label: 'Toggle session log' },
5113
+ { keys: ['Shift', 'M'], combo: true, label: 'Open last message' },
5114
+ { keys: ['J', 'K'], label: 'Previous / next message in detail' },
5115
+ ],
5116
+ },
5117
+ ],
5118
+ [
5119
+ {
5120
+ title: 'Board',
5121
+ rows: [
5122
+ { keys: ['Enter'], label: 'Toggle task detail panel' },
5123
+ { keys: ['D'], label: 'Delete selected task' },
5124
+ { keys: ['N'], label: 'Toggle scratchpad' },
5125
+ { keys: ['R'], label: 'Refresh data' },
5126
+ { keys: ['Esc'], label: 'Close panel / clear selection' },
5127
+ ],
5128
+ },
5129
+ {
5130
+ title: 'View',
5131
+ rows: [
5132
+ { keys: ['['], label: 'Toggle sidebar' },
5133
+ { keys: ['T'], label: 'Toggle theme' },
5134
+ { keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
5135
+ { keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
5136
+ { keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
5137
+ { keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
5138
+ { keys: ['?'], label: 'Show this help' },
5139
+ ],
5140
+ },
5141
+ ],
5142
+ [
5143
+ {
5144
+ title: 'Copy',
5145
+ rows: [
5146
+ { keys: ['Shift', 'C'], combo: true, label: 'Session id' },
5147
+ { keys: ['Ctrl', 'Shift', 'C'], combo: true, label: 'Resume command (claude -r <id>)' },
5148
+ ],
5149
+ },
5150
+ {
5151
+ title: 'Hub',
5152
+ hub: true,
5153
+ rows: [
5154
+ { keys: ['M'], label: 'Jump to marketplace' },
5155
+ { keys: ['$'], label: 'Jump to cost' },
5156
+ { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5157
+ { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5158
+ { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5159
+ { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5160
+ ],
5161
+ },
5162
+ ],
5163
+ ];
5164
+
5165
+ const EMPTY_GROUP = { title: '', rows: [] };
5166
+
5167
+ // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
5168
+ // them on shared row tracks (see .shortcuts in style.css).
5169
+ function buildHelpShortcuts() {
5170
+ const cells = [];
5171
+ SHORTCUT_PAIRS.forEach(([left, right = EMPTY_GROUP], pair) => {
5172
+ const first = pair === 0 ? ' sc-first' : '';
5173
+ const sides = [
5174
+ [left, 'sc-l'],
5175
+ [right, 'sc-r'],
5176
+ ];
5177
+ const head = (g, side) =>
5178
+ g.title ? `<div class="${escapeHtml(`sc-group ${side}${first}${g.hub ? ' sc-hub' : ''}`)}">${g.title}</div>` : '';
5179
+ cells.push(sides.map(([g, side]) => head(g, side)).join(''));
5180
+ for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
5181
+ for (const [group, side] of sides) {
5182
+ const row = group.rows[i];
5183
+ if (!row) continue;
5184
+ const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
5185
+ const keys = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
5186
+ const cls = side + (group.hub ? ' sc-hub' : '');
5187
+ cells.push(
5188
+ `<dt class="${escapeHtml(cls)}">${keys}</dt><dd class="${escapeHtml(cls)}">${escapeHtml(row.label)}</dd>`,
5189
+ );
5190
+ }
5191
+ }
5192
+ });
5193
+ return cells.join('');
5194
+ }
5195
+
5035
5196
  function showHelpModal() {
5036
5197
  const modal = document.getElementById('help-modal');
5198
+ const list = document.getElementById('help-shortcuts');
5199
+ if (!list.childElementCount) list.innerHTML = buildHelpShortcuts();
5200
+ list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
5037
5201
  modal.classList.add('visible');
5038
-
5039
- // Handle keyboard shortcuts
5040
- const keyHandler = (e) => {
5041
- if (e.key === 'Escape' || e.key === '?') {
5042
- e.preventDefault();
5043
- closeHelpModal();
5044
- document.removeEventListener('keydown', keyHandler);
5045
- }
5046
- };
5047
- document.addEventListener('keydown', keyHandler);
5048
5202
  }
5049
5203
 
5050
5204
  function closeHelpModal() {
@@ -5320,13 +5474,7 @@ function _renderStorageSessions() {
5320
5474
  async function _storageViewSession(id) {
5321
5475
  closeStorageManager();
5322
5476
  revealedStorageSessionId = id;
5323
- if (!sessions.some((s) => s.id === id)) {
5324
- lastSessionsHash = '';
5325
- await fetchSessions();
5326
- }
5327
- await fetchTasks(id);
5328
- const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(id)}"]`);
5329
- if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
5477
+ await revealSession(id);
5330
5478
  }
5331
5479
 
5332
5480
  function _storageUnpinSession(id) {
@@ -5618,6 +5766,7 @@ const MODAL_CLOSERS = {
5618
5766
  'team-modal': () => closeTeamModal(),
5619
5767
  'agent-modal': () => closeAgentModal(),
5620
5768
  'help-modal': () => closeHelpModal(),
5769
+ 'session-picker-modal': () => closeSessionPicker(),
5621
5770
  };
5622
5771
 
5623
5772
  document.addEventListener('keydown', (e) => {
@@ -5651,6 +5800,9 @@ document.addEventListener('keydown', (e) => {
5651
5800
  if (close) close();
5652
5801
  else document.getElementById(topId).classList.remove('visible');
5653
5802
  e.stopImmediatePropagation();
5803
+ } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
5804
+ e.preventDefault();
5805
+ closeHelpModal();
5654
5806
  } else if (
5655
5807
  e.code === 'KeyM' &&
5656
5808
  e.shiftKey &&
@@ -5720,6 +5872,12 @@ document.addEventListener('keydown', (e) => {
5720
5872
  showStorageManager();
5721
5873
  return;
5722
5874
  }
5875
+ // Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
5876
+ if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
5877
+ e.preventDefault();
5878
+ openSessionPicker();
5879
+ return;
5880
+ }
5723
5881
  if (e.key === '.' || e.key === '>') {
5724
5882
  const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
5725
5883
  if (sid) {
@@ -5894,16 +6052,18 @@ document.addEventListener('keydown', (e) => {
5894
6052
  }
5895
6053
  return;
5896
6054
  }
5897
- if (e.code === 'KeyC' && e.shiftKey) {
6055
+ if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
5898
6056
  e.preventDefault();
5899
6057
  if (!contextSid) {
5900
6058
  showToast('No session selected');
5901
6059
  return;
5902
6060
  }
6061
+ const text = e.ctrlKey ? `claude -r ${contextSid}` : contextSid;
6062
+ const label = e.ctrlKey ? 'resume command' : 'session id';
5903
6063
  navigator.clipboard
5904
- .writeText(contextSid)
5905
- .then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
5906
- .catch(() => showToast('Failed to copy session id'));
6064
+ .writeText(text)
6065
+ .then(() => showToast(`Copied ${label}: ${contextSid.slice(0, 8)}`, 'success'))
6066
+ .catch(() => showToast(`Failed to copy ${label}`));
5907
6067
  return;
5908
6068
  }
5909
6069
  if (matchKey(e, 'KeyR')) {
@@ -6863,6 +7023,13 @@ function isSessionActive(s) {
6863
7023
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
6864
7024
  }
6865
7025
 
7026
+ 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;
7027
+
7028
+ function sessionDisplayName(session) {
7029
+ const raw = session.name || session.id;
7030
+ return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
7031
+ }
7032
+
6866
7033
  function formatDate(dateStr) {
6867
7034
  const date = new Date(dateStr);
6868
7035
  const now = new Date();
@@ -7416,7 +7583,7 @@ document.addEventListener('click', (e) => {
7416
7583
  }
7417
7584
  }
7418
7585
 
7419
- const header = e.target.closest('.session-group-header, .pinned-sub-header, .project-group-header');
7586
+ const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
7420
7587
  if (header) {
7421
7588
  setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
7422
7589
  }
@@ -8029,6 +8196,112 @@ let _planSessionId = null;
8029
8196
 
8030
8197
  //#endregion
8031
8198
 
8199
+ //#region SESSION_PICKER
8200
+ // Snapshot of what the sidebar is showing, taken once per open: the picker only ever offers
8201
+ // sessions already on screen, and the recency order cannot change while it is up.
8202
+ let spSource = [];
8203
+ let spRows = [];
8204
+ let spIdx = 0;
8205
+
8206
+ function openSessionPicker() {
8207
+ const input = document.getElementById('session-picker-input');
8208
+ input.value = '';
8209
+ spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
8210
+ document.getElementById('session-picker-modal').classList.add('visible');
8211
+ renderSessionPicker();
8212
+ input.focus();
8213
+ }
8214
+
8215
+ function closeSessionPicker() {
8216
+ hideModalOverlay('session-picker-modal');
8217
+ }
8218
+
8219
+ function spMatches(session, query) {
8220
+ return (
8221
+ fuzzyMatch(session.name, query) ||
8222
+ fuzzyMatch(session.id, query) ||
8223
+ fuzzyMatch(session.project, query) ||
8224
+ fuzzyMatch(session.gitBranch, query)
8225
+ );
8226
+ }
8227
+
8228
+ // Spelled out rather than interpolated so the class never comes from a variable.
8229
+ const SP_DOTS = {
8230
+ waiting: '<span class="activity-dot waiting"></span>',
8231
+ live: '<span class="activity-dot live"></span>',
8232
+ active: '<span class="activity-dot active"></span>',
8233
+ idle: '<span class="activity-dot"></span>',
8234
+ };
8235
+
8236
+ function spDotHtml(session) {
8237
+ if (isWaitingSession(session)) return SP_DOTS.waiting;
8238
+ if (isSessionLive(session)) return SP_DOTS.live;
8239
+ return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
8240
+ }
8241
+
8242
+ function renderSessionPicker() {
8243
+ const list = document.getElementById('session-picker-list');
8244
+ const query = document.getElementById('session-picker-input').value.trim();
8245
+ spRows = spSource.filter((s) => spMatches(s, query));
8246
+
8247
+ if (!spRows.length) {
8248
+ spIdx = -1;
8249
+ list.innerHTML = `<div class="sp-empty">${spSource.length ? 'No session matches' : 'No sessions in the current sidebar filter'}</div>`;
8250
+ return;
8251
+ }
8252
+
8253
+ list.innerHTML = spRows
8254
+ .map((s, i) => {
8255
+ const project = s.project ? s.project.split(/[/\\]/).pop() : '';
8256
+ return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}" data-idx="${i}" title="${escapeHtml(s.id)}">
8257
+ ${spDotHtml(s)}
8258
+ <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
8259
+ <span class="sp-project">${escapeHtml(project)}</span>
8260
+ ${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
8261
+ <span class="sp-count">${s.completed}/${s.taskCount}</span>
8262
+ <span class="sp-time">${formatDate(s.modifiedAt)}</span>
8263
+ </button>`;
8264
+ })
8265
+ .join('');
8266
+ spSelect(0);
8267
+ }
8268
+
8269
+ function spSelect(idx) {
8270
+ const list = document.getElementById('session-picker-list');
8271
+ list.children[spIdx]?.classList.remove('selected');
8272
+ spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
8273
+ const row = list.children[spIdx];
8274
+ row?.classList.add('selected');
8275
+ row?.scrollIntoView({ block: 'nearest' });
8276
+ }
8277
+
8278
+ async function spOpen(idx) {
8279
+ const session = spRows[idx];
8280
+ if (!session) return;
8281
+ closeSessionPicker();
8282
+ await revealSession(session.id);
8283
+ }
8284
+
8285
+ function initSessionPicker() {
8286
+ const input = document.getElementById('session-picker-input');
8287
+ input.addEventListener('input', renderSessionPicker);
8288
+ // Bound on the input: the global handler returns early on INPUT targets.
8289
+ input.addEventListener('keydown', (e) => {
8290
+ if (e.key === 'Escape') closeSessionPicker();
8291
+ else if (matchKey(e, 'ArrowDown') || (e.ctrlKey && e.key === 'n')) spSelect(spIdx + 1);
8292
+ else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && e.key === 'p')) spSelect(spIdx - 1);
8293
+ else if (e.key === 'Enter') spOpen(spIdx);
8294
+ else return;
8295
+ e.preventDefault();
8296
+ e.stopPropagation();
8297
+ });
8298
+ document.getElementById('session-picker-list').addEventListener('click', (e) => {
8299
+ const row = e.target.closest('.sp-row');
8300
+ if (row) spOpen(Number(row.dataset.idx));
8301
+ });
8302
+ }
8303
+ //#endregion
8304
+
8032
8305
  //#region PLAN
8033
8306
  function refreshOpenPlan() {
8034
8307
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -8665,6 +8938,7 @@ try {
8665
8938
  } catch (_) {}
8666
8939
  loadSessionGroups();
8667
8940
  initSessionGroupsDnd();
8941
+ initSessionPicker();
8668
8942
  try {
8669
8943
  const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
8670
8944
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
package/public/index.html CHANGED
@@ -346,7 +346,7 @@
346
346
 
347
347
  <!-- Help Modal -->
348
348
  <div id="help-modal" class="modal-overlay" onclick="closeHelpModal()">
349
- <div class="modal" onclick="event.stopPropagation()">
349
+ <div class="modal modal-help" onclick="event.stopPropagation()">
350
350
  <div class="modal-header">
351
351
  <h3 class="modal-title">Keyboard Shortcuts</h3>
352
352
  <button class="modal-close" aria-label="Close dialog" onclick="closeHelpModal()">
@@ -356,136 +356,7 @@
356
356
  </button>
357
357
  </div>
358
358
  <div class="modal-body modal-zoomable">
359
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px;">
360
- <div style="display: grid; gap: 16px; align-content: start;">
361
- <div>
362
- <h4 style="margin: 0 0 8px 0; color: var(--text-primary); font-size: 14px; font-weight: 600;">Global</h4>
363
- <table style="width: 100%; font-size: 13px;">
364
- <tr>
365
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">?</kbd></td>
366
- <td style="padding: 4px 0; color: var(--text-primary);">Show keyboard shortcuts</td>
367
- </tr>
368
- <tr>
369
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Esc</kbd></td>
370
- <td style="padding: 4px 0; color: var(--text-primary);">Close panels or cancel</td>
371
- </tr>
372
- <tr>
373
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Tab</kbd></td>
374
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle sidebar / board focus</td>
375
- </tr>
376
- <tr>
377
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">[</kbd></td>
378
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle sidebar collapse</td>
379
- </tr>
380
- <tr>
381
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">R</kbd></td>
382
- <td style="padding: 4px 0; color: var(--text-primary);">Reload page</td>
383
- </tr>
384
- </table>
385
- </div>
386
- <div>
387
- <h4 style="margin: 0 0 8px 0; color: var(--text-primary); font-size: 14px; font-weight: 600;">Navigation</h4>
388
- <table style="width: 100%; font-size: 13px;">
389
- <tr>
390
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&darr;</kbd></td>
391
- <td style="padding: 4px 0; color: var(--text-primary);">Next task in column</td>
392
- </tr>
393
- <tr>
394
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">K</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&uarr;</kbd></td>
395
- <td style="padding: 4px 0; color: var(--text-primary);">Previous task in column</td>
396
- </tr>
397
- <tr>
398
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">H</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&larr;</kbd></td>
399
- <td style="padding: 4px 0; color: var(--text-primary);">Move to left column</td>
400
- </tr>
401
- <tr>
402
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">L</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&rarr;</kbd></td>
403
- <td style="padding: 4px 0; color: var(--text-primary);">Move to right column</td>
404
- </tr>
405
- <tr>
406
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Enter</kbd></td>
407
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle task detail panel</td>
408
- </tr>
409
- </table>
410
- </div>
411
- </div>
412
- <div style="align-content: start;">
413
- <h4 style="margin: 0 0 8px 0; color: var(--text-primary); font-size: 14px; font-weight: 600;">Actions</h4>
414
- <table style="width: 100%; font-size: 13px;">
415
- <tr>
416
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">P</kbd></td>
417
- <td style="padding: 4px 0; color: var(--text-primary);">Open session plan</td>
418
- </tr>
419
- <tr>
420
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">I</kbd></td>
421
- <td style="padding: 4px 0; color: var(--text-primary);">Open session info</td>
422
- </tr>
423
- <tr>
424
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+D</kbd></td>
425
- <td style="padding: 4px 0; color: var(--text-primary);">Dismiss selected session</td>
426
- </tr>
427
- <tr>
428
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
429
- <td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
430
- </tr>
431
- <tr>
432
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">N</kbd></td>
433
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle scratchpad</td>
434
- </tr>
435
- <tr>
436
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">.</kbd></td>
437
- <td style="padding: 4px 0; color: var(--text-primary);">Pin/unpin selected session</td>
438
- </tr>
439
- <tr>
440
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">&gt;</kbd></td>
441
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle sticky on selected session</td>
442
- </tr>
443
- <tr>
444
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">T</kbd></td>
445
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle theme</td>
446
- </tr>
447
- <tr>
448
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">M</kbd></td>
449
- <td style="padding: 4px 0; color: var(--text-primary);">Jump to marketplace</td>
450
- </tr>
451
- <tr>
452
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">$</kbd></td>
453
- <td style="padding: 4px 0; color: var(--text-primary);">Jump to cost</td>
454
- </tr>
455
- <tr>
456
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+L</kbd></td>
457
- <td style="padding: 4px 0; color: var(--text-primary);">Toggle session log</td>
458
- </tr>
459
- <tr>
460
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+M</kbd></td>
461
- <td style="padding: 4px 0; color: var(--text-primary);">Open last message detail</td>
462
- </tr>
463
- <tr>
464
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl++</kbd> / <kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+-</kbd></td>
465
- <td style="padding: 4px 0; color: var(--text-primary);">Modal text size</td>
466
- </tr>
467
- <tr>
468
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Ctrl+0</kbd></td>
469
- <td style="padding: 4px 0; color: var(--text-primary);">Reset text size</td>
470
- </tr>
471
- <tr>
472
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+S</kbd></td>
473
- <td style="padding: 4px 0; color: var(--text-primary);">Storage manager</td>
474
- </tr>
475
- <tr>
476
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+C</kbd></td>
477
- <td style="padding: 4px 0; color: var(--text-primary);">Copy session id</td>
478
- </tr>
479
- <tr>
480
- <td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">J/K</kbd></td>
481
- <td style="padding: 4px 0; color: var(--text-primary);">Navigate messages in detail modal</td>
482
- </tr>
483
- </table>
484
- </div>
485
- </div>
486
- </div>
487
- <div class="modal-footer">
488
- <button class="btn btn-primary" onclick="closeHelpModal()">Got it</button>
359
+ <dl class="shortcuts" id="help-shortcuts"></dl>
489
360
  </div>
490
361
  </div>
491
362
  </div>
@@ -737,6 +608,14 @@
737
608
  </div>
738
609
  </div>
739
610
 
611
+ <div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
612
+ <div class="modal session-picker" onclick="event.stopPropagation()">
613
+ <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" spellcheck="false">
614
+ <div id="session-picker-list" class="sp-list"></div>
615
+ <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Esc</kbd> close</div>
616
+ </div>
617
+ </div>
618
+
740
619
  <div id="toast" class="toast"></div>
741
620
  </body>
742
621
  </html>
package/public/style.css CHANGED
@@ -4264,6 +4264,13 @@ body.light .msg-assistant .msg-text {
4264
4264
  display: flex;
4265
4265
  }
4266
4266
 
4267
+ /* For pickers: the dialog sits above the vertical centre so its list grows
4268
+ downward instead of shifting rows under the cursor as it filters. */
4269
+ .modal-overlay.top-aligned {
4270
+ align-items: flex-start;
4271
+ padding-top: 12vh;
4272
+ }
4273
+
4267
4274
  .plan-modal-overlay {
4268
4275
  z-index: 10001;
4269
4276
  background: rgba(0, 0, 0, 0.6);
@@ -4512,6 +4519,106 @@ body.light .msg-assistant .msg-text {
4512
4519
  max-width: 440px;
4513
4520
  }
4514
4521
 
4522
+ .modal-help {
4523
+ max-width: 960px;
4524
+ }
4525
+
4526
+ /* One shared grid for both halves (key | label | spacer | key | label): each key
4527
+ gutter is sized by its column's widest combo, and left/right rows share row
4528
+ tracks, so paired groups line up — a shorter group just leaves empty rows.
4529
+ Cells opt into a half via sc-l / sc-r; the markup is generated from
4530
+ SHORTCUT_PAIRS in app.js. */
4531
+ .shortcuts {
4532
+ margin: 0;
4533
+ display: grid;
4534
+ grid-template-columns: max-content 1fr 28px max-content 1fr;
4535
+ column-gap: 14px;
4536
+ }
4537
+
4538
+ .shortcuts dt.sc-l {
4539
+ grid-column: 1;
4540
+ }
4541
+ .shortcuts dd.sc-l {
4542
+ grid-column: 2;
4543
+ }
4544
+ .shortcuts dt.sc-r {
4545
+ grid-column: 4;
4546
+ }
4547
+ .shortcuts dd.sc-r {
4548
+ grid-column: 5;
4549
+ }
4550
+ .shortcuts .sc-group.sc-l {
4551
+ grid-column: 1 / 3;
4552
+ }
4553
+ .shortcuts .sc-group.sc-r {
4554
+ grid-column: 4 / 6;
4555
+ }
4556
+
4557
+ .shortcuts dt {
4558
+ margin: 0;
4559
+ display: flex;
4560
+ align-items: center;
4561
+ align-self: center;
4562
+ gap: 4px;
4563
+ padding: 3px 0;
4564
+ }
4565
+
4566
+ .shortcuts dd {
4567
+ margin: 0;
4568
+ font-size: 13px;
4569
+ color: var(--text-secondary);
4570
+ align-self: center;
4571
+ padding: 3px 0;
4572
+ }
4573
+
4574
+ /* Section headings carry the column: full-width rule + primary ink, so the eye
4575
+ finds the group before it reads any single row. */
4576
+ .shortcuts .sc-group {
4577
+ padding: 18px 0 6px;
4578
+ margin-bottom: 6px;
4579
+ font-size: 11px;
4580
+ font-weight: 700;
4581
+ letter-spacing: 0.16em;
4582
+ text-transform: uppercase;
4583
+ color: var(--text-primary);
4584
+ border-bottom: 1px solid var(--border);
4585
+ }
4586
+
4587
+ .shortcuts .sc-group.sc-first {
4588
+ padding-top: 2px;
4589
+ }
4590
+
4591
+ /* keycap chips: elevated fill + thicker bottom border reads as a key */
4592
+ .sp-hint kbd,
4593
+ .shortcuts kbd {
4594
+ display: inline-flex;
4595
+ align-items: center;
4596
+ justify-content: center;
4597
+ min-width: 22px;
4598
+ height: 22px;
4599
+ padding: 0 6px;
4600
+ box-sizing: border-box;
4601
+ font-family: var(--font-mono, monospace);
4602
+ font-size: 11px;
4603
+ color: var(--text-primary);
4604
+ background: var(--bg-hover);
4605
+ border: 1px solid var(--border);
4606
+ border-bottom-width: 2px;
4607
+ border-radius: 5px;
4608
+ }
4609
+
4610
+ .shortcuts .sc-plus,
4611
+ .shortcuts .sc-or {
4612
+ color: var(--text-muted);
4613
+ font-size: 11px;
4614
+ }
4615
+
4616
+ /* Hub jumps are no-ops outside Claude Code Hub, so the standalone board hides
4617
+ that whole column rather than listing keys that do nothing. */
4618
+ .shortcuts.sc-standalone .sc-hub {
4619
+ display: none;
4620
+ }
4621
+
4515
4622
  @keyframes live-border-pulse {
4516
4623
  0%,
4517
4624
  100% {
@@ -4721,6 +4828,130 @@ select.form-input option:checked {
4721
4828
 
4722
4829
  /* #endregion */
4723
4830
 
4831
+ /* #region SESSION_PICKER */
4832
+ .modal.session-picker {
4833
+ max-height: 70vh;
4834
+ padding: 12px;
4835
+ display: flex;
4836
+ flex-direction: column;
4837
+ gap: 8px;
4838
+ overflow: hidden;
4839
+ }
4840
+
4841
+ .sp-input {
4842
+ padding: 9px 12px;
4843
+ background: var(--bg-deep);
4844
+ font-size: 13px;
4845
+ }
4846
+
4847
+ .sp-list {
4848
+ overflow-y: auto;
4849
+ display: flex;
4850
+ flex-direction: column;
4851
+ gap: 2px;
4852
+ }
4853
+
4854
+ .sp-row {
4855
+ display: flex;
4856
+ align-items: center;
4857
+ gap: 8px;
4858
+ width: 100%;
4859
+ padding: 6px 8px;
4860
+ background: none;
4861
+ border: 1px solid transparent;
4862
+ border-radius: 5px;
4863
+ color: var(--text-secondary);
4864
+ font-family: var(--mono);
4865
+ font-size: 12px;
4866
+ text-align: left;
4867
+ cursor: pointer;
4868
+ }
4869
+
4870
+ .sp-row:hover {
4871
+ background: var(--bg-hover);
4872
+ }
4873
+
4874
+ .sp-row.selected {
4875
+ background: var(--bg-elevated);
4876
+ border-color: var(--accent);
4877
+ }
4878
+
4879
+ .sp-row .activity-dot.live {
4880
+ background: var(--success);
4881
+ }
4882
+
4883
+ .sp-row .activity-dot.active {
4884
+ background: var(--accent);
4885
+ }
4886
+
4887
+ .sp-row .activity-dot.waiting {
4888
+ background: var(--warning);
4889
+ }
4890
+
4891
+ .sp-name,
4892
+ .sp-project,
4893
+ .sp-branch {
4894
+ overflow: hidden;
4895
+ text-overflow: ellipsis;
4896
+ white-space: nowrap;
4897
+ }
4898
+
4899
+ .sp-name {
4900
+ flex: none;
4901
+ max-width: 40%;
4902
+ color: var(--text-primary);
4903
+ }
4904
+
4905
+ .sp-row.current .sp-name {
4906
+ color: var(--accent-text);
4907
+ }
4908
+
4909
+ .sp-project {
4910
+ flex: 1;
4911
+ min-width: 0;
4912
+ color: var(--text-muted);
4913
+ }
4914
+
4915
+ .sp-branch {
4916
+ flex: none;
4917
+ max-width: 25%;
4918
+ padding: 1px 5px;
4919
+ border-radius: 3px;
4920
+ background: var(--bg-hover);
4921
+ color: var(--text-tertiary);
4922
+ font-size: 11px;
4923
+ }
4924
+
4925
+ .sp-count,
4926
+ .sp-time {
4927
+ flex: none;
4928
+ color: var(--text-muted);
4929
+ font-size: 11px;
4930
+ white-space: nowrap;
4931
+ }
4932
+
4933
+ .sp-time {
4934
+ min-width: 62px;
4935
+ text-align: right;
4936
+ }
4937
+
4938
+ .sp-empty {
4939
+ padding: 20px 8px;
4940
+ color: var(--text-muted);
4941
+ font-size: 12px;
4942
+ text-align: center;
4943
+ }
4944
+
4945
+ .sp-hint {
4946
+ display: flex;
4947
+ align-items: center;
4948
+ gap: 5px;
4949
+ color: var(--text-muted);
4950
+ font-size: 11px;
4951
+ }
4952
+
4953
+ /* #endregion */
4954
+
4724
4955
  /* #region TOOL_STATS */
4725
4956
  .modal.tool-stats-modal {
4726
4957
  width: 90%;
@@ -5314,21 +5545,24 @@ pre.mermaid svg {
5314
5545
  }
5315
5546
 
5316
5547
  .project-group-header.kb-selected,
5317
- .pinned-sub-header.kb-selected {
5548
+ .pinned-sub-header.kb-selected,
5549
+ .sg-section-toggle.kb-selected {
5318
5550
  color: var(--text-primary);
5319
5551
  background: var(--bg-hover);
5320
5552
  border-radius: 4px;
5321
5553
  }
5322
5554
 
5323
5555
  .project-group-header .group-chevron,
5324
- .session-group-header .group-chevron {
5556
+ .session-group-header .group-chevron,
5557
+ .sg-section-toggle .group-chevron {
5325
5558
  transition: transform 0.15s ease;
5326
5559
  flex-shrink: 0;
5327
5560
  display: block;
5328
5561
  }
5329
5562
 
5330
5563
  .project-group-header.collapsed .group-chevron,
5331
- .session-group-header.collapsed .group-chevron {
5564
+ .session-group-header.collapsed .group-chevron,
5565
+ .sg-section-toggle.collapsed .group-chevron {
5332
5566
  transform: rotate(-90deg);
5333
5567
  }
5334
5568
 
@@ -5522,6 +5756,18 @@ pre.mermaid svg {
5522
5756
  margin-top: 0;
5523
5757
  }
5524
5758
 
5759
+ .sg-section-toggle {
5760
+ display: flex;
5761
+ align-items: center;
5762
+ gap: 4px;
5763
+ padding-bottom: 4px;
5764
+ cursor: pointer;
5765
+ }
5766
+
5767
+ .sg-section-toggle:hover {
5768
+ color: var(--text-primary);
5769
+ }
5770
+
5525
5771
  .session-group-header {
5526
5772
  display: flex;
5527
5773
  align-items: center;
@@ -5584,7 +5830,8 @@ pre.mermaid svg {
5584
5830
  margin-left: 6px;
5585
5831
  }
5586
5832
 
5587
- .session-group-sessions.collapsed {
5833
+ .session-group-sessions.collapsed,
5834
+ .sg-section-body.collapsed {
5588
5835
  display: none;
5589
5836
  }
5590
5837
 
package/server.js CHANGED
@@ -2007,16 +2007,16 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2007
2007
  // (same pattern as agentsNeedingModel below). Each field shares the same resolve flow:
2008
2008
  // look up in progressMap by agentId, fall back to per-field extractor, persist only
2009
2009
  // on actual change.
2010
+ // progressMap is keyed by tool_use_id; re-key by agentId, first value per field wins.
2010
2011
  const byAgentId = {};
2011
- const nameByAgentId = {};
2012
- const descByAgentId = {};
2013
2012
  if (meta.jsonlPath) {
2014
2013
  try {
2015
2014
  const progressMap = getProgressMap(meta.jsonlPath);
2016
2015
  for (const entry of Object.values(progressMap)) {
2017
- if (entry.prompt && !byAgentId[entry.agentId]) byAgentId[entry.agentId] = entry.prompt;
2018
- if (entry.name && !nameByAgentId[entry.agentId]) nameByAgentId[entry.agentId] = entry.name;
2019
- if (entry.description && !descByAgentId[entry.agentId]) descByAgentId[entry.agentId] = entry.description;
2016
+ const e = (byAgentId[entry.agentId] ||= {});
2017
+ for (const f of ['prompt', 'name', 'description', 'usage']) {
2018
+ if (entry[f] && !e[f]) e[f] = entry[f];
2019
+ }
2020
2020
  }
2021
2021
  } catch (_) {}
2022
2022
  }
@@ -2025,12 +2025,12 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2025
2025
  field: 'prompt',
2026
2026
  flag: 'promptUnavailable',
2027
2027
  lookup: (a) => {
2028
- if (byAgentId[a.agentId]) return byAgentId[a.agentId];
2028
+ if (byAgentId[a.agentId]?.prompt) return byAgentId[a.agentId].prompt;
2029
2029
  try { return extractPromptFromTranscript(subagentJsonlForExtraction(meta, a.agentId)); } catch (_) { return null; }
2030
2030
  },
2031
2031
  },
2032
- { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => nameByAgentId[a.agentId] || null },
2033
- { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => descByAgentId[a.agentId] || null },
2032
+ { field: 'agentName', flag: 'agentNameUnavailable', lookup: (a) => byAgentId[a.agentId]?.name || null },
2033
+ { field: 'description', flag: 'descriptionUnavailable', lookup: (a) => byAgentId[a.agentId]?.description || null },
2034
2034
  ];
2035
2035
  if (meta.jsonlPath) {
2036
2036
  for (const { field, flag, lookup } of reconcileFields) {
@@ -2133,6 +2133,11 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
2133
2133
  }
2134
2134
 
2135
2135
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
2136
+ // Attached after persistAgent so it stays response-only.
2137
+ for (const agent of visibleAgents) {
2138
+ const usage = byAgentId[agent.agentId]?.usage;
2139
+ if (usage) agent.usage = usage;
2140
+ }
2136
2141
  res.json({ agents: visibleAgents, waitingForUser, teamColors });
2137
2142
  } catch {
2138
2143
  res.json({ agents: [], waitingForUser: null });
@@ -2499,7 +2504,6 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
2499
2504
  msg.agentId = entry.agentId;
2500
2505
  if (entry.description) msg.agentDescription = entry.description;
2501
2506
  if (entry.usageText) msg.agentUsage = entry.usageText;
2502
- if (entry.usage) msg.agentUsageStats = entry.usage;
2503
2507
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
2504
2508
  try {
2505
2509
  const agentFile = path.join(agentDir, `${entry.agentId}.jsonl`);