claude-code-kanban 5.0.0 → 5.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "5.0.0",
3
+ "version": "5.1.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
@@ -446,6 +446,33 @@ function isActiveSession(s) {
446
446
  function isSessionLive(s) {
447
447
  return !!s.hasRecentLog && Date.now() - Date.parse(s.modifiedAt) <= LIVE_INDICATOR_MS;
448
448
  }
449
+ function isJustFinished(s) {
450
+ return (
451
+ !isWaitingSession(s) &&
452
+ !isActiveSession(s) &&
453
+ !!s.hasRecentActivity &&
454
+ Date.now() - Date.parse(s.modifiedAt) < JUST_NOW_MS
455
+ );
456
+ }
457
+
458
+ // The JUST_NOW_MS mark is a client-side boundary; no server update arrives when a session crosses it.
459
+ let justFinishedTimer = null;
460
+ function scheduleJustFinishedExpiry() {
461
+ clearTimeout(justFinishedTimer);
462
+ let next = Infinity;
463
+ for (const s of sessions) {
464
+ if (isJustFinished(s)) next = Math.min(next, Date.parse(s.modifiedAt) + JUST_NOW_MS - Date.now());
465
+ }
466
+ if (next === Infinity) return;
467
+ justFinishedTimer = setTimeout(() => {
468
+ if (!skipOffScreen()) renderSessionViews();
469
+ }, next + 50);
470
+ }
471
+
472
+ function renderSessionViews() {
473
+ renderSessions();
474
+ if (isSessionPickerOpen()) renderSessionPicker();
475
+ }
449
476
 
450
477
  const ACTIVITY_PREDICATES = {
451
478
  waiting: isWaitingSession,
@@ -619,6 +646,7 @@ async function fetchTasks(sessionId) {
619
646
  const WAITING_TTL_MS = 30 * 60 * 1000;
620
647
  const AGENT_LOG_MAX = 8;
621
648
  const LIVE_INDICATOR_MS = 10 * 1000;
649
+ const JUST_NOW_MS = 60 * 1000;
622
650
  // #endregion
623
651
 
624
652
  function resetAgentState() {
@@ -1698,6 +1726,7 @@ const ICON_AGENT_ACTIVE =
1698
1726
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><line x1="8" y1="16" x2="8" y2="16"/><line x1="16" y1="16" x2="16" y2="16"/></svg>';
1699
1727
  const ICON_TERMINAL =
1700
1728
  '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>';
1729
+ const TERMINAL_TITLE = 'Running in a terminal here';
1701
1730
  const ICON_CHAT =
1702
1731
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
1703
1732
  const TOOL_ICONS = {
@@ -3356,7 +3385,12 @@ function getFilteredSessions() {
3356
3385
  // The open session keeps its row even when it belongs to another project, so that
3357
3386
  // revealing one by id has something to scroll to. Same exemption the active branch
3358
3387
  // above already makes, for the same reason.
3359
- filteredSessions = filteredSessions.filter((s) => matchesProjectFilter(s.project) || s.id === currentSessionId);
3388
+ filteredSessions = filteredSessions.filter(
3389
+ (s) =>
3390
+ matchesProjectFilter(s.project) ||
3391
+ s.id === currentSessionId ||
3392
+ (filterProject === '__recent__' && isAnyPinned(s.id)),
3393
+ );
3360
3394
  }
3361
3395
 
3362
3396
  if (activityFilter.size > 0) {
@@ -3425,6 +3459,7 @@ function renderSessions() {
3425
3459
  // Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
3426
3460
  // half-typed group name, and the SSE path can fire at any moment — defer instead.
3427
3461
  if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
3462
+ scheduleJustFinishedExpiry();
3428
3463
  refreshProjectList();
3429
3464
 
3430
3465
  // Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
@@ -3493,15 +3528,19 @@ function renderSessions() {
3493
3528
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3494
3529
  const bookmarksCount = loadPins(session.id).length;
3495
3530
  const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3496
- const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
3531
+ const tempClass =
3532
+ session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3533
+ ? 'warm'
3534
+ : 'stale';
3497
3535
  // Open tasks keep an idle session warm, but the green dot is for a session that is working.
3498
3536
  const idleClass =
3499
3537
  tempClass === 'warm' && !session.hasWaitingForUser && !session.hasRecentLog && !session.hasRunningAgents
3500
3538
  ? 'idle'
3501
3539
  : '';
3540
+ const justFinishedClass = isJustFinished(session) ? 'just-finished' : '';
3502
3541
  const sid = escAttrJs(session.id);
3503
3542
  return `
3504
- <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3543
+ <button onclick="openSession('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${session.unread ? 'unread' : ''} ${tempClass} ${idleClass} ${justFinishedClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
3505
3544
  <span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
3506
3545
  <div class="session-name">${escapeHtml(sessionName)}</div>
3507
3546
  ${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
@@ -3519,7 +3558,7 @@ function renderSessions() {
3519
3558
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
3520
3559
  ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escAttrJs(session.planSourceSessionId)}')">${ICON_PLAN}</span>` : ''}
3521
3560
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
3522
- ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="Running in a terminal here">${ICON_TERMINAL}</span>` : ''}
3561
+ ${runningTerminals.has(session.id) ? `<span class="terminal-badge" onclick="event.stopPropagation(); showSessionTerminal('${escAttrJs(session.id)}')" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>` : ''}
3523
3562
  ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
3524
3563
  ${session.hasRunningAgents && !session.hasWaitingForUser ? `<span class="agent-badge agent-badge-active" title="Agents running">${ICON_AGENT_ACTIVE}</span>` : ''}
3525
3564
  ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
@@ -4451,9 +4490,9 @@ function sgHeaderHtml(group, countHtml) {
4451
4490
  const actions = group.transient
4452
4491
  ? `<span class="sg-action sg-keep" title="Keep this group after its sessions end">Keep</span>
4453
4492
  <span class="sg-action sg-delete" title="Delete group (sessions return to Projects)">&times;</span>`
4454
- : `${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}
4455
- <span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4456
- <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>`;
4493
+ : `<span class="sg-action sg-rename" title="Rename group"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>
4494
+ <span class="sg-action sg-delete" title="Delete group (members return to Projects)">&times;</span>
4495
+ ${headerPadBtnHtml(_groupScratchpadKey(group.id), group.name)}`;
4457
4496
  return `
4458
4497
  <div ${attrs} data-group-path="${escapeHtml(sgKey(group.id))}">
4459
4498
  ${groupChevronSvg()}
@@ -8283,7 +8322,7 @@ function formatDate(dateStr) {
8283
8322
  const now = new Date();
8284
8323
  const diff = now - date;
8285
8324
 
8286
- if (diff < 60000) return 'just now';
8325
+ if (diff < JUST_NOW_MS) return 'just now';
8287
8326
  if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
8288
8327
  if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
8289
8328
  return date.toLocaleDateString();
@@ -9704,8 +9743,12 @@ function closeSessionPicker({ refocus = true } = {}) {
9704
9743
  if (refocus && spFromTerminal && wantsTerminal()) focusTerminalPane();
9705
9744
  }
9706
9745
 
9746
+ function isSessionPickerOpen() {
9747
+ return document.getElementById('session-picker-modal').classList.contains('visible');
9748
+ }
9749
+
9707
9750
  function toggleSessionPicker() {
9708
- if (document.getElementById('session-picker-modal').classList.contains('visible')) closeSessionPicker();
9751
+ if (isSessionPickerOpen()) closeSessionPicker();
9709
9752
  else openSessionPicker();
9710
9753
  }
9711
9754
 
@@ -9772,24 +9815,35 @@ function spMatches(session, query) {
9772
9815
  }
9773
9816
 
9774
9817
  // Spelled out rather than interpolated so the class never comes from a variable.
9775
- const SP_DOTS = {
9776
- waiting: '<span class="activity-dot waiting"></span>',
9777
- live: '<span class="activity-dot live"></span>',
9778
- active: '<span class="activity-dot active"></span>',
9779
- idle: '<span class="activity-dot"></span>',
9818
+ const SP_STATES = {
9819
+ waiting: '<span class="sp-state waiting">',
9820
+ live: '<span class="sp-state live">',
9821
+ active: '<span class="sp-state active">',
9822
+ justFinished: '<span class="sp-state just-finished">',
9823
+ recent: '<span class="sp-state recent">',
9824
+ idle: '<span class="sp-state idle">',
9780
9825
  };
9781
9826
 
9827
+ const SP_DOT = '<span class="activity-dot"></span>';
9828
+ const SP_TERMINAL = `<span class="sp-terminal" title="${TERMINAL_TITLE}">${ICON_TERMINAL}</span>`;
9829
+
9830
+ function spState(session) {
9831
+ if (isWaitingSession(session)) return 'waiting';
9832
+ if (isSessionLive(session)) return 'live';
9833
+ if (isActiveSession(session)) return 'active';
9834
+ if (isJustFinished(session)) return 'justFinished';
9835
+ return session.hasRecentActivity || session.inProgress > 0 ? 'recent' : 'idle';
9836
+ }
9837
+
9782
9838
  function spDotHtml(session) {
9783
- if (isWaitingSession(session)) return SP_DOTS.waiting;
9784
- if (isSessionLive(session)) return SP_DOTS.live;
9785
- return isActiveSession(session) ? SP_DOTS.active : SP_DOTS.idle;
9839
+ return `${SP_STATES[spState(session)]}${runningTerminals.has(session.id) ? SP_TERMINAL : SP_DOT}</span>`;
9786
9840
  }
9787
9841
 
9788
9842
  function spPinHtml(session) {
9789
9843
  const state = getSessionPinState(session.id);
9790
9844
  if (state === 'sticky') return `<span class="sp-pin sticky" title="Sticky">${SESSION_STAR_SVG}</span>`;
9791
9845
  if (state === 'pinned') return `<span class="sp-pin pinned" title="Pinned">${SESSION_PIN_SVG}</span>`;
9792
- return '';
9846
+ return '<span class="sp-pin"></span>';
9793
9847
  }
9794
9848
 
9795
9849
  function renderSessionPicker() {
@@ -9821,11 +9875,11 @@ function renderSessionPicker() {
9821
9875
  return `<button class="sp-row${s.id === currentSessionId ? ' current' : ''}${outside ? ' outside' : ''}" data-idx="${i}" title="${escapeHtml(s.project ? `${s.id}\n${s.project}` : s.id)}">
9822
9876
  ${spDotHtml(s)}
9823
9877
  <span class="sp-name">${escapeHtml(sessionDisplayName(s))}</span>
9824
- ${spPinHtml(s)}
9825
9878
  <span class="sp-project">${escapeHtml(project)}</span>
9826
9879
  ${s.gitBranch ? `<span class="sp-branch">${escapeHtml(s.gitBranch)}</span>` : ''}
9827
9880
  <span class="sp-count">${s.completed}/${s.taskCount}</span>
9828
9881
  <span class="sp-time">${formatDate(s.modifiedAt)}</span>
9882
+ ${spPinHtml(s)}
9829
9883
  </button>`;
9830
9884
  })
9831
9885
  .join('');
@@ -11619,7 +11673,7 @@ async function loadTerminals() {
11619
11673
  function setRunningTerminals(ids) {
11620
11674
  runningTerminals = new Set(ids);
11621
11675
  if (ids.some((id) => !sessions.some((s) => s.id === id))) fetchSessions(false).catch(() => {});
11622
- else renderSessions();
11676
+ else renderSessionViews();
11623
11677
  renderActivityChip();
11624
11678
  }
11625
11679
 
package/public/style.css CHANGED
@@ -969,6 +969,20 @@ body.booting .boot-splash {
969
969
  background: var(--text-tertiary);
970
970
  }
971
971
 
972
+ /* Declared on the element, not :root, so it picks up body.light's --success. */
973
+ .session-item.just-finished,
974
+ .sp-state.just-finished {
975
+ --success-faded: color-mix(in srgb, var(--success) 35%, var(--text-muted));
976
+ }
977
+
978
+ .session-item.warm.idle.just-finished .session-time {
979
+ color: var(--success-faded);
980
+ }
981
+
982
+ .session-item.warm.idle.just-finished .session-time::before {
983
+ background: var(--success-faded);
984
+ }
985
+
972
986
  .session-item.warm.permission-pending .session-time {
973
987
  color: var(--warning);
974
988
  }
@@ -6104,16 +6118,46 @@ select.form-input option:checked {
6104
6118
  border-color: var(--accent);
6105
6119
  }
6106
6120
 
6107
- .sp-row .activity-dot.live {
6108
- background: var(--success);
6121
+ .sp-state {
6122
+ flex: none;
6123
+ width: 14px;
6124
+ display: flex;
6125
+ align-items: center;
6126
+ justify-content: center;
6127
+ line-height: 0;
6109
6128
  }
6110
6129
 
6111
- .sp-row .activity-dot.active {
6112
- background: var(--accent);
6130
+ .sp-state.live {
6131
+ color: var(--success);
6113
6132
  }
6114
6133
 
6115
- .sp-row .activity-dot.waiting {
6116
- background: var(--warning);
6134
+ .sp-state.active {
6135
+ color: var(--accent);
6136
+ }
6137
+
6138
+ .sp-state.waiting {
6139
+ color: var(--warning);
6140
+ }
6141
+
6142
+ .sp-state.just-finished {
6143
+ color: var(--success-faded);
6144
+ }
6145
+
6146
+ .sp-state.recent {
6147
+ color: var(--text-tertiary);
6148
+ }
6149
+
6150
+ .sp-state.idle {
6151
+ color: var(--text-muted);
6152
+ }
6153
+
6154
+ .sp-state .activity-dot {
6155
+ background: currentColor;
6156
+ }
6157
+
6158
+ .sp-state.idle .activity-dot {
6159
+ background: transparent;
6160
+ border: 1px solid currentColor;
6117
6161
  }
6118
6162
 
6119
6163
  .sp-name,
@@ -6132,6 +6176,9 @@ select.form-input option:checked {
6132
6176
 
6133
6177
  .sp-pin {
6134
6178
  flex: none;
6179
+ width: 14px;
6180
+ display: flex;
6181
+ justify-content: center;
6135
6182
  line-height: 0;
6136
6183
  }
6137
6184
 
package/server.js CHANGED
@@ -1580,7 +1580,7 @@ app.get('/api/sessions', async (req, res) => {
1580
1580
  if (recentHours > 0) {
1581
1581
  const cutoff = Date.now() - recentHours * 3600 * 1000;
1582
1582
  const activity = projectActivity();
1583
- sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || includeIds.has(s.id));
1583
+ sessions = sessions.filter(s => (activity.get(s.project) || 0) > cutoff || pinnedIds.has(s.id));
1584
1584
  }
1585
1585
 
1586
1586
  const paged = limit !== null && limit > 0;