claude-code-kanban 4.23.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.23.2",
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,
@@ -2951,6 +2959,19 @@ function highlightSelectedAgent() {
2951
2959
  //#region RENDERING
2952
2960
  let revealedPlanSessionId = null;
2953
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
+
2954
2975
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
2955
2976
  async function revealPlanSession(planSessionId) {
2956
2977
  if (revealedPlanSessionId === planSessionId) {
@@ -2959,13 +2980,7 @@ async function revealPlanSession(planSessionId) {
2959
2980
  return;
2960
2981
  }
2961
2982
  revealedPlanSessionId = planSessionId;
2962
- if (!sessions.some((s) => s.id === planSessionId)) {
2963
- lastSessionsHash = '';
2964
- await fetchSessions();
2965
- }
2966
- await fetchTasks(planSessionId);
2967
- const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(planSessionId)}"]`);
2968
- if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2983
+ await revealSession(planSessionId);
2969
2984
  }
2970
2985
 
2971
2986
  async function showAllTasks() {
@@ -3017,15 +3032,10 @@ function renderAllTasks() {
3017
3032
  renderKanban();
3018
3033
  }
3019
3034
 
3020
- function renderSessions() {
3021
- // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3022
- // half-typed group name, and the SSE path can fire at any moment — defer instead.
3023
- if (sgDrag || sgIsEditing()) return;
3024
- // Update project dropdown
3025
- updateProjectDropdown();
3026
-
3027
- // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
3028
- // 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() {
3029
3039
  let filteredSessions = sessions;
3030
3040
  if (sessionFilter === 'active') {
3031
3041
  const activeSessionIds = new Set();
@@ -3109,6 +3119,18 @@ function renderSessions() {
3109
3119
  if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
3110
3120
  }
3111
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
+
3112
3134
  if (filteredSessions.length === 0) {
3113
3135
  let emptyMsg = 'No sessions found';
3114
3136
  let emptyHint = 'Tasks appear when you use Claude Code';
@@ -3140,13 +3162,8 @@ function renderSessions() {
3140
3162
  const total = session.taskCount;
3141
3163
  const percent = total > 0 ? Math.round((session.completed / total) * 100) : 0;
3142
3164
  const isActive = session.id === currentSessionId && viewMode === 'session';
3143
- // hasRecentLog carries the server's registry-idle correction: an open-but-idle
3144
- // terminal keeps touching its JSONL, so mtime alone is not proof of activity.
3145
- const isLive = session.hasRecentLog && Date.now() - Date.parse(session.modifiedAt) <= LIVE_INDICATOR_MS;
3146
- const rawName = session.name || session.id;
3147
- 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)
3148
- ? rawName.slice(0, 8)
3149
- : rawName;
3165
+ const isLive = isSessionLive(session);
3166
+ const sessionName = sessionDisplayName(session);
3150
3167
  const useGrouped = sessionFilter === 'active' && session.project;
3151
3168
  const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
3152
3169
  const secondaryName = useGrouped ? null : session.project ? sessionName : null;
@@ -3212,6 +3229,15 @@ function renderSessions() {
3212
3229
  const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3213
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>`;
3214
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
+
3215
3241
  const renderGroupSessions = (sessions, pinKey) => {
3216
3242
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3217
3243
  const gPinned = sessions.filter((s) => isPlacedPinned(s.id) && !isPlacedSticky(s.id));
@@ -3298,7 +3324,7 @@ function renderSessions() {
3298
3324
  // flat = the "All sessions" view, which has no project sub-blocks.
3299
3325
  const sgSectionHtml = (flat) => {
3300
3326
  if (sessionGroups.length === 0) return '';
3301
- let out = '<div class="sg-section-label">Groups</div>';
3327
+ let out = '';
3302
3328
  for (const group of sessionGroups) {
3303
3329
  const bucket = sgBuckets.get(group.id) || [];
3304
3330
  if (bucket.length === 0 && sgFiltering) continue;
@@ -3341,7 +3367,8 @@ function renderSessions() {
3341
3367
  out += sgHeaderHtml(group, countHtml(bucket));
3342
3368
  out += `<div class="session-group-sessions${collapsed ? ' collapsed' : ''}" data-group-id="${escapeHtml(group.id)}">${body}</div>`;
3343
3369
  }
3344
- return out;
3370
+ if (!out) return '';
3371
+ return sectionHtml(SECTION_GROUPS, 'Groups', false, out);
3345
3372
  };
3346
3373
 
3347
3374
  let html = '';
@@ -3389,17 +3416,19 @@ function renderSessions() {
3389
3416
  stableGroupOrder = [...keptOrder, ...newPaths];
3390
3417
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3391
3418
 
3392
- // Rendered even when nothing is left under it: it is the drop target for ungrouping.
3393
- if (sessionGroups.length > 0) {
3394
- html += '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Projects</div>';
3395
- }
3396
- for (const [projectPath, projectSessions] of sortedGroups) {
3397
- html += projectBlock(projectPath, projectSessions, false);
3398
- }
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
+ }
3399
3428
 
3400
- if (ungrouped.length > 0 && sortedGroups.length > 0) {
3401
- const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
3402
- html += `
3429
+ if (ungrouped.length > 0 && sortedGroups.length > 0) {
3430
+ const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
3431
+ projectsHtml += `
3403
3432
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
3404
3433
  ${groupChevronSvg()}
3405
3434
  <span class="group-name">Ungrouped</span>
@@ -3409,17 +3438,20 @@ function renderSessions() {
3409
3438
  ${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
3410
3439
  </div>
3411
3440
  `;
3412
- } else {
3413
- html += ungrouped.map(renderSessionCard).join('');
3441
+ } else {
3442
+ projectsHtml += ungrouped.map(renderSessionCard).join('');
3443
+ }
3414
3444
  }
3415
3445
 
3446
+ html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml) : projectsHtml;
3447
+
3416
3448
  sessionsList.innerHTML = html;
3417
3449
  } else {
3418
- const ungroupZone =
3419
- sessionGroups.length > 0
3420
- ? '<div class="sg-section-label sg-ungroup-zone" title="Drop here to remove from a group">Sessions</div>'
3421
- : '';
3422
- 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;
3423
3455
  }
3424
3456
 
3425
3457
  const navItems = getNavigableItems();
@@ -4414,14 +4446,24 @@ function getKbId(el) {
4414
4446
  return el.dataset.sessionId || el.dataset.groupPath || null;
4415
4447
  }
4416
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
+
4417
4461
  function getGroupSessionsContainer(header) {
4418
- const cls = header.classList.contains('pinned-sub-header')
4419
- ? 'pinned-sub-items'
4420
- : header.classList.contains('session-group-header')
4421
- ? 'session-group-sessions'
4422
- : 'project-group-sessions';
4462
+ const cls = Object.keys(COLLAPSIBLE_BODY_CLASS).find((c) => header.classList.contains(c));
4463
+ if (!cls) return null;
4423
4464
  let el = header.nextElementSibling;
4424
- 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;
4425
4467
  return el;
4426
4468
  }
4427
4469
 
@@ -4448,16 +4490,27 @@ function getNavigableItems() {
4448
4490
  }
4449
4491
  }
4450
4492
  };
4451
- for (const el of sessionsList.children) {
4452
- if (el.classList.contains('project-group-header') || el.classList.contains('session-group-header')) {
4453
- items.push(el);
4454
- if (!collapsedProjectGroups.has(el.dataset.groupPath)) {
4455
- 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);
4456
4510
  }
4457
- } else if (el.classList.contains('session-item')) {
4458
- items.push(el);
4459
4511
  }
4460
- }
4512
+ };
4513
+ walkTopLevel(sessionsList.children);
4461
4514
  return items;
4462
4515
  }
4463
4516
 
@@ -4541,6 +4594,10 @@ function expandActiveGroups({ onlyNew = false } = {}) {
4541
4594
  // A named group wrapping that project (or the session itself) would keep it hidden.
4542
4595
  const group = sgGroupForSession(s);
4543
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
+ }
4544
4601
  }
4545
4602
  prevActiveSessionIds = activeIds;
4546
4603
  if (changed) persistCollapsedGroups();
@@ -4548,11 +4605,7 @@ function expandActiveGroups({ onlyNew = false } = {}) {
4548
4605
  }
4549
4606
 
4550
4607
  function isGroupHeader(el) {
4551
- return (
4552
- el.classList.contains('project-group-header') ||
4553
- el.classList.contains('pinned-sub-header') ||
4554
- el.classList.contains('session-group-header')
4555
- );
4608
+ return el.matches(COLLAPSIBLE_HEADER_SELECTOR);
4556
4609
  }
4557
4610
 
4558
4611
  function findParentHeader(el) {
@@ -5030,19 +5083,122 @@ async function confirmDelete() {
5030
5083
  //#endregion
5031
5084
 
5032
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
+
5033
5196
  function showHelpModal() {
5034
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);
5035
5201
  modal.classList.add('visible');
5036
-
5037
- // Handle keyboard shortcuts
5038
- const keyHandler = (e) => {
5039
- if (e.key === 'Escape' || e.key === '?') {
5040
- e.preventDefault();
5041
- closeHelpModal();
5042
- document.removeEventListener('keydown', keyHandler);
5043
- }
5044
- };
5045
- document.addEventListener('keydown', keyHandler);
5046
5202
  }
5047
5203
 
5048
5204
  function closeHelpModal() {
@@ -5318,13 +5474,7 @@ function _renderStorageSessions() {
5318
5474
  async function _storageViewSession(id) {
5319
5475
  closeStorageManager();
5320
5476
  revealedStorageSessionId = id;
5321
- if (!sessions.some((s) => s.id === id)) {
5322
- lastSessionsHash = '';
5323
- await fetchSessions();
5324
- }
5325
- await fetchTasks(id);
5326
- const el = document.querySelector(`.session-item[data-session-id="${CSS.escape(id)}"]`);
5327
- if (el) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
5477
+ await revealSession(id);
5328
5478
  }
5329
5479
 
5330
5480
  function _storageUnpinSession(id) {
@@ -5616,6 +5766,7 @@ const MODAL_CLOSERS = {
5616
5766
  'team-modal': () => closeTeamModal(),
5617
5767
  'agent-modal': () => closeAgentModal(),
5618
5768
  'help-modal': () => closeHelpModal(),
5769
+ 'session-picker-modal': () => closeSessionPicker(),
5619
5770
  };
5620
5771
 
5621
5772
  document.addEventListener('keydown', (e) => {
@@ -5649,6 +5800,9 @@ document.addEventListener('keydown', (e) => {
5649
5800
  if (close) close();
5650
5801
  else document.getElementById(topId).classList.remove('visible');
5651
5802
  e.stopImmediatePropagation();
5803
+ } else if (e.key === '?' && document.getElementById('help-modal').classList.contains('visible')) {
5804
+ e.preventDefault();
5805
+ closeHelpModal();
5652
5806
  } else if (
5653
5807
  e.code === 'KeyM' &&
5654
5808
  e.shiftKey &&
@@ -5718,6 +5872,12 @@ document.addEventListener('keydown', (e) => {
5718
5872
  showStorageManager();
5719
5873
  return;
5720
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
+ }
5721
5881
  if (e.key === '.' || e.key === '>') {
5722
5882
  const sid = sessionsList.querySelector('.kb-selected')?.dataset.sessionId || currentSessionId;
5723
5883
  if (sid) {
@@ -5892,16 +6052,18 @@ document.addEventListener('keydown', (e) => {
5892
6052
  }
5893
6053
  return;
5894
6054
  }
5895
- if (e.code === 'KeyC' && e.shiftKey) {
6055
+ if (e.code === 'KeyC' && e.shiftKey && !e.altKey && !e.metaKey) {
5896
6056
  e.preventDefault();
5897
6057
  if (!contextSid) {
5898
6058
  showToast('No session selected');
5899
6059
  return;
5900
6060
  }
6061
+ const text = e.ctrlKey ? `claude -r ${contextSid}` : contextSid;
6062
+ const label = e.ctrlKey ? 'resume command' : 'session id';
5901
6063
  navigator.clipboard
5902
- .writeText(contextSid)
5903
- .then(() => showToast(`Copied session id: ${contextSid.slice(0, 8)}`, 'success'))
5904
- .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}`));
5905
6067
  return;
5906
6068
  }
5907
6069
  if (matchKey(e, 'KeyR')) {
@@ -6861,6 +7023,13 @@ function isSessionActive(s) {
6861
7023
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
6862
7024
  }
6863
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
+
6864
7033
  function formatDate(dateStr) {
6865
7034
  const date = new Date(dateStr);
6866
7035
  const now = new Date();
@@ -7414,7 +7583,7 @@ document.addEventListener('click', (e) => {
7414
7583
  }
7415
7584
  }
7416
7585
 
7417
- const header = e.target.closest('.session-group-header, .pinned-sub-header, .project-group-header');
7586
+ const header = e.target.closest(COLLAPSIBLE_HEADER_SELECTOR);
7418
7587
  if (header) {
7419
7588
  setGroupCollapsed(header, !collapsedProjectGroups.has(header.dataset.groupPath));
7420
7589
  }
@@ -8027,6 +8196,112 @@ let _planSessionId = null;
8027
8196
 
8028
8197
  //#endregion
8029
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
+
8030
8305
  //#region PLAN
8031
8306
  function refreshOpenPlan() {
8032
8307
  if (!_planSessionId || !document.getElementById('plan-modal').classList.contains('visible')) return;
@@ -8663,6 +8938,7 @@ try {
8663
8938
  } catch (_) {}
8664
8939
  loadSessionGroups();
8665
8940
  initSessionGroupsDnd();
8941
+ initSessionPicker();
8666
8942
  try {
8667
8943
  const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
8668
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