claude-code-kanban 4.0.0 → 4.2.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,12 @@ 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
+ maybeFollowLatest();
608
+ }
598
609
  } catch (e) {
599
610
  console.error('[fetchAgents]', e);
600
611
  }
@@ -899,6 +910,11 @@ function parseCommandMessage(text) {
899
910
  return null;
900
911
  }
901
912
 
913
+ function parseCommandArgs(text) {
914
+ const m = (text || '').match(/<command-args>([^<]*)<\/command-args>/);
915
+ return m?.[1].trim() || '';
916
+ }
917
+
902
918
  function cleanMessageText(text) {
903
919
  const cmd = parseCommandMessage(text);
904
920
  if (cmd) return cmd;
@@ -1051,11 +1067,25 @@ function renderMessageList(messages) {
1051
1067
  </div>`);
1052
1068
  } else {
1053
1069
  const cmd = parseCommandMessage(m.text);
1070
+ const cmdArgs = cmd ? parseCommandArgs(m.fullText || m.text) : '';
1054
1071
  const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
1055
1072
  const isCmd = !!cmd;
1073
+ const cmdArgsHtml =
1074
+ cmd && cmdArgs ? ` <span style="color:var(--text-secondary)">${escapeHtml(cmdArgs)}</span>` : '';
1075
+ const chips = [];
1076
+ const imgCount = m.images?.length || 0;
1077
+ const trCount = m.toolResultRefs?.length || 0;
1078
+ if (imgCount) chips.push(`<span class="user-attach-chip">${imgCount} image${imgCount > 1 ? 's' : ''}</span>`);
1079
+ if (trCount)
1080
+ chips.push(`<span class="user-attach-chip">${trCount} tool result${trCount > 1 ? 's' : ''}</span>`);
1081
+ const chipsHtml = chips.length ? `<div class="user-attach-chips">${chips.join('')}</div>` : '';
1082
+ let textHtml;
1083
+ if (displayText) textHtml = isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText;
1084
+ else if (chips.length) textHtml = '<em class="msg-text-muted">(attachment)</em>';
1085
+ else textHtml = '';
1056
1086
  parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
1057
1087
  ${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}
1088
+ <div class="msg-body"><div class="msg-text">${textHtml}${cmdArgsHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1059
1089
  </div>`);
1060
1090
  }
1061
1091
  } else if (m.type === 'assistant') {
@@ -1103,6 +1133,62 @@ function toggleToolGroup(id) {
1103
1133
  if (el) el.classList.toggle('show');
1104
1134
  }
1105
1135
 
1136
+ const WAITING_PLAN_PREVIEW_CHARS = 120;
1137
+ const WAITING_PREVIEW_MAX_CHARS = 200;
1138
+
1139
+ function getWaitingLabel(kind, tool) {
1140
+ if (kind !== 'question') return `Awaiting permission: ${tool}`;
1141
+ if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
1142
+ return 'Question pending';
1143
+ }
1144
+
1145
+ function getWaitingPreview(toolInput) {
1146
+ if (!toolInput) return '';
1147
+ try {
1148
+ const parsed = JSON.parse(toolInput);
1149
+ if (parsed.questions?.[0]?.question) return parsed.questions[0].question;
1150
+ if (parsed.plan) {
1151
+ const t = parsed.plan.match(/^#\s+(.+)/m);
1152
+ return t ? t[1] : parsed.plan.slice(0, WAITING_PLAN_PREVIEW_CHARS);
1153
+ }
1154
+ if (parsed.command) return parsed.command;
1155
+ if (parsed.file_path) return parsed.file_path;
1156
+ } catch (_) {
1157
+ /* toolInput may be truncated/non-JSON */
1158
+ }
1159
+ return '';
1160
+ }
1161
+
1162
+ function renderWaitingEntry() {
1163
+ if (!isWaitingFresh()) return '';
1164
+ const tool = currentWaiting.toolName || 'unknown';
1165
+ const label = getWaitingLabel(currentWaiting.kind, tool);
1166
+ const preview = getWaitingPreview(currentWaiting.toolInput);
1167
+ const previewHtml = preview
1168
+ ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
1169
+ : '';
1170
+ const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
1171
+ return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div>${discardBtn}</div>`;
1172
+ }
1173
+
1174
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
1175
+ async function discardWaiting() {
1176
+ if (!currentSessionId) return;
1177
+ try {
1178
+ const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
1179
+ method: 'POST',
1180
+ });
1181
+ if (res.ok) {
1182
+ currentWaiting = null;
1183
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
1184
+ renderMessages(currentMessages);
1185
+ renderAgentFooter();
1186
+ }
1187
+ } catch (e) {
1188
+ console.error('[discardWaiting]', e);
1189
+ }
1190
+ }
1191
+
1106
1192
  function renderMessages(messages) {
1107
1193
  const container = document.getElementById('message-panel-content');
1108
1194
  const pinnedContainer = document.getElementById('message-panel-pinned');
@@ -1116,7 +1202,7 @@ function renderMessages(messages) {
1116
1202
  currentMessages.length >= MSG_MAX_LOADED
1117
1203
  ? `<div class="msg-limit-banner">Showing last ${MSG_MAX_LOADED} messages</div>`
1118
1204
  : '';
1119
- container.innerHTML = limitBanner + msgsHtml;
1205
+ container.innerHTML = limitBanner + msgsHtml + renderWaitingEntry();
1120
1206
  if (!msgUserScrolledUp) container.scrollTop = container.scrollHeight;
1121
1207
  // Auto-load more if content doesn't overflow yet
1122
1208
  if (
@@ -1131,6 +1217,7 @@ function renderMessages(messages) {
1131
1217
 
1132
1218
  let currentMsgDetailIdx = null;
1133
1219
  let msgDetailFollowLatest = false;
1220
+ const MSG_DETAIL_WAITING_IDX = -2;
1134
1221
  let currentPins = [];
1135
1222
  let pinnedCollapsed = false;
1136
1223
 
@@ -1152,6 +1239,16 @@ const ICON_TASK =
1152
1239
  '<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
1240
  const ICON_WEB =
1154
1241
  '<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>';
1242
+ const ICON_OPEN_EXTERNAL =
1243
+ '<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>';
1244
+ const ICON_COPY =
1245
+ '<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>';
1246
+ const ICON_CHECKMARK =
1247
+ '<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>';
1248
+ const ICON_AGENT_WAITING =
1249
+ '<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>';
1250
+ const ICON_CHAT =
1251
+ '<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
1252
  const TOOL_ICONS = {
1156
1253
  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
1254
  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 +1266,8 @@ const TOOL_ICONS = {
1169
1266
  TaskList: ICON_TASK,
1170
1267
  ToolSearch:
1171
1268
  '<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>',
1269
+ AskUserQuestion: ICON_CHAT,
1270
+ ExitPlanMode: ICON_CHAT,
1174
1271
  Skill:
1175
1272
  '<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
1273
  WebFetch: ICON_WEB,
@@ -1470,10 +1567,6 @@ function _renderPinToDetail(pin) {
1470
1567
  }
1471
1568
 
1472
1569
  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
1570
  const LINK_SVG_PATHS =
1478
1571
  '<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
1572
  const linkSvg = (size) =>
@@ -1482,6 +1575,40 @@ const linkSvg = (size) =>
1482
1575
  //#endregion
1483
1576
 
1484
1577
  //#region MODALS
1578
+ function renderUserAttachments(m) {
1579
+ const parts = [];
1580
+ if (m.images?.length && m.uuid && currentSessionId) {
1581
+ const imgs = m.images
1582
+ .map((img) => {
1583
+ const url = `/api/sessions/${encodeURIComponent(currentSessionId)}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1584
+ return `<img src="${url}" loading="lazy" alt="user image" class="user-attach-image" />`;
1585
+ })
1586
+ .join('');
1587
+ parts.push(
1588
+ `<div class="user-attach-section"><div class="user-attach-label">Attached images</div><div class="user-attach-images">${imgs}</div></div>`,
1589
+ );
1590
+ }
1591
+ if (m.toolResultRefs?.length) {
1592
+ const refs = m.toolResultRefs
1593
+ .map((ref) => {
1594
+ const safeId = escapeHtml(ref.toolUseId);
1595
+ const shortId = ref.toolUseId.length > 14 ? `${ref.toolUseId.slice(0, 14)}…` : ref.toolUseId;
1596
+ const preview = ref.preview ? escapeHtml(ref.preview) : '<em>(no text)</em>';
1597
+ const expandId = `user-tr-${ref.toolUseId}`;
1598
+ return `<details class="user-attach-toolresult">
1599
+ <summary>Tool result <code>${escapeHtml(shortId)}</code></summary>
1600
+ <pre class="${TINTED_PRE_CLASS}" id="${expandId}">${preview}</pre>
1601
+ <button type="button" class="tool-result-expand-btn" data-expand-id="${expandId}" data-tool-use-id="${safeId}" onclick="_toggleToolResultExpand(this)">Show full</button>
1602
+ </details>`;
1603
+ })
1604
+ .join('');
1605
+ parts.push(
1606
+ `<div class="user-attach-section"><div class="user-attach-label">Tool results in this message</div>${refs}</div>`,
1607
+ );
1608
+ }
1609
+ return parts.join('');
1610
+ }
1611
+
1485
1612
  function showMsgDetail(idx) {
1486
1613
  currentMsgDetailIdx = idx;
1487
1614
  const m = currentMessages[idx];
@@ -1546,25 +1673,27 @@ function showMsgDetail(idx) {
1546
1673
  body.innerHTML = renderMarkdown(text);
1547
1674
  }
1548
1675
  } else {
1549
- const rawText = stripAnsi(m.fullText || m.text);
1676
+ const rawText = stripAnsi(m.fullText || m.text || '');
1550
1677
  const cmd = m.type === 'user' ? parseCommandMessage(rawText) : null;
1551
1678
  document.getElementById('msg-detail-title').textContent =
1552
1679
  m.type === 'assistant' ? 'Claude' : m.systemLabel ? 'System' : 'User';
1553
1680
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
1681
+ const userExtras = m.type === 'user' ? renderUserAttachments(m) : '';
1554
1682
  if (m.compactSummary) {
1555
- body.innerHTML = renderMarkdown(m.compactSummary);
1683
+ body.innerHTML = renderMarkdown(m.compactSummary) + userExtras;
1556
1684
  } else if (cmd) {
1557
- const argsMatch = rawText.match(/<command-args>([^<]*)<\/command-args>/);
1558
- const args = argsMatch?.[1].trim() ? argsMatch[1].trim() : null;
1685
+ const args = parseCommandArgs(rawText) || null;
1559
1686
  const cleanBody = rawText
1560
1687
  .replace(/<command-[^>]+>[\s\S]*?<\/command-[^>]+>/g, '')
1561
1688
  .replace(/<local-command-[^>]+>[\s\S]*?<\/local-command-[^>]+>/g, '')
1562
1689
  .trim();
1563
1690
  let cmdHtml = `<code>${escapeHtml(cmd)}${args ? ` ${escapeHtml(args)}` : ''}</code>`;
1564
1691
  if (cleanBody) cmdHtml += `<div style="margin-top:10px">${renderMarkdown(cleanBody)}</div>`;
1565
- body.innerHTML = cmdHtml;
1692
+ body.innerHTML = cmdHtml + userExtras;
1693
+ } else if (rawText) {
1694
+ body.innerHTML = renderMarkdown(rawText) + userExtras;
1566
1695
  } else {
1567
- body.innerHTML = renderMarkdown(rawText);
1696
+ body.innerHTML = userExtras || '<em>No content</em>';
1568
1697
  }
1569
1698
  }
1570
1699
  const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
@@ -1649,8 +1778,7 @@ async function copyWithFeedback(text, btn) {
1649
1778
  await navigator.clipboard.writeText(text);
1650
1779
  btn.dataset.copying = '1';
1651
1780
  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>';
1781
+ btn.innerHTML = ICON_CHECKMARK;
1654
1782
  setTimeout(() => {
1655
1783
  btn.innerHTML = svg;
1656
1784
  delete btn.dataset.copying;
@@ -2028,7 +2156,7 @@ function renderAgentFooter() {
2028
2156
  )
2029
2157
  .slice(0, AGENT_LOG_MAX);
2030
2158
 
2031
- const permFresh = currentWaiting?.timestamp && now - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
2159
+ const permFresh = isWaitingFresh();
2032
2160
 
2033
2161
  if (visible.length === 0 && !permFresh) {
2034
2162
  footer.classList.remove('visible');
@@ -2322,10 +2450,8 @@ function renderSessions() {
2322
2450
 
2323
2451
  // Filter pipeline: active filter → force-include revealed/current (non-pinned) sessions →
2324
2452
  // project filter → search filter → ensure pinned/sticky sessions are always included
2325
- const LIVE_INDICATOR_MS = 10 * 1000;
2326
2453
  let filteredSessions = sessions;
2327
2454
  if (sessionFilter === 'active') {
2328
- const ACTIVE_PLAN_MS = 15 * 60 * 1000;
2329
2455
  const now = Date.now();
2330
2456
  const activeSessionIds = new Set();
2331
2457
  filteredSessions = filteredSessions.filter((s) => {
@@ -2473,15 +2599,13 @@ function renderSessions() {
2473
2599
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2474
2600
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2475
2601
  ${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>` : ''}
2602
+ ${renderLoopBadge(session)}
2476
2603
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2477
2604
  ${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>` : ''}
2478
2605
  ${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>` : ''}
2479
- ${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
2480
- ${session.planSourceSessionId ? `<span class="plan-indicator" title="Implements plan — click to reveal plan session" onclick="event.stopPropagation(); revealPlanSession('${escapeHtml(session.planSourceSessionId)}')">📋</span>` : ''}
2481
- ${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
2482
- ${(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>` : ''}
2483
- ${(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>` : ''}
2484
- ${isLive ? '<span class="pulse"></span>' : ''}
2606
+ ${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>` : ''}
2607
+ ${session.hasWaitingForUser ? `<span class="agent-badge agent-badge-waiting" title="Waiting for user">${ICON_AGENT_WAITING}</span>` : ''}
2608
+ ${isLive || session.hasRunningAgents ? `<span class="pulse" title="${isLive ? 'Live' : 'Active agents'}"></span>` : ''}
2485
2609
  </span>
2486
2610
  <div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
2487
2611
  <span class="progress-text">${session.completed}/${total}</span>
@@ -4147,7 +4271,7 @@ function matchKey(e, ...keys) {
4147
4271
  return keys.some((k) => e.key === k || e.code === k);
4148
4272
  }
4149
4273
 
4150
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
4274
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
4151
4275
  const MODAL_CLOSERS = {
4152
4276
  'preview-modal': () => closePreviewModal(),
4153
4277
  'msg-detail-modal': () => {
@@ -4155,6 +4279,7 @@ const MODAL_CLOSERS = {
4155
4279
  msgDetailFollowLatest = false;
4156
4280
  },
4157
4281
  'plan-modal': () => closePlanModal(),
4282
+ 'loop-modal': () => closeLoopModal(),
4158
4283
  'team-modal': () => closeTeamModal(),
4159
4284
  'agent-modal': () => closeAgentModal(),
4160
4285
  'help-modal': () => closeHelpModal(),
@@ -4189,7 +4314,13 @@ document.addEventListener('keydown', (e) => {
4189
4314
  } else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
4190
4315
  if (matchKey(e, 'ArrowDown', 'KeyJ')) {
4191
4316
  e.preventDefault();
4192
- if (currentMsgDetailIdx < currentMessages.length - 1) {
4317
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
4318
+ msgDetailFollowLatest = true;
4319
+ showWaitingDetail();
4320
+ } else if (currentMsgDetailIdx === currentMessages.length - 1 && isWaitingFresh()) {
4321
+ msgDetailFollowLatest = false;
4322
+ showWaitingDetail();
4323
+ } else if (currentMsgDetailIdx < currentMessages.length - 1) {
4193
4324
  msgDetailFollowLatest = false;
4194
4325
  showMsgDetail(currentMsgDetailIdx + 1);
4195
4326
  } else if (currentMsgDetailIdx === currentMessages.length - 1) {
@@ -4198,7 +4329,12 @@ document.addEventListener('keydown', (e) => {
4198
4329
  }
4199
4330
  } else if (matchKey(e, 'ArrowUp', 'KeyK')) {
4200
4331
  e.preventDefault();
4201
- if (currentMsgDetailIdx > 0) {
4332
+ if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
4333
+ if (currentMessages.length) {
4334
+ msgDetailFollowLatest = false;
4335
+ showMsgDetail(currentMessages.length - 1);
4336
+ }
4337
+ } else if (currentMsgDetailIdx > 0) {
4202
4338
  msgDetailFollowLatest = false;
4203
4339
  showMsgDetail(currentMsgDetailIdx - 1);
4204
4340
  }
@@ -4223,6 +4359,9 @@ document.addEventListener('keydown', (e) => {
4223
4359
  const msgDetailModal = document.getElementById('msg-detail-modal');
4224
4360
  if (msgDetailModal.classList.contains('visible')) {
4225
4361
  closeMsgDetailModal();
4362
+ } else if (isWaitingFresh()) {
4363
+ msgDetailFollowLatest = true;
4364
+ showWaitingDetail();
4226
4365
  } else if (currentMessages.length) {
4227
4366
  msgDetailFollowLatest = true;
4228
4367
  showMsgDetail(currentMessages.length - 1);
@@ -4380,6 +4519,7 @@ document.addEventListener('keydown', (e) => {
4380
4519
  dismissedSessionIds.add(contextSid);
4381
4520
  updateDismissBtnState();
4382
4521
  renderSessions();
4522
+ renderActivityChip();
4383
4523
  const newItems = getNavigableItems();
4384
4524
  const targetIdx = newItems.length > 0 ? Math.max(0, prevIdx - 1) : -1;
4385
4525
  // If the dismissed session is currently open, navigate to the previous one
@@ -4986,11 +5126,50 @@ function renderContextDetail(raw) {
4986
5126
 
4987
5127
  //#region UTILS
4988
5128
  function maybeFollowLatest() {
4989
- if (msgDetailFollowLatest && currentMessages.length) {
5129
+ if (!msgDetailFollowLatest) return;
5130
+ if (isWaitingFresh()) {
5131
+ showWaitingDetail();
5132
+ } else if (currentMessages.length) {
4990
5133
  showMsgDetail(currentMessages.length - 1);
4991
5134
  }
4992
5135
  }
4993
5136
 
5137
+ function isWaitingFresh() {
5138
+ if (!currentWaiting?.timestamp) return false;
5139
+ return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
5140
+ }
5141
+
5142
+ function showWaitingDetail() {
5143
+ if (!isWaitingFresh()) return;
5144
+ currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
5145
+ const tool = currentWaiting.toolName || 'unknown';
5146
+ const label = getWaitingLabel(currentWaiting.kind, tool);
5147
+ const body = document.getElementById('msg-detail-body');
5148
+ let inputHtml = '';
5149
+ if (currentWaiting.toolInput) {
5150
+ let pretty = currentWaiting.toolInput;
5151
+ try {
5152
+ pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
5153
+ } catch (_) {
5154
+ /* keep raw */
5155
+ }
5156
+ inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
5157
+ }
5158
+ body.innerHTML = inputHtml;
5159
+ document.getElementById('msg-detail-title').textContent = label;
5160
+ document.getElementById('msg-detail-agent-btn').style.display = 'none';
5161
+ const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
5162
+ autoSizeModal(modal, body);
5163
+ modal.classList.toggle('live', msgDetailFollowLatest);
5164
+ const overlay = document.getElementById('msg-detail-modal');
5165
+ overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
5166
+ const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
5167
+ document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
5168
+ currentPinDetailId = null;
5169
+ updateMsgDetailPinState();
5170
+ overlay.classList.add('visible');
5171
+ }
5172
+
4994
5173
  function isSessionActive(s) {
4995
5174
  return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
4996
5175
  }
@@ -5088,7 +5267,7 @@ function renderAgentTabs(promptHtml, responseHtml, promptText, responseText) {
5088
5267
  }
5089
5268
  if (!tabs.length) return '';
5090
5269
  const defaultTab = responseHtml ? 'response' : tabs[0].key;
5091
- 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>`;
5270
+ const copyBtnHtml = `<button class="agent-tab-copy" title="Copy" onclick="copyAgentTabActive('${id}',this)">${ICON_COPY}</button>`;
5092
5271
  const tabsHtml = tabs
5093
5272
  .map(
5094
5273
  (t) =>
@@ -5493,13 +5672,13 @@ async function showSessionInfoModal(sessionId) {
5493
5672
  // and re-rendered when they arrive, so the modal doesn't block on network.
5494
5673
  _planSessionId = sessionId;
5495
5674
  const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
5496
- showInfoModal(session, null, cachedTasks, null);
5675
+ showInfoModal(session, null, cachedTasks, null, null);
5497
5676
 
5498
- const rerender = (teamConfig, tasks, planContent) => {
5677
+ const rerender = (teamConfig, tasks, planContent, parentInfo) => {
5499
5678
  if (_planSessionId !== sessionId) return; // user opened a different modal
5500
5679
  const modal = document.getElementById('team-modal');
5501
5680
  if (!modal?.classList.contains('visible')) return; // user closed modal — don't reopen
5502
- showInfoModal(session, teamConfig, tasks, planContent);
5681
+ showInfoModal(session, teamConfig, tasks, planContent, parentInfo);
5503
5682
  };
5504
5683
 
5505
5684
  const teamPromise = session.isTeam
@@ -5520,8 +5699,17 @@ async function showSessionInfoModal(sessionId) {
5520
5699
  .then((r) => (r.ok ? r.json() : []))
5521
5700
  .catch(() => []);
5522
5701
 
5523
- const [teamConfig, planContent, tasks] = await Promise.all([teamPromise, planPromise, tasksPromise]);
5524
- rerender(teamConfig, tasks, planContent);
5702
+ const parentPromise = fetch(`/api/sessions/${sessionId}/parent`)
5703
+ .then((r) => (r.ok ? r.json() : null))
5704
+ .catch(() => null);
5705
+
5706
+ const [teamConfig, planContent, tasks, parentInfo] = await Promise.all([
5707
+ teamPromise,
5708
+ planPromise,
5709
+ tasksPromise,
5710
+ parentPromise,
5711
+ ]);
5712
+ rerender(teamConfig, tasks, planContent, parentInfo);
5525
5713
  }
5526
5714
 
5527
5715
  let _infoModalSessionId = null;
@@ -5538,7 +5726,7 @@ function updateStickyBtnState() {
5538
5726
  if (svg) svg.setAttribute('fill', isSticky ? 'currentColor' : 'none');
5539
5727
  }
5540
5728
 
5541
- function showInfoModal(session, teamConfig, tasks, planContent) {
5729
+ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5542
5730
  const modal = document.getElementById('team-modal');
5543
5731
  const titleEl = document.getElementById('team-modal-title');
5544
5732
  const bodyEl = document.getElementById('team-modal-body');
@@ -5558,6 +5746,13 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5558
5746
  // Each row: [label, displayValue, { openPath?, copyValue? }]
5559
5747
  const infoRows = [];
5560
5748
  infoRows.push(['Session', session.id, { openClaudeDir: true, openFile: session.jsonlPath }]);
5749
+ if (parentInfo?.parentSessionId) {
5750
+ infoRows.push([
5751
+ 'Forked from',
5752
+ parentInfo.parentSessionId,
5753
+ { openClaudeDir: true, openFile: parentInfo.parentJsonlPath, openSession: parentInfo.parentSessionId },
5754
+ ]);
5755
+ }
5561
5756
  if (session.slug && session.hasPlan) {
5562
5757
  infoRows.push(['Slug', session.slug, { openClaudeDir: true, openFile: session.planPath }]);
5563
5758
  }
@@ -5589,19 +5784,25 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5589
5784
  "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;";
5590
5785
  const plainStyle =
5591
5786
  "font-family: 'IBM Plex Mono', monospace; font-size: 12px; user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
5592
- html += `<div class="team-modal-meta" style="margin-bottom: 16px; display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: center;">`;
5787
+ html += `<div class="team-modal-meta info-grid">`;
5593
5788
  infoRows.forEach(([label, value, opts]) => {
5594
5789
  const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
5595
5790
  html += `<span style="font-weight: 500; color: var(--text-secondary); font-size: 12px; white-space: nowrap;">${label}</span>`;
5596
- if (opts?.openClaudeDir || opts?.openPath) {
5597
- const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5598
- const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5599
- 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>`;
5791
+ if (opts?.openSession) {
5792
+ const sid = _escapeForJsAttr(escapeHtml(opts.openSession).replace(/"/g, '&quot;'));
5793
+ html += `<span onclick="openSessionFromInfo('${sid}')" style="${clickableStyle}" title="Open session in app">${escapeHtml(value)}</span>`;
5600
5794
  } else {
5601
5795
  html += `<span style="${plainStyle}" title="${copyVal}">${escapeHtml(value)}</span>`;
5602
5796
  }
5603
5797
  const jsCopyVal = _escapeForJsAttr(copyVal);
5604
- 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>`;
5798
+ const copyBtn = `<button onclick="copyWithFeedback('${jsCopyVal}', this)" title="Copy">${ICON_COPY}</button>`;
5799
+ let openBtn = '';
5800
+ if (opts?.openClaudeDir || opts?.openPath) {
5801
+ const folder = opts.openClaudeDir ? '' : escapeHtml(opts.openPath).replace(/"/g, '&quot;');
5802
+ const file = opts.openFile ? escapeHtml(opts.openFile).replace(/"/g, '&quot;') : '';
5803
+ 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>`;
5804
+ }
5805
+ html += `<span class="info-row-actions">${copyBtn}${openBtn}</span>`;
5605
5806
  });
5606
5807
  html += `</div>`;
5607
5808
 
@@ -5687,6 +5888,11 @@ function showInfoModal(session, teamConfig, tasks, planContent) {
5687
5888
  updateDismissBtnState();
5688
5889
  const costBtn = document.getElementById('session-info-cost-btn');
5689
5890
  if (costBtn) costBtn.style.display = window.__HUB__?.enabled || appConfig.costUrl ? '' : 'none';
5891
+ const mkBtn = document.getElementById('session-info-marketplace-btn');
5892
+ const memBtn = document.getElementById('session-info-memory-btn');
5893
+ const proj = session.project;
5894
+ if (mkBtn) mkBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.marketplaceUrl) ? '' : 'none';
5895
+ if (memBtn) memBtn.style.display = proj && (window.__HUB__?.enabled || appConfig.memoryUrl) ? '' : 'none';
5690
5896
  modal.classList.add('visible');
5691
5897
 
5692
5898
  if (alreadyVisible) return; // re-render during deferred hydration — key handler already attached
@@ -5707,6 +5913,12 @@ function closeTeamModal() {
5707
5913
  _planSessionId = null;
5708
5914
  }
5709
5915
 
5916
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5917
+ function openSessionFromInfo(sessionId) {
5918
+ closeTeamModal();
5919
+ fetchTasks(sessionId);
5920
+ }
5921
+
5710
5922
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5711
5923
  function toggleDismissSession(sessionId) {
5712
5924
  if (dismissedSessionIds.has(sessionId)) {
@@ -5716,6 +5928,7 @@ function toggleDismissSession(sessionId) {
5716
5928
  }
5717
5929
  updateDismissBtnState();
5718
5930
  renderSessions();
5931
+ renderActivityChip();
5719
5932
  }
5720
5933
 
5721
5934
  function updateDismissBtnState() {
@@ -5745,6 +5958,110 @@ function refreshOpenPlan() {
5745
5958
  .catch(() => {});
5746
5959
  }
5747
5960
 
5961
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5962
+ function showLoopModal(sessionId) {
5963
+ const body = document.getElementById('loop-modal-body');
5964
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
5965
+ document.getElementById('loop-modal').classList.add('visible');
5966
+ fetch(`/api/sessions/${sessionId}/loop`)
5967
+ .then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
5968
+ .catch(() => ({ wakeups: [], crons: [] }))
5969
+ .then((data) => {
5970
+ renderLoopModalBody(data);
5971
+ });
5972
+ }
5973
+
5974
+ function fmtLoopDelay(s) {
5975
+ if (s == null) return '';
5976
+ if (s < 60) return `${s}s`;
5977
+ if (s < 3600) return `${Math.round(s / 60)}m`;
5978
+ return `${(s / 3600).toFixed(1)}h`;
5979
+ }
5980
+
5981
+ function fmtLoopFireTime(timestamp, delaySeconds) {
5982
+ if (!timestamp || delaySeconds == null) return { abs: '', rel: '', status: '' };
5983
+ const fireMs = new Date(timestamp).getTime() + delaySeconds * 1000;
5984
+ const fireDate = new Date(fireMs);
5985
+ const diff = fireMs - Date.now();
5986
+ const abs = fireDate.toLocaleString(undefined, {
5987
+ month: 'short',
5988
+ day: 'numeric',
5989
+ hour: '2-digit',
5990
+ minute: '2-digit',
5991
+ });
5992
+ const absSec = Math.abs(Math.round(diff / 1000));
5993
+ const rel =
5994
+ absSec < 60 ? `${absSec}s` : absSec < 3600 ? `${Math.round(absSec / 60)}m` : `${(absSec / 3600).toFixed(1)}h`;
5995
+ if (diff > 0) return { abs, rel: `in ${rel}`, status: 'pending' };
5996
+ return { abs, rel: `${rel} ago`, status: 'fired' };
5997
+ }
5998
+
5999
+ const LOOP_CLOCK_SVG =
6000
+ '<svg width="14" height="14" 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"/><polyline points="12 6 12 12 16 14"/></svg>';
6001
+ const LOOP_CRON_SVG =
6002
+ '<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="M12 8v4l3 3"/><circle cx="12" cy="12" r="10"/></svg>';
6003
+
6004
+ function loopField(label, value, mono = false) {
6005
+ if (!value) return '';
6006
+ const inner = mono ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value);
6007
+ return `<div class="loop-field"><div class="loop-field-label">${label}</div><div class="loop-field-val">${inner}</div></div>`;
6008
+ }
6009
+
6010
+ function renderLoopRow(item, kind) {
6011
+ const when = item.timestamp ? formatDate(item.timestamp) : '';
6012
+ let headline = '';
6013
+ let footer = '';
6014
+ let fields = '';
6015
+ if (kind === 'wakeup') {
6016
+ const fire = fmtLoopFireTime(item.timestamp, item.delaySeconds);
6017
+ const delayLbl = item.delaySeconds != null ? `delay ${fmtLoopDelay(item.delaySeconds)}` : '';
6018
+ if (fire.abs) {
6019
+ headline = `<div class="loop-headline loop-fire-${fire.status}">${LOOP_CLOCK_SVG}<span class="loop-headline-rel">${fire.status === 'pending' ? 'Fires' : 'Fired'} ${escapeHtml(fire.rel)}</span><span class="loop-headline-abs">${escapeHtml(fire.abs)}</span></div>`;
6020
+ }
6021
+ fields = loopField('Reason', item.reason) + loopField('Prompt', item.prompt, true);
6022
+ footer = `<div class="loop-foot">scheduled ${escapeHtml(when)}${delayLbl ? ` · ${delayLbl}` : ''}</div>`;
6023
+ } else {
6024
+ if (item.cron) {
6025
+ headline = `<div class="loop-headline">${LOOP_CRON_SVG}<span class="loop-headline-rel"><code>${escapeHtml(item.cron)}</code></span></div>`;
6026
+ }
6027
+ fields = loopField('Description', item.description) + loopField('Prompt', item.prompt, true);
6028
+ footer = `<div class="loop-foot">created ${escapeHtml(when)}</div>`;
6029
+ }
6030
+ return `<div class="loop-row">${headline}${fields}${footer}</div>`;
6031
+ }
6032
+
6033
+ function renderLoopModalBody(data) {
6034
+ const body = document.getElementById('loop-modal-body');
6035
+ const wakeups = data.wakeups || [];
6036
+ const crons = data.crons || [];
6037
+ if (!wakeups.length && !crons.length) {
6038
+ body.innerHTML =
6039
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
6040
+ return;
6041
+ }
6042
+ const section = (title, items, kind) =>
6043
+ items.length
6044
+ ? `<h4 class="loop-section-title">${title} <span class="loop-count">${items.length}</span></h4>${items.map((i) => renderLoopRow(i, kind)).join('')}`
6045
+ : '';
6046
+ body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
6047
+ }
6048
+
6049
+ function renderLoopBadge(session) {
6050
+ const li = session.loopInfo;
6051
+ const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
6052
+ if (total === 0) return '';
6053
+ let tip = `${li.wakeupCount} wakeup${li.wakeupCount === 1 ? '' : 's'}, ${li.cronCount} cron${li.cronCount === 1 ? '' : 's'}`;
6054
+ if (li.latest?.timestamp && li.latest.delaySeconds != null) {
6055
+ const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
6056
+ if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
6057
+ }
6058
+ return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${session.id}')" title="${escapeHtml(tip)}"><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"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
6059
+ }
6060
+
6061
+ function closeLoopModal() {
6062
+ document.getElementById('loop-modal').classList.remove('visible');
6063
+ }
6064
+
5748
6065
  function openPlanForSession(sid) {
5749
6066
  fetch(`/api/sessions/${sid}/plan`)
5750
6067
  .then((r) => (r.ok ? r.json() : null))
@@ -5801,7 +6118,6 @@ function openCost(sessionId) {
5801
6118
  }
5802
6119
  }
5803
6120
 
5804
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5805
6121
  function openMarketplace(projectPath) {
5806
6122
  const params = new URLSearchParams({ project: projectPath });
5807
6123
  if (window.__HUB__?.enabled) {
@@ -5813,7 +6129,6 @@ function openMarketplace(projectPath) {
5813
6129
  }
5814
6130
  }
5815
6131
 
5816
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
5817
6132
  function openMemory(projectPath) {
5818
6133
  const params = new URLSearchParams({ project: projectPath });
5819
6134
  if (window.__HUB__?.enabled) {
@@ -5825,6 +6140,19 @@ function openMemory(projectPath) {
5825
6140
  }
5826
6141
  }
5827
6142
 
6143
+ function openForInfoModalProject(open) {
6144
+ const s = sessions.find((x) => x.id === _infoModalSessionId);
6145
+ if (s?.project) open(s.project);
6146
+ }
6147
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6148
+ function openMarketplaceForInfoModal() {
6149
+ openForInfoModalProject(openMarketplace);
6150
+ }
6151
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6152
+ function openMemoryForInfoModal() {
6153
+ openForInfoModalProject(openMemory);
6154
+ }
6155
+
5828
6156
  //#endregion
5829
6157
 
5830
6158
  //#region OWNER_FILTER
@@ -5990,6 +6318,12 @@ function makeLimitCell(label, bucket) {
5990
6318
  const strong = document.createElement('strong');
5991
6319
  strong.textContent = pct == null ? '-%' : `${Math.ceil(pct)}%`;
5992
6320
  cell.appendChild(strong);
6321
+ if (reset) {
6322
+ const r = document.createElement('span');
6323
+ r.className = 'footer-limit-reset';
6324
+ r.textContent = ` (${reset})`;
6325
+ cell.appendChild(r);
6326
+ }
5993
6327
  return cell;
5994
6328
  }
5995
6329
  function makeLimitSpan(rl) {