claude-code-kanban 4.13.0 → 4.14.1

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/lib/parsers.js CHANGED
@@ -1291,33 +1291,31 @@ function findTerminatedTeammates(jsonlPath) {
1291
1291
 
1292
1292
  function extractPromptFromTranscript(jsonlPath) {
1293
1293
  const { openSync, readSync, closeSync } = fs;
1294
- const MAX_READ = 65536;
1295
- const CHUNK = 4096;
1294
+ // statSync (outside try) throws on a missing path — callers rely on that.
1295
+ const stat = statSync(jsonlPath);
1296
+ // Workflow-spawned subagent prompts embed full task context and routinely
1297
+ // exceed 64 KB, so read the whole first line up to a generous cap. One buffered
1298
+ // read (vs. per-chunk decode) also avoids corrupting multi-byte chars at chunk
1299
+ // boundaries for large prompts.
1300
+ const readSize = Math.min(2097152, stat.size);
1296
1301
  const fd = openSync(jsonlPath, 'r');
1297
1302
  try {
1298
- let accumulated = '';
1299
- const buf = Buffer.alloc(CHUNK);
1300
- while (accumulated.length < MAX_READ) {
1301
- const bytesRead = readSync(fd, buf, 0, CHUNK, null);
1302
- if (bytesRead === 0) break;
1303
- accumulated += buf.toString('utf8', 0, bytesRead);
1304
- const nlIdx = accumulated.indexOf('\n');
1305
- if (nlIdx === -1) continue;
1306
- const firstLine = accumulated.slice(0, nlIdx);
1307
- try {
1308
- const obj = JSON.parse(firstLine);
1309
- if (obj.type === 'user') {
1310
- const content = obj.message?.content;
1311
- if (typeof content === 'string') return content;
1312
- if (Array.isArray(content)) {
1313
- for (const b of content) {
1314
- if (b.type === 'text' && b.text) return b.text;
1315
- }
1316
- }
1303
+ const buf = Buffer.alloc(readSize);
1304
+ readSync(fd, buf, 0, readSize, 0);
1305
+ // Decode only the first line, not the whole (possibly large) read window.
1306
+ const nlByte = buf.indexOf(0x0a);
1307
+ const firstLine = (nlByte === -1 ? buf : buf.subarray(0, nlByte)).toString('utf8');
1308
+ const obj = JSON.parse(firstLine);
1309
+ if (obj.type === 'user') {
1310
+ const content = obj.message?.content;
1311
+ if (typeof content === 'string') return content;
1312
+ if (Array.isArray(content)) {
1313
+ for (const b of content) {
1314
+ if (b.type === 'text' && b.text) return b.text;
1317
1315
  }
1318
- } catch (_) {}
1319
- break;
1316
+ }
1320
1317
  }
1318
+ } catch (_) {
1321
1319
  } finally {
1322
1320
  closeSync(fd);
1323
1321
  }
@@ -1354,6 +1352,82 @@ function extractModelFromTranscript(jsonlPath) {
1354
1352
  return null;
1355
1353
  }
1356
1354
 
1355
+ // Full-file scan of a subagent transcript for roster stats: model, summed
1356
+ // output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
1357
+ // used by the workflow run view on demand, never from the session-scan hot
1358
+ // path (a full read per agent would blow the /api/sessions budget).
1359
+ function extractTranscriptStats(jsonlPath) {
1360
+ let content;
1361
+ try { content = readFileSync(jsonlPath, 'utf8'); } catch (_) { return null; }
1362
+ let model = null;
1363
+ let outputTokens = 0;
1364
+ let firstTs = null;
1365
+ let lastTs = null;
1366
+ for (const line of content.split('\n')) {
1367
+ if (!line.trim()) continue;
1368
+ let obj;
1369
+ try { obj = JSON.parse(line); } catch (_) { continue; }
1370
+ if (obj.timestamp) {
1371
+ if (!firstTs) firstTs = obj.timestamp;
1372
+ lastTs = obj.timestamp;
1373
+ }
1374
+ const msg = obj.message;
1375
+ if (msg && typeof msg === 'object') {
1376
+ if (!model && msg.model) model = msg.model;
1377
+ const out = msg.usage?.output_tokens;
1378
+ if (typeof out === 'number') outputTokens += out;
1379
+ }
1380
+ }
1381
+ return { model, outputTokens, firstTs, lastTs };
1382
+ }
1383
+
1384
+ // Render a StructuredOutput tool input (arbitrary schema object) as markdown:
1385
+ // one section per field, prose kept verbatim, structured values as JSON blocks.
1386
+ function formatStructuredResult(input) {
1387
+ const parts = [];
1388
+ for (const [key, value] of Object.entries(input)) {
1389
+ if (value == null || value === '') continue;
1390
+ parts.push('### ' + key);
1391
+ parts.push(typeof value === 'string' ? value : '```json\n' + JSON.stringify(value, null, 2) + '\n```');
1392
+ }
1393
+ return parts.length ? parts.join('\n\n') : null;
1394
+ }
1395
+
1396
+ // Workflow-spawned subagents given a schema end their run on a forced
1397
+ // StructuredOutput tool call and never emit a text response — so their
1398
+ // lastMessage is empty. Tail-read the transcript, take the LAST StructuredOutput
1399
+ // tool_use, and format its input as the agent's result. Returns null when absent.
1400
+ function extractStructuredResultFromTranscript(jsonlPath) {
1401
+ const { openSync, readSync, closeSync } = fs;
1402
+ const stat = statSync(jsonlPath);
1403
+ const readSize = Math.min(1048576, stat.size);
1404
+ const start = stat.size - readSize;
1405
+ const fd = openSync(jsonlPath, 'r');
1406
+ try {
1407
+ const buf = Buffer.alloc(readSize);
1408
+ readSync(fd, buf, 0, readSize, start);
1409
+ const text = buf.toString('utf8');
1410
+ // Drop the leading partial line unless we read from the very start.
1411
+ const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
1412
+ if (!clean.includes('"StructuredOutput"')) return null;
1413
+ // Scan from the end for the last StructuredOutput tool call.
1414
+ const lines = clean.split('\n');
1415
+ for (let i = lines.length - 1; i >= 0; i--) {
1416
+ if (!lines[i].includes('"StructuredOutput"')) continue;
1417
+ let obj;
1418
+ try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
1419
+ const content = obj?.message?.content;
1420
+ if (!Array.isArray(content)) continue;
1421
+ const block = content.find(b => b?.type === 'tool_use' && b.name === 'StructuredOutput' && b.input && typeof b.input === 'object');
1422
+ if (block) return formatStructuredResult(block.input);
1423
+ }
1424
+ } catch (_) {
1425
+ } finally {
1426
+ closeSync(fd);
1427
+ }
1428
+ return null;
1429
+ }
1430
+
1357
1431
  // Incremental loop-tool scanner. JSONL is append-only, so we keep per-path
1358
1432
  // state and on each call read ONLY the bytes appended since scannedOffset.
1359
1433
  // Avoids the only full-file read that ran inside the /api/sessions hot path.
@@ -1492,5 +1566,7 @@ module.exports = {
1492
1566
  readCompactSummaries,
1493
1567
  findTerminatedTeammates,
1494
1568
  extractPromptFromTranscript,
1495
- extractModelFromTranscript
1569
+ extractModelFromTranscript,
1570
+ extractStructuredResultFromTranscript,
1571
+ extractTranscriptStats
1496
1572
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "4.13.0",
3
+ "version": "4.14.1",
4
4
  "description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -2634,7 +2634,7 @@ function showAgentModal(agentId) {
2634
2634
  const modalNameLabel = agent.agentName ? ` · ${escapeHtml(agent.agentName)}` : '';
2635
2635
  title.innerHTML = `${statusDot} ${escapeHtml(agent.type || 'unknown')}${modalNameLabel}`;
2636
2636
 
2637
- const shortModel = agent.model ? agent.model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : null;
2637
+ const shortModel = shortModelName(agent.model);
2638
2638
  const shortId = agent.agentId ? agent.agentId.slice(0, 8) : '';
2639
2639
  const chip = (label, value, opts = {}) => {
2640
2640
  const cls = opts.cls ? ` ${opts.cls}` : '';
@@ -2648,7 +2648,7 @@ function showAgentModal(agentId) {
2648
2648
  // toolUseResult), not on the agent-activity record — so read them from there.
2649
2649
  const agentMsg = currentMessages.find((m) => m.tool === 'Agent' && m.agentId === agentId);
2650
2650
  const usageStats = agentMsg?.agentUsageStats || null;
2651
- const fmtTok = (t) => (t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`);
2651
+ const fmtTok = fmtTokens;
2652
2652
 
2653
2653
  const chips = [];
2654
2654
  if (agent.agentId) chips.push(chip('id', escapeHtml(shortId), { cls: 'agent-chip-mono', title: agent.agentId }));
@@ -2796,7 +2796,7 @@ function renderSessions() {
2796
2796
  ((!s.sharedTaskList && (s.pending > 0 || s.inProgress > 0)) ||
2797
2797
  s.hasActiveAgents ||
2798
2798
  s.hasWaitingForUser ||
2799
- s.hasRecentLog);
2799
+ s.hasRecentActivity);
2800
2800
  if (isActive) activeSessionIds.add(s.id);
2801
2801
  return isActive;
2802
2802
  });
@@ -2944,6 +2944,7 @@ function renderSessions() {
2944
2944
  ${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>` : ''}
2945
2945
  ${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>` : ''}
2946
2946
  ${renderLoopBadge(session)}
2947
+ ${renderWorkflowBadge(session)}
2947
2948
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
2948
2949
  ${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>` : ''}
2949
2950
  ${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>` : ''}
@@ -4589,7 +4590,14 @@ function matchKey(e, ...keys) {
4589
4590
  return keys.some((k) => e.key === k || e.code === k);
4590
4591
  }
4591
4592
 
4592
- const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'tool-stats-modal', 'plan-modal', 'loop-modal'];
4593
+ const MODAL_ESC_PRIORITY = [
4594
+ 'preview-modal',
4595
+ 'msg-detail-modal',
4596
+ 'tool-stats-modal',
4597
+ 'plan-modal',
4598
+ 'loop-modal',
4599
+ 'workflow-modal',
4600
+ ];
4593
4601
  const MODAL_CLOSERS = {
4594
4602
  'preview-modal': () => closePreviewModal(),
4595
4603
  'msg-detail-modal': () => {
@@ -4599,6 +4607,7 @@ const MODAL_CLOSERS = {
4599
4607
  'tool-stats-modal': () => closeToolStatsModal(),
4600
4608
  'plan-modal': () => closePlanModal(),
4601
4609
  'loop-modal': () => closeLoopModal(),
4610
+ 'workflow-modal': () => closeWorkflowModal(),
4602
4611
  'team-modal': () => closeTeamModal(),
4603
4612
  'agent-modal': () => closeAgentModal(),
4604
4613
  'help-modal': () => closeHelpModal(),
@@ -5698,8 +5707,12 @@ function filterBySessions(value) {
5698
5707
  sessionFilter = value;
5699
5708
  if (value !== 'active') activityFilter.clear();
5700
5709
  updateUrl();
5710
+ // Instant feedback from cached data, then refetch — the cached list was fetched
5711
+ // with the previous filter's server params (e.g. filter=active), so "All Sessions"
5712
+ // needs a server round-trip to actually include inactive sessions.
5701
5713
  renderSessions();
5702
5714
  renderActivityChip();
5715
+ fetchSessions(false);
5703
5716
  }
5704
5717
 
5705
5718
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML
@@ -6477,6 +6490,208 @@ function closeLoopModal() {
6477
6490
  document.getElementById('loop-modal').classList.remove('visible');
6478
6491
  }
6479
6492
 
6493
+ const WORKFLOW_GEAR_SVG =
6494
+ '<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="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
6495
+
6496
+ // Standard "open in editor" glyph — matches the message-detail / plan modals.
6497
+ const ICON_OPEN_EDITOR =
6498
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><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>';
6499
+
6500
+ function renderWorkflowBadge(session) {
6501
+ if (!session.hasWorkflow) return '';
6502
+ const n = session.workflowCount || 0;
6503
+ const tip = `${n} workflow${n === 1 ? '' : 's'}`;
6504
+ return `<span class="workflow-badge" onclick="event.stopPropagation(); showWorkflowModal('${session.id}')" title="${escapeHtml(tip)}">${WORKFLOW_GEAR_SVG}</span>`;
6505
+ }
6506
+
6507
+ let _workflowSessionId = null;
6508
+ let _workflowHeaderId = null;
6509
+ let _workflowList = [];
6510
+
6511
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6512
+ function showWorkflowModal(sessionId) {
6513
+ _workflowSessionId = sessionId;
6514
+ setWorkflowHeader('Workflows', null);
6515
+ const body = document.getElementById('workflow-modal-body');
6516
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6517
+ document.getElementById('workflow-modal').classList.add('visible');
6518
+ fetch(`/api/sessions/${sessionId}/workflows`)
6519
+ .then((r) => (r.ok ? r.json() : { workflows: [] }))
6520
+ .catch(() => ({ workflows: [] }))
6521
+ .then((data) => {
6522
+ _workflowList = data.workflows || [];
6523
+ if (!_workflowList.length) {
6524
+ setWorkflowHeader('Workflows', null);
6525
+ body.innerHTML =
6526
+ '<div style="padding:24px;text-align:center;color:var(--text-secondary);">No workflow scripts for this session.</div>';
6527
+ } else if (_workflowList.length === 1) {
6528
+ showWorkflowRun(_workflowList[0].id);
6529
+ } else {
6530
+ renderWorkflowPicker(_workflowList);
6531
+ }
6532
+ });
6533
+ }
6534
+
6535
+ function setWorkflowHeader(name, wfId) {
6536
+ const titleText = document.getElementById('workflow-modal-title-text');
6537
+ const headerBtn = document.getElementById('workflow-modal-open-btn');
6538
+ _workflowHeaderId = wfId || null;
6539
+ if (titleText) {
6540
+ titleText.textContent = name;
6541
+ titleText.title = wfId ? name : '';
6542
+ }
6543
+ if (headerBtn) headerBtn.style.display = wfId ? '' : 'none';
6544
+ }
6545
+
6546
+ // Multiple workflows: pick one to drill into (we show a single run at a time).
6547
+ function renderWorkflowPicker(workflows) {
6548
+ setWorkflowHeader('Workflows', null);
6549
+ const body = document.getElementById('workflow-modal-body');
6550
+ body.innerHTML = workflows
6551
+ .map((w) => {
6552
+ const when = w.modifiedAt ? formatDate(w.modifiedAt) : '';
6553
+ return `<div class="workflow-row" onclick="showWorkflowRun('${escapeHtml(w.id)}')">
6554
+ <div class="workflow-row-head">
6555
+ <span class="workflow-row-name">${escapeHtml(w.name || w.id)}</span>
6556
+ <span class="workflow-row-id"><code>${escapeHtml(w.id)}</code></span>
6557
+ <button class="icon-btn workflow-open-icon" aria-label="Open in editor" title="Open in editor" onclick="event.stopPropagation(); openWorkflowInEditor('${escapeHtml(w.id)}')">${ICON_OPEN_EDITOR}</button>
6558
+ </div>
6559
+ ${w.description ? `<div class="workflow-row-desc">${escapeHtml(w.description)}</div>` : ''}
6560
+ ${when ? `<div class="workflow-row-meta">${escapeHtml(when)}</div>` : ''}
6561
+ </div>`;
6562
+ })
6563
+ .join('');
6564
+ }
6565
+
6566
+ function showWorkflowRun(wfId) {
6567
+ const name = _workflowList.find((w) => w.id === wfId)?.name || wfId;
6568
+ setWorkflowHeader(name, wfId);
6569
+ const body = document.getElementById('workflow-modal-body');
6570
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
6571
+ fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/run`)
6572
+ .then((r) => (r.ok ? r.json() : null))
6573
+ .catch(() => null)
6574
+ .then((run) => {
6575
+ if (!run) {
6576
+ body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Failed to load run state.</div>';
6577
+ return;
6578
+ }
6579
+ renderWorkflowRun(run);
6580
+ });
6581
+ }
6582
+
6583
+ function fmtTokens(t) {
6584
+ return t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${t}`;
6585
+ }
6586
+
6587
+ function shortModelName(model) {
6588
+ return model ? model.replace(/^claude-/, '').replace(/-\d{8}$/, '') : '';
6589
+ }
6590
+
6591
+ function renderWorkflowRun(run) {
6592
+ const body = document.getElementById('workflow-modal-body');
6593
+ const allDone = run.startedCount > 0 && run.doneCount >= run.startedCount;
6594
+ const wall =
6595
+ run.startedAt && run.stoppedAt ? formatDuration(new Date(run.stoppedAt) - new Date(run.startedAt)) : null;
6596
+
6597
+ const back =
6598
+ _workflowList.length > 1
6599
+ ? `<div class="wf-back" onclick="renderWorkflowPicker(_workflowList)">‹ All workflows</div>`
6600
+ : '';
6601
+
6602
+ const stats = [`${run.doneCount}/${run.startedCount} agents`, wall ? escapeHtml(wall) : null]
6603
+ .filter(Boolean)
6604
+ .join(' · ');
6605
+
6606
+ const desc = run.description ? `<div class="wf-run-desc">${escapeHtml(run.description)}</div>` : '';
6607
+
6608
+ const phases = (run.phases || [])
6609
+ .map(
6610
+ (p) => `<div class="wf-phase">
6611
+ <span class="wf-phase-mark${allDone ? ' done' : ''}">${allDone ? '✓' : ''}</span>
6612
+ <span class="wf-phase-title">${escapeHtml(p.title)}</span>
6613
+ ${p.detail ? `<span class="wf-phase-detail">${escapeHtml(p.detail)}</span>` : ''}
6614
+ </div>`,
6615
+ )
6616
+ .join('');
6617
+ const phasesBlock = phases ? `<div class="wf-section-title">Phases</div><div class="wf-phases">${phases}</div>` : '';
6618
+
6619
+ const agents = (run.agents || [])
6620
+ .map((a) => {
6621
+ const dur = a.durationMs != null ? formatDuration(a.durationMs) : '';
6622
+ const running = a.status !== 'done';
6623
+ return `<div class="wf-agent" onclick="showAgentModal('${escapeHtml(a.agentId)}')" title="${escapeHtml(a.agentId)}">
6624
+ <span class="wf-agent-type">${escapeHtml(a.type || 'agent')}</span>
6625
+ <span class="wf-agent-model">${escapeHtml(shortModelName(a.model))}</span>
6626
+ <span class="wf-agent-tok">${a.outputTokens ? `${fmtTokens(a.outputTokens)} tok` : ''}</span>
6627
+ <span class="wf-agent-dur">${escapeHtml(dur)}</span>
6628
+ <span class="wf-agent-status ${running ? 'running' : 'done'}">${running ? '●' : '✓'}</span>
6629
+ </div>`;
6630
+ })
6631
+ .join('');
6632
+ const agentsBlock = run.agents?.length
6633
+ ? `<div class="wf-section-title">Agents · ${run.agents.length}</div><div class="wf-agents">${agents}</div>`
6634
+ : '<div class="wf-section-title">Agents</div><div class="wf-empty">No agent runs recorded yet.</div>';
6635
+
6636
+ body.innerHTML = `<div class="workflow-run">
6637
+ ${back}
6638
+ ${desc}
6639
+ <div class="wf-run-stats">${stats} · <code>${escapeHtml(run.id)}</code></div>
6640
+ ${phasesBlock}
6641
+ ${agentsBlock}
6642
+ <details class="wf-source">
6643
+ <summary>Source</summary>
6644
+ <div class="workflow-row-code"></div>
6645
+ </details>
6646
+ </div>`;
6647
+
6648
+ const details = body.querySelector('.wf-source');
6649
+ details.addEventListener(
6650
+ 'toggle',
6651
+ () => {
6652
+ if (details.open) loadWorkflowCode(run.id, details.querySelector('.workflow-row-code'));
6653
+ },
6654
+ { once: true },
6655
+ );
6656
+ }
6657
+
6658
+ function loadWorkflowCode(wfId, codeEl) {
6659
+ if (!codeEl) return;
6660
+ codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Loading…</div>';
6661
+ fetch(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}`)
6662
+ .then((r) => (r.ok ? r.json() : null))
6663
+ .catch(() => null)
6664
+ .then((data) => {
6665
+ if (!data?.content) {
6666
+ codeEl.innerHTML = '<div style="padding:8px 0;color:var(--text-secondary);">Failed to load script.</div>';
6667
+ return;
6668
+ }
6669
+ let highlighted;
6670
+ if (typeof hljs !== 'undefined' && hljs.getLanguage('javascript')) {
6671
+ highlighted = hljs.highlight(data.content, { language: 'javascript' }).value;
6672
+ } else {
6673
+ highlighted = escapeHtml(data.content);
6674
+ }
6675
+ codeEl.innerHTML = `<pre><code class="hljs language-javascript">${highlighted}</code></pre>`;
6676
+ });
6677
+ }
6678
+
6679
+ function openWorkflowInEditor(wfId) {
6680
+ postAndToast(`/api/sessions/${_workflowSessionId}/workflows/${encodeURIComponent(wfId)}/open`, {}, 'in editor');
6681
+ }
6682
+
6683
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML
6684
+ function openWorkflowHeaderScript() {
6685
+ if (_workflowHeaderId) openWorkflowInEditor(_workflowHeaderId);
6686
+ }
6687
+
6688
+ function closeWorkflowModal() {
6689
+ document.getElementById('workflow-modal').classList.remove('visible');
6690
+ _workflowSessionId = null;
6691
+ _workflowList = [];
6692
+ setWorkflowHeader('Workflows', null);
6693
+ }
6694
+
6480
6695
  function openPlanForSession(sid) {
6481
6696
  fetch(`/api/sessions/${sid}/plan`)
6482
6697
  .then((r) => (r.ok ? r.json() : null))
@@ -6974,6 +7189,9 @@ window.addEventListener('popstate', () => {
6974
7189
  ownerFilter = s.owner || '';
6975
7190
  searchQuery = s.search || '';
6976
7191
  loadPreferences();
7192
+ // fetchSessions derives query params from the globals set above — refetch so
7193
+ // back/forward across a filter change doesn't render a stale server-filtered list.
7194
+ fetchSessions(false);
6977
7195
  if (s.projectView) {
6978
7196
  try {
6979
7197
  fetchProjectView(atob(s.projectView));
package/public/index.html CHANGED
@@ -604,6 +604,30 @@
604
604
  </div>
605
605
  </div>
606
606
 
607
+ <!-- Workflow Scripts Modal -->
608
+ <div id="workflow-modal" class="modal-overlay" onclick="closeWorkflowModal()">
609
+ <div class="modal plan-modal" onclick="event.stopPropagation()">
610
+ <div class="modal-header">
611
+ <h3 id="workflow-modal-title" class="modal-title" style="display:flex;align-items:center;gap:8px;min-width:0;">
612
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;flex-shrink:0;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
613
+ <span id="workflow-modal-title-text" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Workflows</span>
614
+ </h3>
615
+ <div style="display:flex;align-items:center;gap:4px;">
616
+ <button id="workflow-modal-open-btn" class="icon-btn" aria-label="Open in editor" title="Open in editor" style="display:none;" onclick="openWorkflowHeaderScript()">
617
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><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>
618
+ </button>
619
+ <button class="modal-close" aria-label="Close dialog" onclick="closeWorkflowModal()">
620
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
621
+ </button>
622
+ </div>
623
+ </div>
624
+ <div id="workflow-modal-body" class="modal-body" style="overflow-y:auto;flex:1 1 auto;min-height:0;"></div>
625
+ <div class="modal-footer">
626
+ <button class="btn btn-secondary" onclick="closeWorkflowModal()">Close</button>
627
+ </div>
628
+ </div>
629
+ </div>
630
+
607
631
  <div id="agent-modal" class="modal-overlay plan-modal-overlay" onclick="closeAgentModal()">
608
632
  <div class="modal plan-modal" onclick="event.stopPropagation()">
609
633
  <div class="modal-header">