claude-code-kanban 4.3.0 → 4.5.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
@@ -161,12 +161,16 @@ async function fetchSessions(includeTasks = true) {
161
161
  const allPinnedIds = new Set([...pinnedSessionIds, ...stickySessionIds]);
162
162
  if (revealedPlanSessionId) allPinnedIds.add(revealedPlanSessionId);
163
163
  if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
164
+ // When server filters by activity, the focused session may not be active —
165
+ // include it in pinned so the server still returns it.
166
+ if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
164
167
  const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
165
168
  const projectParam =
166
169
  filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
167
- const sessionsPromise = fetch(`/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}`).then((r) =>
168
- r.json(),
169
- );
170
+ const filterParam = sessionFilter === 'active' ? '&filter=active' : '';
171
+ const sessionsPromise = fetch(
172
+ `/api/sessions?limit=${sessionLimit}${pinnedParam}${projectParam}${filterParam}`,
173
+ ).then((r) => r.json());
170
174
 
171
175
  let newSessions, newTasks;
172
176
  if (includeTasks) {
@@ -494,10 +498,21 @@ function renderActivityChip() {
494
498
  .join('');
495
499
  }
496
500
 
497
- // biome-ignore lint/correctness/noUnusedVariables: used in HTML
498
- function setActivityFilter(kind) {
501
+ function toggleActivityKind(kind) {
499
502
  if (activityFilter.has(kind)) activityFilter.delete(kind);
500
503
  else activityFilter.add(kind);
504
+ }
505
+
506
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
507
+ function setActivityFilter(kind) {
508
+ if (kind === 'active') {
509
+ // waiting is a sub-state of active — couple them so one click covers all running sessions
510
+ toggleActivityKind('active');
511
+ toggleActivityKind('waiting');
512
+ } else {
513
+ toggleActivityKind(kind);
514
+ }
515
+ localStorage.setItem('activityFilter', JSON.stringify([...activityFilter]));
501
516
  // active/waiting only make sense with the active session filter on
502
517
  const targetFilter = activityFilter.size > 0 ? 'active' : sessionFilter;
503
518
  if (targetFilter !== sessionFilter) {
@@ -571,7 +586,6 @@ async function fetchTasks(sessionId) {
571
586
  const WAITING_TTL_MS = 30 * 60 * 1000;
572
587
  const AGENT_LOG_MAX = 8;
573
588
  const LIVE_INDICATOR_MS = 10 * 1000;
574
- const ACTIVE_PLAN_MS = 10 * 60 * 1000;
575
589
  // #endregion
576
590
 
577
591
  function resetAgentState() {
@@ -1133,42 +1147,80 @@ function toggleToolGroup(id) {
1133
1147
  if (el) el.classList.toggle('show');
1134
1148
  }
1135
1149
 
1136
- const WAITING_PLAN_PREVIEW_CHARS = 120;
1137
- const WAITING_PREVIEW_MAX_CHARS = 200;
1138
-
1139
1150
  function getWaitingLabel(kind, tool) {
1140
1151
  if (kind !== 'question') return `Awaiting permission: ${tool}`;
1141
1152
  if (tool === 'ExitPlanMode') return 'Plan awaiting approval';
1142
1153
  return 'Question pending';
1143
1154
  }
1144
1155
 
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
- }
1156
+ function getWaitingPill(kind, tool) {
1157
+ if (kind === 'question' && tool === 'ExitPlanMode') return 'Plan awaiting approval';
1158
+ if (kind === 'question') return 'Question pending';
1159
+ return 'Awaiting permission';
1160
+ }
1161
+
1162
+ function deriveWaitingDetail(tool, params) {
1163
+ if (!params) return '';
1164
+ const trunc = (s) => (s.length > 80 ? `${s.slice(0, 80)}...` : s);
1165
+ if (params.file_path) return params.file_path.replace(/^.*[/\\]/, '');
1166
+ if (params.command) return trunc(params.command);
1167
+ if (params.pattern) return trunc(params.pattern);
1168
+ if (params.query) return trunc(params.query);
1169
+ if (params.url) return trunc(params.url);
1170
+ if (params.skill) {
1171
+ const s = params.skill + (typeof params.args === 'string' ? ` ${params.args}` : '');
1172
+ return trunc(s);
1173
+ }
1174
+ if (tool === 'AskUserQuestion' && params.questions?.[0]?.question) return trunc(params.questions[0].question);
1175
+ if (tool === 'ExitPlanMode' && typeof params.plan === 'string') {
1176
+ const t = params.plan.match(/^#\s+(.+)/m);
1177
+ return trunc(t ? t[1] : params.plan);
1178
+ }
1179
+ if (params.description) return trunc(String(params.description));
1159
1180
  return '';
1160
1181
  }
1161
1182
 
1183
+ function renderWaitingBody(tool, params) {
1184
+ if (!params) return '';
1185
+ if (tool === 'AskUserQuestion' && Array.isArray(params.questions)) {
1186
+ const items = params.questions
1187
+ .map((q) => {
1188
+ const head = `<div style="font-weight:600">${escapeHtml(q.question || '')}</div>`;
1189
+ const opts = Array.isArray(q.options)
1190
+ ? `<ul style="margin:2px 0 0 16px;padding:0">${q.options
1191
+ .map(
1192
+ (o) =>
1193
+ `<li><span style="font-weight:600">${escapeHtml(o.label || '')}</span>${o.description ? ` — <span style="color:var(--text-muted)">${escapeHtml(o.description)}</span>` : ''}</li>`,
1194
+ )
1195
+ .join('')}</ul>`
1196
+ : '';
1197
+ return `<div style="margin-top:6px">${head}${opts}</div>`;
1198
+ })
1199
+ .join('');
1200
+ return items;
1201
+ }
1202
+ return renderToolParamsHtml(params);
1203
+ }
1204
+
1162
1205
  function renderWaitingEntry() {
1163
1206
  if (!isWaitingFresh()) return '';
1164
1207
  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
- : '';
1208
+ let params = null;
1209
+ if (currentWaiting.toolInput) {
1210
+ try {
1211
+ params = JSON.parse(currentWaiting.toolInput);
1212
+ } catch (_) {
1213
+ /* toolInput may be truncated/non-JSON */
1214
+ }
1215
+ }
1216
+ const pillText = getWaitingPill(currentWaiting.kind, tool);
1217
+ const detail = deriveWaitingDetail(tool, params);
1218
+ const detailHtml = detail ? ` <span style="color:var(--text-secondary)">${escapeHtml(detail)}</span>` : '';
1219
+ const bodyHtml = renderWaitingBody(tool, params);
1220
+ const bodyWrap = bodyHtml ? `<div class="msg-waiting-body">${bodyHtml}</div>` : '';
1221
+ const pill = `<span class="msg-waiting-pill">${escapeHtml(pillText)}</span>`;
1170
1222
  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>`;
1223
+ 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>`;
1172
1224
  }
1173
1225
 
1174
1226
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
@@ -1343,6 +1395,20 @@ function togglePin(msgIndex) {
1343
1395
  currentPins.splice(idx, 1);
1344
1396
  } else {
1345
1397
  pinnedCollapsed = false;
1398
+ // Strip large server-truncated `*Full` payloads (Write contentFull,
1399
+ // MCP passthrough <k>Full) before stashing in localStorage — a few
1400
+ // pinned big writes can blow past the per-origin quota. On pin
1401
+ // expand, the modal falls back to the truncated string + the lazy
1402
+ // /api/sessions/:id/tool-result/:toolUseId endpoint.
1403
+ let paramsForPin = null;
1404
+ if (m.params) {
1405
+ paramsForPin = {};
1406
+ for (const [k, v] of Object.entries(m.params)) {
1407
+ if (k === 'contentFull') continue;
1408
+ if (k.endsWith('Full') && typeof v === 'string' && typeof m.params[k.slice(0, -4)] === 'string') continue;
1409
+ paramsForPin[k] = v;
1410
+ }
1411
+ }
1346
1412
  currentPins.push({
1347
1413
  id,
1348
1414
  type: m.type,
@@ -1352,6 +1418,9 @@ function togglePin(msgIndex) {
1352
1418
  toolUseId: m.toolUseId || null,
1353
1419
  toolResult: m.toolResult || null,
1354
1420
  toolResultTruncated: m.toolResultTruncated || false,
1421
+ toolResultFull: null,
1422
+ answerPayload: m.answerPayload || null,
1423
+ params: paramsForPin,
1355
1424
  detail: m.detail || null,
1356
1425
  fullDetail: m.fullDetail || null,
1357
1426
  description: m.description || null,
@@ -1660,7 +1729,9 @@ function showMsgDetail(idx) {
1660
1729
  } else {
1661
1730
  mainHtml = TASK_TOOLS.has(m.tool) ? '' : '<em>No details</em>';
1662
1731
  }
1663
- body.innerHTML = mainHtml + toolParamsHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1732
+ const answersHtml = m.answerPayload ? renderAnswerPayloadHtml(m.answerPayload) : '';
1733
+ body.innerHTML =
1734
+ mainHtml + toolParamsHtml + answersHtml + taskResultHtml + (hasAgentTabs ? '' : toolResultHtml) + agentExtraHtml;
1664
1735
  } else if (m.type === 'teammate') {
1665
1736
  document.getElementById('msg-detail-title').textContent = m.teammateId || 'Teammate';
1666
1737
  document.getElementById('msg-detail-agent-btn').style.display = 'none';
@@ -1879,16 +1950,75 @@ function renderTaskResult(toolResult) {
1879
1950
  return `${html}</div>`;
1880
1951
  }
1881
1952
 
1953
+ function renderAnswerPayloadHtml(answerPayload) {
1954
+ if (!answerPayload?.answers || typeof answerPayload.answers !== 'object') return '';
1955
+ const qs = Array.isArray(answerPayload.questions) ? answerPayload.questions : [];
1956
+ const findOptionDesc = (qText, label) => {
1957
+ const q = qs.find((x) => x && x.question === qText);
1958
+ if (!q || !Array.isArray(q.options)) return null;
1959
+ const opt = q.options.find((o) => o && o.label === label);
1960
+ return opt?.description ? opt.description : null;
1961
+ };
1962
+ const rows = Object.entries(answerPayload.answers)
1963
+ .map(([q, a]) => {
1964
+ const ansList = Array.isArray(a) ? a : [a];
1965
+ const items = ansList
1966
+ .map((label) => {
1967
+ const desc = findOptionDesc(q, label);
1968
+ const descHtml = desc ? ` <span style="color:var(--text-muted)">— ${escapeHtml(desc)}</span>` : '';
1969
+ return `<li><span style="font-weight:600">${escapeHtml(String(label))}</span>${descHtml}</li>`;
1970
+ })
1971
+ .join('');
1972
+ return `<div style="margin-top:6px">
1973
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(q)}</div>
1974
+ <ul style="margin:2px 0 0 16px;padding:0">${items}</ul>
1975
+ </div>`;
1976
+ })
1977
+ .join('');
1978
+ return `<div style="margin-top:10px;padding-top:8px;border-top:1px solid var(--border)">
1979
+ <div style="font-size:0.8rem;color:var(--text-muted);margin-bottom:4px">Answers</div>
1980
+ ${rows}
1981
+ </div>`;
1982
+ }
1983
+
1882
1984
  function renderToolParamsHtml(params) {
1883
1985
  if (!params) return '';
1884
- const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'plan']);
1986
+ const BLOCK_KEYS = new Set(['old_string', 'new_string', 'content', 'contentFull', 'plan']);
1885
1987
  const badges = [],
1886
- blocks = [];
1988
+ blocks = [],
1989
+ jsonBlocks = [];
1887
1990
  for (const [k, v] of Object.entries(params)) {
1888
1991
  if (BLOCK_KEYS.has(k)) continue;
1992
+ // Skip sibling `<k>Full` entries — they're used as expand targets, not
1993
+ // rendered as their own field. Only treat it as a sibling when the
1994
+ // trimmed key holds a server-truncated string (ends with the truncation
1995
+ // marker), otherwise a real param that happens to end in "Full" would
1996
+ // disappear.
1997
+ if (k.endsWith('Full')) {
1998
+ const baseKey = k.slice(0, -4);
1999
+ const base = params[baseKey];
2000
+ if (typeof base === 'string' && base.endsWith('... (truncated)') && typeof v === 'string') {
2001
+ continue;
2002
+ }
2003
+ }
2004
+ if (v !== null && typeof v === 'object') {
2005
+ let pretty;
2006
+ try {
2007
+ pretty = JSON.stringify(v, null, 2);
2008
+ } catch (_) {
2009
+ pretty = String(v);
2010
+ }
2011
+ if (pretty.length > CONTENT_TRUNCATE_MAX) {
2012
+ pretty = `${pretty.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`;
2013
+ }
2014
+ jsonBlocks.push({ k, pretty });
2015
+ continue;
2016
+ }
1889
2017
  const display = typeof v === 'boolean' ? (v ? 'yes' : 'no') : String(v);
1890
2018
  if (display.length > 60) {
1891
- blocks.push({ k, display });
2019
+ const fullKey = `${k}Full`;
2020
+ const full = typeof params[fullKey] === 'string' ? params[fullKey] : null;
2021
+ blocks.push({ k, display, full });
1892
2022
  } else {
1893
2023
  badges.push(
1894
2024
  `<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>`,
@@ -1897,8 +2027,19 @@ function renderToolParamsHtml(params) {
1897
2027
  }
1898
2028
  let html = '';
1899
2029
  if (badges.length) html += `<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px">${badges.join('')}</div>`;
1900
- for (const { k, display } of blocks) {
1901
- 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>`;
2030
+ for (const { k, display, full } of blocks) {
2031
+ let suffix = '';
2032
+ if (full && full.length > display.length) {
2033
+ const toggle = makeExpandToggle(escapeHtml(display), escapeHtml(full), { fontSize: '0.75rem' });
2034
+ suffix = ` ${toggle.btn}${toggle.full}`;
2035
+ }
2036
+ 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>`;
2037
+ }
2038
+ for (const { k, pretty } of jsonBlocks) {
2039
+ html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">
2040
+ <div style="font-size:0.75rem;color:var(--text-muted);margin-bottom:2px">${escapeHtml(k)}</div>
2041
+ <pre class="${TINTED_PRE_CLASS}" style="max-height:300px;overflow:auto;font-size:0.75rem">${escapeHtml(pretty)}</pre>
2042
+ </div>`;
1902
2043
  }
1903
2044
  if (params.old_string || params.new_string) {
1904
2045
  html += `<div style="margin-top:8px;padding-top:6px;border-top:1px solid var(--border)">`;
@@ -1913,14 +2054,18 @@ function renderToolParamsHtml(params) {
1913
2054
  html += `</div>`;
1914
2055
  }
1915
2056
  if (params.content) {
1916
- const contentTruncated = params.content.length > CONTENT_TRUNCATE_MAX;
1917
- const truncContent = contentTruncated
1918
- ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}\n... (truncated)`
2057
+ // params.contentFull is set by the server when the truncated `content`
2058
+ // ends with `... (truncated)`. Fall back to params.content otherwise so
2059
+ // small writes render as before.
2060
+ const fullContent = params.contentFull || params.content;
2061
+ const isTruncated = !!params.contentFull || params.content.length > CONTENT_TRUNCATE_MAX;
2062
+ const truncContent = isTruncated
2063
+ ? `${params.content.slice(0, CONTENT_TRUNCATE_MAX)}${params.content.length > CONTENT_TRUNCATE_MAX ? '\n... (truncated)' : ''}`
1919
2064
  : params.content;
1920
2065
  let writeMoreBtn = '',
1921
2066
  fullBlock = '';
1922
- if (contentTruncated) {
1923
- const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(params.content), {
2067
+ if (isTruncated) {
2068
+ const toggle = makeExpandToggle(escapeHtml(truncContent), escapeHtml(fullContent), {
1924
2069
  fontSize: '0.75rem',
1925
2070
  maxHeight: '500px',
1926
2071
  tinted: true,
@@ -2098,6 +2243,16 @@ async function postAndToast(url, body, label) {
2098
2243
  async function openMsgInEditor() {
2099
2244
  const m = getDetailMsg();
2100
2245
  if (!m) return;
2246
+ // Write/Edit tool calls record the source path — open that directly instead
2247
+ // of dumping the rendered modal body into a temp buffer.
2248
+ const filePath =
2249
+ m.type === 'tool_use' && (m.tool === 'Write' || m.tool === 'Edit')
2250
+ ? m.params?.file_path || m.fullDetail || null
2251
+ : null;
2252
+ if (filePath) {
2253
+ postAndToast('/api/open-in-editor', { file: filePath }, 'in editor');
2254
+ return;
2255
+ }
2101
2256
  const title = m.type === 'tool_use' ? m.tool : m.compactSummary ? 'compact-summary' : m.type;
2102
2257
  postAndToast('/api/open-in-editor', { content: getMessageDisplayContent(m), title }, 'in editor');
2103
2258
  }
@@ -2452,7 +2607,6 @@ function renderSessions() {
2452
2607
  // project filter → search filter → ensure pinned/sticky sessions are always included
2453
2608
  let filteredSessions = sessions;
2454
2609
  if (sessionFilter === 'active') {
2455
- const now = Date.now();
2456
2610
  const activeSessionIds = new Set();
2457
2611
  filteredSessions = filteredSessions.filter((s) => {
2458
2612
  if (dismissedSessionIds.has(s.id)) return false;
@@ -2461,8 +2615,7 @@ function renderSessions() {
2461
2615
  ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
2462
2616
  s.hasActiveAgents ||
2463
2617
  s.hasWaitingForUser ||
2464
- s.hasRecentLog ||
2465
- (s.hasPlan && !s.planImplementationSessionId && now - new Date(s.modifiedAt).getTime() <= ACTIVE_PLAN_MS));
2618
+ s.hasRecentLog);
2466
2619
  if (isActive) activeSessionIds.add(s.id);
2467
2620
  return isActive;
2468
2621
  });
@@ -2602,12 +2755,13 @@ function renderSessions() {
2602
2755
  ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
2603
2756
  ${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
2604
2757
  ${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
2758
+ ${renderGoalSubtitle(session)}
2605
2759
  <div class="session-progress">
2606
2760
  <span class="session-indicators">
2607
2761
  ${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>` : ''}
2608
2762
  ${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
2609
2763
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
2610
- ${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>` : ''}
2764
+ ${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>` : ''}
2611
2765
  ${renderLoopBadge(session)}
2612
2766
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2613
2767
  ${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>` : ''}
@@ -2704,6 +2858,7 @@ function renderSessions() {
2704
2858
  const folderName = projectPath.split(/[/\\]/).pop();
2705
2859
  const isCollapsed = collapsedProjectGroups.has(projectPath);
2706
2860
  const escapedPath = escapeHtml(projectPath);
2861
+ const activeCount = projectSessions.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
2707
2862
  const breadcrumbParts = projectPath
2708
2863
  .replace(/^\/home\/[^/]+/, '~')
2709
2864
  .split(/[/\\]/)
@@ -2716,7 +2871,7 @@ function renderSessions() {
2716
2871
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="${escapedPath}">
2717
2872
  <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>
2718
2873
  <span class="group-name">${escapeHtml(folderName)}</span>
2719
- <span class="group-count">${projectSessions.length}</span>
2874
+ <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>
2720
2875
  <span class="project-view-btn" data-project-path="${escapedPath}" title="Open project view — combined tasks from all sessions">
2721
2876
  <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>
2722
2877
  </span>
@@ -2730,11 +2885,12 @@ function renderSessions() {
2730
2885
 
2731
2886
  if (ungrouped.length > 0 && sortedGroups.length > 0) {
2732
2887
  const isCollapsed = collapsedProjectGroups.has('__ungrouped__');
2888
+ const ungroupedActive = ungrouped.reduce((n, s) => n + (isSessionActive(s) ? 1 : 0), 0);
2733
2889
  html += `
2734
2890
  <div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__ungrouped__">
2735
2891
  <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>
2736
2892
  <span class="group-name">Ungrouped</span>
2737
- <span class="group-count">${ungrouped.length}</span>
2893
+ <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>
2738
2894
  </div>
2739
2895
  <div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
2740
2896
  ${renderGroupSessions(ungrouped, '__pinned___ungrouped__')}
@@ -4256,13 +4412,14 @@ function matchKey(e, ...keys) {
4256
4412
  return keys.some((k) => e.key === k || e.code === k);
4257
4413
  }
4258
4414
 
4259
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
4415
+ const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'tool-stats-modal', 'plan-modal', 'loop-modal'];
4260
4416
  const MODAL_CLOSERS = {
4261
4417
  'preview-modal': () => closePreviewModal(),
4262
4418
  'msg-detail-modal': () => {
4263
4419
  closeMsgDetailModal();
4264
4420
  msgDetailFollowLatest = false;
4265
4421
  },
4422
+ 'tool-stats-modal': () => closeToolStatsModal(),
4266
4423
  'plan-modal': () => closePlanModal(),
4267
4424
  'loop-modal': () => closeLoopModal(),
4268
4425
  'team-modal': () => closeTeamModal(),
@@ -5791,6 +5948,13 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5791
5948
  });
5792
5949
  html += `</div>`;
5793
5950
 
5951
+ if (session.goal?.condition) {
5952
+ html += `<div class="info-goal-card">
5953
+ <div class="info-goal-head"><span class="info-goal-icon">◎</span>Goal</div>
5954
+ <div class="info-goal-text">${escapeHtml(session.goal.condition)}</div>
5955
+ </div>`;
5956
+ }
5957
+
5794
5958
  if (session.contextStatus) {
5795
5959
  html += `<hr style="border: none; border-top: 1px solid var(--border); margin: 12px 0;">`;
5796
5960
  html += renderContextDetail(session.contextStatus);
@@ -5885,6 +6049,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
5885
6049
  const keyHandler = (e) => {
5886
6050
  if (e.key === 'Escape') {
5887
6051
  if (document.getElementById('plan-modal').classList.contains('visible')) return;
6052
+ if (document.getElementById('tool-stats-modal').classList.contains('visible')) return;
5888
6053
  e.preventDefault();
5889
6054
  closeTeamModal();
5890
6055
  document.removeEventListener('keydown', keyHandler);
@@ -6031,6 +6196,16 @@ function renderLoopModalBody(data) {
6031
6196
  body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
6032
6197
  }
6033
6198
 
6199
+ function renderGoalSubtitle(session) {
6200
+ const g = session.goal;
6201
+ if (!g?.condition) return '';
6202
+ const short = g.condition.length > 70 ? `${g.condition.slice(0, 70)}…` : g.condition;
6203
+ // Only active (unmet) goals reach here — a met goal auto-clears. Clicking
6204
+ // opens the info modal (full text); stopPropagation so it doesn't also
6205
+ // trigger the card's fetchTasks.
6206
+ return `<div class="session-goal" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${escapeHtml(g.condition)}"><span class="session-goal-icon">◎</span>${escapeHtml(short)}</div>`;
6207
+ }
6208
+
6034
6209
  function renderLoopBadge(session) {
6035
6210
  const li = session.loopInfo;
6036
6211
  const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
@@ -6140,6 +6315,108 @@ function openMemoryForInfoModal() {
6140
6315
 
6141
6316
  //#endregion
6142
6317
 
6318
+ //#region TOOL_STATS_MODAL
6319
+ let _toolStatsSortCol = 'count';
6320
+ let _toolStatsSortDir = 'desc';
6321
+ let _toolStatsData = null;
6322
+
6323
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6324
+ function showToolStatsModal(sessionId) {
6325
+ if (!sessionId) return;
6326
+ const body = document.getElementById('tool-stats-modal-body');
6327
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6328
+ document.getElementById('tool-stats-modal').classList.add('visible');
6329
+
6330
+ fetch(`/api/sessions/${sessionId}/tool-stats`)
6331
+ .then((r) => (r.ok ? r.json() : null))
6332
+ .catch(() => null)
6333
+ .then((data) => {
6334
+ if (!data) {
6335
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load tool statistics.</div>';
6336
+ return;
6337
+ }
6338
+ _toolStatsSortCol = 'count';
6339
+ _toolStatsSortDir = 'desc';
6340
+ _toolStatsData = data;
6341
+ body.innerHTML = renderToolStatsBody(data);
6342
+ });
6343
+ }
6344
+
6345
+ function renderToolStatsBody(data) {
6346
+ const { totalCalls, uniqueTools, totalFailed, totalRejected, tools } = data;
6347
+
6348
+ const summary = `
6349
+ <div class="tool-stats-summary">
6350
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val">${totalCalls}</span><span class="tool-stats-chip-lbl">Total calls</span></div>
6351
+ <div class="tool-stats-chip"><span class="tool-stats-chip-val">${uniqueTools}</span><span class="tool-stats-chip-lbl">Unique tools</span></div>
6352
+ <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>
6353
+ <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>
6354
+ </div>`;
6355
+
6356
+ if (!tools?.length) {
6357
+ return (
6358
+ summary +
6359
+ '<div style="padding:24px;text-align:center;color:var(--text-tertiary);">No tool calls recorded in this session.</div>'
6360
+ );
6361
+ }
6362
+
6363
+ const sorted = [...tools].sort((a, b) => {
6364
+ if (_toolStatsSortCol === 'name')
6365
+ return _toolStatsSortDir === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
6366
+ return _toolStatsSortDir === 'asc'
6367
+ ? a[_toolStatsSortCol] - b[_toolStatsSortCol]
6368
+ : b[_toolStatsSortCol] - a[_toolStatsSortCol];
6369
+ });
6370
+ const arrow = (col) => (col === _toolStatsSortCol ? (_toolStatsSortDir === 'asc' ? ' ▲' : ' ▼') : '');
6371
+
6372
+ const table = `<table class="tool-stats-table">
6373
+ <thead><tr>
6374
+ <th onclick="toolStatsSortBy('name')">Tool${arrow('name')}</th>
6375
+ <th onclick="toolStatsSortBy('count')">Calls${arrow('count')}</th>
6376
+ <th onclick="toolStatsSortBy('success')">✓ Success${arrow('success')}</th>
6377
+ <th onclick="toolStatsSortBy('failed')">✗ Failed${arrow('failed')}</th>
6378
+ <th onclick="toolStatsSortBy('rejected')">⊘ Rejected${arrow('rejected')}</th>
6379
+ <th onclick="toolStatsSortBy('impact')" title="Share of total tool output by character count">Impact${arrow('impact')}</th>
6380
+ </tr></thead>
6381
+ <tbody>${sorted
6382
+ .map(
6383
+ (t) => `<tr>
6384
+ <td class="tool-name">${escapeHtml(t.name)}</td>
6385
+ <td>${t.count}</td>
6386
+ <td>${t.success > 0 ? `<span class="badge-success">${t.success}</span>` : '—'}</td>
6387
+ <td>${t.failed > 0 ? `<span class="badge-failed">${t.failed}</span>` : '—'}</td>
6388
+ <td>${t.rejected > 0 ? `<span class="badge-rejected">${t.rejected}</span>` : '—'}</td>
6389
+ <td class="impact-cell">${
6390
+ t.impact != null
6391
+ ? `<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>`
6392
+ : '—'
6393
+ }</td>
6394
+ </tr>`,
6395
+ )
6396
+ .join('')}</tbody>
6397
+ </table>`;
6398
+
6399
+ return summary + table;
6400
+ }
6401
+
6402
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6403
+ function toolStatsSortBy(col) {
6404
+ if (_toolStatsSortCol === col) {
6405
+ _toolStatsSortDir = _toolStatsSortDir === 'asc' ? 'desc' : 'asc';
6406
+ } else {
6407
+ _toolStatsSortCol = col;
6408
+ _toolStatsSortDir = col === 'name' ? 'asc' : 'desc';
6409
+ }
6410
+ if (!_toolStatsData) return;
6411
+ const body = document.getElementById('tool-stats-modal-body');
6412
+ body.innerHTML = renderToolStatsBody(_toolStatsData);
6413
+ }
6414
+
6415
+ function closeToolStatsModal() {
6416
+ document.getElementById('tool-stats-modal').classList.remove('visible');
6417
+ }
6418
+ //#endregion
6419
+
6143
6420
  //#region OWNER_FILTER
6144
6421
  function updateOwnerFilter() {
6145
6422
  const bar = document.getElementById('owner-filter-bar');
@@ -6239,6 +6516,11 @@ try {
6239
6516
  // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
6240
6517
  cg.forEach((p) => collapsedProjectGroups.add(p));
6241
6518
  } catch (_) {}
6519
+ try {
6520
+ const af = JSON.parse(localStorage.getItem('activityFilter') || '[]');
6521
+ // biome-ignore lint/suspicious/useIterableCallbackReturn: forEach side-effect
6522
+ af.forEach((k) => activityFilter.add(k));
6523
+ } catch (_) {}
6242
6524
  initSidebarResize();
6243
6525
  loadPanelWidths();
6244
6526
  initPanelResize('detail-panel', 'detail-panel-resize', '--detail-panel-width', 'detail-panel-width');
package/public/index.html CHANGED
@@ -543,11 +543,30 @@
543
543
  <div id="team-modal-body" class="modal-body"></div>
544
544
  <div class="modal-footer">
545
545
  <button id="session-info-dismiss-btn" class="btn btn-secondary" onclick="toggleDismissSession(_infoModalSessionId); closeTeamModal()">Dismiss</button>
546
+ <button class="btn btn-secondary" onclick="showToolStatsModal(_infoModalSessionId)" title="Tool invocation statistics">Tool Stats</button>
546
547
  <button class="btn btn-primary" onclick="closeTeamModal()">Close</button>
547
548
  </div>
548
549
  </div>
549
550
  </div>
550
551
 
552
+ <!-- Tool Stats Modal (stacked on top of info modal) -->
553
+ <div id="tool-stats-modal" class="modal-overlay plan-modal-overlay" onclick="closeToolStatsModal()">
554
+ <div class="modal tool-stats-modal" onclick="event.stopPropagation()">
555
+ <div class="modal-header">
556
+ <h3 id="tool-stats-modal-title" class="modal-title">Tool Statistics</h3>
557
+ <button class="modal-close" aria-label="Close dialog" onclick="closeToolStatsModal()">
558
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
559
+ <path d="M18 6L6 18M6 6l12 12"/>
560
+ </svg>
561
+ </button>
562
+ </div>
563
+ <div id="tool-stats-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
564
+ <div class="modal-footer">
565
+ <button class="btn btn-primary" onclick="closeToolStatsModal()">Close</button>
566
+ </div>
567
+ </div>
568
+ </div>
569
+
551
570
  <!-- Plan Modal (stacked on top of info modal) -->
552
571
  <div id="plan-modal" class="modal-overlay plan-modal-overlay" onclick="closePlanModal()">
553
572
  <div class="modal plan-modal" onclick="event.stopPropagation()">