claude-code-kanban 4.4.0 → 4.6.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
@@ -27,6 +27,8 @@ let agentDurationInterval = null;
27
27
  let agentPollInterval = null;
28
28
  let selectedTaskId = null;
29
29
  let selectedSessionId = null;
30
+ // Task stays selected (keyboard nav) but its white highlight is dimmed once the detail panel closes.
31
+ let taskHighlightDimmed = false;
30
32
  let focusZone = 'board'; // 'board' | 'sidebar'
31
33
  let appConfig = { marketplaceUrl: null, costUrl: null, memoryUrl: null };
32
34
  let selectedSessionIdx = -1;
@@ -161,12 +163,16 @@ async function fetchSessions(includeTasks = true) {
161
163
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
162
164
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
163
165
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
166
+ // When server filters by activity, the focused session may not be active —
167
+ // include it in pinned so the server still returns it.
168
+ if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
164
169
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
165
170
  const projectParam =
166
171
  filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
167
- const sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}`).then((r) =>
168
- r.json(),
169
- );
172
+ const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
173
+ const sessionsPromise = fetch(
174
+ `/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
175
+ ).then((r) => r.json());
170
176
 
171
177
  let newSessions, newTasks;
172
178
  if (includeTasks) {
@@ -582,7 +588,6 @@ async function fetchTasks(sessionId) {
582
588
  const WAITING_TTL_MS = 30 * 60 * 1000;
583
589
  const AGENT_LOG_MAX = 8;
584
590
  const LIVE_INDICATOR_MS = 10 * 1000;
585
- const ACTIVE_PLAN_MS = 10 * 60 * 1000;
586
591
  // #endregion
587
592
 
588
593
  function resetAgentState() {
@@ -1026,7 +1031,7 @@ function renderToolItem(m, i, compact) {
1026
1031
  ? `onclick="showAgentModal('${escapeHtml(m.agentId)}')" ${combinedStyle}`
1027
1032
  : `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" ${combinedStyle}`;
1028
1033
  const pinBtn = renderMsgPinBtn(m, i);
1029
- return `<div class="msg-item msg-tool${compactClass}" ${itemClickAttr}>
1034
+ return `<div class="msg-item msg-tool${compactClass}" data-msg-idx="${i}" ${itemClickAttr}>
1030
1035
  ${getToolIcon(m.tool)}
1031
1036
  <div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}${agentLink}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>${agentLogBtn}${pinBtn}
1032
1037
  </div>`;
@@ -1068,7 +1073,7 @@ function renderMessageList(messages) {
1068
1073
  continue;
1069
1074
  }
1070
1075
 
1071
- const clickable = `onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1076
+ const clickable = `data-msg-idx="${i}" onclick="msgDetailFollowLatest=false;showMsgDetail(${i})" style="cursor:pointer"`;
1072
1077
  const pinBtn = renderMsgPinBtn(m, i);
1073
1078
  if (m.type === 'user') {
1074
1079
  if (m.systemLabel) {
@@ -1094,9 +1099,10 @@ function renderMessageList(messages) {
1094
1099
  if (displayText) textHtml = isCmd ? `<code>${escapeHtml(displayText)}</code>` : displayText;
1095
1100
  else if (chips.length) textHtml = '<em class="msg-text-muted">(attachment)</em>';
1096
1101
  else textHtml = '';
1097
- parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
1102
+ const queuedTag = m.queued ? '<span class="msg-queued-tag">queued</span>' : '';
1103
+ parts.push(`<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}${m.queued ? ' msg-queued' : ''}" ${clickable}>
1098
1104
  ${MSG_ICON_USER}
1099
- <div class="msg-body"><div class="msg-text">${textHtml}${cmdArgsHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}</div></div>${pinBtn}
1105
+ <div class="msg-body"><div class="msg-text">${textHtml}${cmdArgsHtml}</div>${chipsHtml}<div class="msg-time">${formatDate(m.timestamp)}${queuedTag}</div></div>${pinBtn}
1100
1106
  </div>`);
1101
1107
  }
1102
1108
  } else if (m.type === 'assistant') {
@@ -1144,42 +1150,80 @@ function toggleToolGroup(id) {
1144
1150
  if (el) el.classList.toggle('show');
1145
1151
  }
1146
1152
 
1147
- const WAITING_PLAN_PREVIEW_CHARS = 120;
1148
- const WAITING_PREVIEW_MAX_CHARS = 200;
1149
-
1150
1153
  function getWaitingLabel(kind, tool) {
1151
1154
  if (kind !== 'question') return `Awaiting permission: ${tool}`;
1152
1155
  if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
1153
1156
  return 'Question pending';
1154
1157
  }
1155
1158
 
1156
- function getWaitingPreview(toolInput) {
1157
- if (!toolInput) return '';
1158
- try {
1159
- const parsed = JSON.parse(toolInput);
1160
- if (parsed.questions?.[0]?.question) return parsed.questions[0].question;
1161
- if (parsed.plan) {
1162
- const t = parsed.plan.match(/^#\s+(.+)/m);
1163
- return t ? t[1] : parsed.plan.slice(0, WAITING_PLAN_PREVIEW_CHARS);
1164
- }
1165
- if (parsed.command) return parsed.command;
1166
- if (parsed.file_path) return parsed.file_path;
1167
- } catch (_) {
1168
- /* toolInput may be truncated/non-JSON */
1169
- }
1159
+ function getWaitingPill(kind, tool) {
1160
+ if (kind === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
1161
+ if (kind === 'question') return 'Question pending';
1162
+ return 'Awaiting permission';
1163
+ }
1164
+
1165
+ function deriveWaitingDetail(tool, params) {
1166
+ if (!params) return '';
1167
+ const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
1168
+ if (params.file_path) return params.file_path.replace(/^.*[/\\]/, '');
1169
+ if (params.command) return trunc(params.command);
1170
+ if (params.pattern) return trunc(params.pattern);
1171
+ if (params.query) return trunc(params.query);
1172
+ if (params.url) return trunc(params.url);
1173
+ if (params.skill) {
1174
+ const s = params.skill + (typeof params.args === 'string' ? ` ${params.args}` : '');
1175
+ return trunc(s);
1176
+ }
1177
+ if (tool === 'AskUserQuestion' && params.questions?.[0]?.question) return trunc(params.questions[0].question);
1178
+ if (tool === 'ExitPlanMode' && typeof params.plan === 'string') {
1179
+ const t = params.plan.match(/^#\s+(.+)/m);
1180
+ return trunc(t ? t[1] : params.plan);
1181
+ }
1182
+ if (params.description) return trunc(String(params.description));
1170
1183
  return '';
1171
1184
  }
1172
1185
 
1186
+ function renderWaitingBody(tool, params) {
1187
+ if (!params) return '';
1188
+ if (tool === 'AskUserQuestion' && Array.isArray(params.questions)) {
1189
+ const items = params.questions
1190
+ .map((q) => {
1191
+ const head = `<div style="font-weight:600">${escapeHtml(q.question || '')}</div>`;
1192
+ const opts = Array.isArray(q.options)
1193
+ ? `<ul style="margin:2px 0 0 16px;padding:0">${q.options
1194
+ .map(
1195
+ (o) =>
1196
+ `<li><span style="font-weight:600">${escapeHtml(o.label || '')}</span>${o.description ? ` — <span style="color:var(--text-muted)">${escapeHtml(o.description)}</span>` : ''}</li>`,
1197
+ )
1198
+ .join('')}</ul>`
1199
+ : '';
1200
+ return `<div style="margin-top:6px">${head}${opts}</div>`;
1201
+ })
1202
+ .join('');
1203
+ return items;
1204
+ }
1205
+ return renderToolParamsHtml(params);
1206
+ }
1207
+
1173
1208
  function renderWaitingEntry() {
1174
1209
  if (!isWaitingFresh()) return '';
1175
1210
  const tool = currentWaiting.toolName || 'unknown';
1176
- const label = getWaitingLabel(currentWaiting.kind, tool);
1177
- const preview = getWaitingPreview(currentWaiting.toolInput);
1178
- const previewHtml = preview
1179
- ? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
1180
- : '';
1211
+ let params = null;
1212
+ if (currentWaiting.toolInput) {
1213
+ try {
1214
+ params = JSON.parse(currentWaiting.toolInput);
1215
+ } catch (_) {
1216
+ /* toolInput may be truncated/non-JSON */
1217
+ }
1218
+ }
1219
+ const pillText = getWaitingPill(currentWaiting.kind, tool);
1220
+ const detail = deriveWaitingDetail(tool, params);
1221
+ const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
1222
+ const bodyHtml = renderWaitingBody(tool, params);
1223
+ const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
1224
+ const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
1181
1225
  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>`;
1182
- 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>`;
1226
+ return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${getToolIcon(tool)}<div class="msg-body"><div class="msg-text">${pill} <span style="font-weight:600">${escapeHtml(tool)}</span>${detailHtml}</div>${bodyWrap}<div class="msg-time">waiting…</div></div>${discardBtn}</div>`;
1183
1227
  }
1184
1228
 
1185
1229
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
@@ -1214,6 +1258,7 @@ function renderMessages(messages) {
1214
1258
  ? `<div class="msg-limit-banner">Showing last ${MSG_MAX_LOADED} messages</div>`
1215
1259
  : '';
1216
1260
  container.innerHTML = limitBanner + msgsHtml + renderWaitingEntry();
1261
+ highlightSelectedMsg();
1217
1262
  if (!msgUserScrolledUp) container.scrollTop = container.scrollHeight;
1218
1263
  // Auto-load more if content doesn't overflow yet
1219
1264
  if (
@@ -1228,6 +1273,8 @@ function renderMessages(messages) {
1228
1273
 
1229
1274
  let currentMsgDetailIdx = null;
1230
1275
  let msgDetailFollowLatest = false;
1276
+ // Message stays tracked but its highlight is dimmed once the detail modal closes.
1277
+ let msgHighlightDimmed = false;
1231
1278
  const MSG_DETAIL_WAITING_IDX = -2;
1232
1279
  let currentPins = [];
1233
1280
  let pinnedCollapsed = false;
@@ -1246,6 +1293,8 @@ const MSG_ICON_TEAMMATE =
1246
1293
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>';
1247
1294
  const MSG_ICON_IDLE =
1248
1295
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6"/></svg>';
1296
+ const ICON_AGENT =
1297
+ '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M20 9h2M20 14h2M2 9h2M2 14h2"/></svg>';
1249
1298
  const ICON_TASK =
1250
1299
  '<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>';
1251
1300
  const ICON_WEB =
@@ -1268,7 +1317,7 @@ const TOOL_ICONS = {
1268
1317
  Edit: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>',
1269
1318
  Glob: '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><circle cx="14" cy="14" r="3"/><line x1="16.5" y1="16.5" x2="19" y2="19"/></svg>',
1270
1319
  Grep: '<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"/></svg>',
1271
- Agent: MSG_ICON_TEAMMATE,
1320
+ Agent: ICON_AGENT,
1272
1321
  SendMessage:
1273
1322
  '<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>',
1274
1323
  TaskCreate: ICON_TASK,
@@ -1354,6 +1403,20 @@ function togglePin(msgIndex) {
1354
1403
  currentPins.splice(idx, 1);
1355
1404
  } else {
1356
1405
  pinnedCollapsed = false;
1406
+ // Strip large server-truncated `*Full` payloads (Write contentFull,
1407
+ // MCP passthrough <k>Full) before stashing in localStorage — a few
1408
+ // pinned big writes can blow past the per-origin quota. On pin
1409
+ // expand, the modal falls back to the truncated string + the lazy
1410
+ // /api/sessions/:id/tool-result/:toolUseId endpoint.
1411
+ let paramsForPin = null;
1412
+ if (m.params) {
1413
+ paramsForPin = {};
1414
+ for (const [k, v] of Object.entries(m.params)) {
1415
+ if (k === 'contentFull') continue;
1416
+ if (k.endsWith('Full') && typeof v === 'string' && typeof m.params[k.slice(0, -4)] === 'string') continue;
1417
+ paramsForPin[k] = v;
1418
+ }
1419
+ }
1357
1420
  currentPins.push({
1358
1421
  id,
1359
1422
  type: m.type,
@@ -1363,6 +1426,9 @@ function togglePin(msgIndex) {
1363
1426
  toolUseId: m.toolUseId || null,
1364
1427
  toolResult: m.toolResult || null,
1365
1428
  toolResultTruncated: m.toolResultTruncated || false,
1429
+ toolResultFull: null,
1430
+ answerPayload: m.answerPayload || null,
1431
+ params: paramsForPin,
1366
1432
  detail: m.detail || null,
1367
1433
  fullDetail: m.fullDetail || null,
1368
1434
  description: m.description || null,
@@ -1588,10 +1654,20 @@ const linkSvg = (size) =>
1588
1654
  //#region MODALS
1589
1655
  function renderUserAttachments(m) {
1590
1656
  const parts = [];
1591
- if (m.images?.length && m.uuid && currentSessionId) {
1657
+ if (m.images?.length && currentSessionId) {
1658
+ const sid = encodeURIComponent(currentSessionId);
1592
1659
  const imgs = m.images
1593
1660
  .map((img) => {
1594
- const url = `/api/sessions/${encodeURIComponent(currentSessionId)}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1661
+ // Cache-kind images live on disk (image-cache/<sessionId>/<n>.png); base64
1662
+ // images are read from the JSONL block and need the message uuid.
1663
+ let url;
1664
+ if (img.kind === 'cache') {
1665
+ url = `/api/sessions/${sid}/cached-image/${img.n}`;
1666
+ } else if (m.uuid) {
1667
+ url = `/api/sessions/${sid}/user-image/${encodeURIComponent(m.uuid)}/${img.blockIndex}`;
1668
+ } else {
1669
+ return '';
1670
+ }
1595
1671
  return `<img src="${url}" loading="lazy" alt="user image" class="user-attach-image" />`;
1596
1672
  })
1597
1673
  .join('');
@@ -1620,10 +1696,22 @@ function renderUserAttachments(m) {
1620
1696
  return parts.join('');
1621
1697
  }
1622
1698
 
1699
+ // Highlight the message whose detail is open, mirroring task-card selection.
1700
+ function highlightSelectedMsg() {
1701
+ const container = document.getElementById('message-panel-content');
1702
+ if (!container) return;
1703
+ for (const el of container.querySelectorAll('.msg-item.selected')) el.classList.remove('selected');
1704
+ if (msgHighlightDimmed || currentMsgDetailIdx == null || currentMsgDetailIdx < 0) return;
1705
+ const el = container.querySelector(`.msg-item[data-msg-idx="${currentMsgDetailIdx}"]`);
1706
+ if (el) el.classList.add('selected');
1707
+ }
1708
+
1623
1709
  function showMsgDetail(idx) {
1624
1710
  currentMsgDetailIdx = idx;
1711
+ msgHighlightDimmed = false;
1625
1712
  const m = currentMessages[idx];
1626
1713
  if (!m) return;
1714
+ highlightSelectedMsg();
1627
1715
  const body = document.getElementById('msg-detail-body');
1628
1716
  if (m.type === 'tool_use') {
1629
1717
  document.getElementById('msg-detail-title').textContent = m.tool;
@@ -1671,7 +1759,9 @@ function showMsgDetail(idx) {
1671
1759
  } else {
1672
1760
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1673
1761
  }
1674
- body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1762
+ const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
1763
+ body.innerHTML =
1764
+ mainHtml + toolParamsHtml + answersHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1675
1765
  } else if (m.type === 'teammate') {
1676
1766
  document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
1677
1767
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
@@ -1725,6 +1815,12 @@ function showMsgDetail(idx) {
1725
1815
  function closeMsgDetailModal() {
1726
1816
  resetModalFullscreen('msg-detail-modal');
1727
1817
  msgDetailFollowLatest = false;
1818
+ // Drop the message highlight on close, mirroring task-card behavior.
1819
+ msgHighlightDimmed = true;
1820
+ const container = document.getElementById('message-panel-content');
1821
+ if (container) {
1822
+ for (const el of container.querySelectorAll('.msg-item.selected')) el.classList.remove('selected');
1823
+ }
1728
1824
  }
1729
1825
 
1730
1826
  function _setModalWidth(modal, slot, on, maxWidth, width) {
@@ -1890,16 +1986,75 @@ function renderTaskResult(toolResult) {
1890
1986
  return `${html}</div>`;
1891
1987
  }
1892
1988
 
1989
+ function renderAnswerPayloadHtml(answerPayload) {
1990
+ if (!answerPayload?.answers || typeof answerPayload.answers !== 'object') return '';
1991
+ const qs = Array.isArray(answerPayload.questions) ? answerPayload.questions : [];
1992
+ const findOptionDesc = (qText, label) => {
1993
+ const q = qs.find((x) => x && x.question === qText);
1994
+ if (!q || !Array.isArray(q.options)) return null;
1995
+ const opt = q.options.find((o) => o && o.label === label);
1996
+ return opt?.description ? opt.description : null;
1997
+ };
1998
+ const rows = Object.entries(answerPayload.answers)
1999
+ .map(([q, a]) => {
2000
+ const ansList = Array.isArray(a) ? a : [a];
2001
+ const items = ansList
2002
+ .map((label) => {
2003
+ const desc = findOptionDesc(q, label);
2004
+ const descHtml = desc ? ` <span style="color:var(--text-muted)">— ${escapeHtml(desc)}</span>` : '';
2005
+ return `<li><span style="font-weight:600">${escapeHtml(String(label))}</span>${descHtml}</li>`;
2006
+ })
2007
+ .join('');
2008
+ return `<div style="margin-top:6px">
2009
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(q)}</div>
2010
+ <ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
2011
+ </div>`;
2012
+ })
2013
+ .join('');
2014
+ return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
2015
+ <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Answers</div>
2016
+ ${rows}
2017
+ </div>`;
2018
+ }
2019
+
1893
2020
  function renderToolParamsHtml(params) {
1894
2021
  if (!params) return '';
1895
- const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
2022
+ const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'contentFull', 'plan']);
1896
2023
  const badges = [],
1897
- blocks = [];
2024
+ blocks = [],
2025
+ jsonBlocks = [];
1898
2026
  for (const [k, v] of Object.entries(params)) {
1899
2027
  if (BLOCK_KEYS.has(k)) continue;
2028
+ // Skip sibling `<k>Full` entries — they're used as expand targets, not
2029
+ // rendered as their own field. Only treat it as a sibling when the
2030
+ // trimmed key holds a server-truncated string (ends with the truncation
2031
+ // marker), otherwise a real param that happens to end in "Full" would
2032
+ // disappear.
2033
+ if (k.endsWith('Full')) {
2034
+ const baseKey = k.slice(0, -4);
2035
+ const base = params[baseKey];
2036
+ if (typeof base === 'string' && base.endsWith('... (truncated)') && typeof v === 'string') {
2037
+ continue;
2038
+ }
2039
+ }
2040
+ if (v !== null && typeof v === 'object') {
2041
+ let pretty;
2042
+ try {
2043
+ pretty = JSON.stringify(v, null, 2);
2044
+ } catch (_) {
2045
+ pretty = String(v);
2046
+ }
2047
+ if (pretty.length > CONTENT_TRUNCATE_MAX) {
2048
+ pretty = `${pretty.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`;
2049
+ }
2050
+ jsonBlocks.push({ k, pretty });
2051
+ continue;
2052
+ }
1900
2053
  const display = typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
1901
2054
  if (display.length > 60) {
1902
- blocks.push({ k, display });
2055
+ const fullKey = `${k}Full`;
2056
+ const full = typeof params[fullKey] === 'string' ? params[fullKey] : null;
2057
+ blocks.push({ k, display, full });
1903
2058
  } else {
1904
2059
  badges.push(
1905
2060
  `<span style="display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:3px;background:var(--bg-secondary);font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> ${escapeHtml(display)}</span>`,
@@ -1908,8 +2063,19 @@ function renderToolParamsHtml(params) {
1908
2063
  }
1909
2064
  let html = '';
1910
2065
  if (badges.length) html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">${badges.join('')}</div>`;
1911
- for (const { k, display } of blocks) {
1912
- html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span></div>`;
2066
+ for (const { k, display, full } of blocks) {
2067
+ let suffix = '';
2068
+ if (full && full.length > display.length) {
2069
+ const toggle = makeExpandToggle(escapeHtml(display), escapeHtml(full), { fontSize: '0.75rem' });
2070
+ suffix = ` ${toggle.btn}${toggle.full}`;
2071
+ }
2072
+ html += `<div style="margin-top:6px;font-size:0.75rem"><span style="color:var(--text-muted)">${escapeHtml(k)}:</span> <span style="word-break:break-all">${escapeHtml(display)}</span>${suffix}</div>`;
2073
+ }
2074
+ for (const { k, pretty } of jsonBlocks) {
2075
+ html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2076
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(k)}</div>
2077
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto;font-size:0.75rem">${escapeHtml(pretty)}</pre>
2078
+ </div>`;
1913
2079
  }
1914
2080
  if (params.old_string || params.new_string) {
1915
2081
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
@@ -1924,14 +2090,18 @@ function renderToolParamsHtml(params) {
1924
2090
  html += `</div>`;
1925
2091
  }
1926
2092
  if (params.content) {
1927
- const contentTruncated = params.content.length > CONTENT_TRUNCATE_MAX;
1928
- const truncContent = contentTruncated
1929
- ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`
2093
+ // params.contentFull is set by the server when the truncated `content`
2094
+ // ends with `... (truncated)`. Fall back to params.content otherwise so
2095
+ // small writes render as before.
2096
+ const fullContent = params.contentFull || params.content;
2097
+ const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
2098
+ const truncContent = isTruncated
2099
+ ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}${params.content.length > CONTENT_TRUNCATE_MAX ? '\n... (truncated)' : ''}`
1930
2100
  : params.content;
1931
2101
  let writeMoreBtn = '',
1932
2102
  fullBlock = '';
1933
- if (contentTruncated) {
1934
- const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
2103
+ if (isTruncated) {
2104
+ const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(fullContent), {
1935
2105
  fontSize: '0.75rem',
1936
2106
  maxHeight: '500px',
1937
2107
  tinted: true,
@@ -2109,6 +2279,16 @@ async function postAndToast(url, body, label) {
2109
2279
  async function openMsgInEditor() {
2110
2280
  const m = getDetailMsg();
2111
2281
  if (!m) return;
2282
+ // Write/Edit tool calls record the source path — open that directly instead
2283
+ // of dumping the rendered modal body into a temp buffer.
2284
+ const filePath =
2285
+ m.type === 'tool_use' && (m.tool === 'Write' || m.tool === 'Edit')
2286
+ ? m.params?.file_path || m.fullDetail || null
2287
+ : null;
2288
+ if (filePath) {
2289
+ postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
2290
+ return;
2291
+ }
2112
2292
  const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
2113
2293
  postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
2114
2294
  }
@@ -2221,7 +2401,8 @@ function renderAgentFooter() {
2221
2401
  : '';
2222
2402
  const agentColor = resolveNamedColor(a.color);
2223
2403
  const colorStyle = agentColor ? ` style="border-left:3px solid ${agentColor.color}"` : '';
2224
- return `<div class="agent-card"${colorStyle} onclick="showAgentModal('${a.agentId}')">
2404
+ const selectedClass = a.agentId === currentAgentModalId ? ' selected' : '';
2405
+ return `<div class="agent-card${selectedClass}" data-agent-id="${escapeHtml(a.agentId)}"${colorStyle} onclick="showAgentModal('${a.agentId}')">
2225
2406
  <div class="agent-type-row">${typeNs ? `<span class="agent-type-ns">${escapeHtml(typeNs)}</span>` : ''}<span class="agent-type-name">${escapeHtml(typeName)}</span>${nameBadgeHtml}</div>
2226
2407
  <div class="agent-status-row"><span class="agent-dot ${a.status}"></span><span class="agent-status">${statusText}</span></div>
2227
2408
  ${msgHtml}
@@ -2313,6 +2494,7 @@ function showAgentModal(agentId) {
2313
2494
  const agent = findAgentById(agentId);
2314
2495
  if (!agent) return;
2315
2496
  currentAgentModalId = agentId;
2497
+ highlightSelectedAgent();
2316
2498
  const modal = document.getElementById('agent-modal');
2317
2499
  const title = document.getElementById('agent-modal-title');
2318
2500
  const body = document.getElementById('agent-modal-body');
@@ -2382,6 +2564,16 @@ function showAgentModal(agentId) {
2382
2564
  function closeAgentModal() {
2383
2565
  resetModalFullscreen('agent-modal');
2384
2566
  currentAgentModalId = null;
2567
+ highlightSelectedAgent();
2568
+ }
2569
+
2570
+ function highlightSelectedAgent() {
2571
+ const content = document.getElementById('agent-footer-content');
2572
+ if (!content) return;
2573
+ for (const el of content.querySelectorAll('.agent-card.selected')) el.classList.remove('selected');
2574
+ if (currentAgentModalId == null) return;
2575
+ const el = content.querySelector(`.agent-card[data-agent-id="${currentAgentModalId}"]`);
2576
+ if (el) el.classList.add('selected');
2385
2577
  }
2386
2578
 
2387
2579
  //#endregion
@@ -2463,7 +2655,6 @@ function renderSessions() {
2463
2655
  // project filter → search filter → ensure pinned/sticky sessions are always included
2464
2656
  let filteredSessions = sessions;
2465
2657
  if (sessionFilter === 'active') {
2466
- const now = Date.now();
2467
2658
  const activeSessionIds = new Set();
2468
2659
  filteredSessions = filteredSessions.filter((s) => {
2469
2660
  if (dismissedSessionIds.has(s.id)) return false;
@@ -2472,8 +2663,7 @@ function renderSessions() {
2472
2663
  ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
2473
2664
  s.hasActiveAgents ||
2474
2665
  s.hasWaitingForUser ||
2475
- s.hasRecentLog ||
2476
- (s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS));
2666
+ s.hasRecentLog);
2477
2667
  if (isActive) activeSessionIds.add(s.id);
2478
2668
  return isActive;
2479
2669
  });
@@ -2613,11 +2803,12 @@ function renderSessions() {
2613
2803
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2614
2804
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
2615
2805
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
2806
+ ${renderGoalSubtitle(session)}
2616
2807
  <div class="session-progress">
2617
2808
  <span class="session-indicators">
2618
2809
  ${isTeam ? `<span class="team-badge" title="${memberCount} team members"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
2619
2810
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2620
- ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2811
+ ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info"><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"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
2621
2812
  ${session.hasPlan && !session.planSourceSessionId ? `<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>` : ''}
2622
2813
  ${renderLoopBadge(session)}
2623
2814
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
@@ -2715,6 +2906,7 @@ function renderSessions() {
2715
2906
  const folderName = projectPath.split(/[/\\]/).pop();
2716
2907
  const isCollapsed = collapsedProjectGroups.has(projectPath);
2717
2908
  const escapedPath = escapeHtml(projectPath);
2909
+ const activeCount = projectSessions.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
2718
2910
  const breadcrumbParts = projectPath
2719
2911
  .replace(/^\/home\/[^/]+/, '~')
2720
2912
  .split(/[/\\]/)
@@ -2727,7 +2919,7 @@ function renderSessions() {
2727
2919
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
2728
2920
  <svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
2729
2921
  <span class="group-name">${escapeHtml(folderName)}</span>
2730
- <span class="group-count">${projectSessions.length}</span>
2922
+ <span class="group-count" title="${activeCount} active / ${projectSessions.length} total">${activeCount > 0 ? `<span class="group-count-active">${activeCount}</span><span class="group-count-sep">/</span>` : ''}${projectSessions.length}</span>
2731
2923
  <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
2732
2924
  <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="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
2733
2925
  </span>
@@ -2741,11 +2933,12 @@ function renderSessions() {
2741
2933
 
2742
2934
  if (ungrouped.length > 0 && sortedGroups.length > 0) {
2743
2935
  const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
2936
+ const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
2744
2937
  html += `
2745
2938
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
2746
2939
  <svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
2747
2940
  <span class="group-name">Ungrouped</span>
2748
- <span class="group-count">${ungrouped.length}</span>
2941
+ <span class="group-count" title="${ungroupedActive} active / ${ungrouped.length} total">${ungroupedActive > 0 ? `<span class="group-count-active">${ungroupedActive}</span><span class="group-count-sep">/</span>` : ''}${ungrouped.length}</span>
2749
2942
  </div>
2750
2943
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
2751
2944
  ${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
@@ -2935,7 +3128,7 @@ function renderKanban() {
2935
3128
  document.querySelector(`.task-card[data-task-id="${selectedTaskId}"][data-session-id="${selectedSessionId}"]`) ||
2936
3129
  document.querySelector(`.task-card[data-task-id="${selectedTaskId}"]`);
2937
3130
  if (card) {
2938
- if (focusZone === 'board') card.classList.add('selected');
3131
+ if (focusZone === 'board' && !taskHighlightDimmed) card.classList.add('selected');
2939
3132
  } else {
2940
3133
  selectedTaskId = null;
2941
3134
  selectedSessionId = null;
@@ -3023,6 +3216,7 @@ function selectTask(taskId, sessionId) {
3023
3216
  if (prev) prev.classList.remove('selected');
3024
3217
  selectedTaskId = taskId;
3025
3218
  selectedSessionId = sessionId;
3219
+ taskHighlightDimmed = false;
3026
3220
  if (!taskId) return;
3027
3221
  const card =
3028
3222
  document.querySelector(`.task-card[data-task-id="${taskId}"][data-session-id="${sessionId}"]`) ||
@@ -3263,6 +3457,8 @@ function setFocusZone(zone) {
3263
3457
  }
3264
3458
  }
3265
3459
  } else {
3460
+ // Focusing the board is an explicit nav gesture — restore the highlight.
3461
+ taskHighlightDimmed = false;
3266
3462
  // Session changed while in sidebar — reset stale selection
3267
3463
  if (selectedSessionId && selectedSessionId !== currentSessionId) {
3268
3464
  selectedTaskId = null;
@@ -3565,6 +3761,10 @@ async function addNote(event, taskId, sessionId) {
3565
3761
  function closeDetailPanel() {
3566
3762
  detailPanel.classList.remove('visible');
3567
3763
  document.getElementById('delete-task-btn').style.display = 'none';
3764
+ // Keep the task selected for keyboard nav, but drop its white highlight.
3765
+ taskHighlightDimmed = true;
3766
+ const sel = document.querySelector('.task-card.selected');
3767
+ if (sel) sel.classList.remove('selected');
3568
3768
  }
3569
3769
 
3570
3770
  let deleteTaskId = null;
@@ -5139,6 +5339,8 @@ function isWaitingFresh() {
5139
5339
  function showWaitingDetail() {
5140
5340
  if (!isWaitingFresh()) return;
5141
5341
  currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
5342
+ msgHighlightDimmed = false;
5343
+ highlightSelectedMsg();
5142
5344
  const tool = currentWaiting.toolName || 'unknown';
5143
5345
  const label = getWaitingLabel(currentWaiting.kind, tool);
5144
5346
  const body = document.getElementById('msg-detail-body');
@@ -5778,9 +5980,9 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5778
5980
  infoRows.push(['Team Config', teamConfig.configPath, { openPath: configDir, openFile: teamConfig.configPath }]);
5779
5981
  }
5780
5982
  const clickableStyle =
5781
- "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;";
5983
+ 'font-family: var(--mono); 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;';
5782
5984
  const plainStyle =
5783
- "font-family: 'IBM Plex Mono', monospace; font-size: 12px; user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;";
5985
+ 'font-family: var(--mono); font-size: 12px; color: var(--text-primary); user-select: all; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
5784
5986
  html += `<div class="team-modal-meta info-grid">`;
5785
5987
  infoRows.forEach(([label, value, opts]) => {
5786
5988
  const copyVal = escapeHtml(value).replace(/"/g, '&quot;');
@@ -5803,6 +6005,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5803
6005
  });
5804
6006
  html += `</div>`;
5805
6007
 
6008
+ if (session.goal?.condition) {
6009
+ html += `<div class="info-goal-card">
6010
+ <div class="info-goal-head"><span class="info-goal-icon">◎</span>Goal</div>
6011
+ <div class="info-goal-text">${escapeHtml(session.goal.condition)}</div>
6012
+ </div>`;
6013
+ }
6014
+
5806
6015
  if (session.contextStatus) {
5807
6016
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
5808
6017
  html += renderContextDetail(session.contextStatus);
@@ -6044,6 +6253,16 @@ function renderLoopModalBody(data) {
6044
6253
  body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
6045
6254
  }
6046
6255
 
6256
+ function renderGoalSubtitle(session) {
6257
+ const g = session.goal;
6258
+ if (!g?.condition) return '';
6259
+ const short = g.condition.length > 70 ? `${g.condition.slice(0, 70)}…` : g.condition;
6260
+ // Only active (unmet) goals reach here — a met goal auto-clears. Clicking
6261
+ // opens the info modal (full text); stopPropagation so it doesn't also
6262
+ // trigger the card's fetchTasks.
6263
+ return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
6264
+ }
6265
+
6047
6266
  function renderLoopBadge(session) {
6048
6267
  const li = session.loopInfo;
6049
6268
  const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
@@ -6181,13 +6400,14 @@ function showToolStatsModal(sessionId) {
6181
6400
  }
6182
6401
 
6183
6402
  function renderToolStatsBody(data) {
6184
- const { totalCalls, uniqueTools, totalFailed, tools } = data;
6403
+ const { totalCalls, uniqueTools, totalFailed, totalRejected, tools } = data;
6185
6404
 
6186
6405
  const summary = `
6187
6406
  <div class="tool-stats-summary">
6188
6407
  <div class="tool-stats-chip"><span class="tool-stats-chip-val">${totalCalls}</span><span class="tool-stats-chip-lbl">Total calls</span></div>
6189
6408
  <div class="tool-stats-chip"><span class="tool-stats-chip-val">${uniqueTools}</span><span class="tool-stats-chip-lbl">Unique tools</span></div>
6190
6409
  <div class="tool-stats-chip"><span class="tool-stats-chip-val${totalFailed > 0 ? ' failed' : ''}">${totalFailed}</span><span class="tool-stats-chip-lbl">Failed</span></div>
6410
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val${totalRejected > 0 ? ' rejected' : ''}">${totalRejected}</span><span class="tool-stats-chip-lbl">Rejected</span></div>
6191
6411
  </div>`;
6192
6412
 
6193
6413
  if (!tools?.length) {
@@ -6212,6 +6432,7 @@ function renderToolStatsBody(data) {
6212
6432
  <th onclick="toolStatsSortBy('count')">Calls${arrow('count')}</th>
6213
6433
  <th onclick="toolStatsSortBy('success')">✓ Success${arrow('success')}</th>
6214
6434
  <th onclick="toolStatsSortBy('failed')">✗ Failed${arrow('failed')}</th>
6435
+ <th onclick="toolStatsSortBy('rejected')">⊘ Rejected${arrow('rejected')}</th>
6215
6436
  <th onclick="toolStatsSortBy('impact')" title="Share of total tool output by character count">Impact${arrow('impact')}</th>
6216
6437
  </tr></thead>
6217
6438
  <tbody>${sorted
@@ -6221,6 +6442,7 @@ function renderToolStatsBody(data) {
6221
6442
  <td>${t.count}</td>
6222
6443
  <td>${t.success > 0 ? `<span class="badge-success">${t.success}</span>` : '—'}</td>
6223
6444
  <td>${t.failed > 0 ? `<span class="badge-failed">${t.failed}</span>` : '—'}</td>
6445
+ <td>${t.rejected > 0 ? `<span class="badge-rejected">${t.rejected}</span>` : '—'}</td>
6224
6446
  <td class="impact-cell">${
6225
6447
  t.impact != null
6226
6448
  ? `<div class="impact-cell-inner"><div class="impact-bar-wrap"><div class="impact-bar-fill" style="width:${t.impact}%"></div></div><span class="impact-pct">${t.impact < 1 ? '<1' : t.impact}%</span></div>`