claude-code-kanban 7.2.0 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/app.js CHANGED
@@ -53,6 +53,7 @@ const collapsedProjectGroups = new Set();
53
53
  const SECTION_GROUPS = '__section_groups__';
54
54
  const SECTION_PROJECTS = '__section_projects__';
55
55
  const SECTION_SESSIONS = '__section_sessions__';
56
+ const SECTION_OUTSIDE = '__section_outside__';
56
57
  // Project paths and session ids in Active-view sidebar order, kept across reloads so live
57
58
  // activity does not reshuffle the list. Each holds only what the loaded list still has.
58
59
  const PROJECT_ORDER_KEY = 'project-order';
@@ -373,6 +374,65 @@ async function fetchSessionById(id) {
373
374
  return (await fetchSessionsByIds([id]))[0] || null;
374
375
  }
375
376
 
377
+ // The id may be outside the list the filters asked for. renderSession bails on a session
378
+ // missing from `sessions`, so pull that one row in before opening it; later polls
379
+ // keep it, because fetchSessions pins currentSessionId.
380
+ async function ensureSessionLoaded(id) {
381
+ const loaded = sessions.find((s) => s.id === id);
382
+ if (loaded) return loaded;
383
+ const session = await fetchSessionById(id);
384
+ if (!session) return null;
385
+ const raced = sessions.find((s) => s.id === id);
386
+ if (raced) return raced;
387
+ sessions.push(session);
388
+ return session;
389
+ }
390
+
391
+ // Matches SESSION_SEARCH_MIN in server.js, which answers [] below it.
392
+ const SESSION_SEARCH_MIN = 3;
393
+ const SESSION_SEARCH_DEBOUNCE_MS = 100;
394
+
395
+ // Searches session ids and names in every transcript on the server, so a session outside the
396
+ // sidebar filter is found too. `rows` answers the server rows not in `localRows`, and whether
397
+ // the search for that query is still out; `onFound()` runs when the current query's rows arrive.
398
+ // Only the current query is kept, and a short query drops it, so a later identical query fetches again.
399
+ function sessionSearch(onFound) {
400
+ let current = null;
401
+ let found = null;
402
+ let timer = null;
403
+ const run = async (key) => {
404
+ let ids = [];
405
+ try {
406
+ const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
407
+ if (r.ok) ids = await r.json();
408
+ } catch (_) {}
409
+ if (key !== current) return;
410
+ const rows = ids.length ? await fetchSessionsByIds(ids) : [];
411
+ if (key !== current) return;
412
+ found = rows;
413
+ onFound();
414
+ };
415
+ return {
416
+ rows(query, localRows) {
417
+ const key = query.length < SESSION_SEARCH_MIN ? null : query.toLowerCase();
418
+ // Set once per query: the sidebar re-renders on every live update, and resetting the
419
+ // timer each time would starve the search.
420
+ if (key !== current) {
421
+ clearTimeout(timer);
422
+ current = key;
423
+ found = null;
424
+ if (key) timer = setTimeout(() => run(key), SESSION_SEARCH_DEBOUNCE_MS);
425
+ }
426
+ if (!key) return { rows: [], pending: false };
427
+ if (!found) return { rows: [], pending: true };
428
+ const seen = new Set(localRows.map((s) => s.id));
429
+ return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
430
+ },
431
+ };
432
+ }
433
+
434
+ const sidebarSearch = sessionSearch(renderSessions);
435
+
376
436
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
377
437
  function handleSearch(query) {
378
438
  const wasSearching = !!searchQuery;
@@ -2494,8 +2554,13 @@ function clampModalZoom(v) {
2494
2554
  return Math.min(MODAL_ZOOM_MAX, Math.max(MODAL_ZOOM_MIN, v));
2495
2555
  }
2496
2556
 
2557
+ function modalZoomLabel() {
2558
+ return `${Math.round(modalZoom * 100)}%`;
2559
+ }
2560
+
2497
2561
  function applyModalZoom() {
2498
2562
  document.documentElement.style.setProperty('--modal-zoom', String(modalZoom));
2563
+ for (const el of document.querySelectorAll('.pane-zoom-val')) el.textContent = modalZoomLabel();
2499
2564
  }
2500
2565
 
2501
2566
  // delta 0 resets to 100%
@@ -2505,7 +2570,7 @@ function adjustModalZoom(delta) {
2505
2570
  modalZoom = next;
2506
2571
  store.setItem(MODAL_ZOOM_KEY, String(modalZoom));
2507
2572
  applyModalZoom();
2508
- showToast(`Text ${Math.round(modalZoom * 100)}%`);
2573
+ showToast(`Text ${modalZoomLabel()}`);
2509
2574
  }
2510
2575
 
2511
2576
  // The markup decides what scales: `.modal-zoomable` marks a modal's reading
@@ -3401,15 +3466,8 @@ async function revealSession(id) {
3401
3466
  let session = sessions.find((s) => s.id === id);
3402
3467
  if (!session) {
3403
3468
  lastSessionsHash = '';
3404
- await fetchSessions();
3405
- session = sessions.find((s) => s.id === id);
3406
- }
3407
- if (!session) {
3408
- // The id is outside the list the filters asked for. renderSession bails on a session
3409
- // missing from `sessions`, so pull that one row in before opening it; later polls
3410
- // keep it, because fetchSessions pins currentSessionId.
3411
- session = await fetchSessionById(id);
3412
- if (session) sessions.push(session);
3469
+ await fetchSessions(true, id);
3470
+ session = await ensureSessionLoaded(id);
3413
3471
  }
3414
3472
  const uncollapsed = session ? uncollapseFor(session) : false;
3415
3473
  if (uncollapsed) persistCollapsedGroups();
@@ -3598,36 +3656,8 @@ function renderSessions() {
3598
3656
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3599
3657
  const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3600
3658
 
3601
- if (filteredSessions.length === 0) {
3602
- let emptyMsg = 'No sessions found';
3603
- let emptyHint = 'Tasks appear when you use Claude Code';
3604
-
3605
- if (zenMode) {
3606
- emptyMsg = 'Zen mode: no session open';
3607
- emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3608
- } else if (searchQuery) {
3609
- emptyMsg = `No results for "${searchQuery}"`;
3610
- emptyHint = 'Try a different search term or clear the search';
3611
- } else if (filterProject && sessionFilter === 'active') {
3612
- emptyMsg = 'No active sessions for this project filter';
3613
- emptyHint = 'Try "All Sessions" or clear the project filter';
3614
- } else if (filterProject) {
3615
- emptyMsg = 'No sessions for this project filter';
3616
- emptyHint = 'Clear the project filter to see all';
3617
- } else if (sessionFilter === 'active') {
3618
- emptyMsg = 'No active sessions';
3619
- emptyHint = 'Select "All Sessions" to see all';
3620
- }
3621
- sessionsList.innerHTML = `
3622
- <div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
3623
- <p>${emptyMsg}</p>
3624
- <p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
3625
- </div>
3626
- `;
3627
- return;
3628
- }
3659
+ const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3629
3660
 
3630
- // Helper to render a single session card
3631
3661
  const renderSessionCard = (session) => {
3632
3662
  if (session.placeholder) return renderPlaceholderCard(session);
3633
3663
  const total = session.taskCount;
@@ -3698,6 +3728,63 @@ function renderSessions() {
3698
3728
  `;
3699
3729
  };
3700
3730
 
3731
+ // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3732
+ const sectionHtml = (key, text, ungroup, body, empty = false) => {
3733
+ const collapsed = collapsedProjectGroups.has(key);
3734
+ const escPath = escapeHtml(key);
3735
+ const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3736
+ const emptyCls = empty ? ' sg-section-empty' : '';
3737
+ return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3738
+ <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3739
+ };
3740
+
3741
+ const countHtml = (arr) => {
3742
+ const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3743
+ 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>`;
3744
+ };
3745
+
3746
+ const outsideHtml = outside.rows.length
3747
+ ? sectionHtml(
3748
+ SECTION_OUTSIDE,
3749
+ `Outside current filters ${countHtml(outside.rows)}`,
3750
+ false,
3751
+ collapsedProjectGroups.has(SECTION_OUTSIDE) ? '' : outside.rows.map(renderSessionCard).join(''),
3752
+ )
3753
+ : '';
3754
+
3755
+ if (filteredSessions.length === 0) {
3756
+ let emptyMsg = 'No sessions found';
3757
+ let emptyHint = 'Tasks appear when you use Claude Code';
3758
+
3759
+ if (zenMode) {
3760
+ emptyMsg = 'Zen mode: no session open';
3761
+ emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3762
+ } else if (searchQuery) {
3763
+ emptyMsg = `No results for "${escapeHtml(searchQuery)}"`;
3764
+ emptyHint = outside.rows.length
3765
+ ? 'Matches outside the current filters are below'
3766
+ : outside.pending
3767
+ ? 'Searching all sessions…'
3768
+ : 'Try a different search term or clear the search';
3769
+ } else if (filterProject && sessionFilter === 'active') {
3770
+ emptyMsg = 'No active sessions for this project filter';
3771
+ emptyHint = 'Try "All Sessions" or clear the project filter';
3772
+ } else if (filterProject) {
3773
+ emptyMsg = 'No sessions for this project filter';
3774
+ emptyHint = 'Clear the project filter to see all';
3775
+ } else if (sessionFilter === 'active') {
3776
+ emptyMsg = 'No active sessions';
3777
+ emptyHint = 'Select "All Sessions" to see all';
3778
+ }
3779
+ sessionsList.innerHTML = `
3780
+ <div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
3781
+ <p>${emptyMsg}</p>
3782
+ <p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
3783
+ </div>
3784
+ ${outsideHtml}`;
3785
+ return;
3786
+ }
3787
+
3701
3788
  // Zen draws one card and its panel, so it returns before the grouping below — leaving it in
3702
3789
  // that chain would build the whole list only to discard it.
3703
3790
  if (zenMode) {
@@ -3720,20 +3807,6 @@ function renderSessions() {
3720
3807
  const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3721
3808
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3722
3809
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3723
- const countHtml = (arr) => {
3724
- const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3725
- 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>`;
3726
- };
3727
- // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3728
- const sectionHtml = (key, text, ungroup, body, empty = false) => {
3729
- const collapsed = collapsedProjectGroups.has(key);
3730
- const escPath = escapeHtml(key);
3731
- const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3732
- const emptyCls = empty ? ' sg-section-empty' : '';
3733
- return `<div class="sg-section-label sg-section-toggle${ungroup ? ' sg-ungroup-zone' : ''}${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}" title="${escapeHtml(title)}">${groupChevronSvg(10)}<span>${text}</span></div>
3734
- <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3735
- };
3736
-
3737
3810
  const renderGroupSessions = (sessions, pinKey) => {
3738
3811
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3739
3812
  const gIdlePinned = sessions.filter(isInPinnedGroup);
@@ -3953,13 +4026,13 @@ function renderSessions() {
3953
4026
  const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
3954
4027
  html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
3955
4028
 
3956
- sessionsList.innerHTML = html;
4029
+ sessionsList.innerHTML = html + outsideHtml;
3957
4030
  } else {
3958
4031
  const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3959
4032
  const restHtml =
3960
4033
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3961
4034
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
3962
- sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
4035
+ sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
3963
4036
  }
3964
4037
 
3965
4038
  const navItems = getNavigableItems();
@@ -5017,10 +5090,12 @@ function sgOpenMenu(x, y, kind, ref) {
5017
5090
  menu.id = 'sg-menu';
5018
5091
  menu.className = 'sg-menu';
5019
5092
  menu.setAttribute('role', 'menu');
5020
- menu.setAttribute('aria-label', `Move ${label} to group`);
5093
+ const canFork = kind === 'session' && !!forkableSession(ref);
5094
+ menu.setAttribute('aria-label', canFork ? 'Session actions' : `Move ${label} to group`);
5021
5095
  menu.dataset.sgKind = kind;
5022
5096
  menu.dataset.sgRef = ref;
5023
5097
  menu.innerHTML = `
5098
+ ${canFork ? '<div class="sg-menu-label" aria-hidden="true">Session</div><button class="sg-menu-item" role="menuitem" data-sg-action="fork" title="claude --resume --fork-session">Fork</button>' : ''}
5024
5099
  <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
5025
5100
  ${rows}
5026
5101
  <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
@@ -5068,6 +5143,7 @@ document.addEventListener('click', (e) => {
5068
5143
  const { sgKind, sgRef } = menu.dataset;
5069
5144
  const move = item.dataset.sgMove;
5070
5145
  sgCloseMenu();
5146
+ if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
5071
5147
  if (move === '__none__') {
5072
5148
  sgDetach(sgKind, sgRef);
5073
5149
  persistSessionGroups();
@@ -5311,18 +5387,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5311
5387
  }
5312
5388
 
5313
5389
  // Drops every collapse key that could hide this session: its project, a named group wrapping
5314
- // the project or the session itself, the pinned sub-section only when the card renders inside
5315
- // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
5316
- // depends on the view — every section header. Returns whether anything changed.
5390
+ // the project or the session itself and the project block it renders under there, the pinned sub-section only when the card renders inside
5391
+ // it (pinned, not sticky, idle — see renderGroupSessions), and the section header that holds it:
5392
+ // Groups for a grouped session, else Projects or Sessions, which depends on the view. Returns
5393
+ // whether anything changed.
5317
5394
  function uncollapseFor(session) {
5318
5395
  const group = sgGroupForSession(session);
5319
5396
  const keys = [
5320
5397
  sessionProjectKey(session) || '__ungrouped__',
5321
5398
  ...pinnedCollapseKeys(session),
5322
- group && sgKey(group.id),
5323
- SECTION_GROUPS,
5324
- SECTION_PROJECTS,
5325
- SECTION_SESSIONS,
5399
+ ...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
5326
5400
  ];
5327
5401
  let changed = false;
5328
5402
  for (const key of keys) {
@@ -5917,9 +5991,9 @@ const SHORTCUT_TABS = [
5917
5991
  {
5918
5992
  title: 'Sessions',
5919
5993
  rows: [
5920
- { keys: ['Ctrl', 'Alt', 'N'], combo: true, label: 'New session' },
5921
- { keys: ['Ctrl', 'Alt', 'R'], combo: true, label: 'Resume session (claude -r)' },
5922
- { keys: ['Ctrl', 'Alt', 'S'], combo: true, label: 'Swap to previous session' },
5994
+ { keys: ['Ctrl', 'Alt', 'N'], combo: true, hubMod: true, label: 'New session' },
5995
+ { keys: ['Ctrl', 'Alt', 'R'], combo: true, hubMod: true, label: 'Resume session (claude -r)' },
5996
+ { keys: ['Ctrl', 'Alt', 'S'], combo: true, hubMod: true, label: 'Swap to previous session' },
5923
5997
  ],
5924
5998
  },
5925
5999
  ],
@@ -5949,17 +6023,57 @@ const SHORTCUT_TABS = [
5949
6023
  { keys: ['M'], label: 'Jump to marketplace' },
5950
6024
  { keys: ['$'], label: 'Jump to cost' },
5951
6025
  { keys: ['Ctrl', 'M'], combo: true, label: 'Jump to memory' },
5952
- { keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
5953
- { keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
5954
- { keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
5955
- { keys: ['Ctrl', 'Alt', 'W'], combo: true, label: 'Config dir picker' },
5956
- { keys: ['Ctrl', 'Alt', 'A'], combo: true, label: 'App launcher' },
6026
+ {
6027
+ keys: ['Ctrl', 'Alt', '←/→'],
6028
+ action: ['hub.prevApp', 'hub.nextApp'],
6029
+ combo: true,
6030
+ hubMod: true,
6031
+ label: 'Previous / next hub app',
6032
+ },
6033
+ {
6034
+ keys: ['Alt', '1…9'],
6035
+ action: 'hub.appByNumber',
6036
+ combo: true,
6037
+ hubMod: true,
6038
+ label: 'Jump to hub app by number',
6039
+ },
6040
+ {
6041
+ keys: ['Ctrl', 'Alt', 'P'],
6042
+ action: 'hub.projectPicker',
6043
+ combo: true,
6044
+ hubMod: true,
6045
+ label: 'Project picker',
6046
+ },
6047
+ {
6048
+ keys: ['Ctrl', 'Alt', 'W'],
6049
+ action: 'hub.configDirPicker',
6050
+ combo: true,
6051
+ hubMod: true,
6052
+ label: 'Config dir picker',
6053
+ },
6054
+ { keys: ['Ctrl', 'Alt', 'A'], action: 'hub.appLauncher', combo: true, hubMod: true, label: 'App launcher' },
5957
6055
  ],
5958
6056
  },
5959
6057
  ],
5960
6058
  },
5961
6059
  ];
5962
6060
 
6061
+ // macOS names Control, Option and Shift by their symbols.
6062
+ const IS_MAC = /^Mac/i.test(navigator.userAgentData?.platform || navigator.platform || '');
6063
+ const MAC_KEYS = { Ctrl: '⌃', Alt: '⌥', Shift: '⇧' };
6064
+ if (IS_MAC) document.getElementById('new-session-btn')?.setAttribute('title', 'New session (⌃⌥N)');
6065
+
6066
+ // The keys of a help row as they read on this system. The hub's modifier is Ctrl+Alt, and on
6067
+ // macOS Control+Option for the tab numbers too (Windows and Linux use bare Alt for those). A hub
6068
+ // row shows the keys the hub reports, which the user can change; row.keys until it answers.
6069
+ function helpKeys(row, mac = IS_MAC, keyLabel = hub.keyLabel) {
6070
+ const live = row.action && keyLabel(row.action);
6071
+ if (live) return live;
6072
+ if (!mac) return row.keys;
6073
+ if (row.hubMod) return ['⌃', '⌥', ...row.keys.filter((k) => k !== 'Ctrl' && k !== 'Alt')];
6074
+ return row.keys.map((k) => MAC_KEYS[k] || k);
6075
+ }
6076
+
5963
6077
  // Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
5964
6078
  // them on shared row tracks (see .shortcuts in style.css).
5965
6079
  function buildHelpShortcuts(groups) {
@@ -5975,9 +6089,10 @@ function buildHelpShortcuts(groups) {
5975
6089
  for (let i = 0; i < Math.max(...sides.map(([g]) => g.rows.length)); i++) {
5976
6090
  for (const [group, side] of sides) {
5977
6091
  const row = group.rows[i];
5978
- if (!row) continue;
6092
+ const parts = row && helpKeys(row);
6093
+ if (!parts?.length) continue;
5979
6094
  const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
5980
- const keys = row.keys.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
6095
+ const keys = parts.map((k) => `<kbd>${escapeHtml(k)}</kbd>`).join(sep);
5981
6096
  const label = escapeHtml(row.label);
5982
6097
  cells.push(`<dt class="${escapeHtml(side)}">${keys}</dt><dd class="${escapeHtml(side)}">${label}</dd>`);
5983
6098
  }
@@ -5996,7 +6111,10 @@ const helpTabs = () => SHORTCUT_TABS.filter((t) => hub.inHub || !t.hub);
5996
6111
  function filteredShortcutGroups(query) {
5997
6112
  return helpTabs()
5998
6113
  .flatMap((t) => t.groups)
5999
- .map((g) => ({ title: g.title, rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${r.keys.join(' ')}`, query)) }))
6114
+ .map((g) => ({
6115
+ title: g.title,
6116
+ rows: g.rows.filter((r) => fuzzyMatch(`${r.label} ${helpKeys(r).join(' ')}`, query)),
6117
+ }))
6000
6118
  .filter((g) => g.rows.length);
6001
6119
  }
6002
6120
 
@@ -7630,9 +7748,10 @@ function reviewBridge(textBefore, headingBefore, rangeAt, comboOf, fieldSelector
7630
7748
  const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta']
7631
7749
  .filter(Boolean)
7632
7750
  .join('+');
7633
- return [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
7634
- Boolean,
7635
- );
7751
+ return [
7752
+ e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
7753
+ typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
7754
+ ].filter(Boolean);
7636
7755
  };
7637
7756
  let claims = new Set();
7638
7757
  let forward = new Set();
@@ -11008,38 +11127,9 @@ let spRenderedQuery = null;
11008
11127
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
11009
11128
  const spPeekCache = new Map();
11010
11129
 
11011
- // A query also searches session ids and names in every transcript on the server, so a
11012
- // session outside the sidebar filter is found too. Values: 'pending', then the rows.
11013
- const spSearch = new Map();
11014
- let spSearchTimer = null;
11015
-
11016
- function spSearchKey(query) {
11017
- return query.length >= 3 ? query.toLowerCase() : null;
11018
- }
11019
-
11020
- function spScheduleSearch(key) {
11021
- clearTimeout(spSearchTimer);
11022
- spSearchTimer = setTimeout(async () => {
11023
- spSearch.set(key, 'pending');
11024
- let ids = [];
11025
- try {
11026
- const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
11027
- if (r.ok) ids = await r.json();
11028
- } catch (_) {}
11029
- spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
11030
- renderSessionPicker();
11031
- }, 100);
11032
- }
11033
-
11034
- // The server rows not already matched in the sidebar list, and whether a search is still out.
11035
- function spSearchRows(key, localRows) {
11036
- if (!key) return { rows: [], pending: false };
11037
- const found = spSearch.get(key);
11038
- if (!found) spScheduleSearch(key);
11039
- if (!Array.isArray(found)) return { rows: [], pending: true };
11040
- const seen = new Set(localRows.map((s) => s.id));
11041
- return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
11042
- }
11130
+ const spSearch = sessionSearch(() => {
11131
+ if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
11132
+ });
11043
11133
 
11044
11134
  function openSessionPicker() {
11045
11135
  spFromTerminal = terminalPaneFocused();
@@ -11047,7 +11137,6 @@ function openSessionPicker() {
11047
11137
  input.value = '';
11048
11138
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
11049
11139
  spPeekCache.clear();
11050
- spSearch.clear();
11051
11140
  spRenderedQuery = null;
11052
11141
  document.getElementById('session-picker-modal').classList.add('visible');
11053
11142
  renderSessionPicker();
@@ -11170,15 +11259,14 @@ function renderSessionPicker() {
11170
11259
  // Search rows that arrive for the same query append below, so the cursor stays on its row.
11171
11260
  const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
11172
11261
  spRenderedQuery = query;
11173
- const key = spSearchKey(query);
11174
11262
  const local = spSource.filter((s) => spMatches(s, query));
11175
- const found = spSearchRows(key, local);
11263
+ const found = spSearch.rows(query, local);
11176
11264
  spRows = [...local, ...found.rows];
11177
11265
  if (!spRows.length) {
11178
11266
  spIdx = -1;
11179
11267
  const empty = found.pending
11180
11268
  ? 'Searching all sessions…'
11181
- : spSource.length || key
11269
+ : spSource.length || query.length >= SESSION_SEARCH_MIN
11182
11270
  ? 'No session matches'
11183
11271
  : 'No sessions in the current sidebar filter';
11184
11272
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
@@ -12171,9 +12259,14 @@ function togglePaneSplit() {
12171
12259
  return true;
12172
12260
  }
12173
12261
 
12262
+ function shownPaneSplit() {
12263
+ const sid = paneSessionId();
12264
+ return sid && !wantsTerminal() && getPaneSplit(sid);
12265
+ }
12266
+
12174
12267
  function focusPaneSide(side) {
12175
12268
  const sid = paneSessionId();
12176
- const split = sid && !wantsTerminal() && getPaneSplit(sid);
12269
+ const split = shownPaneSplit();
12177
12270
  if (!split) return false;
12178
12271
  if (getActivePaneId(sid) !== split[side]) {
12179
12272
  setActivePaneId(sid, split[side]);
@@ -12363,6 +12456,20 @@ function updatePaneFades() {
12363
12456
  paneTabs.classList.toggle('fade-r', scrollLeft + clientWidth < scrollWidth - 1);
12364
12457
  }
12365
12458
 
12459
+ // A URL pane's page is on another origin, so its key presses never reach the board: the zoom
12460
+ // keys zoom the whole window and the pane keys do nothing. The bar zooms without keys and
12461
+ // says when the page has the keyboard; a click on it gives the keys back.
12462
+ function paneUrlBarHtml() {
12463
+ return `<div class="pane-url-bar"><button class="icon-btn pane-keys" title="The page has keyboard focus. Click to turn the board shortcuts back on">Board shortcuts off · click to turn on</button><button class="icon-btn" data-pane-zoom="-0.1" title="Smaller" aria-label="Smaller">−</button><button class="icon-btn pane-zoom-val" data-pane-zoom="0" title="Reset size">${modalZoomLabel()}</button><button class="icon-btn" data-pane-zoom="0.1" title="Larger" aria-label="Larger">+</button></div>`;
12464
+ }
12465
+
12466
+ function markPaneFrameFocus() {
12467
+ const el = document.activeElement;
12468
+ const view = el?.classList.contains('pane-url-frame') ? el.parentElement : null;
12469
+ for (const v of paneViews.querySelectorAll('.pane-view.frame-focus')) v.classList.remove('frame-focus');
12470
+ view?.classList.add('frame-focus');
12471
+ }
12472
+
12366
12473
  function paneCardHtml(title, text, button) {
12367
12474
  return `<div class="pane-card"><div class="pane-card-box">${PANE_ICONS.warning}<h3>${escapeHtml(title)}</h3><p>${escapeHtml(text)}</p><button class="btn btn-primary" data-pane-open>${escapeHtml(button)}</button></div></div>`;
12368
12475
  }
@@ -12412,12 +12519,13 @@ async function loadPaneView(sid, pane, view, scroll) {
12412
12519
  }
12413
12520
  if (pane.kind === 'url') {
12414
12521
  const frame = document.createElement('iframe');
12415
- frame.className = 'pane-frame modal-zoomable';
12522
+ frame.className = 'pane-frame pane-url-frame modal-zoomable';
12416
12523
  frame.setAttribute('sandbox', PANE_URL_SANDBOX);
12417
12524
  frame.setAttribute('referrerpolicy', 'no-referrer');
12418
12525
  frame.title = pane.title;
12419
12526
  frame.src = pane.target;
12420
12527
  view.appendChild(frame);
12528
+ view.insertAdjacentHTML('beforeend', paneUrlBarHtml());
12421
12529
  // Framed at once; a refusal found by the probe swaps the frame for the card.
12422
12530
  if ((await probePaneFraming(sid, pane)) === false) {
12423
12531
  frame.remove();
@@ -12907,8 +13015,42 @@ function focusSideOf(el) {
12907
13015
  // That blur is also the only sign that a click in a split moved into a pane's frame.
12908
13016
  window.addEventListener('blur', () => {
12909
13017
  closePaneMenu();
12910
- setTimeout(() => focusSideOf(document.activeElement?.closest?.('.pane-view.on')));
13018
+ setTimeout(() => {
13019
+ syncPaneFrameFocus();
13020
+ watchPaneFrameFocus();
13021
+ });
13022
+ });
13023
+ window.addEventListener('focus', () => {
13024
+ clearInterval(paneFrameFocusPoll);
13025
+ markPaneFrameFocus();
12911
13026
  });
13027
+
13028
+ function syncPaneFrameFocus() {
13029
+ focusSideOf(document.activeElement?.closest?.('.pane-view.on'));
13030
+ markPaneFrameFocus();
13031
+ }
13032
+
13033
+ function splitPaneFrameFocused() {
13034
+ const el = document.activeElement;
13035
+ return el?.tagName === 'IFRAME' && paneViews.contains(el) && !!shownPaneSplit();
13036
+ }
13037
+
13038
+ // A click from one frame into another fires no event in this document, so while a split's frame
13039
+ // has focus the board checks which one it is: one pointer compare four times a second.
13040
+ let paneFrameFocusPoll = 0;
13041
+ function watchPaneFrameFocus() {
13042
+ clearInterval(paneFrameFocusPoll);
13043
+ if (!splitPaneFrameFocused()) return;
13044
+ let last = document.activeElement;
13045
+ paneFrameFocusPoll = setInterval(() => {
13046
+ const el = document.activeElement;
13047
+ if (!splitPaneFrameFocused()) clearInterval(paneFrameFocusPoll);
13048
+ else if (el !== last) {
13049
+ last = el;
13050
+ syncPaneFrameFocus();
13051
+ }
13052
+ }, 250);
13053
+ }
12912
13054
  paneSplitBox.addEventListener(
12913
13055
  'pointerdown',
12914
13056
  (e) => focusSideOf(paneLog.contains(e.target) ? paneBoard : e.target.closest('.side-l, .side-r')),
@@ -13024,6 +13166,8 @@ function applySplitRatio() {
13024
13166
 
13025
13167
  paneViews.addEventListener('click', (e) => {
13026
13168
  if (e.target.closest('[data-pane-open]')) openPaneExternally();
13169
+ const zoom = e.target.closest('[data-pane-zoom]');
13170
+ if (zoom) adjustModalZoom(Number(zoom.dataset.paneZoom));
13027
13171
  });
13028
13172
  bindPreviewRelativeLinks(paneViews, (a) => a.closest('.pane-doc')?.dataset.path);
13029
13173
  paneTabs.addEventListener('scroll', updatePaneFades, { passive: true });
@@ -13193,6 +13337,12 @@ function terminalPaneFocused() {
13193
13337
  return document.getElementById('terminal-pane').contains(document.activeElement);
13194
13338
  }
13195
13339
 
13340
+ // Kanban's own session keys (new, resume, swap) use the hub's modifier, so the hub leaves them alone.
13341
+ // AltGr reaches the page as Ctrl+Alt; when it types a character (Polish AltGr+S is 'ś'), the press is text.
13342
+ function hubModDown(e) {
13343
+ return e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey && !e.getModifierState?.('AltGraph');
13344
+ }
13345
+
13196
13346
  function inPageField(e) {
13197
13347
  const field = e.target?.closest?.(TEXT_FIELD_SELECTOR);
13198
13348
  return !!field && !document.getElementById('terminal-pane').contains(field);
@@ -13200,7 +13350,7 @@ function inPageField(e) {
13200
13350
 
13201
13351
  // `probe` asks which keys the terminal frame hands back, and the frame sees keys only while it has focus.
13202
13352
  function terminalShortcut(e, probe = false) {
13203
- const ctrlAlt = e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey;
13353
+ const ctrlAlt = hubModDown(e);
13204
13354
  const ctrlShift = e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey;
13205
13355
  // Ctrl+_ is Claude Code's undo, so the zoom keys leave it to the terminal.
13206
13356
  const zoom = e.key === '_' ? undefined : zoomDelta(e);
@@ -13308,8 +13458,10 @@ function syncTerminal() {
13308
13458
  }
13309
13459
 
13310
13460
  // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
13311
- function openSession(sessionId) {
13461
+ async function openSession(sessionId) {
13312
13462
  setSessionDismissed(sessionId, false);
13463
+ // Awaited only when missing, so opening a loaded session stays synchronous up to fetchTasks.
13464
+ if (!sessions.some((s) => s.id === sessionId)) await ensureSessionLoaded(sessionId);
13313
13465
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
13314
13466
  if (!alreadyOpen) termState.openedByUser = sessionId;
13315
13467
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -13808,6 +13960,8 @@ async function openTerminal(sessionId, mode, attempt = 0) {
13808
13960
  worktree: spec.worktree,
13809
13961
  model: spec.model,
13810
13962
  prompt: spec.prompt,
13963
+ edit: spec.edit,
13964
+ forkOf: spec.forkOf,
13811
13965
  }),
13812
13966
  },
13813
13967
  });
@@ -13899,7 +14053,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
13899
14053
  'Resuming it here too makes both processes write to the same transcript.',
13900
14054
  [
13901
14055
  ['resume', 'Resume anyway'],
13902
- ['fork', 'Fork'],
13903
14056
  ['shell', 'Shell only'],
13904
14057
  ],
13905
14058
  );
@@ -13924,7 +14077,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
13924
14077
  ]
13925
14078
  : [
13926
14079
  ['resume', 'Resume'],
13927
- ['fork', 'Fork'],
13928
14080
  ['shell', 'Shell'],
13929
14081
  ],
13930
14082
  clean ? '' : tail,
@@ -14097,20 +14249,28 @@ const newSpecs = new Map();
14097
14249
  const dispatchSpecs = new Map();
14098
14250
  const ns = { projects: [], picked: [], matches: [], idx: -1, folder: '', busy: false, resume: false };
14099
14251
  const NEW_SESSION_STAY_KEY = 'new-session-stay';
14252
+ const NEW_SESSION_EDIT_KEY = 'new-session-edit';
14100
14253
 
14101
14254
  const PLACEHOLDER_NAMES = { pick: 'Resume session', dispatch: 'Started session', new: 'New session' };
14102
14255
  const PLACEHOLDER_HINTS = {
14103
14256
  pick: 'pick a session to resume',
14104
14257
  dispatch: 'starting',
14105
14258
  new: 'waiting for your first message',
14259
+ edit: 'press Enter in the terminal to start',
14106
14260
  };
14107
14261
 
14262
+ // The parent may be outside the loaded list, so its short id stands in for its name.
14263
+ function forkLabel(parentId) {
14264
+ return `Fork of ${sessionDisplayName(sessions.find((s) => s.id === parentId) || { id: parentId })}`;
14265
+ }
14266
+
14108
14267
  function placeholderSession(id, spec) {
14109
14268
  return {
14110
14269
  id,
14111
14270
  placeholder: true,
14112
14271
  mode: spec.mode,
14113
- name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14272
+ edit: !!spec.edit,
14273
+ name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14114
14274
  dispatchGroup: spec.group || undefined,
14115
14275
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
14116
14276
  project: spec.cwd,
@@ -14189,7 +14349,7 @@ function renderPlaceholderCard(session) {
14189
14349
  <button onclick="fetchTasks('${escAttrJs(session.id)}')" data-session-id="${escapeHtml(session.id)}" class="session-item session-placeholder ${isActive ? 'active' : ''}" title="${escapeHtml(`${session.id} | ${session.project}`)}">
14190
14350
  <div class="session-name">${escapeHtml(session.name)}</div>
14191
14351
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
14192
- <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.mode] || PLACEHOLDER_HINTS.new}</div>
14352
+ <div class="session-waiting"><span class="pulse"></span>${PLACEHOLDER_HINTS[session.edit ? 'edit' : session.mode] || PLACEHOLDER_HINTS.new}</div>
14193
14353
  </button>
14194
14354
  `;
14195
14355
  }
@@ -14203,7 +14363,15 @@ async function restorePendingSessions() {
14203
14363
  try {
14204
14364
  for (const t of await terminals) {
14205
14365
  if ((t.mode !== 'new' && t.mode !== 'pick') || newSpecs.has(t.id) || dispatchSpecs.has(t.id)) continue;
14206
- newSpecs.set(t.id, { cwd: t.cwd, name: t.name, worktree: t.worktree, mode: t.mode, startedAt: t.startedAt });
14366
+ newSpecs.set(t.id, {
14367
+ cwd: t.cwd,
14368
+ name: t.name,
14369
+ worktree: t.worktree,
14370
+ mode: t.mode,
14371
+ edit: t.edit,
14372
+ forkOf: t.forkOf,
14373
+ startedAt: t.startedAt,
14374
+ });
14207
14375
  }
14208
14376
  } catch (_) {}
14209
14377
  }
@@ -14241,6 +14409,7 @@ function openNewSession(folder, resume = false) {
14241
14409
  document.getElementById('ns-wt').checked = false;
14242
14410
  document.getElementById('ns-model').value = '';
14243
14411
  document.getElementById('ns-stay').checked = store.getItem(NEW_SESSION_STAY_KEY) === 'true';
14412
+ document.getElementById('ns-edit').checked = store.getItem(NEW_SESSION_EDIT_KEY) === 'true';
14244
14413
  setNewSessionError('');
14245
14414
  const current = viewMode === 'session' ? sessions.find((s) => s.id === currentSessionId)?.project : null;
14246
14415
  setNewSessionFolder(folder || current || '');
@@ -14340,12 +14509,15 @@ function folderListOpen() {
14340
14509
  function newSessionValues() {
14341
14510
  const wtOn = document.getElementById('ns-wt').checked;
14342
14511
  const wtName = document.getElementById('ns-wt-name').value.trim();
14512
+ const edit = !ns.resume && document.getElementById('ns-edit').checked;
14343
14513
  return {
14344
14514
  cwd: ns.folder,
14345
14515
  name: document.getElementById('ns-name').value.trim() || null,
14346
14516
  worktree: wtOn ? wtName || true : false,
14347
14517
  model: document.getElementById('ns-model').value || null,
14348
14518
  prompt: document.getElementById('ns-prompt').value.trim() || null,
14519
+ edit,
14520
+ stay: !ns.resume && !edit && document.getElementById('ns-stay').checked,
14349
14521
  };
14350
14522
  }
14351
14523
 
@@ -14371,11 +14543,12 @@ function renderNewSessionForm() {
14371
14543
  const hint = document.getElementById('ns-wt-hint');
14372
14544
  hint.hidden = !wtOn;
14373
14545
  hint.textContent = `.claude/worktrees/${typeof v.worktree === 'string' ? v.worktree : '<name chosen by Claude>'}`;
14546
+ document.getElementById('ns-stay').disabled = v.edit;
14374
14547
  const problem = newSessionProblem(v);
14375
14548
  setNewSessionError(problem);
14376
14549
  const start = document.getElementById('ns-start');
14377
14550
  start.disabled = !v.cwd || !!problem || ns.busy;
14378
- const verb = ns.resume ? 'Resume' : 'Start';
14551
+ const verb = ns.resume ? 'Resume' : v.edit ? 'Open' : 'Start';
14379
14552
  start.textContent = v.cwd ? `${verb} in ${pathBasename(v.cwd)}` : verb;
14380
14553
  }
14381
14554
 
@@ -14424,12 +14597,30 @@ function startNewSession() {
14424
14597
  const id = crypto.randomUUID();
14425
14598
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
14426
14599
  setTerminalMode(id, true);
14427
- if (!ns.resume && document.getElementById('ns-stay').checked) return startNewSessionInBackground(id, v);
14428
- sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14600
+ if (v.stay) return startNewSessionInBackground(id, v);
14429
14601
  closeNewSession();
14602
+ openPlaceholder(id);
14603
+ }
14604
+
14605
+ function openPlaceholder(id) {
14606
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14430
14607
  fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
14431
14608
  }
14432
14609
 
14610
+ function forkableSession(id) {
14611
+ return terminalAvailable() ? sessions.find((s) => s.id === id && !s.placeholder) : undefined;
14612
+ }
14613
+
14614
+ // The fork gets its own id up front, so it runs as a new session and needs no rekey.
14615
+ function forkSession(parentId) {
14616
+ const parent = forkableSession(parentId);
14617
+ if (!parent) return;
14618
+ const id = crypto.randomUUID();
14619
+ newSpecs.set(id, { cwd: parent.project, forkOf: parentId, mode: 'new', startedAt: Date.now() });
14620
+ setTerminalMode(id, true);
14621
+ openPlaceholder(id);
14622
+ }
14623
+
14433
14624
  async function startNewSessionInBackground(id, v) {
14434
14625
  refreshPlaceholders();
14435
14626
  setNewSessionBusy(true);
@@ -14528,6 +14719,10 @@ function initNewSession() {
14528
14719
  document
14529
14720
  .getElementById('ns-stay')
14530
14721
  .addEventListener('change', (e) => store.setItem(NEW_SESSION_STAY_KEY, String(e.target.checked)));
14722
+ document.getElementById('ns-edit').addEventListener('change', (e) => {
14723
+ store.setItem(NEW_SESSION_EDIT_KEY, String(e.target.checked));
14724
+ renderNewSessionForm();
14725
+ });
14531
14726
  for (const id of ['ns-name', 'ns-wt-name', 'ns-prompt', 'ns-model']) {
14532
14727
  document.getElementById(id).addEventListener('input', renderNewSessionForm);
14533
14728
  }