Compare commits
9 Commits
150cd0c686
...
b69dffc398
| Author | SHA1 | Date | |
|---|---|---|---|
| b69dffc398 | |||
| 3fe8d7d3b5 | |||
| d4de363227 | |||
| 51a54e3fdd | |||
| f69ba1f32a | |||
| 9c4fc89cac | |||
| d7246cf062 | |||
| e61afc9dc4 | |||
| b168e6ffd7 |
701
session-viewer-architecture.html
Normal file
701
session-viewer-architecture.html
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Session Viewer — Architecture Explorer</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #0f1117; --surface: #181a20; --surface2: #1e2028;
|
||||||
|
--border: #2a2d37; --border-hover: #3a3d47;
|
||||||
|
--fg: #e1e3ea; --fg2: #9ca0ad; --fg3: #6b7080;
|
||||||
|
--blue: #3b82f6; --green: #10b981; --amber: #f59e0b;
|
||||||
|
--red: #ef4444; --purple: #a78bfa; --pink: #ec4899;
|
||||||
|
--cyan: #06b6d4; --orange: #f97316;
|
||||||
|
--node-client: #1e3a5f; --node-client-border: #3b82f6;
|
||||||
|
--node-server: #3d2e0a; --node-server-border: #f59e0b;
|
||||||
|
--node-shared: #2d1a4e; --node-shared-border: #a78bfa;
|
||||||
|
--node-build: #1a3332; --node-build-border: #06b6d4;
|
||||||
|
--radius: 6px;
|
||||||
|
}
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--fg); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
code, .mono { font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 0.8em; }
|
||||||
|
|
||||||
|
.layout { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
.sidebar { width: 280px; min-width: 280px; border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; padding: 16px; gap: 16px; }
|
||||||
|
.canvas-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.canvas-wrap { flex: 1; overflow: hidden; position: relative; }
|
||||||
|
.canvas-wrap svg { width: 100%; height: 100%; }
|
||||||
|
.prompt-bar { border-top: 1px solid var(--border); padding: 12px 16px; display: flex; align-items: flex-start; gap: 12px; max-height: 180px; overflow-y: auto; background: var(--surface); }
|
||||||
|
.prompt-text { flex: 1; font-size: 13px; line-height: 1.5; color: var(--fg2); white-space: pre-wrap; }
|
||||||
|
.prompt-text em { color: var(--fg); font-style: normal; font-weight: 500; }
|
||||||
|
|
||||||
|
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg3); margin-bottom: 6px; }
|
||||||
|
|
||||||
|
.preset-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.preset-btn { background: var(--surface2); border: 1px solid var(--border); color: var(--fg2); font-size: 12px; padding: 5px 10px; border-radius: var(--radius); cursor: pointer; transition: all 0.15s; }
|
||||||
|
.preset-btn:hover { border-color: var(--border-hover); color: var(--fg); }
|
||||||
|
.preset-btn.active { border-color: var(--blue); color: var(--blue); background: rgba(59,130,246,0.1); }
|
||||||
|
|
||||||
|
.check-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.check-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--fg2); cursor: pointer; padding: 3px 0; }
|
||||||
|
.check-item input { accent-color: var(--blue); }
|
||||||
|
.check-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.conn-item { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--fg2); cursor: pointer; padding: 3px 0; }
|
||||||
|
.conn-item input { accent-color: var(--blue); }
|
||||||
|
|
||||||
|
.comments-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.comment-card { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 10px; font-size: 12px; position: relative; }
|
||||||
|
.comment-card .target { font-weight: 600; color: var(--fg); margin-bottom: 2px; }
|
||||||
|
.comment-card .file { color: var(--fg3); font-size: 11px; }
|
||||||
|
.comment-card .text { color: var(--fg2); margin-top: 4px; }
|
||||||
|
.comment-card .del { position: absolute; top: 6px; right: 8px; background: none; border: none; color: var(--fg3); cursor: pointer; font-size: 14px; line-height: 1; }
|
||||||
|
.comment-card .del:hover { color: var(--red); }
|
||||||
|
.no-comments { font-size: 12px; color: var(--fg3); font-style: italic; }
|
||||||
|
|
||||||
|
.copy-btn { background: var(--blue); color: #fff; border: none; padding: 6px 14px; border-radius: var(--radius); font-size: 12px; font-weight: 500; cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: opacity 0.15s; }
|
||||||
|
.copy-btn:hover { opacity: 0.85; }
|
||||||
|
.copy-btn.copied { background: var(--green); }
|
||||||
|
|
||||||
|
.zoom-controls { position: absolute; bottom: 12px; right: 12px; display: flex; gap: 4px; }
|
||||||
|
.zoom-btn { background: var(--surface); border: 1px solid var(--border); color: var(--fg2); width: 30px; height: 30px; border-radius: var(--radius); cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.zoom-btn:hover { border-color: var(--border-hover); color: var(--fg); }
|
||||||
|
|
||||||
|
.modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; }
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; width: 400px; max-width: 90vw; }
|
||||||
|
.modal h3 { font-size: 15px; margin-bottom: 2px; }
|
||||||
|
.modal .modal-file { font-size: 12px; color: var(--fg3); margin-bottom: 12px; }
|
||||||
|
.modal textarea { width: 100%; height: 80px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--fg); padding: 8px; font-size: 13px; resize: vertical; font-family: inherit; }
|
||||||
|
.modal textarea:focus { outline: none; border-color: var(--blue); }
|
||||||
|
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||||
|
.modal-actions button { padding: 6px 14px; border-radius: var(--radius); font-size: 13px; cursor: pointer; border: 1px solid var(--border); }
|
||||||
|
.modal-actions .cancel { background: var(--surface2); color: var(--fg2); }
|
||||||
|
.modal-actions .save { background: var(--blue); color: #fff; border-color: var(--blue); }
|
||||||
|
|
||||||
|
svg text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }
|
||||||
|
.node-group { cursor: pointer; }
|
||||||
|
.node-group:hover rect { filter: brightness(1.2); }
|
||||||
|
.node-group.commented rect { stroke-width: 2.5; }
|
||||||
|
.layer-label { fill: var(--fg3); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">View Presets</div>
|
||||||
|
<div class="preset-row" id="presets"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Layers</div>
|
||||||
|
<div class="check-group" id="layers"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Connections</div>
|
||||||
|
<div class="check-group" id="connections"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="section-title">Comments <span id="comment-count"></span></div>
|
||||||
|
<div class="comments-list" id="comments-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-area">
|
||||||
|
<div class="canvas-wrap" id="canvas-wrap">
|
||||||
|
<svg id="diagram" xmlns="http://www.w3.org/2000/svg"></svg>
|
||||||
|
<div class="zoom-controls">
|
||||||
|
<button class="zoom-btn" onclick="zoom(-0.15)">−</button>
|
||||||
|
<button class="zoom-btn" onclick="resetZoom()">⌂</button>
|
||||||
|
<button class="zoom-btn" onclick="zoom(0.15)">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompt-bar">
|
||||||
|
<div class="prompt-text" id="prompt-text"></div>
|
||||||
|
<button class="copy-btn" id="copy-btn" onclick="copyPrompt()">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="modal">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="modal-title"></h3>
|
||||||
|
<div class="modal-file" id="modal-file"></div>
|
||||||
|
<textarea id="modal-input" placeholder="What would you change about this component?"></textarea>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="cancel" onclick="closeModal()">Cancel</button>
|
||||||
|
<button class="save" onclick="saveComment()">Add Comment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ─── DATA ───────────────────────────────────────────────────
|
||||||
|
const LAYERS = [
|
||||||
|
{ id: 'client', label: 'Client (React)', color: '#3b82f6', fill: '#1e3a5f', border: '#3b82f6' },
|
||||||
|
{ id: 'server', label: 'Server (Express)', color: '#f59e0b', fill: '#3d2e0a', border: '#f59e0b' },
|
||||||
|
{ id: 'shared', label: 'Shared', color: '#a78bfa', fill: '#2d1a4e', border: '#a78bfa' },
|
||||||
|
{ id: 'build', label: 'Build & Tooling', color: '#06b6d4', fill: '#1a3332', border: '#06b6d4' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONN_TYPES = [
|
||||||
|
{ id: 'data', label: 'Data Flow', color: '#3b82f6', dash: '' },
|
||||||
|
{ id: 'import', label: 'Import / Uses', color: '#6b7280', dash: '4,3' },
|
||||||
|
{ id: 'render', label: 'Renders', color: '#10b981', dash: '6,3' },
|
||||||
|
{ id: 'event', label: 'Event / Callback', color: '#f97316', dash: '3,3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NODES = [
|
||||||
|
// Client: Components
|
||||||
|
{ id: 'app', label: 'App', subtitle: 'src/client/app.tsx', x: 420, y: 50, w: 100, h: 44, layer: 'client' },
|
||||||
|
{ id: 'session-list', label: 'SessionList', subtitle: 'src/client/components/SessionList.tsx', x: 140, y: 130, w: 130, h: 44, layer: 'client' },
|
||||||
|
{ id: 'session-viewer', label: 'SessionViewer', subtitle: 'src/client/components/SessionViewer.tsx', x: 350, y: 130, w: 145, h: 44, layer: 'client' },
|
||||||
|
{ id: 'message-bubble', label: 'MessageBubble', subtitle: 'src/client/components/MessageBubble.tsx', x: 310, y: 210, w: 145, h: 44, layer: 'client' },
|
||||||
|
{ id: 'filter-panel', label: 'FilterPanel', subtitle: 'src/client/components/FilterPanel.tsx', x: 140, y: 210, w: 130, h: 44, layer: 'client' },
|
||||||
|
{ id: 'search-bar', label: 'SearchBar', subtitle: 'src/client/components/SearchBar.tsx', x: 560, y: 130, w: 120, h: 44, layer: 'client' },
|
||||||
|
{ id: 'search-minimap', label: 'SearchMinimap', subtitle: 'src/client/components/SearchMinimap.tsx', x: 530, y: 210, w: 140, h: 44, layer: 'client' },
|
||||||
|
{ id: 'agent-progress', label: 'AgentProgressView', subtitle: 'src/client/components/AgentProgressView.tsx', x: 720, y: 130, w: 165, h: 44, layer: 'client' },
|
||||||
|
{ id: 'export-btn', label: 'ExportButton', subtitle: 'src/client/components/ExportButton.tsx', x: 720, y: 210, w: 130, h: 44, layer: 'client' },
|
||||||
|
{ id: 'error-boundary', label: 'ErrorBoundary', subtitle: 'src/client/components/ErrorBoundary.tsx', x: 600, y: 50, w: 135, h: 44, layer: 'client' },
|
||||||
|
|
||||||
|
// Client: Hooks & Libs
|
||||||
|
{ id: 'use-session', label: 'useSession', subtitle: 'src/client/hooks/useSession.ts', x: 140, y: 290, w: 120, h: 44, layer: 'client' },
|
||||||
|
{ id: 'use-filters', label: 'useFilters', subtitle: 'src/client/hooks/useFilters.ts', x: 300, y: 290, w: 120, h: 44, layer: 'client' },
|
||||||
|
{ id: 'agent-parser', label: 'AgentParser', subtitle: 'src/client/lib/agent-progress-parser.ts', x: 530, y: 290, w: 130, h: 44, layer: 'client' },
|
||||||
|
{ id: 'markdown-lib', label: 'Markdown', subtitle: 'src/client/lib/markdown.ts', x: 720, y: 290, w: 120, h: 44, layer: 'client' },
|
||||||
|
|
||||||
|
// Server
|
||||||
|
{ id: 'sessions-route', label: '/api/sessions', subtitle: 'src/server/routes/sessions.ts', x: 170, y: 420, w: 135, h: 44, layer: 'server' },
|
||||||
|
{ id: 'export-route', label: '/api/export', subtitle: 'src/server/routes/export.ts', x: 370, y: 420, w: 120, h: 44, layer: 'server' },
|
||||||
|
{ id: 'session-discovery', label: 'SessionDiscovery', subtitle: 'src/server/services/session-discovery.ts', x: 100, y: 500, w: 160, h: 44, layer: 'server' },
|
||||||
|
{ id: 'session-parser', label: 'SessionParser', subtitle: 'src/server/services/session-parser.ts', x: 310, y: 500, w: 140, h: 44, layer: 'server' },
|
||||||
|
{ id: 'progress-grouper', label: 'ProgressGrouper', subtitle: 'src/server/services/progress-grouper.ts', x: 510, y: 500, w: 155, h: 44, layer: 'server' },
|
||||||
|
{ id: 'html-exporter', label: 'HtmlExporter', subtitle: 'src/server/services/html-exporter.ts', x: 720, y: 500, w: 140, h: 44, layer: 'server' },
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
{ id: 'shared-types', label: 'Types', subtitle: 'src/shared/types.ts', x: 170, y: 600, w: 100, h: 44, layer: 'shared' },
|
||||||
|
{ id: 'redactor', label: 'SensitiveRedactor', subtitle: 'src/shared/sensitive-redactor.ts', x: 370, y: 600, w: 165, h: 44, layer: 'shared' },
|
||||||
|
{ id: 'escape-html', label: 'escapeHtml', subtitle: 'src/shared/escape-html.ts', x: 590, y: 600, w: 120, h: 44, layer: 'shared' },
|
||||||
|
|
||||||
|
// Build
|
||||||
|
{ id: 'vite', label: 'Vite', subtitle: 'vite.config.ts', x: 950, y: 80, w: 90, h: 44, layer: 'build' },
|
||||||
|
{ id: 'vitest', label: 'Vitest', subtitle: 'vitest.config.ts', x: 950, y: 160, w: 90, h: 44, layer: 'build' },
|
||||||
|
{ id: 'tailwind', label: 'Tailwind', subtitle: 'tailwind.config.js', x: 950, y: 240, w: 100, h: 44, layer: 'build' },
|
||||||
|
{ id: 'cli', label: 'CLI Entry', subtitle: 'bin/session-viewer.js', x: 950, y: 420, w: 110, h: 44, layer: 'build' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONNECTIONS = [
|
||||||
|
// App renders children
|
||||||
|
{ from: 'app', to: 'session-list', type: 'render' },
|
||||||
|
{ from: 'app', to: 'session-viewer', type: 'render' },
|
||||||
|
{ from: 'app', to: 'filter-panel', type: 'render' },
|
||||||
|
{ from: 'app', to: 'search-bar', type: 'render' },
|
||||||
|
{ from: 'app', to: 'export-btn', type: 'render' },
|
||||||
|
{ from: 'app', to: 'error-boundary', type: 'render' },
|
||||||
|
|
||||||
|
// SessionViewer renders messages
|
||||||
|
{ from: 'session-viewer', to: 'message-bubble', type: 'render' },
|
||||||
|
{ from: 'session-viewer', to: 'search-minimap', type: 'render' },
|
||||||
|
{ from: 'message-bubble', to: 'agent-progress', type: 'render', label: 'agent events' },
|
||||||
|
|
||||||
|
// Hooks used by App
|
||||||
|
{ from: 'app', to: 'use-session', type: 'import' },
|
||||||
|
{ from: 'app', to: 'use-filters', type: 'import' },
|
||||||
|
|
||||||
|
// Libs used by components
|
||||||
|
{ from: 'message-bubble', to: 'markdown-lib', type: 'import' },
|
||||||
|
{ from: 'agent-progress', to: 'agent-parser', type: 'import' },
|
||||||
|
|
||||||
|
// Client to Server data flow
|
||||||
|
{ from: 'use-session', to: 'sessions-route', type: 'data', label: 'GET /api/sessions' },
|
||||||
|
{ from: 'export-btn', to: 'export-route', type: 'data', label: 'POST /api/export' },
|
||||||
|
|
||||||
|
// Server internal
|
||||||
|
{ from: 'sessions-route', to: 'session-discovery', type: 'import' },
|
||||||
|
{ from: 'sessions-route', to: 'session-parser', type: 'import' },
|
||||||
|
{ from: 'sessions-route', to: 'progress-grouper', type: 'import' },
|
||||||
|
{ from: 'export-route', to: 'html-exporter', type: 'import' },
|
||||||
|
|
||||||
|
// Server to Shared
|
||||||
|
{ from: 'session-parser', to: 'shared-types', type: 'import' },
|
||||||
|
{ from: 'html-exporter', to: 'redactor', type: 'import' },
|
||||||
|
{ from: 'html-exporter', to: 'escape-html', type: 'import' },
|
||||||
|
{ from: 'html-exporter', to: 'shared-types', type: 'import' },
|
||||||
|
{ from: 'progress-grouper', to: 'shared-types', type: 'import' },
|
||||||
|
{ from: 'sessions-route', to: 'shared-types', type: 'import' },
|
||||||
|
|
||||||
|
// Client to Shared
|
||||||
|
{ from: 'use-filters', to: 'redactor', type: 'import' },
|
||||||
|
{ from: 'use-filters', to: 'shared-types', type: 'import' },
|
||||||
|
|
||||||
|
// Events
|
||||||
|
{ from: 'session-list', to: 'app', type: 'event', label: 'onSelect' },
|
||||||
|
{ from: 'filter-panel', to: 'app', type: 'event', label: 'onFilterChange' },
|
||||||
|
{ from: 'search-bar', to: 'app', type: 'event', label: 'onSearch' },
|
||||||
|
|
||||||
|
// Build connections
|
||||||
|
{ from: 'vite', to: 'app', type: 'import', label: 'bundles' },
|
||||||
|
{ from: 'tailwind', to: 'app', type: 'import', label: 'styles' },
|
||||||
|
{ from: 'cli', to: 'sessions-route', type: 'import', label: 'starts server' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESETS = {
|
||||||
|
'Full System': { layers: ['client','server','shared','build'], conns: ['data','import','render','event'] },
|
||||||
|
'Data Flow': { layers: ['client','server','shared'], conns: ['data','event'] },
|
||||||
|
'Client Only': { layers: ['client'], conns: ['render','import','event'] },
|
||||||
|
'Server Only': { layers: ['server','shared'], conns: ['import','data'] },
|
||||||
|
'Render Tree': { layers: ['client'], conns: ['render'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── STATE ──────────────────────────────────────────────────
|
||||||
|
const state = {
|
||||||
|
preset: 'Full System',
|
||||||
|
layers: { client: true, server: true, shared: true, build: true },
|
||||||
|
conns: { data: true, import: true, render: true, event: true },
|
||||||
|
comments: [],
|
||||||
|
zoom: 1,
|
||||||
|
panX: 0, panY: 0,
|
||||||
|
modalNode: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── RENDER CONTROLS ────────────────────────────────────────
|
||||||
|
function renderPresets() {
|
||||||
|
var el = document.getElementById('presets');
|
||||||
|
var btns = [];
|
||||||
|
var names = Object.keys(PRESETS);
|
||||||
|
for (var i = 0; i < names.length; i++) {
|
||||||
|
var name = names[i];
|
||||||
|
var cls = state.preset === name ? 'preset-btn active' : 'preset-btn';
|
||||||
|
btns.push('<button class="' + cls + '" data-preset="' + name + '">' + name + '</button>');
|
||||||
|
}
|
||||||
|
el.textContent = '';
|
||||||
|
el.insertAdjacentHTML('beforeend', btns.join(''));
|
||||||
|
el.querySelectorAll('.preset-btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() { applyPreset(this.getAttribute('data-preset')); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLayerToggles() {
|
||||||
|
var el = document.getElementById('layers');
|
||||||
|
el.textContent = '';
|
||||||
|
LAYERS.forEach(function(l) {
|
||||||
|
var label = document.createElement('label');
|
||||||
|
label.className = 'check-item';
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.checked = state.layers[l.id];
|
||||||
|
cb.addEventListener('change', function() { toggleLayer(l.id, this.checked); });
|
||||||
|
var dot = document.createElement('span');
|
||||||
|
dot.className = 'dot';
|
||||||
|
dot.style.background = l.color;
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(dot);
|
||||||
|
label.appendChild(document.createTextNode(' ' + l.label));
|
||||||
|
el.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnToggles() {
|
||||||
|
var el = document.getElementById('connections');
|
||||||
|
el.textContent = '';
|
||||||
|
CONN_TYPES.forEach(function(c) {
|
||||||
|
var label = document.createElement('label');
|
||||||
|
label.className = 'conn-item';
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.checked = state.conns[c.id];
|
||||||
|
cb.addEventListener('change', function() { toggleConn(c.id, this.checked); });
|
||||||
|
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
svg.setAttribute('width', '24');
|
||||||
|
svg.setAttribute('height', '12');
|
||||||
|
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
line.setAttribute('x1', '0'); line.setAttribute('y1', '6');
|
||||||
|
line.setAttribute('x2', '24'); line.setAttribute('y2', '6');
|
||||||
|
line.setAttribute('stroke', c.color); line.setAttribute('stroke-width', '2');
|
||||||
|
if (c.dash) line.setAttribute('stroke-dasharray', c.dash);
|
||||||
|
svg.appendChild(line);
|
||||||
|
label.appendChild(cb);
|
||||||
|
label.appendChild(svg);
|
||||||
|
label.appendChild(document.createTextNode(' ' + c.label));
|
||||||
|
el.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComments() {
|
||||||
|
var el = document.getElementById('comments-list');
|
||||||
|
var countEl = document.getElementById('comment-count');
|
||||||
|
countEl.textContent = state.comments.length ? '(' + state.comments.length + ')' : '';
|
||||||
|
el.textContent = '';
|
||||||
|
if (!state.comments.length) {
|
||||||
|
var p = document.createElement('div');
|
||||||
|
p.className = 'no-comments';
|
||||||
|
p.textContent = 'Click a component to add feedback';
|
||||||
|
el.appendChild(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.comments.forEach(function(c, i) {
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.className = 'comment-card';
|
||||||
|
|
||||||
|
var del = document.createElement('button');
|
||||||
|
del.className = 'del';
|
||||||
|
del.textContent = '\u00d7';
|
||||||
|
del.addEventListener('click', function() { deleteComment(i); });
|
||||||
|
|
||||||
|
var target = document.createElement('div');
|
||||||
|
target.className = 'target';
|
||||||
|
target.textContent = c.targetLabel;
|
||||||
|
|
||||||
|
var file = document.createElement('div');
|
||||||
|
file.className = 'file';
|
||||||
|
file.textContent = c.targetFile;
|
||||||
|
|
||||||
|
var text = document.createElement('div');
|
||||||
|
text.className = 'text';
|
||||||
|
text.textContent = c.text;
|
||||||
|
|
||||||
|
card.appendChild(del);
|
||||||
|
card.appendChild(target);
|
||||||
|
card.appendChild(file);
|
||||||
|
card.appendChild(text);
|
||||||
|
el.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ACTIONS ────────────────────────────────────────────────
|
||||||
|
function applyPreset(name) {
|
||||||
|
var p = PRESETS[name];
|
||||||
|
state.preset = name;
|
||||||
|
LAYERS.forEach(function(l) { state.layers[l.id] = p.layers.indexOf(l.id) !== -1; });
|
||||||
|
CONN_TYPES.forEach(function(c) { state.conns[c.id] = p.conns.indexOf(c.id) !== -1; });
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleLayer(id, on) {
|
||||||
|
state.layers[id] = on;
|
||||||
|
state.preset = '';
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConn(id, on) {
|
||||||
|
state.conns[id] = on;
|
||||||
|
state.preset = '';
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteComment(i) {
|
||||||
|
state.comments.splice(i, 1);
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ZOOM & PAN ─────────────────────────────────────────────
|
||||||
|
var isPanning = false, panStartX = 0, panStartY = 0;
|
||||||
|
|
||||||
|
function zoom(delta) {
|
||||||
|
state.zoom = Math.max(0.4, Math.min(2.5, state.zoom + delta));
|
||||||
|
renderDiagram();
|
||||||
|
}
|
||||||
|
function resetZoom() {
|
||||||
|
state.zoom = 1; state.panX = 0; state.panY = 0;
|
||||||
|
renderDiagram();
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrap = document.getElementById('canvas-wrap');
|
||||||
|
wrap.addEventListener('wheel', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
zoom(e.deltaY > 0 ? -0.08 : 0.08);
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
wrap.addEventListener('mousedown', function(e) {
|
||||||
|
if (e.target.closest('.node-group')) return;
|
||||||
|
isPanning = true;
|
||||||
|
panStartX = e.clientX - state.panX;
|
||||||
|
panStartY = e.clientY - state.panY;
|
||||||
|
wrap.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
window.addEventListener('mousemove', function(e) {
|
||||||
|
if (!isPanning) return;
|
||||||
|
state.panX = e.clientX - panStartX;
|
||||||
|
state.panY = e.clientY - panStartY;
|
||||||
|
renderDiagram();
|
||||||
|
});
|
||||||
|
window.addEventListener('mouseup', function() { isPanning = false; wrap.style.cursor = ''; });
|
||||||
|
|
||||||
|
// ─── SVG RENDERING ──────────────────────────────────────────
|
||||||
|
function getLayerDef(layerId) { return LAYERS.find(function(l) { return l.id === layerId; }); }
|
||||||
|
|
||||||
|
function escText(s) {
|
||||||
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiagram() {
|
||||||
|
var svg = document.getElementById('diagram');
|
||||||
|
var rect = wrap.getBoundingClientRect();
|
||||||
|
var vw = rect.width, vh = rect.height;
|
||||||
|
|
||||||
|
var cx = 540, cy = 340;
|
||||||
|
var halfW = (vw / 2) / state.zoom;
|
||||||
|
var halfH = (vh / 2) / state.zoom;
|
||||||
|
var vbX = cx - halfW - state.panX / state.zoom;
|
||||||
|
var vbY = cy - halfH - state.panY / state.zoom;
|
||||||
|
svg.setAttribute('viewBox', vbX + ' ' + vbY + ' ' + (halfW * 2) + ' ' + (halfH * 2));
|
||||||
|
|
||||||
|
var visibleNodeIds = {};
|
||||||
|
NODES.forEach(function(n) {
|
||||||
|
if (n.w > 0 && state.layers[n.layer]) visibleNodeIds[n.id] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
var seenConns = {};
|
||||||
|
var visibleConns = [];
|
||||||
|
CONNECTIONS.forEach(function(c) {
|
||||||
|
if (!state.conns[c.type] || !visibleNodeIds[c.from] || !visibleNodeIds[c.to]) return;
|
||||||
|
var k = c.from + '-' + c.to + '-' + c.type;
|
||||||
|
if (seenConns[k]) return;
|
||||||
|
seenConns[k] = true;
|
||||||
|
visibleConns.push(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
var commentedIds = {};
|
||||||
|
state.comments.forEach(function(c) { commentedIds[c.target] = (commentedIds[c.target] || 0) + 1; });
|
||||||
|
|
||||||
|
// Clear and rebuild SVG using DOM methods
|
||||||
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
||||||
|
|
||||||
|
// Defs
|
||||||
|
var defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
||||||
|
CONN_TYPES.forEach(function(ct) {
|
||||||
|
var marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
|
||||||
|
marker.setAttribute('id', 'arrow-' + ct.id);
|
||||||
|
marker.setAttribute('markerWidth', '8');
|
||||||
|
marker.setAttribute('markerHeight', '6');
|
||||||
|
marker.setAttribute('refX', '7');
|
||||||
|
marker.setAttribute('refY', '3');
|
||||||
|
marker.setAttribute('orient', 'auto');
|
||||||
|
var poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
|
||||||
|
poly.setAttribute('points', '0 0, 8 3, 0 6');
|
||||||
|
poly.setAttribute('fill', ct.color);
|
||||||
|
poly.setAttribute('opacity', '0.7');
|
||||||
|
marker.appendChild(poly);
|
||||||
|
defs.appendChild(marker);
|
||||||
|
});
|
||||||
|
svg.appendChild(defs);
|
||||||
|
|
||||||
|
// Layer bands
|
||||||
|
var layerBands = [
|
||||||
|
{ id: 'client', y1: 25, y2: 345, label: 'CLIENT' },
|
||||||
|
{ id: 'server', y1: 395, y2: 560, label: 'SERVER' },
|
||||||
|
{ id: 'shared', y1: 575, y2: 660, label: 'SHARED' },
|
||||||
|
{ id: 'build', y1: 55, y2: 480, label: 'BUILD' },
|
||||||
|
];
|
||||||
|
layerBands.forEach(function(band) {
|
||||||
|
if (!state.layers[band.id]) return;
|
||||||
|
var ld = getLayerDef(band.id);
|
||||||
|
var bx = band.id === 'build' ? 920 : 60;
|
||||||
|
var bw = band.id === 'build' ? 170 : 840;
|
||||||
|
var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
|
r.setAttribute('x', bx); r.setAttribute('y', band.y1);
|
||||||
|
r.setAttribute('width', bw); r.setAttribute('height', band.y2 - band.y1);
|
||||||
|
r.setAttribute('rx', '8'); r.setAttribute('fill', ld.color);
|
||||||
|
r.setAttribute('opacity', '0.04'); r.setAttribute('stroke', ld.color);
|
||||||
|
r.setAttribute('stroke-opacity', '0.1');
|
||||||
|
svg.appendChild(r);
|
||||||
|
var t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
t.setAttribute('x', bx + 10); t.setAttribute('y', band.y1 + 16);
|
||||||
|
t.setAttribute('class', 'layer-label');
|
||||||
|
t.textContent = band.label;
|
||||||
|
svg.appendChild(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connections
|
||||||
|
var nodeMap = {};
|
||||||
|
NODES.forEach(function(n) { nodeMap[n.id] = n; });
|
||||||
|
|
||||||
|
visibleConns.forEach(function(c) {
|
||||||
|
var from = nodeMap[c.from];
|
||||||
|
var to = nodeMap[c.to];
|
||||||
|
if (!from || !to) return;
|
||||||
|
var ct = CONN_TYPES.find(function(t) { return t.id === c.type; });
|
||||||
|
|
||||||
|
var x1 = from.x + from.w / 2, y1 = from.y + from.h / 2;
|
||||||
|
var x2 = to.x + to.w / 2, y2 = to.y + to.h / 2;
|
||||||
|
|
||||||
|
var sx = x1, sy = y1, ex = x2, ey = y2;
|
||||||
|
var dx = x2 - x1, dy = y2 - y1;
|
||||||
|
if (Math.abs(dy) > Math.abs(dx)) {
|
||||||
|
sy = dy > 0 ? from.y + from.h : from.y;
|
||||||
|
ey = dy > 0 ? to.y : to.y + to.h;
|
||||||
|
} else {
|
||||||
|
sx = dx > 0 ? from.x + from.w : from.x;
|
||||||
|
ex = dx > 0 ? to.x : to.x + to.w;
|
||||||
|
}
|
||||||
|
|
||||||
|
var midY = (sy + ey) / 2;
|
||||||
|
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
path.setAttribute('d', 'M' + sx + ',' + sy + ' C' + sx + ',' + midY + ' ' + ex + ',' + midY + ' ' + ex + ',' + ey);
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke', ct.color);
|
||||||
|
path.setAttribute('stroke-width', '1.5');
|
||||||
|
path.setAttribute('stroke-opacity', '0.5');
|
||||||
|
if (ct.dash) path.setAttribute('stroke-dasharray', ct.dash);
|
||||||
|
path.setAttribute('marker-end', 'url(#arrow-' + ct.id + ')');
|
||||||
|
svg.appendChild(path);
|
||||||
|
|
||||||
|
if (c.label) {
|
||||||
|
var lx = (sx + ex) / 2 + (Math.abs(dx) < 20 ? 8 : 0);
|
||||||
|
var ly = midY - 4;
|
||||||
|
var lt = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
lt.setAttribute('x', lx); lt.setAttribute('y', ly);
|
||||||
|
lt.setAttribute('fill', ct.color);
|
||||||
|
lt.setAttribute('font-size', '9');
|
||||||
|
lt.setAttribute('text-anchor', 'middle');
|
||||||
|
lt.setAttribute('opacity', '0.6');
|
||||||
|
lt.textContent = c.label;
|
||||||
|
svg.appendChild(lt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nodes
|
||||||
|
NODES.forEach(function(n) {
|
||||||
|
if (n.w === 0 || !state.layers[n.layer]) return;
|
||||||
|
var ld = getLayerDef(n.layer);
|
||||||
|
var hasComment = !!commentedIds[n.id];
|
||||||
|
var commentCount = commentedIds[n.id] || 0;
|
||||||
|
|
||||||
|
var g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
g.setAttribute('class', 'node-group' + (hasComment ? ' commented' : ''));
|
||||||
|
g.addEventListener('click', function() { openModal(n.id); });
|
||||||
|
|
||||||
|
var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||||
|
r.setAttribute('x', n.x); r.setAttribute('y', n.y);
|
||||||
|
r.setAttribute('width', n.w); r.setAttribute('height', n.h);
|
||||||
|
r.setAttribute('rx', '6'); r.setAttribute('fill', ld.fill);
|
||||||
|
r.setAttribute('stroke', hasComment ? '#f59e0b' : ld.border);
|
||||||
|
r.setAttribute('stroke-width', hasComment ? '2.5' : '1');
|
||||||
|
r.setAttribute('stroke-opacity', hasComment ? '1' : '0.5');
|
||||||
|
g.appendChild(r);
|
||||||
|
|
||||||
|
var t1 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
t1.setAttribute('x', n.x + n.w / 2); t1.setAttribute('y', n.y + 18);
|
||||||
|
t1.setAttribute('fill', ld.color); t1.setAttribute('font-size', '12');
|
||||||
|
t1.setAttribute('font-weight', '600'); t1.setAttribute('text-anchor', 'middle');
|
||||||
|
t1.textContent = n.label;
|
||||||
|
g.appendChild(t1);
|
||||||
|
|
||||||
|
var t2 = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
t2.setAttribute('x', n.x + n.w / 2); t2.setAttribute('y', n.y + 32);
|
||||||
|
t2.setAttribute('fill', ld.color); t2.setAttribute('font-size', '9');
|
||||||
|
t2.setAttribute('text-anchor', 'middle'); t2.setAttribute('opacity', '0.5');
|
||||||
|
t2.textContent = n.subtitle.replace('src/', '');
|
||||||
|
g.appendChild(t2);
|
||||||
|
|
||||||
|
if (hasComment) {
|
||||||
|
var circ = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circ.setAttribute('cx', n.x + n.w - 6); circ.setAttribute('cy', n.y + 6);
|
||||||
|
circ.setAttribute('r', '5'); circ.setAttribute('fill', '#f59e0b');
|
||||||
|
g.appendChild(circ);
|
||||||
|
var ct = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
||||||
|
ct.setAttribute('x', n.x + n.w - 6); ct.setAttribute('y', n.y + 9.5);
|
||||||
|
ct.setAttribute('fill', '#000'); ct.setAttribute('font-size', '8');
|
||||||
|
ct.setAttribute('font-weight', '700'); ct.setAttribute('text-anchor', 'middle');
|
||||||
|
ct.textContent = String(commentCount);
|
||||||
|
g.appendChild(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.appendChild(g);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── MODAL ──────────────────────────────────────────────────
|
||||||
|
function openModal(nodeId) {
|
||||||
|
var node = NODES.find(function(n) { return n.id === nodeId && n.w > 0; });
|
||||||
|
if (!node) return;
|
||||||
|
state.modalNode = node;
|
||||||
|
document.getElementById('modal-title').textContent = node.label;
|
||||||
|
document.getElementById('modal-file').textContent = node.subtitle;
|
||||||
|
document.getElementById('modal-input').value = '';
|
||||||
|
document.getElementById('modal').classList.add('open');
|
||||||
|
setTimeout(function() { document.getElementById('modal-input').focus(); }, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modal').classList.remove('open');
|
||||||
|
state.modalNode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveComment() {
|
||||||
|
var text = document.getElementById('modal-input').value.trim();
|
||||||
|
if (!text || !state.modalNode) return;
|
||||||
|
state.comments.push({
|
||||||
|
id: Date.now(),
|
||||||
|
target: state.modalNode.id,
|
||||||
|
targetLabel: state.modalNode.label,
|
||||||
|
targetFile: state.modalNode.subtitle,
|
||||||
|
text: text
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
updateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modal').addEventListener('click', function(e) {
|
||||||
|
if (e.target === document.getElementById('modal')) closeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
if (e.key === 'Enter' && e.metaKey) saveComment();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PROMPT ─────────────────────────────────────────────────
|
||||||
|
function updatePrompt() {
|
||||||
|
var el = document.getElementById('prompt-text');
|
||||||
|
var activeLayers = LAYERS.filter(function(l) { return state.layers[l.id]; }).map(function(l) { return l.label; });
|
||||||
|
|
||||||
|
if (!state.comments.length) {
|
||||||
|
var layerStr = activeLayers.length === LAYERS.length ? 'full system' : activeLayers.join(', ');
|
||||||
|
el.textContent = 'Viewing session-viewer architecture (' + layerStr + '). Click any component to add feedback.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = [];
|
||||||
|
var layerNote = activeLayers.length === LAYERS.length
|
||||||
|
? '' : ', focusing on the ' + activeLayers.join(' and ') + ' layer' + (activeLayers.length > 1 ? 's' : '');
|
||||||
|
|
||||||
|
parts.push('This is the session-viewer architecture' + layerNote + '.\n');
|
||||||
|
parts.push('Architecture feedback:\n');
|
||||||
|
|
||||||
|
state.comments.forEach(function(c) {
|
||||||
|
parts.push(c.targetLabel + ' (' + c.targetFile + '):\n' + c.text + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
el.textContent = parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── COPY ───────────────────────────────────────────────────
|
||||||
|
function copyPrompt() {
|
||||||
|
var el = document.getElementById('prompt-text');
|
||||||
|
var text = el.textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
var btn = document.getElementById('copy-btn');
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(function() { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── UPDATE ALL ─────────────────────────────────────────────
|
||||||
|
function updateAll() {
|
||||||
|
renderPresets();
|
||||||
|
renderLayerToggles();
|
||||||
|
renderConnToggles();
|
||||||
|
renderComments();
|
||||||
|
renderDiagram();
|
||||||
|
updatePrompt();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── INIT ───────────────────────────────────────────────────
|
||||||
|
updateAll();
|
||||||
|
window.addEventListener('resize', function() { renderDiagram(); });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -54,9 +54,13 @@ export function App() {
|
|||||||
return filters.filterMessages(currentSession.messages);
|
return filters.filterMessages(currentSession.messages);
|
||||||
}, [currentSession, filters.filterMessages]);
|
}, [currentSession, filters.filterMessages]);
|
||||||
|
|
||||||
|
const progressEnabled = filters.enabledCategories.has("hook_progress");
|
||||||
|
|
||||||
|
// Count across all session messages (not just filtered) — recompute only on session change.
|
||||||
|
// This avoids re-running 37 regex patterns whenever filter toggles change.
|
||||||
const sensitiveCount = useMemo(
|
const sensitiveCount = useMemo(
|
||||||
() => countSensitiveMessages(filteredMessages),
|
() => countSensitiveMessages(currentSession?.messages || []),
|
||||||
[filteredMessages]
|
[currentSession?.messages]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track which filtered-message indices match the search query
|
// Track which filtered-message indices match the search query
|
||||||
@@ -224,7 +228,7 @@ export function App() {
|
|||||||
|
|
||||||
{/* Main */}
|
{/* Main */}
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="glass flex items-center px-5 py-2.5 border-b border-border z-10">
|
<div className="glass flex items-center px-5 py-4 border-b border-border z-10">
|
||||||
{/* Left spacer — mirrors right side width to keep search centered */}
|
{/* Left spacer — mirrors right side width to keep search centered */}
|
||||||
<div className="flex-1 min-w-0" />
|
<div className="flex-1 min-w-0" />
|
||||||
|
|
||||||
@@ -239,7 +243,7 @@ export function App() {
|
|||||||
onPrev={goToPrevMatch}
|
onPrev={goToPrevMatch}
|
||||||
/>
|
/>
|
||||||
{filters.selectedForRedaction.size > 0 && (
|
{filters.selectedForRedaction.size > 0 && (
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
|
||||||
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
|
||||||
{filters.selectedForRedaction.size} selected
|
{filters.selectedForRedaction.size} selected
|
||||||
</span>
|
</span>
|
||||||
@@ -285,6 +289,8 @@ export function App() {
|
|||||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
focusedIndex={activeFocusIndex}
|
focusedIndex={activeFocusIndex}
|
||||||
|
toolProgress={currentSession?.toolProgress}
|
||||||
|
progressEnabled={progressEnabled}
|
||||||
/>
|
/>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
254
src/client/components/AgentProgressView.test.tsx
Normal file
254
src/client/components/AgentProgressView.test.tsx
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { AgentProgressView } from "./AgentProgressView";
|
||||||
|
import type { ParsedMessage } from "../lib/types";
|
||||||
|
|
||||||
|
// Mock markdown renderer to avoid pulling in marked/hljs in jsdom
|
||||||
|
vi.mock("../lib/markdown", () => ({
|
||||||
|
renderMarkdown: (text: string) => `<p>${text}</p>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Build a ParsedMessage whose content is a JSON agent_progress payload */
|
||||||
|
function makeAgentProgressEvent(
|
||||||
|
messageOverrides: Record<string, unknown> = {},
|
||||||
|
dataOverrides: Record<string, unknown> = {},
|
||||||
|
msgOverrides: Partial<ParsedMessage> = {}
|
||||||
|
): ParsedMessage {
|
||||||
|
const data = {
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_abc",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "/src/foo.ts" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
...messageOverrides,
|
||||||
|
},
|
||||||
|
normalizedMessages: [],
|
||||||
|
type: "agent_progress",
|
||||||
|
prompt: "Explore the codebase",
|
||||||
|
agentId: "a6945d4",
|
||||||
|
...dataOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
category: "hook_progress",
|
||||||
|
content: JSON.stringify(data),
|
||||||
|
rawIndex: 0,
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
progressSubtype: "agent",
|
||||||
|
...msgOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AgentProgressView", () => {
|
||||||
|
it("renders prompt banner with truncated prompt text", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
const prompt = getByTestId("agent-prompt");
|
||||||
|
expect(prompt.textContent).toContain("Explore the codebase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders agent ID and turn count in header", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
const view = getByTestId("agent-progress-view");
|
||||||
|
expect(view.textContent).toContain("a6945d4");
|
||||||
|
expect(view.textContent).toContain("1 turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders summary rows with timestamps and tool names", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
|
||||||
|
const rows = getAllByTestId("agent-event-row");
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
// Should contain tool name "Read" and the file path
|
||||||
|
expect(rows[0].textContent).toContain("Read");
|
||||||
|
expect(rows[0].textContent).toContain("src/foo.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summary row for Read shows file path", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentProgressEvent({
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "t1",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "/src/components/App.tsx" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
const rows = getAllByTestId("agent-event-row");
|
||||||
|
expect(rows[0].textContent).toContain("src/components/App.tsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summary row for text response shows line count", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentProgressEvent({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Line 1\nLine 2\nLine 3" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { getAllByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
const rows = getAllByTestId("agent-event-row");
|
||||||
|
expect(rows[0].textContent).toContain("3 lines");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a summary row expands drill-down panel", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getAllByTestId, queryByTestId } = render(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initially no drill-down
|
||||||
|
expect(queryByTestId("agent-drilldown")).toBeNull();
|
||||||
|
|
||||||
|
// Click to expand
|
||||||
|
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||||
|
expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drill-down shows pretty-printed tool input JSON", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getAllByTestId, getByTestId } = render(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||||
|
const drilldown = getByTestId("agent-drilldown");
|
||||||
|
expect(drilldown.textContent).toContain("file_path");
|
||||||
|
expect(drilldown.textContent).toContain("/src/foo.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drill-down shows full tool result content", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentProgressEvent({
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "toolu_abc",
|
||||||
|
content: "Full file contents here",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { getAllByTestId, getByTestId } = render(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||||
|
const drilldown = getByTestId("agent-drilldown");
|
||||||
|
expect(drilldown.textContent).toContain("Full file contents here");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking expanded row collapses it", () => {
|
||||||
|
const events = [makeAgentProgressEvent()];
|
||||||
|
const { getAllByTestId, queryByTestId } = render(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||||
|
expect(queryByTestId("agent-drilldown")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Collapse
|
||||||
|
fireEvent.click(getAllByTestId("agent-event-row")[0]);
|
||||||
|
expect(queryByTestId("agent-drilldown")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only one drill-down open at a time (accordion behavior)", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentProgressEvent(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ uuid: "ev-1" }
|
||||||
|
),
|
||||||
|
makeAgentProgressEvent(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t2", name: "Grep", input: { pattern: "foo" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ uuid: "ev-2" }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const { getAllByTestId } = render(
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = getAllByTestId("agent-event-row");
|
||||||
|
|
||||||
|
// Expand first
|
||||||
|
fireEvent.click(rows[0]);
|
||||||
|
let drilldowns = getAllByTestId("agent-drilldown");
|
||||||
|
expect(drilldowns.length).toBe(1);
|
||||||
|
|
||||||
|
// Click second - should close first and open second
|
||||||
|
fireEvent.click(rows[1]);
|
||||||
|
drilldowns = getAllByTestId("agent-drilldown");
|
||||||
|
expect(drilldowns.length).toBe(1);
|
||||||
|
// Second drill-down should show Grep pattern
|
||||||
|
expect(drilldowns[0].textContent).toContain("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles events with missing timestamps gracefully", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentProgressEvent(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/a.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: undefined,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ timestamp: undefined }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
// Should render without crashing
|
||||||
|
const { getByTestId } = render(<AgentProgressView events={events} />);
|
||||||
|
expect(getByTestId("agent-progress-view")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
353
src/client/components/AgentProgressView.tsx
Normal file
353
src/client/components/AgentProgressView.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import type { ParsedMessage } from "../lib/types";
|
||||||
|
import {
|
||||||
|
parseAgentEvents,
|
||||||
|
summarizeToolCall,
|
||||||
|
stripLineNumbers,
|
||||||
|
type AgentEvent,
|
||||||
|
type ParsedAgentProgress,
|
||||||
|
} from "../lib/agent-progress-parser";
|
||||||
|
import { renderMarkdown } from "../lib/markdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: ParsedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function formatTime(ts?: string): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeRange(first?: string, last?: string): string {
|
||||||
|
if (!first) return "";
|
||||||
|
const f = formatTime(first);
|
||||||
|
const l = formatTime(last);
|
||||||
|
if (!l || f === l) return f;
|
||||||
|
return `${f}\u2013${l}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortToolName(name: string): string {
|
||||||
|
if (name.startsWith("mcp__morph-mcp__")) {
|
||||||
|
const short = name.slice("mcp__morph-mcp__".length);
|
||||||
|
if (short === "warpgrep_codebase_search") return "WarpGrep";
|
||||||
|
if (short === "edit_file") return "FastEdit";
|
||||||
|
return short;
|
||||||
|
}
|
||||||
|
if (name.startsWith("mcp__")) {
|
||||||
|
const parts = name.split("__");
|
||||||
|
return parts[parts.length - 1] || name;
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SVG Icons (16x16) ─────────────────────────────────────
|
||||||
|
|
||||||
|
function IconFile() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 1.5H4a1 1 0 00-1 1v11a1 1 0 001 1h8a1 1 0 001-1V5.5L9 1.5z" />
|
||||||
|
<path d="M9 1.5V5.5h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconSearch() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="7" cy="7" r="4.5" />
|
||||||
|
<path d="M10.5 10.5L14 14" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFolder() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 4.5V12a1 1 0 001 1h10a1 1 0 001-1V6a1 1 0 00-1-1H8L6.5 3.5H3a1 1 0 00-1 1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTerminal() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3.5h12a1 1 0 011 1v7a1 1 0 01-1 1H2a1 1 0 01-1-1v-7a1 1 0 011-1z" />
|
||||||
|
<path d="M4 7l2 1.5L4 10" />
|
||||||
|
<path d="M8 10h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPencil() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M11.5 1.5l3 3L5 14H2v-3L11.5 1.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconAgent() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<rect x="3" y="2" width="10" height="8" rx="1" />
|
||||||
|
<circle cx="6" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="10" cy="6" r="1" fill="currentColor" stroke="none" />
|
||||||
|
<path d="M5 13v-3h6v3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconGlobe() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="8" cy="8" r="6.5" />
|
||||||
|
<path d="M1.5 8h13M8 1.5c-2 2.5-2 9.5 0 13M8 1.5c2 2.5 2 9.5 0 13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconWrench() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10 2a4 4 0 00-3.87 5.03L2 11.17V14h2.83l4.14-4.13A4 4 0 0010 2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconCheck() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 8.5l3.5 3.5L13 4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconChat() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 2.5h12a1 1 0 011 1v7a1 1 0 01-1 1H5l-3 3V3.5a1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconNote() {
|
||||||
|
return (
|
||||||
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 2.5h8a1 1 0 011 1v9a1 1 0 01-1 1H4a1 1 0 01-1-1v-9a1 1 0 011-1z" />
|
||||||
|
<path d="M5.5 5.5h5M5.5 8h5M5.5 10.5h3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Icon selection ────────────────────────────────────────
|
||||||
|
|
||||||
|
function ToolIcon({ name }: { name: string }) {
|
||||||
|
const short = shortToolName(name);
|
||||||
|
switch (short) {
|
||||||
|
case "Read": return <IconFile />;
|
||||||
|
case "Grep": case "WarpGrep": return <IconSearch />;
|
||||||
|
case "Glob": return <IconFolder />;
|
||||||
|
case "Bash": return <IconTerminal />;
|
||||||
|
case "Write": case "Edit": case "FastEdit": return <IconPencil />;
|
||||||
|
case "Task": return <IconAgent />;
|
||||||
|
case "WebFetch": case "WebSearch": return <IconGlobe />;
|
||||||
|
default: return <IconWrench />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EventIcon({ event }: { event: AgentEvent }) {
|
||||||
|
switch (event.kind) {
|
||||||
|
case "tool_call": return <ToolIcon name={event.toolName} />;
|
||||||
|
case "tool_result": return <IconCheck />;
|
||||||
|
case "text_response": return <IconChat />;
|
||||||
|
case "user_text": return <IconNote />;
|
||||||
|
case "raw_content": return <IconWrench />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Summary row label ─────────────────────────────────────
|
||||||
|
|
||||||
|
function summaryLabel(event: AgentEvent): string {
|
||||||
|
switch (event.kind) {
|
||||||
|
case "tool_call":
|
||||||
|
return summarizeToolCall(event.toolName, event.input);
|
||||||
|
case "tool_result":
|
||||||
|
return `Result (${event.content.length.toLocaleString()} chars)`;
|
||||||
|
case "text_response":
|
||||||
|
return `Text response (${event.lineCount} lines)`;
|
||||||
|
case "user_text":
|
||||||
|
return event.text.length > 80
|
||||||
|
? event.text.slice(0, 79) + "\u2026"
|
||||||
|
: event.text;
|
||||||
|
case "raw_content":
|
||||||
|
return event.content.length > 60
|
||||||
|
? event.content.slice(0, 59) + "\u2026"
|
||||||
|
: event.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryToolName(event: AgentEvent): string {
|
||||||
|
if (event.kind === "tool_call") return shortToolName(event.toolName);
|
||||||
|
if (event.kind === "tool_result") return "Result";
|
||||||
|
if (event.kind === "text_response") return "Response";
|
||||||
|
if (event.kind === "user_text") return "Prompt";
|
||||||
|
return "Raw";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drill-down content ────────────────────────────────────
|
||||||
|
// All content originates from local JSONL session files owned by the user.
|
||||||
|
// Same trust model as MessageBubble's markdown rendering.
|
||||||
|
// This is a local-only developer tool, not exposed to untrusted input.
|
||||||
|
|
||||||
|
function RenderedMarkdown({ content, label }: { content: string; label?: string }) {
|
||||||
|
const html = useMemo(() => renderMarkdown(content), [content]);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-foreground-muted mb-1">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="prose-message-progress max-h-96 overflow-y-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrillDown({ event }: { event: AgentEvent }) {
|
||||||
|
if (event.kind === "tool_call") {
|
||||||
|
const jsonBlock = "```json\n" + JSON.stringify(event.input, null, 2) + "\n```";
|
||||||
|
return <RenderedMarkdown content={jsonBlock} label="Input" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "tool_result") {
|
||||||
|
// Strip cat-n line number prefixes so hljs can detect syntax,
|
||||||
|
// then wrap in a language-tagged code fence for highlighting.
|
||||||
|
const stripped = stripLineNumbers(event.content);
|
||||||
|
const lang = event.language || "";
|
||||||
|
const wrapped = "```" + lang + "\n" + stripped + "\n```";
|
||||||
|
return <RenderedMarkdown content={wrapped} label="Result" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "text_response") {
|
||||||
|
return <RenderedMarkdown content={event.text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.kind === "user_text") {
|
||||||
|
return <RenderedMarkdown content={event.text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw_content
|
||||||
|
return <RenderedMarkdown content={(event as { content: string }).content} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AgentProgressView({ events }: Props) {
|
||||||
|
const [expandedIndex, setExpandedIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const parsed: ParsedAgentProgress = useMemo(
|
||||||
|
() => parseAgentEvents(events),
|
||||||
|
[events]
|
||||||
|
);
|
||||||
|
|
||||||
|
const promptPreview = parsed.prompt
|
||||||
|
? parsed.prompt.length > 100
|
||||||
|
? parsed.prompt.slice(0, 99) + "\u2026"
|
||||||
|
: parsed.prompt
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="agent-progress-view" className="flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-2.5 py-2 border-b border-border-muted">
|
||||||
|
{promptPreview && (
|
||||||
|
<div
|
||||||
|
data-testid="agent-prompt"
|
||||||
|
className="text-xs text-foreground font-medium leading-snug mb-0.5"
|
||||||
|
>
|
||||||
|
“{promptPreview}”
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-foreground-muted font-mono leading-tight">
|
||||||
|
Agent {parsed.agentId || "unknown"}
|
||||||
|
{" \u00B7 "}
|
||||||
|
{parsed.turnCount} turn{parsed.turnCount !== 1 ? "s" : ""}
|
||||||
|
{parsed.firstTimestamp && (
|
||||||
|
<>
|
||||||
|
{" \u00B7 "}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatTimeRange(parsed.firstTimestamp, parsed.lastTimestamp)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity feed */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{parsed.events.map((event, i) => {
|
||||||
|
const isExpanded = expandedIndex === i;
|
||||||
|
return (
|
||||||
|
<div key={i}>
|
||||||
|
<button
|
||||||
|
data-testid="agent-event-row"
|
||||||
|
onClick={() => setExpandedIndex(isExpanded ? null : i)}
|
||||||
|
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-left font-mono text-[11px] hover:bg-surface-overlay/50 transition-colors ${
|
||||||
|
isExpanded ? "bg-surface-overlay/30" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-14 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(event.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-shrink-0 w-4 text-foreground-muted">
|
||||||
|
<EventIcon event={event} />
|
||||||
|
</span>
|
||||||
|
<span className="w-14 flex-shrink-0 whitespace-nowrap font-semibold text-foreground-secondary">
|
||||||
|
{summaryToolName(event)}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 truncate text-foreground-secondary">
|
||||||
|
{summaryLabel(event)}
|
||||||
|
</span>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 flex-shrink-0 text-foreground-muted transition-transform duration-150 ${
|
||||||
|
isExpanded ? "rotate-90" : ""
|
||||||
|
}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div
|
||||||
|
data-testid="agent-drilldown"
|
||||||
|
className="px-2.5 py-2 bg-surface-inset/50"
|
||||||
|
>
|
||||||
|
<DrillDown event={event} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { CATEGORY_COLORS } from "../lib/constants";
|
|||||||
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
|
||||||
import { redactMessage } from "../../shared/sensitive-redactor";
|
import { redactMessage } from "../../shared/sensitive-redactor";
|
||||||
import { escapeHtml } from "../../shared/escape-html";
|
import { escapeHtml } from "../../shared/escape-html";
|
||||||
|
import { ProgressBadge } from "./ProgressBadge";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
message: ParsedMessage;
|
message: ParsedMessage;
|
||||||
@@ -13,6 +14,8 @@ interface Props {
|
|||||||
selectedForRedaction: boolean;
|
selectedForRedaction: boolean;
|
||||||
onToggleRedactionSelection: () => void;
|
onToggleRedactionSelection: () => void;
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
|
progressEvents?: ParsedMessage[];
|
||||||
|
progressEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +32,8 @@ export function MessageBubble({
|
|||||||
selectedForRedaction,
|
selectedForRedaction,
|
||||||
onToggleRedactionSelection,
|
onToggleRedactionSelection,
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
|
progressEvents,
|
||||||
|
progressEnabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const colors = CATEGORY_COLORS[message.category];
|
const colors = CATEGORY_COLORS[message.category];
|
||||||
const label = CATEGORY_LABELS[message.category];
|
const label = CATEGORY_LABELS[message.category];
|
||||||
@@ -110,7 +115,7 @@ export function MessageBubble({
|
|||||||
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
|
||||||
|
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="flex items-center gap-2 px-4 pl-5 h-10">
|
<div className="flex items-center gap-1.5 px-5 min-h-10 py-2.5">
|
||||||
{isCollapsible && (
|
{isCollapsible && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
|
||||||
@@ -196,15 +201,18 @@ export function MessageBubble({
|
|||||||
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */}
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
className="prose-message text-body text-foreground px-5 pb-4 pt-1 max-w-none break-words overflow-hidden"
|
||||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{collapsed && message.category === "thinking" && collapsedPreview && (
|
{collapsed && message.category === "thinking" && collapsedPreview && (
|
||||||
<div className="px-4 pl-5 pb-3 pt-1">
|
<div className="px-5 pb-3 pt-1">
|
||||||
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
|
||||||
|
<ProgressBadge events={progressEvents} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
240
src/client/components/ProgressBadge.test.tsx
Normal file
240
src/client/components/ProgressBadge.test.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { render, fireEvent } from "@testing-library/react";
|
||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { ProgressBadge } from "./ProgressBadge";
|
||||||
|
import type { ParsedMessage } from "../lib/types";
|
||||||
|
|
||||||
|
// Mock the markdown module to avoid pulling in marked/hljs in jsdom
|
||||||
|
vi.mock("../lib/markdown", () => ({
|
||||||
|
renderMarkdown: (text: string) => `<p>${text}</p>`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makeEvent(
|
||||||
|
overrides: Partial<ParsedMessage> = {}
|
||||||
|
): ParsedMessage {
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
category: "hook_progress",
|
||||||
|
content: "Running pre-commit hook",
|
||||||
|
rawIndex: 0,
|
||||||
|
timestamp: "2025-01-15T10:30:00Z",
|
||||||
|
progressSubtype: "hook",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ProgressBadge", () => {
|
||||||
|
describe("collapsed state", () => {
|
||||||
|
it("shows pill counts but hides event content", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ progressSubtype: "hook" }),
|
||||||
|
makeEvent({ progressSubtype: "hook" }),
|
||||||
|
makeEvent({ progressSubtype: "bash" }),
|
||||||
|
];
|
||||||
|
const { container, queryByText } = render(
|
||||||
|
<ProgressBadge events={events} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pill counts visible
|
||||||
|
expect(container.textContent).toContain("hook: 2");
|
||||||
|
expect(container.textContent).toContain("bash: 1");
|
||||||
|
|
||||||
|
// Event content should NOT be visible when collapsed
|
||||||
|
expect(queryByText("Running pre-commit hook")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("expanded state", () => {
|
||||||
|
it("shows all event content when clicked", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ content: "First event" }),
|
||||||
|
makeEvent({ content: "Second event" }),
|
||||||
|
];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Click to expand
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Content should be visible (rendered as markdown via mock)
|
||||||
|
expect(container.innerHTML).toContain("First event");
|
||||||
|
expect(container.innerHTML).toContain("Second event");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders content through markdown into prose-message-progress container", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({ content: "**bold text**" }),
|
||||||
|
];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Our mock wraps in <p>, so the prose container should have rendered HTML
|
||||||
|
const proseEl = container.querySelector(".prose-message-progress");
|
||||||
|
expect(proseEl).toBeInTheDocument();
|
||||||
|
// Content is from local JSONL session files owned by the user,
|
||||||
|
// same trust model as MessageBubble's markdown rendering
|
||||||
|
expect(proseEl?.innerHTML).toContain("<p>**bold text**</p>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not have max-h-48 or overflow-y-auto on expanded container", () => {
|
||||||
|
const events = [makeEvent()];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
|
||||||
|
expect(expandedDiv).toBeInTheDocument();
|
||||||
|
expect(expandedDiv?.className).not.toContain("max-h-48");
|
||||||
|
expect(expandedDiv?.className).not.toContain("overflow-y-auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not have truncate class on content", () => {
|
||||||
|
const events = [makeEvent()];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Generic (non-agent) expanded view should not truncate
|
||||||
|
const proseElements = container.querySelectorAll(".prose-message-progress");
|
||||||
|
for (const el of proseElements) {
|
||||||
|
expect(el.className).not.toContain("truncate");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays timestamps and subtype badges per event", () => {
|
||||||
|
const events = [
|
||||||
|
makeEvent({
|
||||||
|
timestamp: "2025-01-15T10:30:00Z",
|
||||||
|
progressSubtype: "bash",
|
||||||
|
content: "npm test",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Subtype badge visible
|
||||||
|
expect(container.textContent).toContain("bash");
|
||||||
|
// Timestamp visible (formatted by toLocaleTimeString)
|
||||||
|
const expandedDiv = container.querySelector("[data-testid='progress-expanded']");
|
||||||
|
expect(expandedDiv).toBeInTheDocument();
|
||||||
|
// The timestamp text should exist somewhere in the expanded area
|
||||||
|
expect(expandedDiv?.textContent).toMatch(/\d{1,2}:\d{2}:\d{2}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("handles empty events array", () => {
|
||||||
|
const { container } = render(<ProgressBadge events={[]} />);
|
||||||
|
// Should render without crashing, no pills
|
||||||
|
expect(container.querySelector("button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles missing timestamps", () => {
|
||||||
|
const events = [makeEvent({ timestamp: undefined })];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
expect(container.textContent).toContain("--:--:--");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults undefined subtype to hook", () => {
|
||||||
|
const events = [makeEvent({ progressSubtype: undefined })];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// In pill counts
|
||||||
|
expect(container.textContent).toContain("hook: 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agent subtype delegation", () => {
|
||||||
|
function makeAgentEvent(
|
||||||
|
overrides: Partial<ParsedMessage> = {}
|
||||||
|
): ParsedMessage {
|
||||||
|
const data = {
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_abc",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "/src/foo.ts" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
},
|
||||||
|
type: "agent_progress",
|
||||||
|
prompt: "Explore the codebase",
|
||||||
|
agentId: "a6945d4",
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
category: "hook_progress",
|
||||||
|
content: JSON.stringify(data),
|
||||||
|
rawIndex: 0,
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
progressSubtype: "agent",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders AgentProgressView when all events are agent subtype", () => {
|
||||||
|
const events = [makeAgentEvent(), makeAgentEvent()];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Should render AgentProgressView
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='agent-progress-view']")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// Should NOT render generic prose-message-progress
|
||||||
|
expect(
|
||||||
|
container.querySelector(".prose-message-progress")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders generic list when events are mixed subtypes", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentEvent(),
|
||||||
|
makeEvent({ progressSubtype: "hook" }),
|
||||||
|
];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Expand
|
||||||
|
fireEvent.click(container.querySelector("button")!);
|
||||||
|
|
||||||
|
// Should NOT render AgentProgressView
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='agent-progress-view']")
|
||||||
|
).toBeNull();
|
||||||
|
// Should render generic view
|
||||||
|
expect(
|
||||||
|
container.querySelector(".prose-message-progress")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pills and collapsed state unchanged regardless of subtype", () => {
|
||||||
|
const events = [makeAgentEvent(), makeAgentEvent(), makeAgentEvent()];
|
||||||
|
const { container } = render(<ProgressBadge events={events} />);
|
||||||
|
|
||||||
|
// Pills show agent count
|
||||||
|
expect(container.textContent).toContain("agent: 3");
|
||||||
|
|
||||||
|
// No expanded content initially
|
||||||
|
expect(
|
||||||
|
container.querySelector("[data-testid='progress-expanded']")
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/client/components/ProgressBadge.tsx
Normal file
116
src/client/components/ProgressBadge.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import type { ParsedMessage, ProgressSubtype } from "../lib/types";
|
||||||
|
import { renderMarkdown } from "../lib/markdown";
|
||||||
|
import { AgentProgressView } from "./AgentProgressView";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: ParsedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTYPE_COLORS: Record<ProgressSubtype, string> = {
|
||||||
|
hook: "text-category-hook bg-category-hook/10",
|
||||||
|
bash: "text-category-tool bg-category-tool/10",
|
||||||
|
mcp: "text-category-result bg-category-result/10",
|
||||||
|
agent: "text-category-thinking bg-category-thinking/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(ts?: string): string {
|
||||||
|
if (!ts) return "--:--:--";
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return "--:--:--";
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressBadge({ events }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Count by subtype
|
||||||
|
const counts: Partial<Record<ProgressSubtype, number>> = {};
|
||||||
|
for (const e of events) {
|
||||||
|
const sub = e.progressSubtype || "hook";
|
||||||
|
counts[sub] = (counts[sub] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all events are agent subtype
|
||||||
|
const allAgent = events.length > 0 && events.every(
|
||||||
|
(e) => e.progressSubtype === "agent"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lazily render markdown only when expanded
|
||||||
|
const renderedEvents = useMemo(() => {
|
||||||
|
if (!expanded || allAgent) return [];
|
||||||
|
return events.map((e) => ({
|
||||||
|
uuid: e.uuid,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
subtype: e.progressSubtype || "hook",
|
||||||
|
// Content sourced from local JSONL session files owned by the user.
|
||||||
|
// Same trust model as MessageBubble's markdown rendering.
|
||||||
|
html: renderMarkdown(e.content),
|
||||||
|
}));
|
||||||
|
}, [events, expanded, allAgent]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-1 px-5 pb-3">
|
||||||
|
{/* Pill row */}
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="flex items-center gap-1.5 text-caption"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 text-foreground-muted transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
{(Object.entries(counts) as [ProgressSubtype, number][]).map(
|
||||||
|
([sub, count]) => (
|
||||||
|
<span
|
||||||
|
key={sub}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded font-mono text-[11px] ${SUBTYPE_COLORS[sub]}`}
|
||||||
|
>
|
||||||
|
{sub}: {count}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded drawer */}
|
||||||
|
{expanded && allAgent && (
|
||||||
|
<div
|
||||||
|
data-testid="progress-expanded"
|
||||||
|
className="mt-2 rounded-lg bg-surface-inset border border-border-muted overflow-hidden"
|
||||||
|
>
|
||||||
|
<AgentProgressView events={events} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded && !allAgent && (
|
||||||
|
<div
|
||||||
|
data-testid="progress-expanded"
|
||||||
|
className="mt-2 rounded-lg bg-surface-inset border border-border-muted p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{renderedEvents.map((e) => (
|
||||||
|
<div key={e.uuid} className="flex items-start font-mono text-[11px]">
|
||||||
|
<span className="w-20 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(e.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={`w-16 flex-shrink-0 whitespace-nowrap font-medium ${SUBTYPE_COLORS[e.subtype].split(" ")[0]}`}>
|
||||||
|
{e.subtype}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="prose-message-progress flex-1 min-w-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: e.html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/client/components/ProgressGroup.tsx
Normal file
118
src/client/components/ProgressGroup.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import type { ParsedMessage, ProgressSubtype } from "../lib/types";
|
||||||
|
import { renderMarkdown } from "../lib/markdown";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
events: ParsedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTYPE_COLORS: Record<ProgressSubtype, string> = {
|
||||||
|
hook: "text-category-hook",
|
||||||
|
bash: "text-category-tool",
|
||||||
|
mcp: "text-category-result",
|
||||||
|
agent: "text-category-thinking",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTime(ts?: string): string {
|
||||||
|
if (!ts) return "";
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
return d.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSummary(events: ParsedMessage[]): string {
|
||||||
|
const counts: Partial<Record<ProgressSubtype, number>> = {};
|
||||||
|
for (const e of events) {
|
||||||
|
const sub = e.progressSubtype || "hook";
|
||||||
|
counts[sub] = (counts[sub] || 0) + 1;
|
||||||
|
}
|
||||||
|
const parts = (Object.entries(counts) as [ProgressSubtype, number][]).map(
|
||||||
|
([sub, count]) => `${count} ${sub}`
|
||||||
|
);
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeRange(events: ParsedMessage[]): string {
|
||||||
|
const timestamps = events
|
||||||
|
.map((e) => e.timestamp)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
if (timestamps.length === 0) return "";
|
||||||
|
const first = formatTime(timestamps[0]);
|
||||||
|
const last = formatTime(timestamps[timestamps.length - 1]);
|
||||||
|
if (first === last) return first;
|
||||||
|
return `${first}\u2013${last}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressGroup({ events }: Props) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const summary = buildSummary(events);
|
||||||
|
const range = timeRange(events);
|
||||||
|
|
||||||
|
// Lazily render markdown only when expanded
|
||||||
|
const renderedEvents = useMemo(() => {
|
||||||
|
if (!expanded) return [];
|
||||||
|
return events.map((e) => ({
|
||||||
|
uuid: e.uuid,
|
||||||
|
timestamp: e.timestamp,
|
||||||
|
subtype: e.progressSubtype || "hook",
|
||||||
|
// Content sourced from local JSONL session files owned by the user.
|
||||||
|
// Same trust model as MessageBubble's markdown rendering.
|
||||||
|
// This is a local-only developer tool, not exposed to untrusted input.
|
||||||
|
html: renderMarkdown(e.content),
|
||||||
|
}));
|
||||||
|
}, [events, expanded]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="w-full flex items-center gap-3 group/pg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
<span className="text-[11px] font-mono text-foreground-muted flex-shrink-0 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className={`w-3 h-3 transition-transform duration-150 ${expanded ? "rotate-90" : ""}`}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
{events.length} progress event{events.length !== 1 ? "s" : ""}
|
||||||
|
<span className="text-foreground-muted/60">·</span>
|
||||||
|
{summary}
|
||||||
|
{range && (
|
||||||
|
<>
|
||||||
|
<span className="text-foreground-muted/60">·</span>
|
||||||
|
<span className="tabular-nums">{range}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-2 mb-1 mx-auto max-w-5xl rounded-lg bg-surface-inset border border-border-muted p-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{renderedEvents.map((e) => (
|
||||||
|
<div key={e.uuid} className="flex items-start font-mono text-[11px]">
|
||||||
|
<span className="w-20 flex-shrink-0 whitespace-nowrap tabular-nums text-foreground-muted">
|
||||||
|
{formatTime(e.timestamp)}
|
||||||
|
</span>
|
||||||
|
<span className={`w-16 flex-shrink-0 whitespace-nowrap font-medium ${SUBTYPE_COLORS[e.subtype]}`}>
|
||||||
|
{e.subtype}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className="prose-message-progress flex-1 min-w-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: e.html }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ export function SearchBar({
|
|||||||
const showControls = !!localQuery || !!query;
|
const showControls = !!localQuery || !!query;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 sm:w-96">
|
<div className="min-w-80 max-w-md w-full">
|
||||||
{/* Unified search container */}
|
{/* Unified search container */}
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
@@ -137,7 +137,7 @@ export function SearchBar({
|
|||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
placeholder="Search messages..."
|
placeholder="Search messages..."
|
||||||
className="flex-1 min-w-0 bg-transparent px-2.5 py-2 text-body text-foreground
|
className="flex-1 min-w-0 bg-transparent pl-3 pr-2.5 py-2 text-body text-foreground
|
||||||
placeholder:text-foreground-muted
|
placeholder:text-foreground-muted
|
||||||
focus:outline-none"
|
focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useMemo } from "react";
|
||||||
import type { SessionEntry } from "../lib/types";
|
import type { SessionEntry } from "../lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,13 +11,16 @@ interface Props {
|
|||||||
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
|
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
|
||||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
|
||||||
// Group by project
|
// Group by project (memoized to avoid recomputing on unrelated rerenders)
|
||||||
const grouped = new Map<string, SessionEntry[]>();
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, SessionEntry[]>();
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
const group = grouped.get(session.project) || [];
|
const group = map.get(session.project) || [];
|
||||||
group.push(session);
|
group.push(session);
|
||||||
grouped.set(session.project, group);
|
map.set(session.project, group);
|
||||||
}
|
}
|
||||||
|
return map;
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
// Auto-select project when selectedId changes
|
// Auto-select project when selectedId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,7 +76,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
<div className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted" style={{ background: "var(--color-surface-inset)" }}>
|
<div className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted" style={{ background: "var(--color-surface-inset)" }}>
|
||||||
{formatProjectName(selectedProject)}
|
{formatProjectName(selectedProject)}
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1">
|
<div className="py-1 px-2">
|
||||||
{projectSessions.map((session, idx) => {
|
{projectSessions.map((session, idx) => {
|
||||||
const isSelected = selectedId === session.id;
|
const isSelected = selectedId === session.id;
|
||||||
return (
|
return (
|
||||||
@@ -81,13 +84,13 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => onSelect(session.id)}
|
onClick={() => onSelect(session.id)}
|
||||||
className={`
|
className={`
|
||||||
w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg transition-all duration-200
|
w-full text-left my-0.5 px-3 py-2.5 rounded-lg transition-all duration-200
|
||||||
${isSelected
|
${isSelected
|
||||||
? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25"
|
? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25"
|
||||||
: "hover:bg-surface-overlay"
|
: "hover:bg-surface-overlay"
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
style={{ width: "calc(100% - 1rem)", animationDelay: `${idx * 30}ms` }}
|
style={{ animationDelay: `${idx * 30}ms` }}
|
||||||
>
|
>
|
||||||
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
|
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
|
||||||
{session.summary || session.firstPrompt || "Untitled Session"}
|
{session.summary || session.firstPrompt || "Untitled Session"}
|
||||||
@@ -113,7 +116,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
|
|
||||||
// Project list
|
// Project list
|
||||||
return (
|
return (
|
||||||
<div className="py-1 animate-fade-in">
|
<div className="py-1 px-2 animate-fade-in">
|
||||||
{[...grouped.entries()].map(([project, projectSessions]) => {
|
{[...grouped.entries()].map(([project, projectSessions]) => {
|
||||||
const latest = projectSessions.reduce((a, b) =>
|
const latest = projectSessions.reduce((a, b) =>
|
||||||
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
(a.modified || a.created) > (b.modified || b.created) ? a : b
|
||||||
@@ -123,8 +126,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
|
|||||||
<button
|
<button
|
||||||
key={project}
|
key={project}
|
||||||
onClick={() => setSelectedProject(project)}
|
onClick={() => setSelectedProject(project)}
|
||||||
className="w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
|
className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
|
||||||
style={{ width: "calc(100% - 1rem)" }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-body font-medium text-foreground truncate">
|
<div className="text-body font-medium text-foreground truncate">
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface Props {
|
|||||||
onToggleRedactionSelection: (uuid: string) => void;
|
onToggleRedactionSelection: (uuid: string) => void;
|
||||||
autoRedactEnabled: boolean;
|
autoRedactEnabled: boolean;
|
||||||
focusedIndex?: number;
|
focusedIndex?: number;
|
||||||
|
toolProgress?: Record<string, ParsedMessage[]>;
|
||||||
|
progressEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
|
||||||
@@ -44,6 +46,8 @@ export function SessionViewer({
|
|||||||
onToggleRedactionSelection,
|
onToggleRedactionSelection,
|
||||||
autoRedactEnabled,
|
autoRedactEnabled,
|
||||||
focusedIndex = -1,
|
focusedIndex = -1,
|
||||||
|
toolProgress,
|
||||||
|
progressEnabled,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -58,10 +62,21 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
}, [focusedIndex]);
|
}, [focusedIndex]);
|
||||||
|
|
||||||
// Auto-scroll to hash anchor on load
|
// Auto-scroll to hash anchor on initial session load only.
|
||||||
|
// Track whether we've already scrolled for this session to avoid
|
||||||
|
// re-triggering when filter toggles change messages.length.
|
||||||
|
const hashScrolledRef = useRef(false);
|
||||||
|
|
||||||
|
// Reset the flag when the underlying session data changes (new session loaded)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
hashScrolledRef.current = false;
|
||||||
|
}, [allMessages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hashScrolledRef.current) return;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash;
|
||||||
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
|
||||||
|
hashScrolledRef.current = true;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = document.getElementById(hash.slice(1));
|
const el = document.getElementById(hash.slice(1));
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -79,6 +94,7 @@ export function SessionViewer({
|
|||||||
if (messages.length === 0) return [];
|
if (messages.length === 0) return [];
|
||||||
|
|
||||||
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
const visibleUuids = new Set(messages.map((m) => m.uuid));
|
||||||
|
|
||||||
const items: Array<
|
const items: Array<
|
||||||
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
| { type: "message"; message: ParsedMessage; messageIndex: number }
|
||||||
| { type: "redacted_divider"; key: string }
|
| { type: "redacted_divider"; key: string }
|
||||||
@@ -88,14 +104,17 @@ export function SessionViewer({
|
|||||||
let prevWasRedactedGap = false;
|
let prevWasRedactedGap = false;
|
||||||
let prevTimestamp: string | undefined;
|
let prevTimestamp: string | undefined;
|
||||||
let messageIndex = 0;
|
let messageIndex = 0;
|
||||||
|
|
||||||
for (const msg of allMessages) {
|
for (const msg of allMessages) {
|
||||||
if (redactedUuids.has(msg.uuid)) {
|
if (redactedUuids.has(msg.uuid)) {
|
||||||
prevWasRedactedGap = true;
|
prevWasRedactedGap = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!visibleUuids.has(msg.uuid)) {
|
if (!visibleUuids.has(msg.uuid)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prevWasRedactedGap) {
|
if (prevWasRedactedGap) {
|
||||||
items.push({
|
items.push({
|
||||||
type: "redacted_divider",
|
type: "redacted_divider",
|
||||||
@@ -174,8 +193,8 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-6 py-5">
|
<div className="max-w-6xl mx-auto px-6 py-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<span className="text-caption text-foreground-muted tabular-nums">
|
<span className="text-caption text-foreground-muted tabular-nums">
|
||||||
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
{messages.length} message{messages.length !== 1 ? "s" : ""}
|
||||||
</span>
|
</span>
|
||||||
@@ -187,7 +206,7 @@ export function SessionViewer({
|
|||||||
}
|
}
|
||||||
if (item.type === "time_gap") {
|
if (item.type === "time_gap") {
|
||||||
return (
|
return (
|
||||||
<div key={item.key} className="flex items-center gap-3 py-2">
|
<div key={item.key} className="flex items-center gap-3 py-3">
|
||||||
<div className="flex-1 h-px bg-border-muted" />
|
<div className="flex-1 h-px bg-border-muted" />
|
||||||
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
|
||||||
{item.duration} later
|
{item.duration} later
|
||||||
@@ -204,6 +223,11 @@ export function SessionViewer({
|
|||||||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
|
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
|
||||||
const isDimmed = searchQuery && !isMatch;
|
const isDimmed = searchQuery && !isMatch;
|
||||||
const isFocused = item.messageIndex === focusedIndex;
|
const isFocused = item.messageIndex === focusedIndex;
|
||||||
|
// Look up progress events for this tool_call
|
||||||
|
const progressEvents =
|
||||||
|
msg.category === "tool_call" && msg.toolUseId && toolProgress
|
||||||
|
? toolProgress[msg.toolUseId]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={msg.uuid}
|
key={msg.uuid}
|
||||||
@@ -221,6 +245,8 @@ export function SessionViewer({
|
|||||||
onToggleRedactionSelection(msg.uuid)
|
onToggleRedactionSelection(msg.uuid)
|
||||||
}
|
}
|
||||||
autoRedactEnabled={autoRedactEnabled}
|
autoRedactEnabled={autoRedactEnabled}
|
||||||
|
progressEvents={progressEvents}
|
||||||
|
progressEnabled={progressEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export function Tooltip({ content, children, delayMs = 150, side = "top" }: Prop
|
|||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: position.x,
|
left: position.x,
|
||||||
top: side === "top" ? position.y - 8 : position.y + 8,
|
top: side === "top" ? position.y - 12 : position.y + 12,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
data-side={side}
|
data-side={side}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import type { SessionEntry, SessionDetailResponse } from "../lib/types";
|
import type { SessionEntry, SessionDetailResponse } from "../lib/types";
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
@@ -22,6 +22,9 @@ export function useSession(): SessionState {
|
|||||||
const [sessionLoading, setSessionLoading] = useState(false);
|
const [sessionLoading, setSessionLoading] = useState(false);
|
||||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Track in-flight session request to prevent stale responses
|
||||||
|
const sessionAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchSessions = useCallback(async (refresh = false) => {
|
const fetchSessions = useCallback(async (refresh = false) => {
|
||||||
setSessionsLoading(true);
|
setSessionsLoading(true);
|
||||||
setSessionsError(null);
|
setSessionsError(null);
|
||||||
@@ -44,20 +47,32 @@ export function useSession(): SessionState {
|
|||||||
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
|
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
|
||||||
|
|
||||||
const loadSession = useCallback(async (id: string) => {
|
const loadSession = useCallback(async (id: string) => {
|
||||||
|
// Abort any in-flight request to prevent stale responses
|
||||||
|
sessionAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
sessionAbortRef.current = controller;
|
||||||
|
|
||||||
setSessionLoading(true);
|
setSessionLoading(true);
|
||||||
setSessionError(null);
|
setSessionError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`);
|
const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setCurrentSession(data);
|
setCurrentSession(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Ignore aborted requests — a newer request superseded this one
|
||||||
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
setSessionError(
|
setSessionError(
|
||||||
err instanceof Error ? err.message : "Failed to load session"
|
err instanceof Error ? err.message : "Failed to load session"
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
|
// Only clear loading if this controller wasn't superseded
|
||||||
|
if (sessionAbortRef.current === controller) {
|
||||||
setSessionLoading(false);
|
setSessionLoading(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
308
src/client/lib/agent-progress-parser.ts
Normal file
308
src/client/lib/agent-progress-parser.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import type { ParsedMessage } from "../lib/types";
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface AgentToolCall {
|
||||||
|
kind: "tool_call";
|
||||||
|
toolName: string;
|
||||||
|
toolUseId: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentToolResult {
|
||||||
|
kind: "tool_result";
|
||||||
|
toolUseId: string;
|
||||||
|
content: string;
|
||||||
|
timestamp?: string;
|
||||||
|
/** Language hint derived from the preceding tool_call's file path */
|
||||||
|
language?: string;
|
||||||
|
/** Tool name of the preceding tool_call (Read, Grep, Bash, etc.) */
|
||||||
|
sourceTool?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentTextResponse {
|
||||||
|
kind: "text_response";
|
||||||
|
text: string;
|
||||||
|
lineCount: number;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentUserText {
|
||||||
|
kind: "user_text";
|
||||||
|
text: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentRawContent {
|
||||||
|
kind: "raw_content";
|
||||||
|
content: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AgentEvent =
|
||||||
|
| AgentToolCall
|
||||||
|
| AgentToolResult
|
||||||
|
| AgentTextResponse
|
||||||
|
| AgentUserText
|
||||||
|
| AgentRawContent;
|
||||||
|
|
||||||
|
export interface ParsedAgentProgress {
|
||||||
|
events: AgentEvent[];
|
||||||
|
prompt?: string;
|
||||||
|
agentId?: string;
|
||||||
|
firstTimestamp?: string;
|
||||||
|
lastTimestamp?: string;
|
||||||
|
turnCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content block types from agent progress JSON ───────────
|
||||||
|
|
||||||
|
interface ContentBlock {
|
||||||
|
type: string;
|
||||||
|
id?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
tool_use_id?: string;
|
||||||
|
content?: string | ContentBlock[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentProgressData {
|
||||||
|
message?: {
|
||||||
|
type?: string;
|
||||||
|
message?: {
|
||||||
|
role?: string;
|
||||||
|
content?: ContentBlock[];
|
||||||
|
};
|
||||||
|
timestamp?: string;
|
||||||
|
};
|
||||||
|
type?: string;
|
||||||
|
prompt?: string;
|
||||||
|
agentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsing ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function parseAgentEvents(
|
||||||
|
rawEvents: ParsedMessage[]
|
||||||
|
): ParsedAgentProgress {
|
||||||
|
if (rawEvents.length === 0) {
|
||||||
|
return { events: [], turnCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: AgentEvent[] = [];
|
||||||
|
let prompt: string | undefined;
|
||||||
|
let agentId: string | undefined;
|
||||||
|
const timestamps: string[] = [];
|
||||||
|
|
||||||
|
for (const raw of rawEvents) {
|
||||||
|
let data: AgentProgressData;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(raw.content);
|
||||||
|
} catch {
|
||||||
|
events.push({
|
||||||
|
kind: "raw_content",
|
||||||
|
content: raw.content,
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
});
|
||||||
|
if (raw.timestamp) timestamps.push(raw.timestamp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract metadata from first event that has it
|
||||||
|
if (!prompt && data.prompt) prompt = data.prompt;
|
||||||
|
if (!agentId && data.agentId) agentId = data.agentId;
|
||||||
|
|
||||||
|
const msg = data.message;
|
||||||
|
const ts = msg?.timestamp || raw.timestamp;
|
||||||
|
if (ts) timestamps.push(ts);
|
||||||
|
|
||||||
|
const contentBlocks = msg?.message?.content;
|
||||||
|
if (!Array.isArray(contentBlocks)) {
|
||||||
|
events.push({
|
||||||
|
kind: "raw_content",
|
||||||
|
content: raw.content,
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgType = msg?.type; // "user" | "assistant"
|
||||||
|
|
||||||
|
for (const block of contentBlocks) {
|
||||||
|
if (block.type === "tool_use") {
|
||||||
|
events.push({
|
||||||
|
kind: "tool_call",
|
||||||
|
toolName: block.name || "unknown",
|
||||||
|
toolUseId: block.id || "",
|
||||||
|
input: block.input || {},
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
} else if (block.type === "tool_result") {
|
||||||
|
const resultContent =
|
||||||
|
typeof block.content === "string"
|
||||||
|
? block.content
|
||||||
|
: Array.isArray(block.content)
|
||||||
|
? block.content
|
||||||
|
.map((b: ContentBlock) => b.text || "")
|
||||||
|
.join("")
|
||||||
|
: JSON.stringify(block.content);
|
||||||
|
events.push({
|
||||||
|
kind: "tool_result",
|
||||||
|
toolUseId: block.tool_use_id || "",
|
||||||
|
content: resultContent,
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
} else if (block.type === "text" && block.text) {
|
||||||
|
if (msgType === "user") {
|
||||||
|
events.push({
|
||||||
|
kind: "user_text",
|
||||||
|
text: block.text,
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const lineCount = block.text.split("\n").length;
|
||||||
|
events.push({
|
||||||
|
kind: "text_response",
|
||||||
|
text: block.text,
|
||||||
|
lineCount,
|
||||||
|
timestamp: ts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-pass: link tool_results to their preceding tool_calls
|
||||||
|
const callMap = new Map<string, AgentToolCall>();
|
||||||
|
for (const ev of events) {
|
||||||
|
if (ev.kind === "tool_call") {
|
||||||
|
callMap.set(ev.toolUseId, ev);
|
||||||
|
} else if (ev.kind === "tool_result" && ev.toolUseId) {
|
||||||
|
const call = callMap.get(ev.toolUseId);
|
||||||
|
if (call) {
|
||||||
|
ev.sourceTool = call.toolName;
|
||||||
|
const filePath = extractFilePath(call);
|
||||||
|
if (filePath) {
|
||||||
|
ev.language = languageFromPath(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const turnCount = events.filter((e) => e.kind === "tool_call").length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
prompt,
|
||||||
|
agentId,
|
||||||
|
firstTimestamp: timestamps[0],
|
||||||
|
lastTimestamp: timestamps[timestamps.length - 1],
|
||||||
|
turnCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Language detection ──────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFilePath(call: AgentToolCall): string | undefined {
|
||||||
|
return (call.input.file_path as string) || (call.input.path as string) || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
|
||||||
|
py: "python", rb: "ruby", go: "go", rs: "rust",
|
||||||
|
java: "java", kt: "kotlin", swift: "swift",
|
||||||
|
c: "c", cpp: "cpp", h: "c", hpp: "cpp",
|
||||||
|
css: "css", scss: "scss", less: "less",
|
||||||
|
html: "html", xml: "xml", svg: "xml",
|
||||||
|
json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
|
||||||
|
md: "markdown", sh: "bash", bash: "bash", zsh: "bash",
|
||||||
|
sql: "sql", graphql: "graphql",
|
||||||
|
dockerfile: "dockerfile",
|
||||||
|
};
|
||||||
|
|
||||||
|
function languageFromPath(filePath: string): string | undefined {
|
||||||
|
const basename = filePath.split("/").pop() || "";
|
||||||
|
if (basename.toLowerCase() === "dockerfile") return "dockerfile";
|
||||||
|
const ext = basename.split(".").pop()?.toLowerCase();
|
||||||
|
if (!ext) return undefined;
|
||||||
|
return EXT_TO_LANG[ext];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip `cat -n` style line number prefixes (e.g. " 1→" or " 42→")
|
||||||
|
* so syntax highlighters can detect the language.
|
||||||
|
*/
|
||||||
|
export function stripLineNumbers(text: string): string {
|
||||||
|
// Match lines starting with optional spaces, digits, then → (the arrow from cat -n)
|
||||||
|
const lines = text.split("\n");
|
||||||
|
if (lines.length < 2) return text;
|
||||||
|
// Check if most lines have the pattern
|
||||||
|
const pattern = /^\s*\d+\u2192/;
|
||||||
|
const matchCount = lines.filter((l) => pattern.test(l) || l.trim() === "").length;
|
||||||
|
if (matchCount < lines.length * 0.5) return text;
|
||||||
|
return lines
|
||||||
|
.map((l) => {
|
||||||
|
const match = l.match(/^\s*\d+\u2192(.*)/);
|
||||||
|
return match ? match[1] : l;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool call summarization ────────────────────────────────
|
||||||
|
|
||||||
|
function stripLeadingSlash(p: string): string {
|
||||||
|
return p.startsWith("/") ? p.slice(1) : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string, max: number): string {
|
||||||
|
return s.length <= max ? s : s.slice(0, max - 1) + "\u2026";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeToolCall(
|
||||||
|
toolName: string,
|
||||||
|
input: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
const filePath = input.file_path as string | undefined;
|
||||||
|
const path = input.path as string | undefined;
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case "Read":
|
||||||
|
return filePath ? stripLeadingSlash(filePath) : "Read";
|
||||||
|
case "Write":
|
||||||
|
return filePath ? stripLeadingSlash(filePath) : "Write";
|
||||||
|
case "Edit":
|
||||||
|
return filePath ? stripLeadingSlash(filePath) : "Edit";
|
||||||
|
case "mcp__morph-mcp__edit_file":
|
||||||
|
return path ? stripLeadingSlash(path) : "edit_file";
|
||||||
|
case "Grep": {
|
||||||
|
const pattern = input.pattern as string | undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (pattern) parts.push(`"${pattern}"`);
|
||||||
|
if (path) parts.push(`in ${stripLeadingSlash(path)}`);
|
||||||
|
return parts.length > 0 ? parts.join(" ") : "Grep";
|
||||||
|
}
|
||||||
|
case "Glob": {
|
||||||
|
const pattern = input.pattern as string | undefined;
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (pattern) parts.push(pattern);
|
||||||
|
if (path) parts.push(`in ${stripLeadingSlash(path)}`);
|
||||||
|
return parts.length > 0 ? parts.join(" ") : "Glob";
|
||||||
|
}
|
||||||
|
case "Bash": {
|
||||||
|
const command = input.command as string | undefined;
|
||||||
|
return command ? truncate(command, 80) : "Bash";
|
||||||
|
}
|
||||||
|
case "Task": {
|
||||||
|
const desc = input.description as string | undefined;
|
||||||
|
return desc ? truncate(desc, 60) : "Task";
|
||||||
|
}
|
||||||
|
case "mcp__morph-mcp__warpgrep_codebase_search": {
|
||||||
|
const searchString = input.search_string as string | undefined;
|
||||||
|
return searchString ? truncate(searchString, 60) : "codebase_search";
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return toolName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export type {
|
export type {
|
||||||
MessageCategory,
|
MessageCategory,
|
||||||
ParsedMessage,
|
ParsedMessage,
|
||||||
|
ProgressSubtype,
|
||||||
SessionEntry,
|
SessionEntry,
|
||||||
SessionListResponse,
|
SessionListResponse,
|
||||||
SessionDetailResponse,
|
SessionDetailResponse,
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ mark.search-highlight {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 4px;
|
height: 3px;
|
||||||
background: rgba(254, 240, 138, 0.7);
|
background: rgba(254, 240, 138, 0.7);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -362,7 +362,7 @@ mark.search-highlight {
|
|||||||
.prose-message p {
|
.prose-message p {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
line-height: 1.625;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-message p:first-child {
|
.prose-message p:first-child {
|
||||||
@@ -383,7 +383,7 @@ mark.search-highlight {
|
|||||||
.prose-message li {
|
.prose-message li {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-message code:not(pre code) {
|
.prose-message code:not(pre code) {
|
||||||
@@ -445,6 +445,116 @@ mark.search-highlight {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════
|
||||||
|
Progress markdown (compact variant)
|
||||||
|
═══════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.prose-message-progress {
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress h1,
|
||||||
|
.prose-message-progress h2,
|
||||||
|
.prose-message-progress h3,
|
||||||
|
.prose-message-progress h4,
|
||||||
|
.prose-message-progress h5,
|
||||||
|
.prose-message-progress h6 {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress p {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress ul,
|
||||||
|
.prose-message-progress ol {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress li {
|
||||||
|
margin-top: 0.125rem;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress code:not(pre code) {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.0625rem 0.25rem;
|
||||||
|
border-radius: 0.1875rem;
|
||||||
|
background: var(--color-surface-inset);
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
color: #c4a1ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress pre {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress blockquote {
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
padding-left: 0.5rem;
|
||||||
|
color: var(--color-foreground-secondary);
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
text-decoration-color: rgba(91, 156, 245, 0.3);
|
||||||
|
transition: text-decoration-color 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress a:hover {
|
||||||
|
color: var(--color-accent-dark);
|
||||||
|
text-decoration-color: rgba(125, 180, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 0.375rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress th,
|
||||||
|
.prose-message-progress td {
|
||||||
|
border: 1px solid var(--color-border-muted);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose-message-progress th {
|
||||||
|
background: var(--color-surface-inset);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════
|
||||||
Focus ring system
|
Focus ring system
|
||||||
═══════════════════════════════════════════════ */
|
═══════════════════════════════════════════════ */
|
||||||
@@ -481,10 +591,10 @@ mark.search-highlight {
|
|||||||
.custom-checkbox:checked::after {
|
.custom-checkbox:checked::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 1px;
|
top: 6.25%;
|
||||||
left: 4px;
|
left: 25%;
|
||||||
width: 5px;
|
width: 31.25%;
|
||||||
height: 9px;
|
height: 56.25%;
|
||||||
border: solid #0c1017;
|
border: solid #0c1017;
|
||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { discoverSessions } from "../services/session-discovery.js";
|
import { discoverSessions } from "../services/session-discovery.js";
|
||||||
import { parseSession } from "../services/session-parser.js";
|
import { parseSession } from "../services/session-parser.js";
|
||||||
|
import { groupProgress } from "../services/progress-grouper.js";
|
||||||
import type { SessionEntry } from "../../shared/types.js";
|
import type { SessionEntry } from "../../shared/types.js";
|
||||||
|
|
||||||
export const sessionsRouter = Router();
|
export const sessionsRouter = Router();
|
||||||
@@ -8,13 +9,30 @@ export const sessionsRouter = Router();
|
|||||||
// Simple cache to avoid re-discovering sessions on every detail request
|
// Simple cache to avoid re-discovering sessions on every detail request
|
||||||
let cachedSessions: SessionEntry[] = [];
|
let cachedSessions: SessionEntry[] = [];
|
||||||
let cacheTimestamp = 0;
|
let cacheTimestamp = 0;
|
||||||
|
let cachePromise: Promise<SessionEntry[]> | null = null;
|
||||||
|
let cacheGeneration = 0;
|
||||||
const CACHE_TTL_MS = 30_000;
|
const CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
async function getCachedSessions(): Promise<SessionEntry[]> {
|
async function getCachedSessions(): Promise<SessionEntry[]> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - cacheTimestamp > CACHE_TTL_MS) {
|
if (now - cacheTimestamp > CACHE_TTL_MS) {
|
||||||
cachedSessions = await discoverSessions();
|
// Deduplicate concurrent calls: reuse in-flight promise
|
||||||
cacheTimestamp = now;
|
if (!cachePromise) {
|
||||||
|
const gen = ++cacheGeneration;
|
||||||
|
cachePromise = discoverSessions().then((sessions) => {
|
||||||
|
// Only write cache if no newer generation has started
|
||||||
|
if (gen === cacheGeneration) {
|
||||||
|
cachedSessions = sessions;
|
||||||
|
cacheTimestamp = Date.now();
|
||||||
|
}
|
||||||
|
cachePromise = null;
|
||||||
|
return sessions;
|
||||||
|
}).catch((err) => {
|
||||||
|
cachePromise = null;
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return cachePromise;
|
||||||
}
|
}
|
||||||
return cachedSessions;
|
return cachedSessions;
|
||||||
}
|
}
|
||||||
@@ -23,6 +41,7 @@ sessionsRouter.get("/", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
if (req.query.refresh === "1") {
|
if (req.query.refresh === "1") {
|
||||||
cacheTimestamp = 0;
|
cacheTimestamp = 0;
|
||||||
|
cachePromise = null; // Discard any in-flight request so we force a fresh discovery
|
||||||
}
|
}
|
||||||
const sessions = await getCachedSessions();
|
const sessions = await getCachedSessions();
|
||||||
res.json({ sessions });
|
res.json({ sessions });
|
||||||
@@ -40,11 +59,13 @@ sessionsRouter.get("/:id", async (req, res) => {
|
|||||||
res.status(404).json({ error: "Session not found" });
|
res.status(404).json({ error: "Session not found" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const messages = await parseSession(entry.path);
|
const allMessages = await parseSession(entry.path);
|
||||||
|
const { messages, toolProgress } = groupProgress(allMessages);
|
||||||
res.json({
|
res.json({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
project: entry.project,
|
project: entry.project,
|
||||||
messages,
|
messages,
|
||||||
|
toolProgress,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to load session:", err);
|
console.error("Failed to load session:", err);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import hljs from "highlight.js";
|
import hljs from "highlight.js";
|
||||||
import { markedHighlight } from "marked-highlight";
|
import { markedHighlight } from "marked-highlight";
|
||||||
import type { ExportRequest, ParsedMessage } from "../../shared/types.js";
|
import type { ExportRequest, ParsedMessage, ProgressSubtype } from "../../shared/types.js";
|
||||||
import { CATEGORY_LABELS } from "../../shared/types.js";
|
import { CATEGORY_LABELS } from "../../shared/types.js";
|
||||||
import { redactMessage } from "../../shared/sensitive-redactor.js";
|
import { redactMessage } from "../../shared/sensitive-redactor.js";
|
||||||
import { escapeHtml } from "../../shared/escape-html.js";
|
import { escapeHtml } from "../../shared/escape-html.js";
|
||||||
@@ -22,7 +22,11 @@ marked.use(
|
|||||||
|
|
||||||
// Categories whose content is structured data (JSON, logs, snapshots) — not markdown.
|
// Categories whose content is structured data (JSON, logs, snapshots) — not markdown.
|
||||||
// Rendered as preformatted text to avoid the cost of markdown parsing on large blobs.
|
// Rendered as preformatted text to avoid the cost of markdown parsing on large blobs.
|
||||||
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "tool_result", "file_snapshot"]);
|
// Note: tool_result is handled explicitly in renderMessage() for diff detection.
|
||||||
|
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "file_snapshot"]);
|
||||||
|
|
||||||
|
// Categories that render collapsed by default
|
||||||
|
const COLLAPSIBLE_CATEGORIES = new Set(["thinking", "tool_call", "tool_result"]);
|
||||||
|
|
||||||
// Category dot/border colors matching the client-side design
|
// Category dot/border colors matching the client-side design
|
||||||
const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = {
|
const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = {
|
||||||
@@ -37,10 +41,19 @@ const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: strin
|
|||||||
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
|
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Progress subtype colors for the export badge pills
|
||||||
|
const PROGRESS_SUBTYPE_COLORS: Record<ProgressSubtype, { text: string; bg: string }> = {
|
||||||
|
hook: { text: "#484f58", bg: "rgba(72,79,88,0.1)" },
|
||||||
|
bash: { text: "#d29922", bg: "rgba(210,153,34,0.1)" },
|
||||||
|
mcp: { text: "#8b8cf8", bg: "rgba(139,140,248,0.1)" },
|
||||||
|
agent: { text: "#bc8cff", bg: "rgba(188,140,255,0.1)" },
|
||||||
|
};
|
||||||
|
|
||||||
export async function generateExportHtml(
|
export async function generateExportHtml(
|
||||||
req: ExportRequest
|
req: ExportRequest
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req;
|
const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req;
|
||||||
|
const toolProgress = session.toolProgress || {};
|
||||||
|
|
||||||
const visibleSet = new Set(visibleMessageUuids);
|
const visibleSet = new Set(visibleMessageUuids);
|
||||||
const redactedSet = new Set(redactedMessageUuids);
|
const redactedSet = new Set(redactedMessageUuids);
|
||||||
@@ -65,7 +78,8 @@ export async function generateExportHtml(
|
|||||||
lastWasRedacted = false;
|
lastWasRedacted = false;
|
||||||
}
|
}
|
||||||
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
|
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
|
||||||
messageHtmlParts.push(renderMessage(msgToRender));
|
const progressEvents = msg.toolUseId ? toolProgress[msg.toolUseId] : undefined;
|
||||||
|
messageHtmlParts.push(renderMessage(msgToRender, progressEvents));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hljsCss = getHighlightCss();
|
const hljsCss = getHighlightCss();
|
||||||
@@ -108,6 +122,9 @@ ${hljsCss}
|
|||||||
${messageHtmlParts.join("\n ")}
|
${messageHtmlParts.join("\n ")}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
${getExportJs()}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -123,9 +140,11 @@ function renderRedactedDivider(): string {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMessage(msg: ParsedMessage): string {
|
function renderMessage(msg: ParsedMessage, progressEvents?: ParsedMessage[]): string {
|
||||||
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
|
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
|
||||||
const label = CATEGORY_LABELS[msg.category];
|
const label = CATEGORY_LABELS[msg.category];
|
||||||
|
const isCollapsible = COLLAPSIBLE_CATEGORIES.has(msg.category);
|
||||||
|
|
||||||
let bodyHtml: string;
|
let bodyHtml: string;
|
||||||
|
|
||||||
if (msg.category === "tool_call") {
|
if (msg.category === "tool_call") {
|
||||||
@@ -133,10 +152,11 @@ function renderMessage(msg: ParsedMessage): string {
|
|||||||
? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
|
||||||
: "";
|
: "";
|
||||||
bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
|
bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
|
||||||
|
} else if (msg.category === "tool_result") {
|
||||||
|
bodyHtml = isDiffContent(msg.content)
|
||||||
|
? renderDiffHtml(msg.content)
|
||||||
|
: `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
||||||
} else if (PREFORMATTED_CATEGORIES.has(msg.category)) {
|
} else if (PREFORMATTED_CATEGORIES.has(msg.category)) {
|
||||||
// These categories contain structured data (JSON, logs, snapshots), not prose.
|
|
||||||
// Rendering them through marked is both incorrect and extremely slow on large
|
|
||||||
// content (370KB JSON blobs take ~300ms each in marked.parse).
|
|
||||||
bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
|
||||||
} else {
|
} else {
|
||||||
bodyHtml = renderMarkdown(msg.content);
|
bodyHtml = renderMarkdown(msg.content);
|
||||||
@@ -147,16 +167,119 @@ function renderMessage(msg: ParsedMessage): string {
|
|||||||
? `<span class="header-sep">·</span><span class="message-time">${escapeHtml(timestamp)}</span>`
|
? `<span class="header-sep">·</span><span class="message-time">${escapeHtml(timestamp)}</span>`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return `<div class="message" style="border-color: ${style.border}">
|
// Build collapsed preview for collapsible categories
|
||||||
|
let previewHtml = "";
|
||||||
|
if (isCollapsible) {
|
||||||
|
let previewText: string;
|
||||||
|
if (msg.category === "thinking") {
|
||||||
|
const lineCount = msg.content.split("\n").filter(l => l.trim()).length;
|
||||||
|
previewText = `${lineCount} line${lineCount !== 1 ? "s" : ""}`;
|
||||||
|
} else if (msg.category === "tool_call") {
|
||||||
|
previewText = msg.toolName || "Unknown Tool";
|
||||||
|
} else {
|
||||||
|
// tool_result — first 120 chars of first line
|
||||||
|
previewText = (msg.content.split("\n")[0] || "Result").substring(0, 120);
|
||||||
|
}
|
||||||
|
previewHtml = `<span class="header-sep">·</span><span class="collapsed-preview">${escapeHtml(previewText)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chevron toggle button for collapsible messages
|
||||||
|
const chevronHtml = isCollapsible
|
||||||
|
? `<button class="collapsible-toggle" aria-label="Toggle"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8.25 4.5l7.5 7.5-7.5 7.5"/></svg></button>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const dataAttrs = isCollapsible ? ' data-collapsed="true"' : "";
|
||||||
|
|
||||||
|
// Progress badge for tool_call messages
|
||||||
|
const progressHtml = (msg.category === "tool_call" && progressEvents && progressEvents.length > 0)
|
||||||
|
? renderProgressBadge(progressEvents)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<div class="message"${dataAttrs} style="border-color: ${style.border}">
|
||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
<span class="message-dot" style="background: ${style.dot}"></span>
|
${chevronHtml}<span class="message-dot" style="background: ${style.dot}"></span>
|
||||||
<span class="message-label">${escapeHtml(label)}</span>
|
<span class="message-label">${escapeHtml(label)}</span>
|
||||||
${timestampHtml}
|
${timestampHtml}
|
||||||
|
${previewHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="message-body prose-message">${bodyHtml}</div>
|
<div class="message-body prose-message">${bodyHtml}</div>
|
||||||
|
${progressHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDiffContent(content: string): boolean {
|
||||||
|
const lines = content.split("\n").slice(0, 30);
|
||||||
|
let hunkHeaders = 0;
|
||||||
|
let diffLines = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith("@@") || line.startsWith("diff --")) {
|
||||||
|
hunkHeaders++;
|
||||||
|
} else if (line.startsWith("+++") || line.startsWith("---")) {
|
||||||
|
hunkHeaders++;
|
||||||
|
} else if (line.startsWith("+") || line.startsWith("-")) {
|
||||||
|
diffLines++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hunkHeaders >= 1 && diffLines >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiffHtml(content: string): string {
|
||||||
|
const lines = content.split("\n");
|
||||||
|
const htmlLines = lines.map((line) => {
|
||||||
|
const escaped = escapeHtml(line);
|
||||||
|
if (line.startsWith("@@")) {
|
||||||
|
return `<span class="diff-hunk">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("+++") || line.startsWith("---")) {
|
||||||
|
return `<span class="diff-meta">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("diff --")) {
|
||||||
|
return `<span class="diff-header">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("+")) {
|
||||||
|
return `<span class="diff-add">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
if (line.startsWith("-")) {
|
||||||
|
return `<span class="diff-del">${escaped}</span>`;
|
||||||
|
}
|
||||||
|
return escaped;
|
||||||
|
});
|
||||||
|
return `<pre class="hljs diff-view"><code>${htmlLines.join("\n")}</code></pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProgressBadge(events: ParsedMessage[]): string {
|
||||||
|
// Count by subtype
|
||||||
|
const counts: Partial<Record<ProgressSubtype, number>> = {};
|
||||||
|
for (const e of events) {
|
||||||
|
const sub = e.progressSubtype || "hook";
|
||||||
|
counts[sub] = (counts[sub] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pill row
|
||||||
|
const pills = (Object.entries(counts) as [ProgressSubtype, number][])
|
||||||
|
.map(([sub, count]) => {
|
||||||
|
const colors = PROGRESS_SUBTYPE_COLORS[sub] || PROGRESS_SUBTYPE_COLORS.hook;
|
||||||
|
return `<span class="progress-pill" style="color:${colors.text};background:${colors.bg}">${escapeHtml(sub)}: ${count}</span>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
// Drawer rows
|
||||||
|
const rows = events
|
||||||
|
.map((e) => {
|
||||||
|
const time = e.timestamp ? formatTimestamp(e.timestamp) : "--:--:--";
|
||||||
|
const sub = e.progressSubtype || "hook";
|
||||||
|
return `<div class="progress-row"><span class="progress-time">${escapeHtml(time)}</span><span class="progress-subtype">${escapeHtml(sub)}</span><span class="progress-content">${escapeHtml(e.content)}</span></div>`;
|
||||||
|
})
|
||||||
|
.join("\n ");
|
||||||
|
|
||||||
|
return `<div class="progress-badge">
|
||||||
|
<button class="progress-toggle">${pills}</button>
|
||||||
|
<div class="progress-drawer" style="display:none">
|
||||||
|
${rows}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// marked.parse() is called synchronously here. In marked v14+ it can return
|
// marked.parse() is called synchronously here. In marked v14+ it can return
|
||||||
// Promise<string> if async extensions are configured. Our markedHighlight setup
|
// Promise<string> if async extensions are configured. Our markedHighlight setup
|
||||||
// is synchronous, so the cast is safe — but do not add async extensions without
|
// is synchronous, so the cast is safe — but do not add async extensions without
|
||||||
@@ -179,6 +302,28 @@ function formatTimestamp(ts: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExportJs(): string {
|
||||||
|
return `
|
||||||
|
document.addEventListener("click", function(e) {
|
||||||
|
var toggle = e.target.closest(".collapsible-toggle");
|
||||||
|
if (toggle) {
|
||||||
|
var msg = toggle.closest(".message");
|
||||||
|
if (!msg) return;
|
||||||
|
var collapsed = msg.getAttribute("data-collapsed") === "true";
|
||||||
|
msg.setAttribute("data-collapsed", collapsed ? "false" : "true");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var progressToggle = e.target.closest(".progress-toggle");
|
||||||
|
if (progressToggle) {
|
||||||
|
var drawer = progressToggle.nextElementSibling;
|
||||||
|
if (drawer && drawer.classList.contains("progress-drawer")) {
|
||||||
|
drawer.style.display = drawer.style.display === "none" ? "block" : "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function getHighlightCss(): string {
|
function getHighlightCss(): string {
|
||||||
// Dark theme highlight.js (GitHub Dark) matching the client
|
// Dark theme highlight.js (GitHub Dark) matching the client
|
||||||
return `
|
return `
|
||||||
@@ -259,6 +404,19 @@ body {
|
|||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
border: 1px solid #30363d;
|
border: 1px solid #30363d;
|
||||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.message::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 0.75rem 0 0 0.75rem;
|
||||||
|
background: currentColor;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.message-header {
|
.message-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -298,6 +456,97 @@ body {
|
|||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Collapsible toggle */
|
||||||
|
.collapsible-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #484f58;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.collapsible-toggle:hover { color: #e6edf3; }
|
||||||
|
.message[data-collapsed="false"] .collapsible-toggle svg { transform: rotate(90deg); }
|
||||||
|
.message[data-collapsed="true"] .message-body { display: none; }
|
||||||
|
.collapsed-preview {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #484f58;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Diff highlighting */
|
||||||
|
.diff-view { font-size: 0.8125rem; line-height: 1.6; }
|
||||||
|
.diff-add { color: #7ee787; background: rgba(46,160,67,0.15); display: block; }
|
||||||
|
.diff-del { color: #ffa198; background: rgba(248,81,73,0.15); display: block; }
|
||||||
|
.diff-hunk { color: #bc8cff; display: block; }
|
||||||
|
.diff-meta { color: #8b949e; display: block; }
|
||||||
|
.diff-header { color: #e6edf3; font-weight: 600; display: block; }
|
||||||
|
|
||||||
|
/* Progress badge */
|
||||||
|
.progress-badge {
|
||||||
|
padding: 0.25rem 1rem 0.75rem;
|
||||||
|
}
|
||||||
|
.progress-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
.progress-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
.progress-drawer {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #161b22;
|
||||||
|
border: 1px solid #21262d;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.progress-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding: 0.125rem 0;
|
||||||
|
}
|
||||||
|
.progress-time {
|
||||||
|
width: 5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #484f58;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.progress-subtype {
|
||||||
|
width: 3.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.progress-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tool name */
|
/* Tool name */
|
||||||
.tool-name { font-weight: 500; margin-bottom: 0.5rem; }
|
.tool-name { font-weight: 500; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
@@ -405,6 +654,10 @@ body {
|
|||||||
body { background: #1c2128; }
|
body { background: #1c2128; }
|
||||||
.session-export { padding: 0; max-width: 100%; }
|
.session-export { padding: 0; max-width: 100%; }
|
||||||
.session-header, .message { box-shadow: none; break-inside: avoid; }
|
.session-header, .message { box-shadow: none; break-inside: avoid; }
|
||||||
|
.message-body { display: block !important; }
|
||||||
|
.collapsed-preview { display: none !important; }
|
||||||
|
.collapsible-toggle { display: none !important; }
|
||||||
|
.progress-drawer { display: block !important; }
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/server/services/progress-grouper.ts
Normal file
42
src/server/services/progress-grouper.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { ParsedMessage } from "../../shared/types.js";
|
||||||
|
|
||||||
|
export interface GroupedProgress {
|
||||||
|
messages: ParsedMessage[];
|
||||||
|
toolProgress: Record<string, ParsedMessage[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupProgress(messages: ParsedMessage[]): GroupedProgress {
|
||||||
|
// Build set of all toolUseId values from tool_call messages
|
||||||
|
const toolUseIds = new Set<string>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.category === "tool_call" && msg.toolUseId) {
|
||||||
|
toolUseIds.add(msg.toolUseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered: ParsedMessage[] = [];
|
||||||
|
const toolProgress: Record<string, ParsedMessage[]> = {};
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
// Parented progress: hook_progress with a parentToolUseId matching a known tool_call
|
||||||
|
if (
|
||||||
|
msg.category === "hook_progress" &&
|
||||||
|
msg.parentToolUseId &&
|
||||||
|
toolUseIds.has(msg.parentToolUseId)
|
||||||
|
) {
|
||||||
|
if (!toolProgress[msg.parentToolUseId]) {
|
||||||
|
toolProgress[msg.parentToolUseId] = [];
|
||||||
|
}
|
||||||
|
toolProgress[msg.parentToolUseId].push(msg);
|
||||||
|
} else {
|
||||||
|
filtered.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort each group by rawIndex
|
||||||
|
for (const key of Object.keys(toolProgress)) {
|
||||||
|
toolProgress[key].sort((a, b) => a.rawIndex - b.rawIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages: filtered, toolProgress };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import type { ParsedMessage } from "../../shared/types.js";
|
import type { ParsedMessage, ProgressSubtype } from "../../shared/types.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real Claude Code JSONL format (verified from actual session files):
|
* Real Claude Code JSONL format (verified from actual session files):
|
||||||
@@ -19,6 +19,7 @@ import type { ParsedMessage } from "../../shared/types.js";
|
|||||||
|
|
||||||
interface ContentBlock {
|
interface ContentBlock {
|
||||||
type: string;
|
type: string;
|
||||||
|
id?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -31,6 +32,7 @@ interface RawLine {
|
|||||||
type?: string;
|
type?: string;
|
||||||
uuid?: string;
|
uuid?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
|
parentToolUseID?: string;
|
||||||
message?: {
|
message?: {
|
||||||
role?: string;
|
role?: string;
|
||||||
content?: string | ContentBlock[];
|
content?: string | ContentBlock[];
|
||||||
@@ -85,12 +87,16 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
|
|||||||
const progressText = data
|
const progressText = data
|
||||||
? formatProgressData(data)
|
? formatProgressData(data)
|
||||||
: "Progress event";
|
: "Progress event";
|
||||||
|
const dataType = typeof data?.type === "string" ? data.type : "";
|
||||||
|
const progressSubtype = deriveProgressSubtype(dataType);
|
||||||
messages.push({
|
messages.push({
|
||||||
uuid,
|
uuid,
|
||||||
category: "hook_progress",
|
category: "hook_progress",
|
||||||
content: progressText,
|
content: progressText,
|
||||||
timestamp,
|
timestamp,
|
||||||
rawIndex,
|
rawIndex,
|
||||||
|
parentToolUseId: raw.parentToolUseID,
|
||||||
|
progressSubtype,
|
||||||
});
|
});
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
@@ -155,7 +161,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
|
|||||||
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
|
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
|
||||||
category: "tool_result",
|
category: "tool_result",
|
||||||
content: resultText,
|
content: resultText,
|
||||||
toolName: block.tool_use_id,
|
toolUseId: block.tool_use_id,
|
||||||
timestamp,
|
timestamp,
|
||||||
rawIndex,
|
rawIndex,
|
||||||
});
|
});
|
||||||
@@ -215,6 +221,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
|
|||||||
toolInput: JSON.stringify(block.input, null, 2),
|
toolInput: JSON.stringify(block.input, null, 2),
|
||||||
timestamp,
|
timestamp,
|
||||||
rawIndex,
|
rawIndex,
|
||||||
|
toolUseId: block.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,3 +246,10 @@ function formatProgressData(data: Record<string, unknown>): string {
|
|||||||
function detectSystemReminder(text: string): boolean {
|
function detectSystemReminder(text: string): boolean {
|
||||||
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
|
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function deriveProgressSubtype(dataType: string): ProgressSubtype {
|
||||||
|
if (dataType === "bash_progress") return "bash";
|
||||||
|
if (dataType === "mcp_progress") return "mcp";
|
||||||
|
if (dataType === "agent_progress") return "agent";
|
||||||
|
return "hook";
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export type MessageCategory =
|
|||||||
| "file_snapshot"
|
| "file_snapshot"
|
||||||
| "summary";
|
| "summary";
|
||||||
|
|
||||||
|
export type ProgressSubtype = "hook" | "bash" | "mcp" | "agent";
|
||||||
|
|
||||||
export interface ParsedMessage {
|
export interface ParsedMessage {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
category: MessageCategory;
|
category: MessageCategory;
|
||||||
@@ -17,6 +19,9 @@ export interface ParsedMessage {
|
|||||||
toolInput?: string;
|
toolInput?: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
rawIndex: number;
|
rawIndex: number;
|
||||||
|
toolUseId?: string;
|
||||||
|
parentToolUseId?: string;
|
||||||
|
progressSubtype?: ProgressSubtype;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionEntry {
|
export interface SessionEntry {
|
||||||
@@ -39,6 +44,7 @@ export interface SessionDetailResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
project: string;
|
project: string;
|
||||||
messages: ParsedMessage[];
|
messages: ParsedMessage[];
|
||||||
|
toolProgress?: Record<string, ParsedMessage[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExportRequest {
|
export interface ExportRequest {
|
||||||
|
|||||||
14
tests/fixtures/sample-session.jsonl
vendored
14
tests/fixtures/sample-session.jsonl
vendored
@@ -1,14 +1,18 @@
|
|||||||
{"type":"file-history-snapshot","messageId":"snap-1","snapshot":{"messageId":"snap-1","trackedFileBackups":[],"timestamp":"2025-10-15T10:30:00Z"},"isSnapshotUpdate":false}
|
{"type":"file-history-snapshot","messageId":"snap-1","snapshot":{"messageId":"snap-1","trackedFileBackups":[],"timestamp":"2025-10-15T10:30:00Z"},"isSnapshotUpdate":false}
|
||||||
{"type":"progress","data":{"type":"hook","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
|
{"type":"progress","data":{"type":"hook_progress","hookEvent":"SessionStart","hookName":"init_hook","command":"setup"},"uuid":"prog-0","timestamp":"2025-10-15T10:30:00Z"}
|
||||||
|
{"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
|
||||||
{"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"}
|
{"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"}
|
||||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"}
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"}
|
||||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_read1","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
|
||||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
|
{"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"read_hook","status":"callback"},"parentToolUseID":"toolu_read1","uuid":"prog-read-hook","timestamp":"2025-10-15T10:30:15Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read1","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
|
||||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"}
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"}
|
||||||
{"type":"user","message":{"role":"user","content":"<system-reminder>Remember to check environment variables</system-reminder>"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
|
{"type":"user","message":{"role":"user","content":"<system-reminder>Remember to check environment variables</system-reminder>"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
|
||||||
{"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"}
|
{"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"}
|
||||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_edit1","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
|
||||||
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
|
{"type":"progress","data":{"type":"bash_progress","status":"running","toolName":"Bash"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-bash","timestamp":"2025-10-15T10:31:05Z"}
|
||||||
|
{"type":"progress","data":{"type":"mcp_progress","serverName":"morph-mcp","toolName":"edit_file","status":"completed"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-mcp","timestamp":"2025-10-15T10:31:06Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_edit1","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
|
||||||
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"}
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"}
|
||||||
{"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"}
|
{"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"}
|
||||||
{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}
|
{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}
|
||||||
|
|||||||
511
tests/unit/agent-progress-parser.test.ts
Normal file
511
tests/unit/agent-progress-parser.test.ts
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
parseAgentEvents,
|
||||||
|
summarizeToolCall,
|
||||||
|
stripLineNumbers,
|
||||||
|
} from "../../src/client/lib/agent-progress-parser";
|
||||||
|
import type { ParsedMessage } from "../../src/shared/types";
|
||||||
|
|
||||||
|
/** Build a fake ParsedMessage whose content is a JSON agent_progress payload */
|
||||||
|
function makeAgentEvent(
|
||||||
|
dataOverrides: Record<string, unknown> = {},
|
||||||
|
msgOverrides: Partial<ParsedMessage> = {}
|
||||||
|
): ParsedMessage {
|
||||||
|
const data = {
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "toolu_abc123",
|
||||||
|
name: "Read",
|
||||||
|
input: { file_path: "/src/foo.ts" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
},
|
||||||
|
normalizedMessages: [],
|
||||||
|
type: "agent_progress",
|
||||||
|
prompt: "Explore the codebase",
|
||||||
|
agentId: "a6945d4",
|
||||||
|
...dataOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: crypto.randomUUID(),
|
||||||
|
category: "hook_progress",
|
||||||
|
content: JSON.stringify(data),
|
||||||
|
rawIndex: 0,
|
||||||
|
timestamp: "2026-01-30T16:22:21.000Z",
|
||||||
|
progressSubtype: "agent",
|
||||||
|
...msgOverrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("agent-progress-parser", () => {
|
||||||
|
describe("parseAgentEvents", () => {
|
||||||
|
it("parses a user text event and extracts the prompt", () => {
|
||||||
|
const event = makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Find the auth implementation" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:00.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.prompt).toBe("Explore the codebase");
|
||||||
|
expect(result.agentId).toBe("a6945d4");
|
||||||
|
expect(result.events.length).toBe(1);
|
||||||
|
expect(result.events[0].kind).toBe("user_text");
|
||||||
|
if (result.events[0].kind === "user_text") {
|
||||||
|
expect(result.events[0].text).toBe("Find the auth implementation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses assistant turn with tool_use blocks and extracts tool info", () => {
|
||||||
|
const event = makeAgentEvent(); // default has Read tool_use
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.events.length).toBe(1);
|
||||||
|
expect(result.events[0].kind).toBe("tool_call");
|
||||||
|
if (result.events[0].kind === "tool_call") {
|
||||||
|
expect(result.events[0].toolName).toBe("Read");
|
||||||
|
expect(result.events[0].toolUseId).toBe("toolu_abc123");
|
||||||
|
expect(result.events[0].input).toEqual({ file_path: "/src/foo.ts" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses user turn with tool_result and extracts result content", () => {
|
||||||
|
const event = makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "toolu_abc123",
|
||||||
|
content: "File contents here...",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:22.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.events[0].kind).toBe("tool_result");
|
||||||
|
if (result.events[0].kind === "tool_result") {
|
||||||
|
expect(result.events[0].toolUseId).toBe("toolu_abc123");
|
||||||
|
expect(result.events[0].content).toBe("File contents here...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses assistant turn with text content as text_response", () => {
|
||||||
|
const event = makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Here is my analysis of the codebase.\nLine 2\nLine 3\nLine 4" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:30.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.events[0].kind).toBe("text_response");
|
||||||
|
if (result.events[0].kind === "text_response") {
|
||||||
|
expect(result.events[0].text).toContain("Here is my analysis");
|
||||||
|
expect(result.events[0].lineCount).toBe(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts agentId and prompt from events", () => {
|
||||||
|
const events = [makeAgentEvent()];
|
||||||
|
const result = parseAgentEvents(events);
|
||||||
|
expect(result.agentId).toBe("a6945d4");
|
||||||
|
expect(result.prompt).toBe("Explore the codebase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates time range from first/last event timestamps", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentEvent(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "text", text: "Go" }] },
|
||||||
|
timestamp: "2026-01-30T16:22:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamp: "2026-01-30T16:22:00.000Z" }
|
||||||
|
),
|
||||||
|
makeAgentEvent(
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:30.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ timestamp: "2026-01-30T16:22:30.000Z" }
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = parseAgentEvents(events);
|
||||||
|
expect(result.firstTimestamp).toBe("2026-01-30T16:22:00.000Z");
|
||||||
|
expect(result.lastTimestamp).toBe("2026-01-30T16:22:30.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts turns correctly (tool_call events = turns)", () => {
|
||||||
|
const events = [
|
||||||
|
// User text
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "text", text: "Go" }] },
|
||||||
|
timestamp: "2026-01-30T16:22:00.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Tool call 1
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:01.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Tool result 1
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "t1", content: "..." }],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:02.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Tool call 2
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t2", name: "Grep", input: { pattern: "foo" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:03.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Tool result 2
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "t2", content: "..." }],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:04.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
// Final text
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Done" }],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:05.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = parseAgentEvents(events);
|
||||||
|
expect(result.turnCount).toBe(2); // 2 tool_call events
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles malformed JSON content gracefully", () => {
|
||||||
|
const event: ParsedMessage = {
|
||||||
|
uuid: "bad-json",
|
||||||
|
category: "hook_progress",
|
||||||
|
content: "this is not valid json {{{",
|
||||||
|
rawIndex: 0,
|
||||||
|
timestamp: "2026-01-30T16:22:00.000Z",
|
||||||
|
progressSubtype: "agent",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.events.length).toBe(1);
|
||||||
|
expect(result.events[0].kind).toBe("raw_content");
|
||||||
|
if (result.events[0].kind === "raw_content") {
|
||||||
|
expect(result.events[0].content).toBe("this is not valid json {{{");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty events array", () => {
|
||||||
|
const result = parseAgentEvents([]);
|
||||||
|
expect(result.events).toEqual([]);
|
||||||
|
expect(result.agentId).toBeUndefined();
|
||||||
|
expect(result.prompt).toBeUndefined();
|
||||||
|
expect(result.turnCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles tool_result with array content blocks", () => {
|
||||||
|
const event = makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
tool_use_id: "toolu_xyz",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "Part 1\n" },
|
||||||
|
{ type: "text", text: "Part 2\n" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:22.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
expect(result.events[0].kind).toBe("tool_result");
|
||||||
|
if (result.events[0].kind === "tool_result") {
|
||||||
|
expect(result.events[0].content).toBe("Part 1\nPart 2\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple tool_use blocks in a single assistant message", () => {
|
||||||
|
const event = makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "a.ts" } },
|
||||||
|
{ type: "tool_use", id: "t2", name: "Read", input: { file_path: "b.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:01.000Z",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = parseAgentEvents([event]);
|
||||||
|
const toolCalls = result.events.filter((e) => e.kind === "tool_call");
|
||||||
|
expect(toolCalls.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("summarizeToolCall", () => {
|
||||||
|
it("summarizes Read tool with file_path", () => {
|
||||||
|
const summary = summarizeToolCall("Read", { file_path: "/src/components/App.tsx" });
|
||||||
|
expect(summary).toBe("src/components/App.tsx");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Grep tool with pattern + path", () => {
|
||||||
|
const summary = summarizeToolCall("Grep", {
|
||||||
|
pattern: "useChat",
|
||||||
|
path: "/src/hooks/",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("useChat");
|
||||||
|
expect(summary).toContain("src/hooks/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Bash tool with truncated command", () => {
|
||||||
|
const summary = summarizeToolCall("Bash", {
|
||||||
|
command: "npm run test -- --watch --verbose --coverage --reporter=json",
|
||||||
|
});
|
||||||
|
expect(summary.length).toBeLessThanOrEqual(80);
|
||||||
|
expect(summary).toContain("npm run test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Task tool with description", () => {
|
||||||
|
const summary = summarizeToolCall("Task", {
|
||||||
|
description: "Find all auth handlers",
|
||||||
|
subagent_type: "Explore",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("Find all auth handlers");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Glob tool with pattern", () => {
|
||||||
|
const summary = summarizeToolCall("Glob", {
|
||||||
|
pattern: "**/*.test.ts",
|
||||||
|
path: "/src/",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("**/*.test.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Edit tool with file_path", () => {
|
||||||
|
const summary = summarizeToolCall("Edit", {
|
||||||
|
file_path: "/src/lib/utils.ts",
|
||||||
|
old_string: "foo",
|
||||||
|
new_string: "bar",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("src/lib/utils.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes Write tool with file_path", () => {
|
||||||
|
const summary = summarizeToolCall("Write", {
|
||||||
|
file_path: "/src/new-file.ts",
|
||||||
|
content: "export const x = 1;",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("src/new-file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes unknown tool with generic label", () => {
|
||||||
|
const summary = summarizeToolCall("CustomTool", { foo: "bar" });
|
||||||
|
expect(summary).toBe("CustomTool");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes mcp__morph-mcp__warpgrep_codebase_search with search_string", () => {
|
||||||
|
const summary = summarizeToolCall("mcp__morph-mcp__warpgrep_codebase_search", {
|
||||||
|
search_string: "Find authentication middleware",
|
||||||
|
repo_path: "/data/projects/app",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("Find authentication middleware");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("summarizes mcp__morph-mcp__edit_file with path", () => {
|
||||||
|
const summary = summarizeToolCall("mcp__morph-mcp__edit_file", {
|
||||||
|
path: "/src/lib/utils.ts",
|
||||||
|
code_edit: "// changes",
|
||||||
|
instruction: "Fix bug",
|
||||||
|
});
|
||||||
|
expect(summary).toContain("src/lib/utils.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading slash from file paths", () => {
|
||||||
|
const summary = summarizeToolCall("Read", { file_path: "/src/foo.ts" });
|
||||||
|
expect(summary).toBe("src/foo.ts");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripLineNumbers", () => {
|
||||||
|
it("strips cat-n style line numbers with arrow prefix", () => {
|
||||||
|
const input = " 1\u2192import React from 'react';\n 2\u2192\n 3\u2192export default function App() {";
|
||||||
|
const result = stripLineNumbers(input);
|
||||||
|
expect(result).toBe("import React from 'react';\n\nexport default function App() {");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns text unchanged if no line numbers detected", () => {
|
||||||
|
const input = "just plain text\nno line numbers here";
|
||||||
|
expect(stripLineNumbers(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns text unchanged for short single-line content", () => {
|
||||||
|
const input = "single line";
|
||||||
|
expect(stripLineNumbers(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles double-digit line numbers", () => {
|
||||||
|
const lines = Array.from({ length: 15 }, (_, i) =>
|
||||||
|
` ${i + 1}\u2192line ${i + 1}`
|
||||||
|
).join("\n");
|
||||||
|
const result = stripLineNumbers(lines);
|
||||||
|
expect(result).toContain("line 1");
|
||||||
|
expect(result).not.toMatch(/^\s*\d+\u2192/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tool_result language linking", () => {
|
||||||
|
it("links tool_result to preceding Read call and sets language", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Read", input: { file_path: "/src/app.tsx" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:01.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_result", tool_use_id: "t1", content: "import React..." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:02.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = parseAgentEvents(events);
|
||||||
|
const toolResult = result.events.find((e) => e.kind === "tool_result");
|
||||||
|
expect(toolResult).toBeDefined();
|
||||||
|
if (toolResult?.kind === "tool_result") {
|
||||||
|
expect(toolResult.language).toBe("tsx");
|
||||||
|
expect(toolResult.sourceTool).toBe("Read");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets no language for Grep results", () => {
|
||||||
|
const events = [
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "t1", name: "Grep", input: { pattern: "foo" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:01.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
makeAgentEvent({
|
||||||
|
message: {
|
||||||
|
type: "user",
|
||||||
|
message: {
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_result", tool_use_id: "t1", content: "src/a.ts:10:foo" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
timestamp: "2026-01-30T16:22:02.000Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = parseAgentEvents(events);
|
||||||
|
const toolResult = result.events.find((e) => e.kind === "tool_result");
|
||||||
|
if (toolResult?.kind === "tool_result") {
|
||||||
|
expect(toolResult.language).toBeUndefined();
|
||||||
|
expect(toolResult.sourceTool).toBe("Grep");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -112,6 +112,19 @@ describe("filters", () => {
|
|||||||
expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
|
expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hook_progress included when category is enabled", () => {
|
||||||
|
const enabled = new Set<MessageCategory>(ALL_CATEGORIES);
|
||||||
|
const filtered = filterMessages(messages, enabled);
|
||||||
|
expect(filtered.find((m) => m.category === "hook_progress")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hook_progress excluded when category is disabled", () => {
|
||||||
|
const enabled = new Set<MessageCategory>(ALL_CATEGORIES);
|
||||||
|
enabled.delete("hook_progress");
|
||||||
|
const filtered = filterMessages(messages, enabled);
|
||||||
|
expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("category counts are computed correctly", () => {
|
it("category counts are computed correctly", () => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const cat of ALL_CATEGORIES) {
|
for (const cat of ALL_CATEGORIES) {
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ function makeMessage(
|
|||||||
function makeExportRequest(
|
function makeExportRequest(
|
||||||
messages: ParsedMessage[],
|
messages: ParsedMessage[],
|
||||||
visible?: string[],
|
visible?: string[],
|
||||||
redacted?: string[]
|
redacted?: string[],
|
||||||
|
toolProgress?: Record<string, ParsedMessage[]>
|
||||||
): ExportRequest {
|
): ExportRequest {
|
||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
id: "test-session",
|
id: "test-session",
|
||||||
project: "test-project",
|
project: "test-project",
|
||||||
messages,
|
messages,
|
||||||
|
toolProgress,
|
||||||
},
|
},
|
||||||
visibleMessageUuids: visible || messages.map((m) => m.uuid),
|
visibleMessageUuids: visible || messages.map((m) => m.uuid),
|
||||||
redactedMessageUuids: redacted || [],
|
redactedMessageUuids: redacted || [],
|
||||||
@@ -118,4 +120,149 @@ describe("html-exporter", () => {
|
|||||||
// Verify singular — should NOT contain "1 messages"
|
// Verify singular — should NOT contain "1 messages"
|
||||||
expect(html).not.toMatch(/\b1 messages\b/);
|
expect(html).not.toMatch(/\b1 messages\b/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Collapsible messages ───
|
||||||
|
|
||||||
|
it("thinking messages render collapsed by default", async () => {
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "think-1",
|
||||||
|
category: "thinking",
|
||||||
|
content: "Line one\nLine two\nLine three\nLine four\nLine five",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain('data-collapsed="true"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call messages render collapsed with tool name preview", async () => {
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "tc-1",
|
||||||
|
category: "tool_call",
|
||||||
|
content: "",
|
||||||
|
toolName: "Read",
|
||||||
|
toolInput: '{"path": "/foo/bar.ts"}',
|
||||||
|
toolUseId: "tu-1",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain('data-collapsed="true"');
|
||||||
|
expect(html).toContain("collapsed-preview");
|
||||||
|
expect(html).toContain("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_result messages render collapsed with content preview", async () => {
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "tr-1",
|
||||||
|
category: "tool_result",
|
||||||
|
content: "This is the first line of a tool result that should be truncated in the preview display when collapsed",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain('data-collapsed="true"');
|
||||||
|
expect(html).toContain("collapsed-preview");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collapsible messages include toggle button", async () => {
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "think-2",
|
||||||
|
category: "thinking",
|
||||||
|
content: "Some thoughts",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain("collapsible-toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-collapsible messages do not have collapse attributes on their message div", async () => {
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "user-1",
|
||||||
|
category: "user_message",
|
||||||
|
content: "Hello there",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
// Extract the message div — it should NOT have data-collapsed (CSS will have it as a selector)
|
||||||
|
const messageDiv = html.match(/<div class="message"[^>]*>/);
|
||||||
|
expect(messageDiv).not.toBeNull();
|
||||||
|
expect(messageDiv![0]).not.toContain("data-collapsed");
|
||||||
|
expect(messageDiv![0]).not.toContain("collapsible-toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("export includes toggle JavaScript", async () => {
|
||||||
|
const msg = makeMessage({ uuid: "msg-js", content: "Hello" });
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
expect(html).toContain("collapsible-toggle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_result with diff content gets diff highlighting", async () => {
|
||||||
|
const diffContent = [
|
||||||
|
"diff --git a/foo.ts b/foo.ts",
|
||||||
|
"--- a/foo.ts",
|
||||||
|
"+++ b/foo.ts",
|
||||||
|
"@@ -1,3 +1,3 @@",
|
||||||
|
" unchanged",
|
||||||
|
"-old line",
|
||||||
|
"+new line",
|
||||||
|
].join("\n");
|
||||||
|
const msg = makeMessage({
|
||||||
|
uuid: "tr-diff",
|
||||||
|
category: "tool_result",
|
||||||
|
content: diffContent,
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain("diff-add");
|
||||||
|
expect(html).toContain("diff-del");
|
||||||
|
expect(html).toContain("diff-hunk");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call with progress events renders progress badge", async () => {
|
||||||
|
const toolMsg = makeMessage({
|
||||||
|
uuid: "tc-prog",
|
||||||
|
category: "tool_call",
|
||||||
|
content: "",
|
||||||
|
toolName: "Bash",
|
||||||
|
toolUseId: "tu-prog",
|
||||||
|
});
|
||||||
|
const progressEvents: ParsedMessage[] = [
|
||||||
|
makeMessage({
|
||||||
|
uuid: "pe-1",
|
||||||
|
category: "hook_progress",
|
||||||
|
content: "Running...",
|
||||||
|
progressSubtype: "bash",
|
||||||
|
timestamp: "2025-01-01T12:00:00Z",
|
||||||
|
}),
|
||||||
|
makeMessage({
|
||||||
|
uuid: "pe-2",
|
||||||
|
category: "hook_progress",
|
||||||
|
content: "Done",
|
||||||
|
progressSubtype: "bash",
|
||||||
|
timestamp: "2025-01-01T12:00:01Z",
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const html = await generateExportHtml(
|
||||||
|
makeExportRequest([toolMsg], undefined, undefined, { "tu-prog": progressEvents })
|
||||||
|
);
|
||||||
|
expect(html).toContain("progress-badge");
|
||||||
|
expect(html).toContain("progress-drawer");
|
||||||
|
expect(html).toContain("bash");
|
||||||
|
expect(html).toContain("2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tool_call without progress events has no badge", async () => {
|
||||||
|
const toolMsg = makeMessage({
|
||||||
|
uuid: "tc-no-prog",
|
||||||
|
category: "tool_call",
|
||||||
|
content: "",
|
||||||
|
toolName: "Read",
|
||||||
|
toolUseId: "tu-no-prog",
|
||||||
|
});
|
||||||
|
const html = await generateExportHtml(makeExportRequest([toolMsg]));
|
||||||
|
// The CSS will contain .progress-badge as a selector, but the message HTML should not
|
||||||
|
// have an actual progress-badge div element
|
||||||
|
expect(html).not.toContain('<div class="progress-badge">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("print CSS forces content visible", async () => {
|
||||||
|
const msg = makeMessage({ uuid: "msg-print", content: "Hello" });
|
||||||
|
const html = await generateExportHtml(makeExportRequest([msg]));
|
||||||
|
expect(html).toContain("@media print");
|
||||||
|
// Should override collapsed hidden state for print
|
||||||
|
expect(html).toMatch(/\.message-body\s*\{[^}]*display:\s*block/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
98
tests/unit/progress-grouper.test.ts
Normal file
98
tests/unit/progress-grouper.test.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { groupProgress } from "../../src/server/services/progress-grouper.js";
|
||||||
|
import type { ParsedMessage } from "../../src/shared/types.js";
|
||||||
|
|
||||||
|
function makeMsg(
|
||||||
|
overrides: Partial<ParsedMessage> & { uuid: string; rawIndex: number }
|
||||||
|
): ParsedMessage {
|
||||||
|
return {
|
||||||
|
category: "assistant_text",
|
||||||
|
content: "test",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("progress-grouper", () => {
|
||||||
|
it("partitions parented progress into toolProgress map", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1", progressSubtype: "hook" }),
|
||||||
|
makeMsg({ uuid: "hp-2", rawIndex: 2, category: "hook_progress", parentToolUseId: "toolu_1", progressSubtype: "bash" }),
|
||||||
|
makeMsg({ uuid: "txt-1", rawIndex: 3, category: "assistant_text" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
expect(result.toolProgress["toolu_1"]).toHaveLength(2);
|
||||||
|
expect(result.toolProgress["toolu_1"][0].uuid).toBe("hp-1");
|
||||||
|
expect(result.toolProgress["toolu_1"][1].uuid).toBe("hp-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes parented progress from messages array", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "txt-1", rawIndex: 2, category: "assistant_text" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
expect(result.messages).toHaveLength(2);
|
||||||
|
expect(result.messages.map((m) => m.uuid)).toEqual(["tc-1", "txt-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps orphaned progress (no parentToolUseId) in messages", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "hp-orphan", rawIndex: 0, category: "hook_progress", progressSubtype: "hook" }),
|
||||||
|
makeMsg({ uuid: "txt-1", rawIndex: 1, category: "assistant_text" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
expect(result.messages).toHaveLength(2);
|
||||||
|
expect(result.messages[0].uuid).toBe("hp-orphan");
|
||||||
|
expect(Object.keys(result.toolProgress)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps progress with invalid parentToolUseId (no matching tool_call) in messages", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "hp-invalid", rawIndex: 0, category: "hook_progress", parentToolUseId: "toolu_nonexistent" }),
|
||||||
|
makeMsg({ uuid: "txt-1", rawIndex: 1, category: "assistant_text" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
expect(result.messages).toHaveLength(2);
|
||||||
|
expect(result.messages[0].uuid).toBe("hp-invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty results for empty input", () => {
|
||||||
|
const result = groupProgress([]);
|
||||||
|
expect(result.messages).toEqual([]);
|
||||||
|
expect(result.toolProgress).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sorts each toolProgress group by rawIndex", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-3", rawIndex: 5, category: "hook_progress", parentToolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-1", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-2", rawIndex: 3, category: "hook_progress", parentToolUseId: "toolu_1" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
const group = result.toolProgress["toolu_1"];
|
||||||
|
expect(group.map((m) => m.rawIndex)).toEqual([1, 3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple tool_call parents independently", () => {
|
||||||
|
const messages: ParsedMessage[] = [
|
||||||
|
makeMsg({ uuid: "tc-1", rawIndex: 0, category: "tool_call", toolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "hp-1a", rawIndex: 1, category: "hook_progress", parentToolUseId: "toolu_1" }),
|
||||||
|
makeMsg({ uuid: "tc-2", rawIndex: 2, category: "tool_call", toolUseId: "toolu_2" }),
|
||||||
|
makeMsg({ uuid: "hp-2a", rawIndex: 3, category: "hook_progress", parentToolUseId: "toolu_2" }),
|
||||||
|
makeMsg({ uuid: "hp-2b", rawIndex: 4, category: "hook_progress", parentToolUseId: "toolu_2" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = groupProgress(messages);
|
||||||
|
expect(result.toolProgress["toolu_1"]).toHaveLength(1);
|
||||||
|
expect(result.toolProgress["toolu_2"]).toHaveLength(2);
|
||||||
|
expect(result.messages).toHaveLength(2); // only the 2 tool_calls
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -216,4 +216,107 @@ describe("session-parser", () => {
|
|||||||
expect(msgs[0].category).toBe("user_message");
|
expect(msgs[0].category).toBe("user_message");
|
||||||
expect(msgs[1].category).toBe("assistant_text");
|
expect(msgs[1].category).toBe("assistant_text");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts toolUseId from tool_use blocks with id field", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", id: "toolu_abc123", name: "Read", input: { file_path: "/src/index.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
uuid: "a-tu-1",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs).toHaveLength(1);
|
||||||
|
expect(msgs[0].category).toBe("tool_call");
|
||||||
|
expect(msgs[0].toolUseId).toBe("toolu_abc123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toolUseId is undefined when tool_use block has no id field", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "assistant",
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "tool_use", name: "Read", input: { file_path: "/src/index.ts" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
uuid: "a-tu-2",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs).toHaveLength(1);
|
||||||
|
expect(msgs[0].toolUseId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts parentToolUseId and progressSubtype from hook_progress", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "hook_progress", hookEvent: "PreToolUse", hookName: "check" },
|
||||||
|
parentToolUseID: "toolu_abc123",
|
||||||
|
uuid: "p-linked",
|
||||||
|
timestamp: "2025-10-15T10:00:00Z",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs).toHaveLength(1);
|
||||||
|
expect(msgs[0].category).toBe("hook_progress");
|
||||||
|
expect(msgs[0].parentToolUseId).toBe("toolu_abc123");
|
||||||
|
expect(msgs[0].progressSubtype).toBe("hook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives progressSubtype 'bash' from bash_progress data type", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "bash_progress", status: "running" },
|
||||||
|
parentToolUseID: "toolu_bash1",
|
||||||
|
uuid: "p-bash",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs[0].progressSubtype).toBe("bash");
|
||||||
|
expect(msgs[0].parentToolUseId).toBe("toolu_bash1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives progressSubtype 'mcp' from mcp_progress data type", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "mcp_progress", serverName: "morph-mcp" },
|
||||||
|
parentToolUseID: "toolu_mcp1",
|
||||||
|
uuid: "p-mcp",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs[0].progressSubtype).toBe("mcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives progressSubtype 'agent' from agent_progress data type", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "agent_progress", status: "started" },
|
||||||
|
parentToolUseID: "toolu_agent1",
|
||||||
|
uuid: "p-agent",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs[0].progressSubtype).toBe("agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parentToolUseId is undefined when progress has no parentToolUseID", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "hook_progress", hookEvent: "SessionStart" },
|
||||||
|
uuid: "p-orphan",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs[0].parentToolUseId).toBeUndefined();
|
||||||
|
expect(msgs[0].progressSubtype).toBe("hook");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("progressSubtype defaults to 'hook' for unknown data types", () => {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
type: "progress",
|
||||||
|
data: { type: "unknown_thing", status: "ok" },
|
||||||
|
uuid: "p-unknown",
|
||||||
|
});
|
||||||
|
const msgs = parseSessionContent(line);
|
||||||
|
expect(msgs[0].progressSubtype).toBe("hook");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user