claude-code-kanban 7.3.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/lib/terminal.js CHANGED
@@ -23,7 +23,7 @@ const priority = require('./priority');
23
23
 
24
24
  const WS_PATH = '/api/terminal/ws';
25
25
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
26
- const MODES = new Set(['auto', 'resume', 'fork', 'shell', 'new', 'pick']);
26
+ const MODES = new Set(['auto', 'resume', 'shell', 'new', 'pick']);
27
27
  const PICK_POLL_MS = 1000;
28
28
  // How long before the PTY a registry entry may claim to have started (clock skew between the two).
29
29
  const PICK_START_SLACK_MS = 2000;
@@ -59,7 +59,6 @@ const RESTORE_GAP_MS = 2000;
59
59
  // The hub stops cck with `taskkill /f /t`, which can deliver the PTY exits to cck a moment
60
60
  // before cck itself dies. The delay keeps that teardown out of the saved list.
61
61
  const SAVE_DELAY_MS = 1000;
62
- // A fork runs under its parent's id until claude names the new one, so it has no id to resume.
63
62
  const RESTORABLE_MODES = new Set(['resume', 'new']);
64
63
  // Quitting claude drops the PTY to its shell, so the PTY's exit cannot tell cck that claude is gone.
65
64
  const CLAUDE_EXIT_POLL_MS = 5000;
@@ -177,10 +176,11 @@ function shellArgs(shell, claudeArgs) {
177
176
 
178
177
  function claudeArgsFor(mode, id, spec) {
179
178
  if (mode === 'shell') return null;
180
- if (mode === 'fork') return ['--resume', id, '--fork-session'];
181
179
  if (mode === 'pick') return ['--resume'];
182
180
  if (mode === 'new') {
183
- const args = ['--session-id', id, ...(spec.extraArgs || [])];
181
+ // claude takes --session-id with --resume only together with --fork-session.
182
+ const fork = spec.forkOf ? ['--resume', spec.forkOf, '--fork-session'] : [];
183
+ const args = [...fork, '--session-id', id, ...(spec.extraArgs || [])];
184
184
  if (spec.name) args.push('--name', spec.name);
185
185
  if (spec.model) args.push('--model', spec.model);
186
186
  // Last, because its value is optional and a following flag must not be taken for it.
@@ -203,7 +203,9 @@ function extraArgsError(args) {
203
203
 
204
204
  // Returns the new-session options from a hello, or a string naming the bad field.
205
205
  function parseNewSpec(msg) {
206
- if (typeof msg.cwd !== 'string' || !msg.cwd) return 'folder';
206
+ const forkOf = msg.forkOf ?? null;
207
+ if (forkOf !== null && (typeof forkOf !== 'string' || !UUID_RE.test(forkOf))) return 'session to fork';
208
+ if (!forkOf && (typeof msg.cwd !== 'string' || !msg.cwd)) return 'folder';
207
209
  const name = typeof msg.name === 'string' ? msg.name.trim() : '';
208
210
  if (name && !NAME_RE.test(name)) return 'name';
209
211
  const worktree = msg.worktree ?? false;
@@ -222,7 +224,8 @@ function parseNewSpec(msg) {
222
224
  const extraArgs = msg.extraArgs ?? [];
223
225
  const bad = extraArgsError(extraArgs);
224
226
  if (bad) return bad;
225
- return { cwd: msg.cwd, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
227
+ if (forkOf && worktree) return 'worktree: a fork runs in its parent session folder';
228
+ return { cwd: forkOf ? null : msg.cwd, forkOf, name: name || null, worktree, model, taskList, prompt: prompt || null, extraArgs, edit };
226
229
  }
227
230
 
228
231
  function samePath(a, b) {
@@ -489,13 +492,7 @@ function createTerminalService(o) {
489
492
  if (s.claudeExitedAt) return null;
490
493
  if (s.claudePid || s.mode === 'pick') return s.claudePid;
491
494
  const live = registry || o.liveSessions();
492
- const own = ownEntry(live, s, s.startedAt);
493
- if (own || s.mode !== 'fork') {
494
- s.claudePid = own?.pid;
495
- return s.claudePid;
496
- }
497
- const claimed = new Set([...sessions.values()].map((x) => x.claudePid).filter(Boolean));
498
- s.claudePid = findPickProcess(live, s, claimed);
495
+ s.claudePid = ownEntry(live, s, s.startedAt)?.pid;
499
496
  return s.claudePid;
500
497
  }
501
498
 
@@ -578,6 +575,7 @@ function createTerminalService(o) {
578
575
  const s = {
579
576
  id, mode, cwd, pty: proc, term, serializer, sockets: new Set(), exited: false, ended: false, paused: false, startedAt: Date.now(),
580
577
  name: spec?.name || null, worktree: spec?.worktree || false, taskList, onOutput: null, edit: !!spec?.edit,
578
+ forkOf: spec?.forkOf || null,
581
579
  };
582
580
  if (s.edit) queueText(s, claudeCommand(shell, claudeArgs, spec.prompt), false);
583
581
  else if (spec?.prompt) queueText(s, spec.prompt, true);
@@ -730,14 +728,16 @@ function createTerminalService(o) {
730
728
  return { code: 4002, status: 400, error: 'cmd cannot take a typed prompt; clear the prompt or set the terminal shell to pwsh or gitbash' };
731
729
  }
732
730
  }
733
- const known = await o.resolveCwd(id);
731
+ // `claude --resume` finds a fork's parent only from the parent's own project folder.
732
+ const [known, forkCwd] = await Promise.all([o.resolveCwd(id), spec?.forkOf ? o.resolveCwd(spec.forkOf) : null]);
734
733
  let cwd = known;
735
734
  if (mode === 'new' || mode === 'pick') {
736
735
  if (known !== null) return { code: 4006, status: 409, error: 'session id already in use' };
737
- if (typeof msg.cwd !== 'string' || !(await o.isAllowedFolder(msg.cwd))) {
736
+ cwd = spec?.forkOf ? forkCwd : msg.cwd;
737
+ if (spec?.forkOf && cwd === null) return { code: 4004, status: 404, error: 'unknown session to fork' };
738
+ if (!spec?.forkOf && (typeof cwd !== 'string' || !(await o.isAllowedFolder(cwd)))) {
738
739
  return { code: 4004, status: 403, error: 'folder is not a known project or a folder picked in this run' };
739
740
  }
740
- cwd = msg.cwd;
741
741
  } else if (mode !== 'shell' && known === null) {
742
742
  return { code: 4004, status: 404, error: 'unknown session' };
743
743
  }
@@ -783,7 +783,7 @@ function createTerminalService(o) {
783
783
  function list() {
784
784
  return [...sessions.values()].filter((s) => !s.ended).map((s) => ({
785
785
  id: s.id, mode: s.mode, cwd: s.cwd, pid: s.pty.pid, clients: s.sockets.size, startedAt: s.startedAt,
786
- name: s.name, worktree: s.worktree, edit: s.edit,
786
+ name: s.name, worktree: s.worktree, edit: s.edit, forkOf: s.forkOf,
787
787
  }));
788
788
  }
789
789
 
@@ -827,12 +827,11 @@ function createTerminalService(o) {
827
827
  }
828
828
 
829
829
  // Pastes into claude's input box and stops there: Enter would also submit whatever the
830
- // user had half-typed, and over a permission dialog it would answer it. A fork runs under
831
- // a new session id, so its terminal id does not name the session the text is for. A
832
- // pending queued prompt owns the input box until it is sent.
830
+ // user had half-typed, and over a permission dialog it would answer it. A pending queued
831
+ // prompt owns the input box until it is sent.
833
832
  function paste(id, text) {
834
833
  const s = sessions.get(id);
835
- if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell' || s.mode === 'fork') return false;
834
+ if (!s || s.ended || s.exited || s.claudeExitedAt || s.onOutput || s.mode === 'shell') return false;
836
835
  if (onTrustScreen(s)) return false;
837
836
  // Until the user runs the typed command, the input box is the shell's command line.
838
837
  if (s.edit && !s.claudePid) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.3.0",
3
+ "version": "7.4.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
  "type": "commonjs",
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;
@@ -3406,15 +3466,8 @@ async function revealSession(id) {
3406
3466
  let session = sessions.find((s) => s.id === id);
3407
3467
  if (!session) {
3408
3468
  lastSessionsHash = '';
3409
- await fetchSessions();
3410
- session = sessions.find((s) => s.id === id);
3411
- }
3412
- if (!session) {
3413
- // The id is outside the list the filters asked for. renderSession bails on a session
3414
- // missing from `sessions`, so pull that one row in before opening it; later polls
3415
- // keep it, because fetchSessions pins currentSessionId.
3416
- session = await fetchSessionById(id);
3417
- if (session) sessions.push(session);
3469
+ await fetchSessions(true, id);
3470
+ session = await ensureSessionLoaded(id);
3418
3471
  }
3419
3472
  const uncollapsed = session ? uncollapseFor(session) : false;
3420
3473
  if (uncollapsed) persistCollapsedGroups();
@@ -3603,36 +3656,8 @@ function renderSessions() {
3603
3656
  const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
3604
3657
  const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : applyStableSessionOrder(getFilteredSessions());
3605
3658
 
3606
- if (filteredSessions.length === 0) {
3607
- let emptyMsg = 'No sessions found';
3608
- let emptyHint = 'Tasks appear when you use Claude Code';
3659
+ const outside = sidebarSearch.rows(zenMode ? '' : searchQuery, filteredSessions);
3609
3660
 
3610
- if (zenMode) {
3611
- emptyMsg = 'Zen mode: no session open';
3612
- emptyHint = 'Press Ctrl+Shift+Z to leave zen mode, then open a session';
3613
- } else if (searchQuery) {
3614
- emptyMsg = `No results for "${searchQuery}"`;
3615
- emptyHint = 'Try a different search term or clear the search';
3616
- } else if (filterProject && sessionFilter === 'active') {
3617
- emptyMsg = 'No active sessions for this project filter';
3618
- emptyHint = 'Try "All Sessions" or clear the project filter';
3619
- } else if (filterProject) {
3620
- emptyMsg = 'No sessions for this project filter';
3621
- emptyHint = 'Clear the project filter to see all';
3622
- } else if (sessionFilter === 'active') {
3623
- emptyMsg = 'No active sessions';
3624
- emptyHint = 'Select "All Sessions" to see all';
3625
- }
3626
- sessionsList.innerHTML = `
3627
- <div style="padding: 24px 12px; text-align: center; color: var(--text-muted); font-size: 12px;">
3628
- <p>${emptyMsg}</p>
3629
- <p style="margin-top: 8px; font-size: 11px;">${emptyHint}</p>
3630
- </div>
3631
- `;
3632
- return;
3633
- }
3634
-
3635
- // Helper to render a single session card
3636
3661
  const renderSessionCard = (session) => {
3637
3662
  if (session.placeholder) return renderPlaceholderCard(session);
3638
3663
  const total = session.taskCount;
@@ -3703,6 +3728,63 @@ function renderSessions() {
3703
3728
  `;
3704
3729
  };
3705
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
+
3706
3788
  // Zen draws one card and its panel, so it returns before the grouping below — leaving it in
3707
3789
  // that chain would build the whole list only to discard it.
3708
3790
  if (zenMode) {
@@ -3725,20 +3807,6 @@ function renderSessions() {
3725
3807
  const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
3726
3808
  const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isInPinnedGroup(s) ? 1 : 0);
3727
3809
  const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
3728
- const countHtml = (arr) => {
3729
- const active = arr.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
3730
- 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>`;
3731
- };
3732
- // `ungroup` marks the label as the drop target for pulling an item out of a named group.
3733
- const sectionHtml = (key, text, ungroup, body, empty = false) => {
3734
- const collapsed = collapsedProjectGroups.has(key);
3735
- const escPath = escapeHtml(key);
3736
- const title = ungroup ? 'Click to collapse — drop here to remove from a group' : 'Click to collapse';
3737
- const emptyCls = empty ? ' sg-section-empty' : '';
3738
- 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>
3739
- <div class="sg-section-body${collapsed ? ' collapsed' : ''}${emptyCls}" data-group-path="${escPath}">${body}</div>`;
3740
- };
3741
-
3742
3810
  const renderGroupSessions = (sessions, pinKey) => {
3743
3811
  if (!groupPinned || pinnedSessionIds.size === 0) return sessions.map(renderSessionCard).join('');
3744
3812
  const gIdlePinned = sessions.filter(isInPinnedGroup);
@@ -3958,13 +4026,13 @@ function renderSessions() {
3958
4026
  const noProjects = sortedGroups.length === 0 && ungrouped.length === 0;
3959
4027
  html += sectioned ? sectionHtml(SECTION_PROJECTS, 'Projects', true, projectsHtml, noProjects) : projectsHtml;
3960
4028
 
3961
- sessionsList.innerHTML = html;
4029
+ sessionsList.innerHTML = html + outsideHtml;
3962
4030
  } else {
3963
4031
  const sectioned = sessionGroups.length > 0 || transientGroups.size > 0;
3964
4032
  const restHtml =
3965
4033
  sectioned && collapsedProjectGroups.has(SECTION_SESSIONS) ? '' : sgRest.map(renderSessionCard).join('');
3966
4034
  const tail = sectioned ? sectionHtml(SECTION_SESSIONS, 'Sessions', true, restHtml, sgRest.length === 0) : restHtml;
3967
- sessionsList.innerHTML = html + sgSectionHtml(true) + tail;
4035
+ sessionsList.innerHTML = html + sgSectionHtml(true) + tail + outsideHtml;
3968
4036
  }
3969
4037
 
3970
4038
  const navItems = getNavigableItems();
@@ -5022,10 +5090,12 @@ function sgOpenMenu(x, y, kind, ref) {
5022
5090
  menu.id = 'sg-menu';
5023
5091
  menu.className = 'sg-menu';
5024
5092
  menu.setAttribute('role', 'menu');
5025
- 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`);
5026
5095
  menu.dataset.sgKind = kind;
5027
5096
  menu.dataset.sgRef = ref;
5028
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>' : ''}
5029
5099
  <div class="sg-menu-label" aria-hidden="true">Move ${label} to group</div>
5030
5100
  ${rows}
5031
5101
  <button class="sg-menu-item" role="menuitem" data-sg-move="__new__">+ New group…</button>
@@ -5073,6 +5143,7 @@ document.addEventListener('click', (e) => {
5073
5143
  const { sgKind, sgRef } = menu.dataset;
5074
5144
  const move = item.dataset.sgMove;
5075
5145
  sgCloseMenu();
5146
+ if (item.dataset.sgAction === 'fork') return forkSession(sgRef);
5076
5147
  if (move === '__none__') {
5077
5148
  sgDetach(sgKind, sgRef);
5078
5149
  persistSessionGroups();
@@ -5316,18 +5387,16 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5316
5387
  }
5317
5388
 
5318
5389
  // Drops every collapse key that could hide this session: its project, a named group wrapping
5319
- // the project or the session itself, the pinned sub-section only when the card renders inside
5320
- // it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
5321
- // 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.
5322
5394
  function uncollapseFor(session) {
5323
5395
  const group = sgGroupForSession(session);
5324
5396
  const keys = [
5325
5397
  sessionProjectKey(session) || '__ungrouped__',
5326
5398
  ...pinnedCollapseKeys(session),
5327
- group && sgKey(group.id),
5328
- SECTION_GROUPS,
5329
- SECTION_PROJECTS,
5330
- SECTION_SESSIONS,
5399
+ ...(group ? [sgKey(group.id), sgHostOf(group, session), SECTION_GROUPS] : [SECTION_PROJECTS, SECTION_SESSIONS]),
5331
5400
  ];
5332
5401
  let changed = false;
5333
5402
  for (const key of keys) {
@@ -11058,38 +11127,9 @@ let spRenderedQuery = null;
11058
11127
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
11059
11128
  const spPeekCache = new Map();
11060
11129
 
11061
- // A query also searches session ids and names in every transcript on the server, so a
11062
- // session outside the sidebar filter is found too. Values: 'pending', then the rows.
11063
- const spSearch = new Map();
11064
- let spSearchTimer = null;
11065
-
11066
- function spSearchKey(query) {
11067
- return query.length >= 3 ? query.toLowerCase() : null;
11068
- }
11069
-
11070
- function spScheduleSearch(key) {
11071
- clearTimeout(spSearchTimer);
11072
- spSearchTimer = setTimeout(async () => {
11073
- spSearch.set(key, 'pending');
11074
- let ids = [];
11075
- try {
11076
- const r = await fetch(`/api/sessions/search?q=${encodeURIComponent(key)}`);
11077
- if (r.ok) ids = await r.json();
11078
- } catch (_) {}
11079
- spSearch.set(key, ids.length ? await fetchSessionsByIds(ids) : []);
11080
- renderSessionPicker();
11081
- }, 100);
11082
- }
11083
-
11084
- // The server rows not already matched in the sidebar list, and whether a search is still out.
11085
- function spSearchRows(key, localRows) {
11086
- if (!key) return { rows: [], pending: false };
11087
- const found = spSearch.get(key);
11088
- if (!found) spScheduleSearch(key);
11089
- if (!Array.isArray(found)) return { rows: [], pending: true };
11090
- const seen = new Set(localRows.map((s) => s.id));
11091
- return { rows: found.filter((s) => !seen.has(s.id)), pending: false };
11092
- }
11130
+ const spSearch = sessionSearch(() => {
11131
+ if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
11132
+ });
11093
11133
 
11094
11134
  function openSessionPicker() {
11095
11135
  spFromTerminal = terminalPaneFocused();
@@ -11097,7 +11137,6 @@ function openSessionPicker() {
11097
11137
  input.value = '';
11098
11138
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
11099
11139
  spPeekCache.clear();
11100
- spSearch.clear();
11101
11140
  spRenderedQuery = null;
11102
11141
  document.getElementById('session-picker-modal').classList.add('visible');
11103
11142
  renderSessionPicker();
@@ -11220,15 +11259,14 @@ function renderSessionPicker() {
11220
11259
  // Search rows that arrive for the same query append below, so the cursor stays on its row.
11221
11260
  const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
11222
11261
  spRenderedQuery = query;
11223
- const key = spSearchKey(query);
11224
11262
  const local = spSource.filter((s) => spMatches(s, query));
11225
- const found = spSearchRows(key, local);
11263
+ const found = spSearch.rows(query, local);
11226
11264
  spRows = [...local, ...found.rows];
11227
11265
  if (!spRows.length) {
11228
11266
  spIdx = -1;
11229
11267
  const empty = found.pending
11230
11268
  ? 'Searching all sessions…'
11231
- : spSource.length || key
11269
+ : spSource.length || query.length >= SESSION_SEARCH_MIN
11232
11270
  ? 'No session matches'
11233
11271
  : 'No sessions in the current sidebar filter';
11234
11272
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
@@ -13420,8 +13458,10 @@ function syncTerminal() {
13420
13458
  }
13421
13459
 
13422
13460
  // Only paths the user drives call this; restores, deep links and refreshes call fetchTasks, so they never take focus.
13423
- function openSession(sessionId) {
13461
+ async function openSession(sessionId) {
13424
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);
13425
13465
  const alreadyOpen = sessionId === currentSessionId && viewMode === 'session' && termState.shown;
13426
13466
  if (!alreadyOpen) termState.openedByUser = sessionId;
13427
13467
  else if (termState.attached && promptAwaitsUser(sessionId)) focusTerminalPane();
@@ -13921,6 +13961,7 @@ async function openTerminal(sessionId, mode, attempt = 0) {
13921
13961
  model: spec.model,
13922
13962
  prompt: spec.prompt,
13923
13963
  edit: spec.edit,
13964
+ forkOf: spec.forkOf,
13924
13965
  }),
13925
13966
  },
13926
13967
  });
@@ -14012,7 +14053,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14012
14053
  'Resuming it here too makes both processes write to the same transcript.',
14013
14054
  [
14014
14055
  ['resume', 'Resume anyway'],
14015
- ['fork', 'Fork'],
14016
14056
  ['shell', 'Shell only'],
14017
14057
  ],
14018
14058
  );
@@ -14037,7 +14077,6 @@ function onTerminalMessage(sessionId, msg, tail = '') {
14037
14077
  ]
14038
14078
  : [
14039
14079
  ['resume', 'Resume'],
14040
- ['fork', 'Fork'],
14041
14080
  ['shell', 'Shell'],
14042
14081
  ],
14043
14082
  clean ? '' : tail,
@@ -14220,13 +14259,18 @@ const PLACEHOLDER_HINTS = {
14220
14259
  edit: 'press Enter in the terminal to start',
14221
14260
  };
14222
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
+
14223
14267
  function placeholderSession(id, spec) {
14224
14268
  return {
14225
14269
  id,
14226
14270
  placeholder: true,
14227
14271
  mode: spec.mode,
14228
14272
  edit: !!spec.edit,
14229
- name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14273
+ name: spec.name || (spec.forkOf && forkLabel(spec.forkOf)) || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
14230
14274
  dispatchGroup: spec.group || undefined,
14231
14275
  dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
14232
14276
  project: spec.cwd,
@@ -14325,6 +14369,7 @@ async function restorePendingSessions() {
14325
14369
  worktree: t.worktree,
14326
14370
  mode: t.mode,
14327
14371
  edit: t.edit,
14372
+ forkOf: t.forkOf,
14328
14373
  startedAt: t.startedAt,
14329
14374
  });
14330
14375
  }
@@ -14553,11 +14598,29 @@ function startNewSession() {
14553
14598
  newSpecs.set(id, { ...(ns.resume ? { cwd: v.cwd } : v), mode: ns.resume ? 'pick' : 'new', startedAt: Date.now() });
14554
14599
  setTerminalMode(id, true);
14555
14600
  if (v.stay) return startNewSessionInBackground(id, v);
14556
- sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14557
14601
  closeNewSession();
14602
+ openPlaceholder(id);
14603
+ }
14604
+
14605
+ function openPlaceholder(id) {
14606
+ sessions = mergePlaceholders(sessions.filter((s) => !s.placeholder));
14558
14607
  fetchTasks(id).then(() => sessionsList.querySelector('.session-item.active')?.scrollIntoView({ block: 'nearest' }));
14559
14608
  }
14560
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
+
14561
14624
  async function startNewSessionInBackground(id, v) {
14562
14625
  refreshPlaceholders();
14563
14626
  setNewSessionBusy(true);
package/public/index.html CHANGED
@@ -138,6 +138,7 @@
138
138
  class="pf-input"
139
139
  placeholder="Any project"
140
140
  autocomplete="off"
141
+ data-bwignore data-1p-ignore data-lpignore="true"
141
142
  spellcheck="false"
142
143
  role="combobox"
143
144
  aria-expanded="true"
@@ -169,6 +170,8 @@
169
170
  type="text"
170
171
  class="search-input"
171
172
  placeholder="Search tasks, sessions, projects..."
173
+ autocomplete="off"
174
+ data-bwignore data-1p-ignore data-lpignore="true"
172
175
  oninput="handleSearch(this.value)"
173
176
  />
174
177
  <button id="search-clear-btn" class="search-clear" onclick="clearSearch()" title="Clear search" aria-label="Clear search">
@@ -503,7 +506,7 @@
503
506
  </div>
504
507
  <div class="help-bar">
505
508
  <div class="help-tabs" id="help-tabs" role="tablist" aria-label="Shortcut groups"></div>
506
- <input type="search" id="help-filter" class="form-input help-filter" placeholder="Filter shortcuts" aria-label="Filter shortcuts" autocomplete="off" spellcheck="false">
509
+ <input type="search" id="help-filter" class="form-input help-filter" placeholder="Filter shortcuts" aria-label="Filter shortcuts" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
507
510
  </div>
508
511
  <div class="modal-body">
509
512
  <dl class="shortcuts" id="help-shortcuts"></dl>
@@ -802,7 +805,7 @@
802
805
  <label class="ns-label" for="ns-folder">Folder</label>
803
806
  <div class="ns-field ns-folder-field">
804
807
  <div class="ns-inline">
805
- <input type="text" id="ns-folder" class="form-input" placeholder="Type to find a project" autocomplete="off" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="ns-folder-list">
808
+ <input type="text" id="ns-folder" class="form-input" placeholder="Type to find a project" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false" role="combobox" aria-expanded="false" aria-controls="ns-folder-list">
806
809
  <button type="button" id="ns-browse" class="btn btn-secondary ns-browse" onclick="browseNewSessionFolder()">Browse…</button>
807
810
  </div>
808
811
  <div id="ns-folder-list" class="ns-dropdown" role="listbox" hidden></div>
@@ -811,7 +814,7 @@
811
814
  <div class="ns-row ns-extra">
812
815
  <label class="ns-label" for="ns-name">Name</label>
813
816
  <div class="ns-field">
814
- <input type="text" id="ns-name" class="form-input" placeholder="Optional" maxlength="80" autocomplete="off" spellcheck="false">
817
+ <input type="text" id="ns-name" class="form-input" placeholder="Optional" maxlength="80" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
815
818
  </div>
816
819
  </div>
817
820
  <div class="ns-row ns-extra">
@@ -828,7 +831,7 @@
828
831
  <div class="ns-field">
829
832
  <div class="ns-inline">
830
833
  <label class="ns-check"><input type="checkbox" id="ns-wt"> New worktree</label>
831
- <input type="text" id="ns-wt-name" class="form-input" placeholder="Name (optional)" maxlength="64" autocomplete="off" spellcheck="false" disabled aria-label="Worktree name">
834
+ <input type="text" id="ns-wt-name" class="form-input" placeholder="Name (optional)" maxlength="64" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false" disabled aria-label="Worktree name">
832
835
  </div>
833
836
  <div id="ns-wt-hint" class="ns-hint" hidden></div>
834
837
  </div>
@@ -864,7 +867,7 @@
864
867
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
865
868
  </button>
866
869
  </div>
867
- <input id="pane-pop-input" class="form-input" placeholder="Search linked docs, or paste a URL or a file path" autocomplete="off" spellcheck="false">
870
+ <input id="pane-pop-input" class="form-input" placeholder="Search linked docs, or paste a URL or a file path" autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
868
871
  <div id="pane-pop-detect" class="pane-pop-detect"></div>
869
872
  <div class="pane-pop-label">Linked docs</div>
870
873
  <div id="pane-pop-docs" class="pane-pop-docs"></div>
@@ -874,7 +877,7 @@
874
877
 
875
878
  <div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
876
879
  <div class="modal session-picker" onclick="event.stopPropagation()">
877
- <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" spellcheck="false">
880
+ <input type="text" id="session-picker-input" class="form-input sp-input" placeholder="Jump to session..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
878
881
  <div class="sp-body">
879
882
  <div id="session-picker-list" class="sp-list"></div>
880
883
  <div id="session-picker-peek" class="sp-peek"></div>
@@ -885,7 +888,7 @@
885
888
 
886
889
  <div id="project-picker-modal" class="modal-overlay top-aligned" onclick="closeProjectPicker()">
887
890
  <div class="modal session-picker" onclick="event.stopPropagation()">
888
- <input type="text" id="project-picker-input" class="form-input sp-input" placeholder="Filter by project..." autocomplete="off" spellcheck="false">
891
+ <input type="text" id="project-picker-input" class="form-input sp-input" placeholder="Filter by project..." autocomplete="off" data-bwignore data-1p-ignore data-lpignore="true" spellcheck="false">
889
892
  <div class="pp-chips">
890
893
  <button type="button" class="pf-recent" id="pp-recent-chip" aria-pressed="false" title="List only projects active in the last 24h" onclick="ppToggleRecentOnly()">Recent</button>
891
894
  </div>
package/public/style.css CHANGED
@@ -7143,7 +7143,8 @@ select.form-input option:checked {
7143
7143
 
7144
7144
  /* #region SESSION_PICKER */
7145
7145
  .modal.session-picker {
7146
- max-height: 70vh;
7146
+ max-width: 920px;
7147
+ max-height: 74vh;
7147
7148
  padding: 12px;
7148
7149
  display: flex;
7149
7150
  flex-direction: column;
@@ -7152,9 +7153,9 @@ select.form-input option:checked {
7152
7153
  }
7153
7154
 
7154
7155
  .sp-input {
7155
- padding: 9px 12px;
7156
+ padding: 10px 12px;
7156
7157
  background: var(--bg-deep);
7157
- font-size: 13px;
7158
+ font-size: 14px;
7158
7159
  }
7159
7160
 
7160
7161
  .modal.session-picker.peeking {
@@ -7197,7 +7198,7 @@ select.form-input option:checked {
7197
7198
  gap: 8px;
7198
7199
  margin-bottom: 6px;
7199
7200
  font-family: var(--mono);
7200
- font-size: 11px;
7201
+ font-size: 11.5px;
7201
7202
  color: var(--text-muted);
7202
7203
  white-space: nowrap;
7203
7204
  overflow: hidden;
@@ -7215,13 +7216,13 @@ select.form-input option:checked {
7215
7216
  align-items: center;
7216
7217
  gap: 8px;
7217
7218
  width: 100%;
7218
- padding: 6px 8px;
7219
+ padding: 7px 8px;
7219
7220
  background: none;
7220
7221
  border: 1px solid transparent;
7221
7222
  border-radius: 5px;
7222
7223
  color: var(--text-secondary);
7223
7224
  font-family: var(--mono);
7224
- font-size: 12px;
7225
+ font-size: 13px;
7225
7226
  text-align: left;
7226
7227
  cursor: pointer;
7227
7228
  }
@@ -7330,14 +7331,14 @@ select.form-input option:checked {
7330
7331
  border-radius: 3px;
7331
7332
  background: var(--bg-hover);
7332
7333
  color: var(--text-tertiary);
7333
- font-size: 11px;
7334
+ font-size: 11.5px;
7334
7335
  }
7335
7336
 
7336
7337
  .sp-count,
7337
7338
  .sp-time {
7338
7339
  flex: none;
7339
7340
  color: var(--text-muted);
7340
- font-size: 11px;
7341
+ font-size: 11.5px;
7341
7342
  white-space: nowrap;
7342
7343
  }
7343
7344