claude-code-kanban 3.10.0 → 4.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/public/app.js CHANGED
@@ -18,6 +18,7 @@ let bulkDeleteSessionId = null; // Track session for bulk delete
18
18
  let ownerFilter = '';
19
19
  let currentAgents = [];
20
20
  let currentWaiting = null;
21
+ let lastWaitingHash = '';
21
22
  let lastAgentsHash = '';
22
23
  let messagePanelOpen = false;
23
24
  let lastMessagesHash = '';
@@ -443,11 +444,12 @@ function renderActivityChip() {
443
444
  let waiting = 0;
444
445
  let active = 0;
445
446
  for (const s of sessions) {
447
+ if (dismissedSessionIds.has(s.id)) continue;
446
448
  if (s.hasWaitingForUser) waiting++;
447
449
  else if (s.inProgress > 0 || s.hasRecentLog || s.hasRunningAgents) active++;
448
450
  }
449
451
 
450
- const key = `${waiting}|${active}|${[...activityFilter].sort().join(',')}`;
452
+ const key = `${waiting}|${active}|${dismissedSessionIds.size}|${[...activityFilter].sort().join(',')}`;
451
453
  if (key === lastChipKey) return;
452
454
  lastChipKey = key;
453
455
 
@@ -565,15 +567,18 @@ async function fetchTasks(sessionId) {
565
567
  }
566
568
  }
567
569
 
568
- const _AGENT_COOLDOWN_MS = 3 * 60 * 1000;
569
- const _AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for force-stopping
570
+ // #region TIMINGS
570
571
  const WAITING_TTL_MS = 30 * 60 * 1000;
571
572
  const AGENT_LOG_MAX = 8;
573
+ const LIVE_INDICATOR_MS = 10 * 1000;
574
+ const ACTIVE_PLAN_MS = 10 * 60 * 1000;
575
+ // #endregion
572
576
 
573
577
  function resetAgentState() {
574
578
  currentAgents = [];
575
579
  currentWaiting = null;
576
580
  lastAgentsHash = '';
581
+ lastWaitingHash = '';
577
582
  renderAgentFooter();
578
583
  }
579
584
 
@@ -595,6 +600,11 @@ async function fetchAgents(sessionId) {
595
600
  for (const k of Object.keys(ownerColorCache)) delete ownerColorCache[k];
596
601
  renderAgentFooter();
597
602
  if (currentSessionId === sessionId) renderKanban();
603
+ const waitHash = JSON.stringify(currentWaiting);
604
+ if (waitHash !== lastWaitingHash) {
605
+ lastWaitingHash = waitHash;
606
+ if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
607
+ }
598
608
  } catch (e) {
599
609
  console.error('[fetchAgents]', e);
600
610
  }
@@ -899,6 +909,11 @@ function parseCommandMessage(text) {
899
909
  return null;
900
910
  }
901
911
 
912
+ function parseCommandArgs(text) {
913
+ const m = (text || '').match(/<command-args>([^<]*)<\/command-args>/);
914
+ return m?.[1].trim() || '';
915
+ }
916
+
902
917
  function cleanMessageText(text) {
903
918
  const cmd = parseCommandMessage(text);
904
919
  if (cmd) return cmd;
@@ -1051,11 +1066,25 @@ function renderMessageList(messages) {
1051
1066
  </div>`);
1052
1067
  } else {
1053
1068
  const cmd = parseCommandMessage(m.text);
1069
+ const cmdArgs = cmd ? parseCommandArgs(m.fullText || m.text) : '';
1054
1070
  const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
1055
1071
  const isCmd = !!cmd;
1072
+ const cmdArgsHtml =
1073
+ cmd && cmdArgs ? ` <span style="color:var(--text-secondary)">${escapeHtml(cmdArgs)}</span>` : '';
1074
+ const chips = [];
1075
+ const imgCount = m.images?.length || 0;
1076
+ const trCount = m.toolResultRefs?.length || 0;
1077
+ if (imgCount) chips.push(`<span class="user-attach-chip">${imgCount} image${imgCount > 1 ? 's' : ''}</span>`);
1078
+ if (trCount)
1079
+ chips.push(`<span class="user-attach-chip">${trCount} tool result${trCount > 1 ? 's' : ''}</span>`);
1080
+ const chipsHtml = chips.length ? `<div class="user-attach-chips">${chips.join('')}</div>` : '';
1081
+ let textHtml;
1082
+ if (displayText) textHtml = isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText;
1083
+ else if (chips.length) textHtml = '<em class="msg-text-muted">(attachment)</em>';
1084
+ else textHtml = '';
1056
1085
  parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
1057
1086
  ${MSG_ICON_USER}
1058
- <div class="msg-body"><div class="msg-text">${isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1087
+ <div class="msg-body"><div class="msg-text">${textHtml}${cmdArgsHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1059
1088
  </div>`);
1060
1089
  }
1061
1090
  } else if (m.type === 'assistant') {
@@ -1103,6 +1132,45 @@ function toggleToolGroup(id) {
1103
1132
  if (el) el.classList.toggle('show');
1104
1133
  }
1105
1134
 
1135
+ const WAITING_PLAN_PREVIEW_CHARS = 120;
1136
+ const WAITING_PREVIEW_MAX_CHARS = 200;
1137
+
1138
+ function getWaitingLabel(kind, tool) {
1139
+ if (kind !== 'question') return `Awaiting permission: ${tool}`;
1140
+ if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
1141
+ return 'Question pending';
1142
+ }
1143
+
1144
+ function getWaitingPreview(toolInput) {
1145
+ if (!toolInput) return '';
1146
+ try {
1147
+ const parsed = JSON.parse(toolInput);
1148
+ if (parsed.questions?.[0]?.question) return parsed.questions[0].question;
1149
+ if (parsed.plan) {
1150
+ const t = parsed.plan.match(/^#\s+(.+)/m);
1151
+ return t ? t[1] : parsed.plan.slice(0, WAITING_PLAN_PREVIEW_CHARS);
1152
+ }
1153
+ if (parsed.command) return parsed.command;
1154
+ if (parsed.file_path) return parsed.file_path;
1155
+ } catch (_) {
1156
+ /* toolInput may be truncated/non-JSON */
1157
+ }
1158
+ return '';
1159
+ }
1160
+
1161
+ function renderWaitingEntry() {
1162
+ if (!currentWaiting?.timestamp) return '';
1163
+ const age = Date.now() - new Date(currentWaiting.timestamp).getTime();
1164
+ if (age >= WAITING_TTL_MS) return '';
1165
+ const tool = currentWaiting.toolName || 'unknown';
1166
+ const label = getWaitingLabel(currentWaiting.kind, tool);
1167
+ const preview = getWaitingPreview(currentWaiting.toolInput);
1168
+ const previewHtml = preview
1169
+ ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
1170
+ : '';
1171
+ return `<div class="msg-item msg-waiting">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div></div>`;
1172
+ }
1173
+
1106
1174
  function renderMessages(messages) {
1107
1175
  const container = document.getElementById('message-panel-content');
1108
1176
  const pinnedContainer = document.getElementById('message-panel-pinned');
@@ -1116,7 +1184,7 @@ function renderMessages(messages) {
1116
1184
  currentMessages.length >= MSG_MAX_LOADED
1117
1185
  ? `<div class="msg-limit-banner">Showing last ${MSG_MAX_LOADED} messages</div>`
1118
1186
  : '';
1119
- container.innerHTML = limitBanner + msgsHtml;
1187
+ container.innerHTML = limitBanner + msgsHtml + renderWaitingEntry();
1120
1188
  if (!msgUserScrolledUp) container.scrollTop = container.scrollHeight;
1121
1189
  // Auto-load more if content doesn't overflow yet
1122
1190
  if (
@@ -1152,6 +1220,16 @@ const ICON_TASK =
1152
1220
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
1153
1221
  const ICON_WEB =
1154
1222
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>';
1223
+ const ICON_OPEN_EXTERNAL =
1224
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3h7v7"/><path d="M10 14L21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>';
1225
+ const ICON_COPY =
1226
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
1227
+ const ICON_CHECKMARK =
1228
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1229
+ const ICON_AGENT_WAITING =
1230
+ '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>';
1231
+ const ICON_CHAT =
1232
+ '<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>';
1155
1233
  const TOOL_ICONS = {
1156
1234
  Bash: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="18" rx="2"/><polyline points="7 10 10 13 7 16"/><line x1="13" y1="16" x2="17" y2="16"/></svg>',
1157
1235
  Read: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
@@ -1169,8 +1247,8 @@ const TOOL_ICONS = {
1169
1247
  TaskList: ICON_TASK,
1170
1248
  ToolSearch:
1171
1249
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
1172
- AskUserQuestion:
1173
- '<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>',
1250
+ AskUserQuestion: ICON_CHAT,
1251
+ ExitPlanMode: ICON_CHAT,
1174
1252
  Skill:
1175
1253
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
1176
1254
  WebFetch: ICON_WEB,
@@ -1470,10 +1548,6 @@ function _renderPinToDetail(pin) {
1470
1548
  }
1471
1549
 
1472
1550
  const SESSION_PIN_SVG = PIN_SVG.replace('width="14" height="14"', 'width="12" height="12"');
1473
- const MARKETPLACE_SVG =
1474
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>';
1475
- const MEMORY_SVG =
1476
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>';
1477
1551
  const LINK_SVG_PATHS =
1478
1552
  '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>';
1479
1553
  const linkSvg = (size) =>
@@ -1482,6 +1556,40 @@ const linkSvg = (size) =>
1482
1556
  //#endregion
1483
1557
 
1484
1558
  //#region MODALS
1559
+ function renderUserAttachments(m) {
1560
+ const parts = [];
1561
+ if (m.images?.length && m.uuid && currentSessionId) {
1562
+ const imgs = m.images
1563
+ .map((img) => {
1564
+ const url = `/api/sessions/${encodeURIComponent(currentSessionId)}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1565
+ return `<img src="${url}" loading="lazy" alt="user image" class="user-attach-image" />`;
1566
+ })
1567
+ .join('');
1568
+ parts.push(
1569
+ `<div class="user-attach-section"><div class="user-attach-label">Attached images</div><div class="user-attach-images">${imgs}</div></div>`,
1570
+ );
1571
+ }
1572
+ if (m.toolResultRefs?.length) {
1573
+ const refs = m.toolResultRefs
1574
+ .map((ref) => {
1575
+ const safeId = escapeHtml(ref.toolUseId);
1576
+ const shortId = ref.toolUseId.length > 14 ? `${ref.toolUseId.slice(0, 14)}…` : ref.toolUseId;
1577
+ const preview = ref.preview ? escapeHtml(ref.preview) : '<em>(no text)</em>';
1578
+ const expandId = `user-tr-${ref.toolUseId}`;
1579
+ return `<details class="user-attach-toolresult">
1580
+ <summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
1581
+ <pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
1582
+ <button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${safeId}" onclick="_toggleToolResultExpand(this)">Show full</button>
1583
+ </details>`;
1584
+ })
1585
+ .join('');
1586
+ parts.push(
1587
+ `<div class="user-attach-section"><div class="user-attach-label">Tool results in this message</div>${refs}</div>`,
1588
+ );
1589
+ }
1590
+ return parts.join('');
1591
+ }
1592
+
1485
1593
  function showMsgDetail(idx) {
1486
1594
  currentMsgDetailIdx = idx;
1487
1595
  const m = currentMessages[idx];
@@ -1546,25 +1654,27 @@ function showMsgDetail(idx) {
1546
1654
  body.innerHTML = renderMarkdown(text);
1547
1655
  }
1548
1656
  } else {
1549
- const rawText = stripAnsi(m.fullText || m.text);
1657
+ const rawText = stripAnsi(m.fullText || m.text || '');
1550
1658
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1551
1659
  document.getElementById('msg-detail-title').textContent =
1552
1660
  m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
1553
1661
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1662
+ const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1554
1663
  if (m.compactSummary) {
1555
- body.innerHTML = renderMarkdown(m.compactSummary);
1664
+ body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
1556
1665
  } else if (cmd) {
1557
- const argsMatch = rawText.match(/<command-args>([^<]*)<\/command-args>/);
1558
- const args = argsMatch?.[1].trim() ? argsMatch[1].trim() : null;
1666
+ const args = parseCommandArgs(rawText) || null;
1559
1667
  const cleanBody = rawText
1560
1668
  .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
1561
1669
  .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
1562
1670
  .trim();
1563
1671
  let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
1564
1672
  if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
1565
- body.innerHTML = cmdHtml;
1673
+ body.innerHTML = cmdHtml + userExtras;
1674
+ } else if (rawText) {
1675
+ body.innerHTML = renderMarkdown(rawText) + userExtras;
1566
1676
  } else {
1567
- body.innerHTML = renderMarkdown(rawText);
1677
+ body.innerHTML = userExtras || '<em>No content</em>';
1568
1678
  }
1569
1679
  }
1570
1680
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
@@ -1649,8 +1759,7 @@ async function copyWithFeedback(text, btn) {
1649
1759
  await navigator.clipboard.writeText(text);
1650
1760
  btn.dataset.copying = '1';
1651
1761
  const svg = btn.innerHTML;
1652
- btn.innerHTML =
1653
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M20 6L9 17l-5-5"/></svg>';
1762
+ btn.innerHTML = ICON_CHECKMARK;
1654
1763
  setTimeout(() => {
1655
1764
  btn.innerHTML = svg;
1656
1765
  delete btn.dataset.copying;
@@ -2322,10 +2431,8 @@ function renderSessions() {
2322
2431
 
2323
2432
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
2324
2433
  // project filter → search filter → ensure pinned/sticky sessions are always included
2325
- const LIVE_INDICATOR_MS = 10 * 1000;
2326
2434
  let filteredSessions = sessions;
2327
2435
  if (sessionFilter === 'active') {
2328
- const ACTIVE_PLAN_MS = 15 * 60 * 1000;
2329
2436
  const now = Date.now();
2330
2437
  const activeSessionIds = new Set();
2331
2438
  filteredSessions = filteredSessions.filter((s) => {
@@ -2458,6 +2565,7 @@ function renderSessions() {
2458
2565
  const showCtx = !!session.contextStatus;
2459
2566
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
2460
2567
  const bookmarksCount = loadPins(session.id).length;
2568
+ const hasScratchpad = !!(localStorage.getItem(_sessionScratchpadKey(session.id)) || '').trim();
2461
2569
  const tempClass = session.hasRecentLog || session.inProgress || session.hasWaitingForUser ? 'warm' : 'stale';
2462
2570
  return `
2463
2571
  <button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${tooltip}">
@@ -2474,12 +2582,10 @@ function renderSessions() {
2474
2582
  ${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
2475
2583
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2476
2584
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
2477
- ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2478
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2479
- ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
2480
- ${(window.__HUB__?.enabled || appConfig.marketplaceUrl) && session.project ? `<span class="marketplace-btn" data-project-path="${escapeHtml(session.project)}" onclick="event.stopPropagation(); openMarketplace(this.dataset.projectPath)" title="Open in Marketplace">${MARKETPLACE_SVG}</span>` : ''}
2481
- ${(window.__HUB__?.enabled || appConfig.memoryUrl) && session.project ? `<span class="marketplace-btn" data-project-path="${escapeHtml(session.project)}" onclick="event.stopPropagation(); openMemory(this.dataset.projectPath)" title="Open in Memory">${MEMORY_SVG}</span>` : ''}
2482
- ${isLive ? '<span class="pulse"></span>' : ''}
2585
+ ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" 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>` : ''}
2586
+ ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
2587
+ ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2588
+ ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
2483
2589
  </span>
2484
2590
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
2485
2591
  <span class="progress-text">${session.completed}/${total}</span>
@@ -3613,9 +3719,17 @@ const _scratchpadCharcount = document.getElementById('scratchpad-charcount');
3613
3719
 
3614
3720
  let _scratchpadKeyOverride = null;
3615
3721
 
3722
+ function _sessionScratchpadKey(sessionId) {
3723
+ return `scratchpad-${sessionId}`;
3724
+ }
3725
+
3726
+ function _isSessionScratchpadKey(key) {
3727
+ return key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:');
3728
+ }
3729
+
3616
3730
  function _scratchpadKey() {
3617
3731
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
3618
- if (currentSessionId) return `scratchpad-${currentSessionId}`;
3732
+ if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
3619
3733
  if (currentProjectPath) return `scratchpad-project:${currentProjectPath}`;
3620
3734
  return null;
3621
3735
  }
@@ -3628,6 +3742,11 @@ function toggleScratchpad() {
3628
3742
  }
3629
3743
  }
3630
3744
 
3745
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
3746
+ function openSessionScratchpad(sessionId) {
3747
+ showScratchpad(_sessionScratchpadKey(sessionId));
3748
+ }
3749
+
3631
3750
  function showScratchpad(keyOverride) {
3632
3751
  _scratchpadKeyOverride = keyOverride || null;
3633
3752
  const key = _scratchpadKey();
@@ -3652,11 +3771,16 @@ function saveScratchpad() {
3652
3771
  const key = _scratchpadKey();
3653
3772
  if (!key) return;
3654
3773
  const val = _scratchpadTextarea.value;
3655
- if (val.trim()) {
3774
+ const had = !!(localStorage.getItem(key) || '').trim();
3775
+ const has = !!val.trim();
3776
+ if (has) {
3656
3777
  localStorage.setItem(key, val);
3657
3778
  } else {
3658
3779
  localStorage.removeItem(key);
3659
3780
  }
3781
+ if (had !== has && _isSessionScratchpadKey(key)) {
3782
+ renderSessions();
3783
+ }
3660
3784
  }
3661
3785
 
3662
3786
  _scratchpadTextarea.addEventListener('input', () => {
@@ -4075,7 +4199,7 @@ function _findOrphanedKeys() {
4075
4199
  const key = localStorage.key(i);
4076
4200
  if (key.startsWith('pinned-messages-')) {
4077
4201
  if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
4078
- } else if (key.startsWith('scratchpad-') && !key.startsWith('scratchpad-project:')) {
4202
+ } else if (_isSessionScratchpadKey(key)) {
4079
4203
  if (!known.has(key.slice('scratchpad-'.length))) orphaned.push(key);
4080
4204
  } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
4081
4205
  if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
@@ -4360,6 +4484,7 @@ document.addEventListener('keydown', (e) => {
4360
4484
  dismissedSessionIds.add(contextSid);
4361
4485
  updateDismissBtnState();
4362
4486
  renderSessions();
4487
+ renderActivityChip();
4363
4488
  const newItems = getNavigableItems();
4364
4489
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
4365
4490
  // If the dismissed session is currently open, navigate to the previous one
@@ -5068,7 +5193,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
5068
5193
  }
5069
5194
  if (!tabs.length) return '';
5070
5195
  const defaultTab = responseHtml ? 'response' : tabs[0].key;
5071
- const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg></button>`;
5196
+ const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)">${ICON_COPY}</button>`;
5072
5197
  const tabsHtml = tabs
5073
5198
  .map(
5074
5199
  (t) =>
@@ -5473,13 +5598,13 @@ async function showSessionInfoModal(sessionId) {
5473
5598
  // and re-rendered when they arrive, so the modal doesn't block on network.
5474
5599
  _planSessionId = sessionId;
5475
5600
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
5476
- showInfoModal(session, null, cachedTasks, null);
5601
+ showInfoModal(session, null, cachedTasks, null, null);
5477
5602
 
5478
- const rerender = (teamConfig, tasks, planContent) => {
5603
+ const rerender = (teamConfig, tasks, planContent, parentInfo) => {
5479
5604
  if (_planSessionId !== sessionId) return; // user opened a different modal
5480
5605
  const modal = document.getElementById('team-modal');
5481
5606
  if (!modal?.classList.contains('visible')) return; // user closed modal — don't reopen
5482
- showInfoModal(session, teamConfig, tasks, planContent);
5607
+ showInfoModal(session, teamConfig, tasks, planContent, parentInfo);
5483
5608
  };
5484
5609
 
5485
5610
  const teamPromise = session.isTeam
@@ -5500,8 +5625,17 @@ async function showSessionInfoModal(sessionId) {
5500
5625
  .then((r) => (r.ok ? r.json() : []))
5501
5626
  .catch(() => []);
5502
5627
 
5503
- const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
5504
- rerender(teamConfig, tasks, planContent);
5628
+ const parentPromise = fetch(`/api/sessions/${sessionId}/parent`)
5629
+ .then((r) => (r.ok ? r.json() : null))
5630
+ .catch(() => null);
5631
+
5632
+ const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
5633
+ teamPromise,
5634
+ planPromise,
5635
+ tasksPromise,
5636
+ parentPromise,
5637
+ ]);
5638
+ rerender(teamConfig, tasks, planContent, parentInfo);
5505
5639
  }
5506
5640
 
5507
5641
  let _infoModalSessionId = null;
@@ -5518,7 +5652,7 @@ function updateStickyBtnState() {
5518
5652
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
5519
5653
  }
5520
5654
 
5521
- function showInfoModal(session, teamConfig, tasks, planContent) {
5655
+ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5522
5656
  const modal = document.getElementById('team-modal');
5523
5657
  const titleEl = document.getElementById('team-modal-title');
5524
5658
  const bodyEl = document.getElementById('team-modal-body');
@@ -5538,6 +5672,13 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5538
5672
  // Each row: [label, displayValue, { openPath?, copyValue? }]
5539
5673
  const infoRows = [];
5540
5674
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
5675
+ if (parentInfo?.parentSessionId) {
5676
+ infoRows.push([
5677
+ 'Forked from',
5678
+ parentInfo.parentSessionId,
5679
+ { openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
5680
+ ]);
5681
+ }
5541
5682
  if (session.slug && session.hasPlan) {
5542
5683
  infoRows.push(['Slug', session.slug, { openClaudeDir: true, openFile: session.planPath }]);
5543
5684
  }
@@ -5569,19 +5710,25 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5569
5710
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; color: var(--accent-text); text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px;";
5570
5711
  const plainStyle =
5571
5712
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
5572
- html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: center;">`;
5713
+ html += `<div class="team-modal-meta info-grid">`;
5573
5714
  infoRows.forEach(([label, value, opts]) => {
5574
5715
  const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
5575
5716
  html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
5576
- if (opts?.openClaudeDir || opts?.openPath) {
5577
- const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5578
- const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5579
- html += `<span data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" style="${clickableStyle}" title="Open in editor">${escapeHtml(value)}</span>`;
5717
+ if (opts?.openSession) {
5718
+ const sid = _escapeForJsAttr(escapeHtml(opts.openSession).replace(/"/g, '&quot;'));
5719
+ html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
5580
5720
  } else {
5581
5721
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
5582
5722
  }
5583
5723
  const jsCopyVal = _escapeForJsAttr(copyVal);
5584
- html += `<button onclick="navigator.clipboard.writeText('${jsCopyVal}'); this.textContent='✓'; setTimeout(() => this.textContent='Copy', 1000)" style="padding: 2px 8px; font-size: 11px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; color: var(--text-secondary); cursor: pointer; white-space: nowrap;">Copy</button>`;
5724
+ const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
5725
+ let openBtn = '';
5726
+ if (opts?.openClaudeDir || opts?.openPath) {
5727
+ const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5728
+ const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5729
+ openBtn = `<button data-folder="${folder}" data-file="${file}" data-claude-dir="${opts.openClaudeDir ? '1' : ''}" onclick="openFolderInEditor(this.dataset.claudeDir ? undefined : this.dataset.folder, this.dataset.file || undefined)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>`;
5730
+ }
5731
+ html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
5585
5732
  });
5586
5733
  html += `</div>`;
5587
5734
 
@@ -5667,6 +5814,11 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5667
5814
  updateDismissBtnState();
5668
5815
  const costBtn = document.getElementById('session-info-cost-btn');
5669
5816
  if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
5817
+ const mkBtn = document.getElementById('session-info-marketplace-btn');
5818
+ const memBtn = document.getElementById('session-info-memory-btn');
5819
+ const proj = session.project;
5820
+ if (mkBtn) mkBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.marketplaceUrl) ? '' : 'none';
5821
+ if (memBtn) memBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.memoryUrl) ? '' : 'none';
5670
5822
  modal.classList.add('visible');
5671
5823
 
5672
5824
  if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
@@ -5687,6 +5839,12 @@ function closeTeamModal() {
5687
5839
  _planSessionId = null;
5688
5840
  }
5689
5841
 
5842
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5843
+ function openSessionFromInfo(sessionId) {
5844
+ closeTeamModal();
5845
+ fetchTasks(sessionId);
5846
+ }
5847
+
5690
5848
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5691
5849
  function toggleDismissSession(sessionId) {
5692
5850
  if (dismissedSessionIds.has(sessionId)) {
@@ -5696,6 +5854,7 @@ function toggleDismissSession(sessionId) {
5696
5854
  }
5697
5855
  updateDismissBtnState();
5698
5856
  renderSessions();
5857
+ renderActivityChip();
5699
5858
  }
5700
5859
 
5701
5860
  function updateDismissBtnState() {
@@ -5781,7 +5940,6 @@ function openCost(sessionId) {
5781
5940
  }
5782
5941
  }
5783
5942
 
5784
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5785
5943
  function openMarketplace(projectPath) {
5786
5944
  const params = new URLSearchParams({ project: projectPath });
5787
5945
  if (window.__HUB__?.enabled) {
@@ -5793,7 +5951,6 @@ function openMarketplace(projectPath) {
5793
5951
  }
5794
5952
  }
5795
5953
 
5796
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5797
5954
  function openMemory(projectPath) {
5798
5955
  const params = new URLSearchParams({ project: projectPath });
5799
5956
  if (window.__HUB__?.enabled) {
@@ -5805,6 +5962,19 @@ function openMemory(projectPath) {
5805
5962
  }
5806
5963
  }
5807
5964
 
5965
+ function openForInfoModalProject(open) {
5966
+ const s = sessions.find((x) => x.id === _infoModalSessionId);
5967
+ if (s?.project) open(s.project);
5968
+ }
5969
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5970
+ function openMarketplaceForInfoModal() {
5971
+ openForInfoModalProject(openMarketplace);
5972
+ }
5973
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5974
+ function openMemoryForInfoModal() {
5975
+ openForInfoModalProject(openMemory);
5976
+ }
5977
+
5808
5978
  //#endregion
5809
5979
 
5810
5980
  //#region OWNER_FILTER
@@ -5970,6 +6140,12 @@ function makeLimitCell(label, bucket) {
5970
6140
  const strong = document.createElement('strong');
5971
6141
  strong.textContent = pct == null ? '-%' : `${Math.ceil(pct)}%`;
5972
6142
  cell.appendChild(strong);
6143
+ if (reset) {
6144
+ const r = document.createElement('span');
6145
+ r.className = 'footer-limit-reset';
6146
+ r.textContent = ` (${reset})`;
6147
+ cell.appendChild(r);
6148
+ }
5973
6149
  return cell;
5974
6150
  }
5975
6151
  function makeLimitSpan(rl) {
package/public/index.html CHANGED
@@ -527,6 +527,12 @@
527
527
  <button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost" onclick="openCost(_infoModalSessionId)">
528
528
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>
529
529
  </button>
530
+ <button id="session-info-marketplace-btn" class="icon-btn" style="display:none" title="Open in Marketplace" onclick="openMarketplaceForInfoModal()">
531
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>
532
+ </button>
533
+ <button id="session-info-memory-btn" class="icon-btn" style="display:none" title="Open in Memory" onclick="openMemoryForInfoModal()">
534
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>
535
+ </button>
530
536
  <button class="modal-close" aria-label="Close dialog" onclick="closeTeamModal()">
531
537
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
532
538
  <path d="M18 6L6 18M6 6l12 12"/>