Compare commits

...

9 Commits

Author SHA1 Message Date
b69dffc398 Add interactive architecture explorer as standalone HTML reference
Self-contained 701-line HTML document providing a visual map of the
session-viewer codebase architecture. Includes interactive node
diagrams for client, server, shared, and build layers with
dependency relationships.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:05:11 -05:00
3fe8d7d3b5 Add comprehensive test suite for progress tracking system
Test fixture updates:
- Add toolUseId fields (toolu_read1, toolu_edit1) to tool_use blocks
- Add parentToolUseID-linked progress events for read and edit tools
- Add orphaned SessionStart progress event (no parent)
- Update tool_result references to match new toolUseId values
- Add bash_progress and mcp_progress subtypes for subtype derivation

session-parser tests (7 new):
- toolUseId extraction from tool_use blocks with and without id field
- parentToolUseId and progressSubtype extraction from hook_progress
- Subtype derivation for bash_progress, mcp_progress, agent_progress
- Fallback to "hook" for unknown data types
- Undefined parentToolUseId when field is absent

progress-grouper tests (7 new):
- Partition parented progress into toolProgress map
- Remove parented progress from filtered messages array
- Keep orphaned progress (no parentToolUseId) in main stream
- Keep progress with invalid parentToolUseId (no matching tool_call)
- Empty input handling
- Sort each group by rawIndex
- Multiple tool_call parents tracked independently

agent-progress-parser tests (full suite):
- Parse user text events with prompt/agentId metadata extraction
- Parse tool_use blocks into AgentToolCall events
- Parse tool_result blocks with content extraction
- Parse text content as text_response with line counting
- Handle multiple content blocks in single turn
- Post-pass tool_result→tool_call linking (sourceTool, language)
- Empty input and malformed JSON → raw_content fallback
- stripLineNumbers for cat-n prefixed output
- summarizeToolCall for Read, Grep, Glob, Bash, Task, WarpGrep, etc.

ProgressBadge component tests:
- Collapsed state shows pill counts, hides content
- Expanded state shows all event content via markdown
- Subtype counting accuracy
- Agent-only events route to AgentProgressView

AgentProgressView component tests:
- Prompt banner rendering with truncation
- Agent ID and turn count display
- Summary rows with timestamps and tool names
- Click-to-expand drill-down content

html-exporter tests (8 new):
- Collapsible rendering for thinking, tool_call, tool_result
- Toggle button and JavaScript inclusion
- Non-collapsible messages lack collapse attributes
- Diff content detection and highlighting
- Progress badge rendering with toolProgress data

filters tests (2 new):
- hook_progress included/excluded by category toggle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:05:01 -05:00
d4de363227 Polish UI: SearchBar sizing, SessionList layout, Tooltip offset, and progress CSS
SearchBar:
- Switch from fixed w-80/sm:w-96 to fluid min-w-80 max-w-md w-full
- Adjust left padding for better visual alignment with search icon

SessionList:
- Memoize project grouping computation with useMemo
- Move horizontal padding from per-item margin (mx-2 + width calc hack)
  to container padding (px-2) for cleaner layout and full-width buttons
- Remove inline width override that was compensating for the old margins

Tooltip:
- Increase offset from 8px to 12px for better visual separation

CSS:
- Add prose-message-progress variant with compact 11px mono typography
  for progress event content (code blocks, tables, links, blockquotes)
- Reduce search minimap marker height from 4px to 3px
- Normalize prose-message line-height: paragraphs 1.625→1.6, list
  items 1.5→1.6 for consistent rhythm
- Switch custom checkbox checkmark sizing from fixed px to percentages
  for better scaling across different zoom levels

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:04:38 -05:00
51a54e3fdd Enhance HTML export with collapsible messages, diff highlighting, and progress badges
Major overhaul of the static HTML export to match the interactive viewer:

Collapsible messages:
- thinking, tool_call, and tool_result categories render collapsed by
  default with data-collapsed attribute
- Chevron toggle button rotates on expand/collapse
- Collapsed preview shows: line count (thinking), tool name (tool_call),
  or first 120 chars (tool_result)
- Print media query forces all sections expanded and hides toggle UI

Diff highlighting:
- tool_result content is checked for diff patterns (hunk headers, +/-
  lines) via isDiffContent() heuristic
- Diff content renders with color-coded spans: green additions, red
  deletions, purple hunk headers, gray meta lines

Progress badges:
- tool_call messages with associated progress events render a clickable
  pill row showing event counts by subtype (hook/bash/mcp/agent)
- Clicking toggles a drawer with timestamped event log
- Subtype colors match the client-side ProgressBadge component

Interactive JavaScript:
- Export now includes a <script> block for toggle interactivity
- Single delegated click handler on document for both collapsible
  messages and progress drawers

CSS additions:
- Left color bar via ::before pseudo-element on .message
- Collapsible toggle, collapsed preview, diff highlighting, and
  progress badge/drawer styles
- Print overrides to show all content and hide interactive controls

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:04:21 -05:00
f69ba1f32a Wire progress events through session viewer and fix request race condition
MessageBubble:
- Accept progressEvents and progressEnabled props
- Render ProgressBadge below tool_call content when progress data exists
- Normalize left padding from mixed pl-5/px-4 to consistent px-5

SessionViewer:
- Thread toolProgress map and progressEnabled flag through to messages
- Look up progress events by toolUseId for each tool_call message
- Fix hash-anchor scroll firing on every filter toggle by tracking
  whether scroll has occurred per session load (hashScrolledRef)
- Increase vertical spacing on time gap dividers and header area

App:
- Derive progressEnabled from hook_progress category filter state
- Pass toolProgress and progressEnabled to SessionViewer
- Optimize sensitiveCount to compute across all session messages
  (not filtered subset) to avoid re-running 37 regex patterns on
  every filter toggle
- Tighten redaction selection badge gap from 2 to 1.5

useSession:
- Add AbortController to loadSession to cancel stale in-flight requests
  when user rapidly switches sessions
- Only clear loading state if the completing request is still current
- Ignore AbortError exceptions from cancelled fetches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:04:04 -05:00
9c4fc89cac Add progress visualization components: ProgressBadge, ProgressGroup, AgentProgressView
Three new components for rendering tool progress events inline:

ProgressBadge — Attaches below tool_call messages. Shows color-coded
pills counting events by subtype (hook/bash/mcp/agent). Expands on
click to show either a timestamped event log (mixed subtypes) or the
full AgentProgressView (all-agent subtypes). Lazy-renders markdown
only when expanded via useMemo.

ProgressGroup — Standalone progress divider for orphaned progress
events in the message stream. Centered pill-style summary with event
count, subtype breakdown, and time range. Expands to show the same
timestamped log format as ProgressBadge.

AgentProgressView — Rich drill-down view for agent sub-conversations.
Parses agent_progress JSON via parseAgentEvents into a structured
activity feed with:
- Header showing quoted prompt, agent ID, turn count, and time range
- Per-event rows with timestamp, SVG icon (10 tool-specific icons),
  short tool name, and summarized description
- Click-to-expand drill-down rendering tool inputs as JSON code blocks,
  tool results with language-detected syntax highlighting (after
  stripping cat-n line numbers), and text responses as markdown

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:03:46 -05:00
d7246cf062 Add client-side agent progress parser with tool call summarization
New agent-progress-parser.ts provides rich parsing of agent_progress
JSON payloads from hook_progress events into a structured event stream:

Event types: AgentToolCall, AgentToolResult, AgentTextResponse,
AgentUserText, AgentRawContent — unified under AgentEvent discriminated
union with ParsedAgentProgress metadata (prompt, agentId, timestamps,
turnCount).

Key capabilities:
- Parse nested message.message.content blocks from agent progress JSON
- Post-pass linking of tool_results to their preceding tool_calls via
  toolUseId map, enriching results with sourceTool and language hints
- Language detection from file paths (30+ extensions mapped)
- stripLineNumbers() to remove cat-n style prefixes for syntax detection
- summarizeToolCall() for human-readable one-line summaries of common
  Claude tools (Read, Write, Edit, Grep, Glob, Bash, Task, WarpGrep)

Also re-exports ProgressSubtype from client types barrel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:03:29 -05:00
e61afc9dc4 Add server-side progress grouper and fix session cache race condition
New progress-grouper service partitions ParsedMessage arrays into two
outputs: a filtered messages list (orphaned progress stays inline) and
a toolProgress map keyed by parentToolUseId. Only hook_progress events
whose parentToolUseId matches an existing tool_call are extracted; all
others remain in the main message stream. Each group is sorted by
rawIndex for chronological display.

Session route integration:
- Pipe parseSession output through groupProgress before responding
- Return toolProgress map alongside messages in session detail endpoint

Cache improvements:
- Deduplicate concurrent getCachedSessions() calls with a shared
  in-flight promise (cachePromise) to prevent thundering herd on
  multiple simultaneous requests
- Track cache generation to avoid stale writes when a newer discovery
  supersedes an in-flight one
- Clear cachePromise on refresh=1 to force a fresh discovery cycle

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:03:14 -05:00
b168e6ffd7 Add progress tracking fields to shared types and session parser
Introduce ProgressSubtype union ("hook" | "bash" | "mcp" | "agent") and
three new fields on ParsedMessage: toolUseId, parentToolUseId, and
progressSubtype. These enable linking hook_progress events to the
tool_call that spawned them and classifying progress by source.

Session parser changes:
- Extract `id` from tool_use content blocks into toolUseId
- Extract `tool_use_id` from tool_result blocks into toolUseId (was
  previously misassigned to toolName)
- Read `parentToolUseID` from raw progress lines
- Derive progressSubtype from the `data.type` field using a new
  deriveProgressSubtype() helper
- Add `toolProgress` map to SessionDetailResponse for grouped progress

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 23:03:00 -05:00
27 changed files with 3529 additions and 59 deletions

View 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)">&#x2212;</button>
<button class="zoom-btn" onclick="resetZoom()">&#x2302;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>

View File

@@ -54,9 +54,13 @@ export function App() {
return filters.filterMessages(currentSession.messages);
}, [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(
() => countSensitiveMessages(filteredMessages),
[filteredMessages]
() => countSensitiveMessages(currentSession?.messages || []),
[currentSession?.messages]
);
// Track which filtered-message indices match the search query
@@ -224,7 +228,7 @@ export function App() {
{/* Main */}
<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 */}
<div className="flex-1 min-w-0" />
@@ -239,7 +243,7 @@ export function App() {
onPrev={goToPrevMatch}
/>
{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">
{filters.selectedForRedaction.size} selected
</span>
@@ -285,6 +289,8 @@ export function App() {
onToggleRedactionSelection={filters.toggleRedactionSelection}
autoRedactEnabled={filters.autoRedactEnabled}
focusedIndex={activeFocusIndex}
toolProgress={currentSession?.toolProgress}
progressEnabled={progressEnabled}
/>
</ErrorBoundary>
</div>

View 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();
});
});

View 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"
>
&ldquo;{promptPreview}&rdquo;
</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>
);
}

View File

@@ -5,6 +5,7 @@ import { CATEGORY_COLORS } from "../lib/constants";
import { renderMarkdown, highlightSearchText } from "../lib/markdown";
import { redactMessage } from "../../shared/sensitive-redactor";
import { escapeHtml } from "../../shared/escape-html";
import { ProgressBadge } from "./ProgressBadge";
interface Props {
message: ParsedMessage;
@@ -13,6 +14,8 @@ interface Props {
selectedForRedaction: boolean;
onToggleRedactionSelection: () => void;
autoRedactEnabled: boolean;
progressEvents?: ParsedMessage[];
progressEnabled?: boolean;
}
/**
@@ -29,6 +32,8 @@ export function MessageBubble({
selectedForRedaction,
onToggleRedactionSelection,
autoRedactEnabled,
progressEvents,
progressEnabled,
}: Props) {
const colors = CATEGORY_COLORS[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}`} />
{/* 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 && (
<button
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 */}
{!collapsed && (
<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 }}
/>
)}
{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>
</div>
)}
{message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
<ProgressBadge events={progressEvents} />
)}
</div>
);
}

View 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();
});
});
});

View 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>
);
}

View 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">&middot;</span>
{summary}
{range && (
<>
<span className="text-foreground-muted/60">&middot;</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>
);
}

View File

@@ -107,7 +107,7 @@ export function SearchBar({
const showControls = !!localQuery || !!query;
return (
<div className="w-80 sm:w-96">
<div className="min-w-80 max-w-md w-full">
{/* Unified search container */}
<div
className={`
@@ -137,7 +137,7 @@ export function SearchBar({
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
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
focus:outline-none"
/>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import type { SessionEntry } from "../lib/types";
interface Props {
@@ -11,13 +11,16 @@ interface Props {
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
const [selectedProject, setSelectedProject] = useState<string | null>(null);
// Group by project
const grouped = new Map<string, SessionEntry[]>();
for (const session of sessions) {
const group = grouped.get(session.project) || [];
group.push(session);
grouped.set(session.project, group);
}
// Group by project (memoized to avoid recomputing on unrelated rerenders)
const grouped = useMemo(() => {
const map = new Map<string, SessionEntry[]>();
for (const session of sessions) {
const group = map.get(session.project) || [];
group.push(session);
map.set(session.project, group);
}
return map;
}, [sessions]);
// Auto-select project when selectedId changes
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)" }}>
{formatProjectName(selectedProject)}
</div>
<div className="py-1">
<div className="py-1 px-2">
{projectSessions.map((session, idx) => {
const isSelected = selectedId === session.id;
return (
@@ -81,13 +84,13 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
key={session.id}
onClick={() => onSelect(session.id)}
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
? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25"
: "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"}`}>
{session.summary || session.firstPrompt || "Untitled Session"}
@@ -113,7 +116,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
// Project list
return (
<div className="py-1 animate-fade-in">
<div className="py-1 px-2 animate-fade-in">
{[...grouped.entries()].map(([project, projectSessions]) => {
const latest = projectSessions.reduce((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
key={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"
style={{ width: "calc(100% - 1rem)" }}
className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
>
<div className="flex items-center justify-between">
<div className="text-body font-medium text-foreground truncate">

View File

@@ -13,6 +13,8 @@ interface Props {
onToggleRedactionSelection: (uuid: string) => void;
autoRedactEnabled: boolean;
focusedIndex?: number;
toolProgress?: Record<string, ParsedMessage[]>;
progressEnabled?: boolean;
}
function MessageSkeleton({ delay = 0 }: { delay?: number }) {
@@ -44,6 +46,8 @@ export function SessionViewer({
onToggleRedactionSelection,
autoRedactEnabled,
focusedIndex = -1,
toolProgress,
progressEnabled,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -58,10 +62,21 @@ export function SessionViewer({
}
}, [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(() => {
hashScrolledRef.current = false;
}, [allMessages]);
useEffect(() => {
if (hashScrolledRef.current) return;
const hash = window.location.hash;
if (hash && hash.startsWith("#msg-") && messages.length > 0) {
hashScrolledRef.current = true;
requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1));
if (el) {
@@ -79,6 +94,7 @@ export function SessionViewer({
if (messages.length === 0) return [];
const visibleUuids = new Set(messages.map((m) => m.uuid));
const items: Array<
| { type: "message"; message: ParsedMessage; messageIndex: number }
| { type: "redacted_divider"; key: string }
@@ -88,14 +104,17 @@ export function SessionViewer({
let prevWasRedactedGap = false;
let prevTimestamp: string | undefined;
let messageIndex = 0;
for (const msg of allMessages) {
if (redactedUuids.has(msg.uuid)) {
prevWasRedactedGap = true;
continue;
}
if (!visibleUuids.has(msg.uuid)) {
continue;
}
if (prevWasRedactedGap) {
items.push({
type: "redacted_divider",
@@ -174,8 +193,8 @@ export function SessionViewer({
}
return (
<div className="max-w-6xl mx-auto px-6 py-5">
<div className="flex items-center justify-between mb-4">
<div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-6">
<span className="text-caption text-foreground-muted tabular-nums">
{messages.length} message{messages.length !== 1 ? "s" : ""}
</span>
@@ -187,7 +206,7 @@ export function SessionViewer({
}
if (item.type === "time_gap") {
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" />
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
{item.duration} later
@@ -204,6 +223,11 @@ export function SessionViewer({
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
const isDimmed = searchQuery && !isMatch;
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 (
<div
key={msg.uuid}
@@ -221,6 +245,8 @@ export function SessionViewer({
onToggleRedactionSelection(msg.uuid)
}
autoRedactEnabled={autoRedactEnabled}
progressEvents={progressEvents}
progressEnabled={progressEnabled}
/>
</div>
);

View File

@@ -68,7 +68,7 @@ export function Tooltip({ content, children, delayMs = 150, side = "top" }: Prop
style={{
position: "fixed",
left: position.x,
top: side === "top" ? position.y - 8 : position.y + 8,
top: side === "top" ? position.y - 12 : position.y + 12,
zIndex: 9999,
}}
data-side={side}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import type { SessionEntry, SessionDetailResponse } from "../lib/types";
interface SessionState {
@@ -22,6 +22,9 @@ export function useSession(): SessionState {
const [sessionLoading, setSessionLoading] = useState(false);
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) => {
setSessionsLoading(true);
setSessionsError(null);
@@ -44,19 +47,31 @@ export function useSession(): SessionState {
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
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);
setSessionError(null);
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}`);
const data = await res.json();
setCurrentSession(data);
} catch (err) {
// Ignore aborted requests — a newer request superseded this one
if (err instanceof DOMException && err.name === "AbortError") return;
setSessionError(
err instanceof Error ? err.message : "Failed to load session"
);
} finally {
setSessionLoading(false);
// Only clear loading if this controller wasn't superseded
if (sessionAbortRef.current === controller) {
setSessionLoading(false);
}
}
}, []);

View 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;
}
}

View File

@@ -1,6 +1,7 @@
export type {
MessageCategory,
ParsedMessage,
ProgressSubtype,
SessionEntry,
SessionListResponse,
SessionDetailResponse,

View File

@@ -247,7 +247,7 @@ mark.search-highlight {
position: absolute;
right: 0;
width: 8px;
height: 4px;
height: 3px;
background: rgba(254, 240, 138, 0.7);
border: none;
padding: 0;
@@ -362,7 +362,7 @@ mark.search-highlight {
.prose-message p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
line-height: 1.625;
line-height: 1.6;
}
.prose-message p:first-child {
@@ -383,7 +383,7 @@ mark.search-highlight {
.prose-message li {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
line-height: 1.5;
line-height: 1.6;
}
.prose-message code:not(pre code) {
@@ -445,6 +445,116 @@ mark.search-highlight {
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
═══════════════════════════════════════════════ */
@@ -481,10 +591,10 @@ mark.search-highlight {
.custom-checkbox:checked::after {
content: "";
position: absolute;
top: 1px;
left: 4px;
width: 5px;
height: 9px;
top: 6.25%;
left: 25%;
width: 31.25%;
height: 56.25%;
border: solid #0c1017;
border-width: 0 2px 2px 0;
transform: rotate(45deg);

View File

@@ -1,6 +1,7 @@
import { Router } from "express";
import { discoverSessions } from "../services/session-discovery.js";
import { parseSession } from "../services/session-parser.js";
import { groupProgress } from "../services/progress-grouper.js";
import type { SessionEntry } from "../../shared/types.js";
export const sessionsRouter = Router();
@@ -8,13 +9,30 @@ export const sessionsRouter = Router();
// Simple cache to avoid re-discovering sessions on every detail request
let cachedSessions: SessionEntry[] = [];
let cacheTimestamp = 0;
let cachePromise: Promise<SessionEntry[]> | null = null;
let cacheGeneration = 0;
const CACHE_TTL_MS = 30_000;
async function getCachedSessions(): Promise<SessionEntry[]> {
const now = Date.now();
if (now - cacheTimestamp > CACHE_TTL_MS) {
cachedSessions = await discoverSessions();
cacheTimestamp = now;
// Deduplicate concurrent calls: reuse in-flight promise
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;
}
@@ -23,6 +41,7 @@ sessionsRouter.get("/", async (req, res) => {
try {
if (req.query.refresh === "1") {
cacheTimestamp = 0;
cachePromise = null; // Discard any in-flight request so we force a fresh discovery
}
const sessions = await getCachedSessions();
res.json({ sessions });
@@ -40,11 +59,13 @@ sessionsRouter.get("/:id", async (req, res) => {
res.status(404).json({ error: "Session not found" });
return;
}
const messages = await parseSession(entry.path);
const allMessages = await parseSession(entry.path);
const { messages, toolProgress } = groupProgress(allMessages);
res.json({
id: entry.id,
project: entry.project,
messages,
toolProgress,
});
} catch (err) {
console.error("Failed to load session:", err);

View File

@@ -1,7 +1,7 @@
import { marked } from "marked";
import hljs from "highlight.js";
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 { redactMessage } from "../../shared/sensitive-redactor.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.
// 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
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" },
};
// 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(
req: ExportRequest
): Promise<string> {
const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req;
const toolProgress = session.toolProgress || {};
const visibleSet = new Set(visibleMessageUuids);
const redactedSet = new Set(redactedMessageUuids);
@@ -65,7 +78,8 @@ export async function generateExportHtml(
lastWasRedacted = false;
}
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();
@@ -108,6 +122,9 @@ ${hljsCss}
${messageHtmlParts.join("\n ")}
</main>
</div>
<script>
${getExportJs()}
</script>
</body>
</html>`;
}
@@ -123,9 +140,11 @@ function renderRedactedDivider(): string {
</div>`;
}
function renderMessage(msg: ParsedMessage): string {
function renderMessage(msg: ParsedMessage, progressEvents?: ParsedMessage[]): string {
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
const label = CATEGORY_LABELS[msg.category];
const isCollapsible = COLLAPSIBLE_CATEGORIES.has(msg.category);
let bodyHtml: string;
if (msg.category === "tool_call") {
@@ -133,10 +152,11 @@ function renderMessage(msg: ParsedMessage): string {
? `<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}`;
} 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)) {
// 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>`;
} else {
bodyHtml = renderMarkdown(msg.content);
@@ -147,16 +167,119 @@ function renderMessage(msg: ParsedMessage): string {
? `<span class="header-sep">&middot;</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">&middot;</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">
<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>
${timestampHtml}
${previewHtml}
</div>
<div class="message-body prose-message">${bodyHtml}</div>
${progressHtml}
</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
// Promise<string> if async extensions are configured. Our markedHighlight setup
// 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 {
// Dark theme highlight.js (GitHub Dark) matching the client
return `
@@ -259,6 +404,19 @@ body {
border-radius: 0.75rem;
border: 1px solid #30363d;
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 {
display: flex;
@@ -298,6 +456,97 @@ body {
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 { font-weight: 500; margin-bottom: 0.5rem; }
@@ -405,6 +654,10 @@ body {
body { background: #1c2128; }
.session-export { padding: 0; max-width: 100%; }
.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; }
}
`;
}

View 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 };
}

View File

@@ -1,5 +1,5 @@
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):
@@ -19,6 +19,7 @@ import type { ParsedMessage } from "../../shared/types.js";
interface ContentBlock {
type: string;
id?: string;
text?: string;
thinking?: string;
name?: string;
@@ -31,6 +32,7 @@ interface RawLine {
type?: string;
uuid?: string;
timestamp?: string;
parentToolUseID?: string;
message?: {
role?: string;
content?: string | ContentBlock[];
@@ -85,12 +87,16 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const progressText = data
? formatProgressData(data)
: "Progress event";
const dataType = typeof data?.type === "string" ? data.type : "";
const progressSubtype = deriveProgressSubtype(dataType);
messages.push({
uuid,
category: "hook_progress",
content: progressText,
timestamp,
rawIndex,
parentToolUseId: raw.parentToolUseID,
progressSubtype,
});
return messages;
}
@@ -155,7 +161,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
category: "tool_result",
content: resultText,
toolName: block.tool_use_id,
toolUseId: block.tool_use_id,
timestamp,
rawIndex,
});
@@ -215,6 +221,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
toolInput: JSON.stringify(block.input, null, 2),
timestamp,
rawIndex,
toolUseId: block.id,
});
}
}
@@ -239,3 +246,10 @@ function formatProgressData(data: Record<string, unknown>): string {
function detectSystemReminder(text: string): boolean {
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";
}

View File

@@ -9,6 +9,8 @@ export type MessageCategory =
| "file_snapshot"
| "summary";
export type ProgressSubtype = "hook" | "bash" | "mcp" | "agent";
export interface ParsedMessage {
uuid: string;
category: MessageCategory;
@@ -17,6 +19,9 @@ export interface ParsedMessage {
toolInput?: string;
timestamp?: string;
rawIndex: number;
toolUseId?: string;
parentToolUseId?: string;
progressSubtype?: ProgressSubtype;
}
export interface SessionEntry {
@@ -39,6 +44,7 @@ export interface SessionDetailResponse {
id: string;
project: string;
messages: ParsedMessage[];
toolProgress?: Record<string, ParsedMessage[]>;
}
export interface ExportRequest {

View File

@@ -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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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":"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"}

View 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");
}
});
});
});

View File

@@ -112,6 +112,19 @@ describe("filters", () => {
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", () => {
const counts: Record<string, number> = {};
for (const cat of ALL_CATEGORIES) {

View File

@@ -16,13 +16,15 @@ function makeMessage(
function makeExportRequest(
messages: ParsedMessage[],
visible?: string[],
redacted?: string[]
redacted?: string[],
toolProgress?: Record<string, ParsedMessage[]>
): ExportRequest {
return {
session: {
id: "test-session",
project: "test-project",
messages,
toolProgress,
},
visibleMessageUuids: visible || messages.map((m) => m.uuid),
redactedMessageUuids: redacted || [],
@@ -118,4 +120,149 @@ describe("html-exporter", () => {
// Verify singular — should NOT contain "1 messages"
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/);
});
});

View 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
});
});

View File

@@ -216,4 +216,107 @@ describe("session-parser", () => {
expect(msgs[0].category).toBe("user_message");
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");
});
});