claude-code-kanban 7.14.0 → 7.15.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/parsers.js CHANGED
@@ -673,16 +673,41 @@ function countTranscriptsBornBefore(dir, birthtimeMs) {
673
673
  // Cache: jsonlPath -> { ino, size, mtimeMs }, the transcript as it last grew.
674
674
  // Claude Code, `claude --resume` and backup tools set a transcript's mtime without writing
675
675
  // to it (#50), so only a file that grew, shrank or was replaced counts as activity.
676
+ // When a session exits, Claude Code appends bookkeeping lines (cost-state, last-prompt,
677
+ // mode, ai-title ...) with no `timestamp`; a write of only such lines is not activity either.
676
678
  const logActivityCache = new Map();
679
+ const BOOKKEEPING_APPEND_MAX = 64 * 1024;
680
+
681
+ function isBookkeepingAppend(jsonlPath, from, to) {
682
+ if (to - from > BOOKKEEPING_APPEND_MAX || from < 1) return false;
683
+ // Reads from one byte early, so the append is known to start on a line boundary.
684
+ const buf = Buffer.allocUnsafe(to - from + 1);
685
+ try {
686
+ const fd = fs.openSync(jsonlPath, 'r');
687
+ try { fs.readSync(fd, buf, 0, buf.length, from - 1); } finally { fs.closeSync(fd); }
688
+ } catch { return false; }
689
+ const text = buf.toString('utf8');
690
+ if (text[0] !== '\n' || !text.endsWith('\n') || text.includes('"timestamp":')) return false;
691
+ return text.slice(1, -1).split('\n').every((line) => {
692
+ if (!line) return true;
693
+ try {
694
+ const obj = JSON.parse(line);
695
+ return obj !== null && typeof obj === 'object';
696
+ } catch { return false; }
697
+ });
698
+ }
677
699
 
678
700
  function transcriptActivityMs(jsonlPath, stat) {
679
701
  const prev = logActivityCache.get(jsonlPath);
680
- if (prev && prev.ino === stat.ino && prev.size === stat.size) return prev.mtimeMs;
702
+ const sameFile = prev && prev.ino === stat.ino;
703
+ if (sameFile && prev.size === stat.size) return prev.mtimeMs;
704
+ const quiet = sameFile && stat.size > prev.size && isBookkeepingAppend(jsonlPath, prev.size, stat.size);
705
+ const mtimeMs = quiet ? prev.mtimeMs : stat.mtimeMs;
681
706
  logActivityCache.delete(jsonlPath);
682
- logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs });
707
+ logActivityCache.set(jsonlPath, { ino: stat.ino, size: stat.size, mtimeMs });
683
708
  if (logActivityCache.size > SESSION_INFO_CACHE_MAX) logActivityCache.delete(logActivityCache.keys().next().value);
684
709
  sessionCacheDirty = true;
685
- return stat.mtimeMs;
710
+ return mtimeMs;
686
711
  }
687
712
 
688
713
  const sessionCachesDirty = () => sessionCacheDirty;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "7.14.0",
3
+ "version": "7.15.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
@@ -369,7 +369,7 @@ async function fetchSessions(includeTasks = true, focusId = currentSessionId) {
369
369
  schedulePublishSession();
370
370
  }
371
371
  // A list too short to scroll gives no scroll event, so fill it until it can scroll.
372
- if (sessionsHasMore) setTimeout(loadMoreIfNearEnd);
372
+ if (sessionsHasMore) setTimeout(() => loadMoreIfNearEnd());
373
373
 
374
374
  if (includeTasks && searchQuery) await loadSearchTasks();
375
375
  } catch (error) {
@@ -418,11 +418,11 @@ function resetSessionPage() {
418
418
  }
419
419
 
420
420
  const LOAD_MORE_MARGIN_PX = 240;
421
- function loadMoreIfNearEnd() {
422
- const { scrollTop, clientHeight, scrollHeight } = sessionsList;
423
- if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) loadMoreSessions();
421
+ function loadMoreIfNearEnd(el = sessionsList, load = loadMoreSessions) {
422
+ const { scrollTop, clientHeight, scrollHeight } = el;
423
+ if (clientHeight > 0 && scrollTop + clientHeight >= scrollHeight - LOAD_MORE_MARGIN_PX) load();
424
424
  }
425
- sessionsList.addEventListener('scroll', loadMoreIfNearEnd, { passive: true });
425
+ sessionsList.addEventListener('scroll', () => loadMoreIfNearEnd(), { passive: true });
426
426
 
427
427
  // Sessions by id, in the order given, ignoring every sidebar filter — that is what `include` means.
428
428
  async function fetchSessionsByIds(ids) {
@@ -6446,6 +6446,7 @@ const SHORTCUT_TABS = [
6446
6446
  { keys: ['I'], label: 'Session info' },
6447
6447
  { keys: ['.'], label: 'Pin / unpin' },
6448
6448
  { keys: ['>'], label: 'Toggle sticky' },
6449
+ { keys: ['Ctrl', 'Shift', '.'], combo: true, label: 'Toggle sticky (from the terminal)' },
6449
6450
  { keys: ['Shift', 'F10'], combo: true, label: 'Session menu: move to group, fork (or Menu key)' },
6450
6451
  { keys: ['Ctrl', 'D'], combo: true, label: 'Dismiss session (pinned: to Pinned group)' },
6451
6452
  { keys: ['Ctrl', 'Shift', 'L'], combo: true, label: 'Toggle session log' },
@@ -12273,6 +12274,52 @@ let spFromTerminal = false;
12273
12274
  let spRenderedQuery = null;
12274
12275
  // Per-open cache: spSource is frozen while the picker is up, so a fetched log stays valid.
12275
12276
  const spPeekCache = new Map();
12277
+ // 'board' offers the sidebar's sessions plus a short tail of the newest others; 'all' offers
12278
+ // every session, newest first, paged like the sidebar's All filter. Each open starts on 'board'.
12279
+ let spScope = 'board';
12280
+ let spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12281
+ let spRowEls = [];
12282
+ const SP_RECENT_PAGE = 50;
12283
+ const SP_RECENT_TAIL = 5;
12284
+
12285
+ // The first load asks for the board's count plus the tail, so the tail is there even when
12286
+ // the newest sessions are all on the board.
12287
+ async function spLoadRecent() {
12288
+ const state = spRecent;
12289
+ if (state.loading || !state.hasMore) return;
12290
+ state.loading = true;
12291
+ state.limit = state.limit ? state.limit + SP_RECENT_PAGE : spSource.length + SP_RECENT_TAIL;
12292
+ try {
12293
+ const res = await api('/api/sessions', { query: { limit: state.limit, pins: 'off' } });
12294
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
12295
+ const rows = await res.json();
12296
+ if (state !== spRecent) return;
12297
+ state.rows = rows;
12298
+ state.hasMore = res.headers.get('X-Has-More') === 'true';
12299
+ } catch (error) {
12300
+ console.error('Failed to load recent sessions:', error);
12301
+ state.hasMore = false;
12302
+ } finally {
12303
+ state.loading = false;
12304
+ }
12305
+ if (!isSessionPickerOpen()) return;
12306
+ renderSessionPicker();
12307
+ spLoadMoreNearEnd();
12308
+ }
12309
+
12310
+ function spSetScope(scope) {
12311
+ spScope = scope;
12312
+ for (const el of document.querySelectorAll('#session-picker-scope [data-scope]')) {
12313
+ el.setAttribute('aria-checked', String(el.dataset.scope === scope));
12314
+ }
12315
+ renderSessionPicker();
12316
+ spLoadMoreNearEnd();
12317
+ }
12318
+
12319
+ // Also run after each load: a page too short to scroll gives no scroll event.
12320
+ function spLoadMoreNearEnd() {
12321
+ if (spScope === 'all') loadMoreIfNearEnd(document.getElementById('session-picker-list'), spLoadRecent);
12322
+ }
12276
12323
 
12277
12324
  const spSearch = sessionSearch(() => {
12278
12325
  if (document.getElementById('session-picker-modal').classList.contains('visible')) renderSessionPicker();
@@ -12285,9 +12332,11 @@ function openSessionPicker() {
12285
12332
  spSource = getFilteredSessions().sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt));
12286
12333
  spPeekCache.clear();
12287
12334
  spRenderedQuery = null;
12335
+ spRecent = { rows: [], limit: 0, hasMore: true, loading: false };
12288
12336
  document.getElementById('session-picker-modal').classList.add('visible');
12289
- renderSessionPicker();
12337
+ spSetScope('board');
12290
12338
  input.focus();
12339
+ spLoadRecent();
12291
12340
  }
12292
12341
 
12293
12342
  // refocus: false when a session is being opened, which moves the terminal focus itself.
@@ -12403,29 +12452,39 @@ function spPinHtml(session) {
12403
12452
  function renderSessionPicker() {
12404
12453
  const list = document.getElementById('session-picker-list');
12405
12454
  const query = document.getElementById('session-picker-input').value.trim();
12406
- // Search rows that arrive for the same query append below, so the cursor stays on its row.
12407
- const selectedId = query === spRenderedQuery ? spRows[spIdx]?.id : null;
12408
- spRenderedQuery = query;
12409
- const local = spSource.filter((s) => spMatches(s, query));
12455
+ // Rows that arrive for the same query and scope append below, so the cursor stays on its row.
12456
+ const renderKey = `${spScope}\n${query}`;
12457
+ const selectedId = renderKey === spRenderedQuery ? spRows[spIdx]?.id : null;
12458
+ spRenderedQuery = renderKey;
12459
+ const all = spScope === 'all';
12460
+ const board = new Set(spSource.map((s) => s.id));
12461
+ const local = (all ? spRecent.rows : spSource).filter((s) => spMatches(s, query));
12410
12462
  const found = spSearch.rows(query, local);
12411
- spRows = [...local, ...found.rows];
12463
+ const tail = !all && !query ? spRecent.rows.filter((s) => !board.has(s.id)).slice(0, SP_RECENT_TAIL) : [];
12464
+ const extra = [...found.rows, ...tail];
12465
+ spRows = [...local, ...extra];
12412
12466
  if (!spRows.length) {
12413
12467
  spIdx = -1;
12414
- const empty = found.pending
12415
- ? 'Searching all sessions…'
12416
- : spSource.length || query.length >= SESSION_SEARCH_MIN
12417
- ? 'No session matches'
12418
- : 'No sessions in the current sidebar filter';
12468
+ spRowEls = [];
12469
+ const empty =
12470
+ found.pending || (all && spRecent.loading)
12471
+ ? 'Searching all sessions…'
12472
+ : all || spSource.length || query.length >= SESSION_SEARCH_MIN
12473
+ ? 'No session matches'
12474
+ : 'No sessions in the current sidebar filter';
12419
12475
  list.innerHTML = `<div class="sp-empty">${empty}</div>`;
12420
12476
  return;
12421
12477
  }
12422
12478
 
12423
- // Rows past the sidebar list come from the server search and show their full project path.
12479
+ // Rows not on the board show their full project path; in the board scope they also sit below
12480
+ // the board's rows, under a label.
12481
+ const section = !all && extra.length ? '<div class="sp-section">Not on board</div>' : '';
12424
12482
  list.innerHTML = spRows
12425
12483
  .map((s, i) => {
12426
- const outside = i >= local.length;
12484
+ const outside = !board.has(s.id);
12485
+ const below = !all && i >= local.length;
12427
12486
  const project = s.project ? (outside ? s.project : pathBasename(s.project)) : '';
12428
- return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12487
+ return `${below && i === local.length ? section : ''}<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${below ? ' outside' : ''} ${sessionBarClass(s)}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
12429
12488
  ${spDotHtml(s)}
12430
12489
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
12431
12490
  <span class="sp-project">${escapeHtml(project)}</span>
@@ -12436,18 +12495,21 @@ function renderSessionPicker() {
12436
12495
  </button>`;
12437
12496
  })
12438
12497
  .join('');
12498
+ // The section label sits between rows, so row index and child index differ.
12499
+ spRowEls = list.querySelectorAll('.sp-row');
12500
+ spIdx = -1;
12439
12501
  const keep = spRows.findIndex((s) => s.id === selectedId);
12440
12502
  spSelect(Math.max(0, keep));
12441
12503
  }
12442
12504
 
12443
12505
  function spSelect(idx) {
12444
- const list = document.getElementById('session-picker-list');
12445
- list.children[spIdx]?.classList.remove('selected');
12506
+ spRowEls[spIdx]?.classList.remove('selected');
12446
12507
  spIdx = Math.min(Math.max(idx, 0), spRows.length - 1);
12447
- const row = list.children[spIdx];
12508
+ const row = spRowEls[spIdx];
12448
12509
  row?.classList.add('selected');
12449
12510
  row?.scrollIntoView({ block: 'nearest' });
12450
12511
  spSchedulePeek();
12512
+ if (spScope === 'all' && spIdx >= spRows.length - 5) spLoadRecent();
12451
12513
  }
12452
12514
 
12453
12515
  async function spOpen(idx) {
@@ -12468,14 +12530,22 @@ function initSessionPicker() {
12468
12530
  else if (matchKey(e, 'ArrowUp') || (e.ctrlKey && !e.shiftKey && e.key === 'p')) spSelect(spIdx - 1);
12469
12531
  else if (e.key === 'Enter') spOpen(spIdx);
12470
12532
  else if (e.ctrlKey && e.code === 'Space') spSetPeeking(!spIsPeeking());
12533
+ else if (e.key === 'Tab' && !e.ctrlKey && !e.altKey && !e.metaKey) spSetScope(spScope === 'all' ? 'board' : 'all');
12471
12534
  else return;
12472
12535
  e.preventDefault();
12473
12536
  e.stopPropagation();
12474
12537
  });
12475
- document.getElementById('session-picker-list').addEventListener('click', (e) => {
12538
+ const list = document.getElementById('session-picker-list');
12539
+ list.addEventListener('click', (e) => {
12476
12540
  const row = e.target.closest('.sp-row');
12477
12541
  if (row) spOpen(Number(row.dataset.idx));
12478
12542
  });
12543
+ list.addEventListener('scroll', spLoadMoreNearEnd, { passive: true });
12544
+ document.getElementById('session-picker-scope').addEventListener('click', (e) => {
12545
+ const scope = e.target.closest('[data-scope]')?.dataset.scope;
12546
+ if (scope) spSetScope(scope);
12547
+ input.focus();
12548
+ });
12479
12549
  // Vimium eats Escape inside a text input and only blurs it, so the page never sees the key.
12480
12550
  // A blur that no click in the picker caused, while the window keeps focus, is that Escape.
12481
12551
  const modal = document.getElementById('session-picker-modal');
@@ -15045,6 +15115,7 @@ function terminalShortcut(e, probe = false) {
15045
15115
  if (ctrlShift && e.code === 'KeyL') return toggleMessagePanel;
15046
15116
  if (ctrlShift && e.code === 'KeyM') return toggleLastMessage;
15047
15117
  if (ctrlShift && e.code === 'KeyD') return dismissContextSession;
15118
+ if (ctrlShift && e.code === 'Period') return currentSessionId ? () => toggleSessionSticky(currentSessionId) : null;
15048
15119
  if (ctrlAlt && (e.code === 'KeyN' || e.code === 'KeyR') && terminalAvailable()) {
15049
15120
  return () => openNewSession(null, e.code === 'KeyR');
15050
15121
  }
@@ -15365,6 +15436,7 @@ const TERMINAL_PROBE_KEYS = [
15365
15436
  ['Backquote', '`', '~'],
15366
15437
  ['Equal', '=', '+'],
15367
15438
  ['Minus', '-', '_'],
15439
+ ['Period', '.', '>'],
15368
15440
  ['NumpadAdd', '+', '+'],
15369
15441
  ['NumpadSubtract', '-', '-'],
15370
15442
  ['Numpad0', '0', '0'],
package/public/index.html CHANGED
@@ -885,12 +885,18 @@
885
885
 
886
886
  <div id="session-picker-modal" class="modal-overlay top-aligned" onclick="closeSessionPicker()">
887
887
  <div class="modal session-picker" onclick="event.stopPropagation()">
888
- <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">
888
+ <div class="sp-head">
889
+ <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">
890
+ <div id="session-picker-scope" class="sp-scope" role="radiogroup" aria-label="Sessions" title="Tab switches scope">
891
+ <button type="button" role="radio" tabindex="-1" data-scope="board">Board</button>
892
+ <button type="button" role="radio" tabindex="-1" data-scope="all">All</button>
893
+ </div>
894
+ </div>
889
895
  <div class="sp-body">
890
896
  <div id="session-picker-list" class="sp-list"></div>
891
897
  <div id="session-picker-peek" class="sp-peek"></div>
892
898
  </div>
893
- <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
899
+ <div class="sp-hint"><kbd>&uarr;</kbd><kbd>&darr;</kbd> move &middot; <kbd>Enter</kbd> open &middot; <kbd>Tab</kbd> board / all &middot; <kbd>Ctrl</kbd><kbd>Space</kbd> peek &middot; <kbd>Esc</kbd> close</div>
894
900
  </div>
895
901
  </div>
896
902
 
package/public/style.css CHANGED
@@ -8746,6 +8746,54 @@ select.form-input option:checked {
8746
8746
  overflow: hidden;
8747
8747
  }
8748
8748
 
8749
+ .sp-head {
8750
+ display: flex;
8751
+ align-items: center;
8752
+ gap: 8px;
8753
+ }
8754
+
8755
+ .sp-head .sp-input {
8756
+ flex: 1;
8757
+ }
8758
+
8759
+ .sp-scope {
8760
+ flex: none;
8761
+ display: flex;
8762
+ gap: 2px;
8763
+ padding: 2px;
8764
+ border: 1px solid var(--border);
8765
+ border-radius: 6px;
8766
+ }
8767
+
8768
+ .sp-scope button {
8769
+ padding: 4px 10px;
8770
+ background: none;
8771
+ border: none;
8772
+ border-radius: 4px;
8773
+ font-family: var(--mono);
8774
+ font-size: 11.5px;
8775
+ color: var(--text-muted);
8776
+ cursor: pointer;
8777
+ }
8778
+
8779
+ .sp-scope button:hover {
8780
+ color: var(--text-secondary);
8781
+ }
8782
+
8783
+ .sp-scope button[aria-checked='true'] {
8784
+ background: var(--bg-deep);
8785
+ color: var(--text-primary);
8786
+ }
8787
+
8788
+ .sp-section {
8789
+ margin-top: 8px;
8790
+ padding: 2px 8px;
8791
+ color: var(--text-muted);
8792
+ font-size: 10.5px;
8793
+ letter-spacing: 0.04em;
8794
+ text-transform: uppercase;
8795
+ }
8796
+
8749
8797
  .sp-input {
8750
8798
  padding: 10px 12px;
8751
8799
  background: var(--bg-deep);
@@ -8909,6 +8957,10 @@ select.form-input option:checked {
8909
8957
  margin-top: 8px;
8910
8958
  }
8911
8959
 
8960
+ .sp-section + .sp-row.outside {
8961
+ margin-top: 0;
8962
+ }
8963
+
8912
8964
  .sp-row.current .sp-name {
8913
8965
  color: var(--accent-text);
8914
8966
  }