Compare commits

...

34 Commits

Author SHA1 Message Date
teernisse
66f8cc3eb4 Add beads issue tracking infrastructure
Initialize beads (br) for dependency-aware issue tracking in this project.
Beads provides a lightweight task database with graph-aware triage, used
for coordinating work on the JSONL-first discovery feature.

Files added:
- .beads/config.yaml: Project configuration (issue prefix, defaults)
- .beads/issues.jsonl: Issue database with JSONL-first discovery tasks
- .beads/metadata.json: Beads metadata (commit correlation, etc.)
- .beads/.gitignore: Ignore SQLite databases, lock files, temp files

Also ignore .bv/ (beads viewer local state) in project .gitignore.

Beads is non-invasive: it never executes git/jj commands. The .beads/
directory is manually committed alongside code changes.

Usage:
- br ready --json: Find unblocked work
- br update <id> --status in_progress: Claim a task
- br close <id> --reason "done": Complete a task
- bv --robot-triage: Get graph-aware recommendations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:59 -05:00
teernisse
04343f6a9a Switch to Geist font family
Replace Inter with Geist font from jsDelivr CDN for a more modern look
that aligns with Vercel's design system.

Changes:
- index.html: Add Geist font stylesheet from jsDelivr
- main.css: Update font-family to use "Geist" as primary
- tailwind.config.js: Update fontFamily.sans to start with "Geist"

Geist is Vercel's open-source font family designed for readability and
clarity in developer tools. It pairs well with JetBrains Mono for code
blocks (already in use).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:49 -05:00
teernisse
007cbbcb69 Enhance UI with density toggle and mobile responsiveness
Add density toggle, mobile sidebar behavior, and various polish improvements
to the session viewer interface.

Density toggle (comfortable/compact):
- Persisted to localStorage as "session-viewer-density"
- Compact mode reduces message padding and spacing
- Toggle button in nav bar with LayoutRows icon
- Visual indicator when compact mode is active

Mobile sidebar:
- Sidebar slides in/out with transform transition
- Hamburger menu button visible on mobile (md:hidden)
- Backdrop overlay when sidebar is open
- Auto-close sidebar after selecting a session

Session info improvements:
- Show project/session title in nav bar when viewing session
- Add session ID display with copy button in SessionViewer
- Copy button shows check icon for 1.5s after copying

SessionList enhancements:
- Relative time format: "5m ago", "2h ago", "3d ago"
- Total message count per project in project list
- Truncated project names showing last 2 path segments
- Full path available in title attribute

MessageBubble:
- Add compact prop for reduced padding
- Add accentBorder property to category colors
- Migrate inline SVGs to shared Icons

SessionViewer:
- Sticky session info header with glass effect
- Add compact prop that propagates to MessageBubble
- Keyboard shortcut hints in empty state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:43 -05:00
teernisse
b67c302464 Add Icons component and migrate inline SVGs
Extract reusable icon components to a new Icons.tsx module, reducing
code duplication and enabling consistent icon styling across the app.

Icons included:
- Navigation: ChevronRight, ChevronLeft, ChevronDown, ChevronUp
- Actions: Search, X, Copy, Check, Refresh, Download
- UI: Menu, LayoutRows, Filter, Chat, ChatBubble
- Status: EyeSlash, Shield, AlertCircle, Clipboard
- Loading: Spinner (uses fill instead of stroke)

Implementation:
- icon() factory function creates SVG components from path data
- Configurable size (default "w-4 h-4") and strokeWidth (default 1.5)
- All icons use currentColor for easy theming

Components migrated:
- MessageBubble: collapse chevron, copy button
- SessionList: back arrow, project chevron, chat bubble
- SessionViewer: chat icon, filter icon

Also adds keyboard shortcut hints to the empty session state,
helping users discover j/k navigation and / search commands.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:30 -05:00
teernisse
8fddd50193 Implement JSONL-first session discovery with tiered lookup
Rewrite session discovery to be filesystem-first, addressing the widespread
bug where Claude Code's sessions-index.json files are unreliable (87 MB of
unindexed sessions, 17% loss rate across all projects).

Architecture: Three-tier metadata lookup

Tier 1 - Index validation (instant):
  - Parse sessions-index.json into Map<sessionId, IndexEntry>
  - Validate entry.modified against actual file stat.mtimeMs
  - Use 1s tolerance to account for ISO string → filesystem mtime rounding
  - Trust content fields only (messageCount, summary, firstPrompt)
  - Timestamps always come from fs.stat, never from index

Tier 2 - Persistent cache hit (instant):
  - Check MetadataCache by (filePath, mtimeMs, size)
  - If match, use cached metadata
  - Survives server restarts

Tier 3 - Full JSONL parse (~5-50ms/file):
  - Call extractSessionMetadata() with shared parser helpers
  - Cache result for future lookups

Key correctness guarantees:
- All .jsonl files appear regardless of index state
- SessionEntry timestamps always from fs.stat (list ordering never stale)
- Message counts exact (shared helpers ensure parser parity)
- Duration computed from JSONL timestamps, not index

Performance:
- Bounded concurrency: 32 concurrent operations per project
- mapWithLimit() prevents file handle exhaustion
- Warm start <1s (stat all files, in-memory lookups)
- Cold start ~3-5s for 3,103 files (stat + parse phases)

TOCTOU handling:
- Files that disappear between readdir and stat: silently skipped
- Files that disappear between stat and read: silently skipped
- File actively being written: partial parse handled gracefully

Include PRD document that drove this implementation with detailed
requirements, edge cases, and verification plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:20 -05:00
teernisse
f15a1b1b58 Add persistent metadata cache with atomic writes
Introduce MetadataCache class in metadata-cache.ts that persists extracted
session metadata to ~/.cache/session-viewer/metadata.json for fast warm
starts across server restarts.

Key features:

- Invalidation keyed on (mtimeMs, size): If either changes, entry is
  re-extracted via Tier 3 parsing. This catches both content changes
  and file truncation/corruption.

- Dirty-flag write-behind: Only writes to disk when entries have changed,
  coalescing multiple discovery passes into a single write operation.

- Atomic writes: Uses temp file + rename pattern to prevent corruption
  from crashes during write. Safe for concurrent server restarts.

- Stale entry pruning: Removes entries for files that no longer exist
  on disk during the save operation.

- Graceful degradation: Missing or corrupt cache file triggers fallback
  to Tier 3 extraction for all files (cache rebuilt on next save).

Cache file format:
{
  "version": 1,
  "entries": {
    "/path/to/session.jsonl": {
      "mtimeMs": 1234567890,
      "size": 12345,
      "messageCount": 42,
      "firstPrompt": "...",
      "summary": "...",
      "firstTimestamp": "...",
      "lastTimestamp": "..."
    }
  }
}

Test coverage includes:
- Cache hit/miss/invalidation behavior
- Dirty flag triggers write only when entries changed
- Concurrent save coalescing
- Stale entry pruning on save

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:53:05 -05:00
teernisse
eda20a9886 Add lightweight session metadata extraction service
Introduce extractSessionMetadata() in a new session-metadata.ts module
that extracts only what the list view needs from JSONL files:

- messageCount: Uses shared countMessagesForLine() for exact parity
- firstPrompt: First non-system-reminder user message, truncated to 200 chars
- summary: Last type="summary" line's summary field
- firstTimestamp/lastTimestamp: For duration computation

Design goals:
- Parser parity: Uses forEachJsonlLine() and countMessagesForLine() from
  session-parser.ts, ensuring list counts always match detail-view counts
- No string building: Avoids JSON.stringify and markdown processing
- 2-3x faster than full parse: Only captures metadata, skips content
- Graceful degradation: Handles malformed lines identically to full parser

This is the Tier 3 data source for JSONL-first session discovery. When
neither the sessions-index.json nor the persistent cache has valid data,
this function extracts fresh metadata from the file.

Test coverage includes:
- Output matches parseSessionContent().length on sample fixtures
- Duration extraction from JSONL timestamps
- firstPrompt extraction skips system-reminder content
- Empty files return zero counts and empty strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:52:52 -05:00
teernisse
c20652924d Extract shared JSONL parsing helpers for parser parity
Introduce three shared helpers in session-parser.ts that both the full
parser and the lightweight metadata extractor can use:

- forEachJsonlLine(content, onLine): Iterates JSONL lines with consistent
  malformed-line handling. Skips invalid JSON lines identically to how
  parseSessionContent handles them. Returns parse error count for diagnostics.

- countMessagesForLine(parsed): Returns the number of messages a single
  JSONL line expands into, using the same classification rules as the
  full parser. User arrays expand tool_result and text blocks; assistant
  arrays expand thinking, text, and tool_use.

- classifyLine(parsed): Classifies a parsed line into one of 8 types
  (user, assistant, system, progress, summary, file_snapshot, queue, other).

The internal extractMessages() function now uses these shared helpers,
ensuring no behavior change while enabling the upcoming metadata extraction
service to reuse the same logic. This guarantees list counts can never drift
from detail-view counts, regardless of future parser changes.

Test coverage includes:
- Malformed line handling parity with full parser
- Parse error counting for truncated/corrupted files
- countMessagesForLine output matches extractMessages().length
- Edge cases: empty files, progress events, array content expansion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-28 00:52:41 -05:00
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
150cd0c686 Wire up ErrorBoundary, URL session sync, j/k navigation, and refresh
Wrap SessionViewer in an ErrorBoundary so render errors show a
recovery UI instead of a white screen.

Sync the selected session ID with the URL search param (?session=).
On initial load, load the session from the URL if it exists in the
session list. On session change, update the URL via
history.replaceState without triggering navigation.

Add j/k keyboard navigation to step through filtered messages.
Search focus takes precedence over keyboard focus; both reset when
filters or session changes.

Add a refresh button in the sidebar header that calls the new
refreshSessions hook, with a spinning icon while loading.

Pass sensitiveCount to FilterPanel for the new badge display.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:36:18 -05:00
4ec186d45b Skip entrance animation for messages beyond the first 20
Messages at index 20+ no longer receive the animate-fade-in class or
animationDelay inline style. This avoids scheduling hundreds of CSS
animations on large sessions where the stagger would be invisible
anyway (the earlier cap of 300ms max delay was already clamping them).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:36:02 -05:00
957f9bc744 Polish SearchBar with arrow key navigation and conditional controls
Arrow Up/Down keys now cycle through search matches while the input
is focused, matching the behavior of browser find-in-page. Navigation
buttons gain active:scale-95 press feedback and explicit sizing.

The right-side control region is now conditionally rendered: keyboard
hint (/) shows when empty, match count + nav + clear show when active.
A visual divider separates navigation arrows from the clear button.
The match count badge highlights the current position number with a
distinct weight.

Tests cover empty state visibility, active search state rendering,
arrow key and button navigation, and clear button behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:55 -05:00
10f23ccecc Pretty-print JSON in tool inputs and preformatted blocks
Tool call inputs and structured data categories (hook_progress,
file_snapshot) now attempt JSON.parse + JSON.stringify(_, null, 2)
before escaping to HTML. Non-JSON content passes through unchanged.
The detection fast-paths by checking the first non-whitespace
character for { or [ before attempting parse.

Also renames the copy state variable from linkCopied to contentCopied
to match the current behavior of copying message content rather than
anchor links.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:45 -05:00
b0b330e0ba Add session list refresh with server cache bypass
useSession now exposes a refreshSessions() callback that fetches
/api/sessions?refresh=1. The sessions route checks for the refresh
query parameter and resets the cache timestamp to zero, forcing a
fresh scan of ~/.claude/projects/ on the next request.

This enables users to pick up new sessions without restarting the
server or waiting for the 30-second cache to expire.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:37 -05:00
e5c5e470b0 Add ErrorBoundary component with recovery UI
React class component that catches render errors in child trees and
displays a styled error panel with the exception message and a
'Try again' button that resets state. Follows the existing design
system with red accent colors and gradient icon background.

Tests verify normal rendering, error capture with message display,
and the presence of the recovery button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:30 -05:00
89ee0cb313 Add Tooltip component and show sensitive message count on auto-redact
Introduce a reusable Tooltip component with delayed hover reveal,
viewport-aware horizontal nudging, and smooth CSS entrance animation.
Supports top/bottom positioning via a data-side attribute.

FilterPanel now wraps the auto-redact checkbox in a Tooltip that
explains what auto-redaction detects. When sensitive messages exist
in the current view, a red pill badge displays the count next to
the label, giving users immediate visibility into how many messages
contain detectable secrets before toggling auto-redact on.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:23 -05:00
6681f07fc0 Add countSensitiveMessages for pre-scan sensitive content detection
Export a new countSensitiveMessages() function that returns how many
messages in an array contain at least one sensitive pattern match.
Checks both content and toolInput fields, counting each message at
most once regardless of how many matches it contains.

Tests verify zero counts for clean messages, correct counting with
mixed sensitive/clean messages, and the single-count-per-message
invariant when multiple secrets appear in one message.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:15 -05:00
4027dd65be Persist filter and auto-redact preferences to localStorage
Category toggles and the auto-redact checkbox now survive page
reloads. On mount, useFilters reads from localStorage keys
session-viewer:enabledCategories and session-viewer:autoRedact,
falling back to defaults when storage is empty, corrupted, or
contains invalid category names. Each state change writes back
to localStorage in a useEffect.

Tests cover round-trip persistence, invalid data recovery, corrupted
JSON fallback, and the boolean coercion for auto-redact.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:35:08 -05:00
a8b602fbde Overhaul AGENTS.md with comprehensive tooling reference
Replace the minimal beads workflow section with detailed documentation
for all agent tooling: br (beads_rust) CLI with workflow patterns and
key invariants, bv robot mode with triage/planning/graph analysis/
history/forecasting commands, hybrid semantic search, static site
export, ast-grep vs ripgrep guidance, Morph WarpGrep usage, UBS
static analysis, and cass cross-agent session search.

Each section includes concrete command examples, jq quick references,
and anti-patterns to avoid. The structure follows a discovery-first
approach — agents start with triage, then drill into specifics.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:34:59 -05:00
b030734915 Add comprehensive README with architecture and usage docs
Introduce a README covering the full project: quick start,
feature inventory (session navigation, message display, search,
filtering, redaction, export), architecture overview with
directory layout, tech stack table, API endpoint reference,
npm scripts, environment variables, and security properties.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:42:35 -05:00
c66ce4ae16 Change message copy button to copy content instead of anchor link
The per-message copy button previously copied a URL with a hash
anchor (#msg-{uuid}) for deep linking. Replace this with copying
the actual message content: for tool_call messages it copies the
tool name and input; for all other categories it copies the text
body. This is more immediately useful — users copying message
content is far more common than sharing anchor links.

Button title updated from "Copy link to message" to "Copy message
content" to match the new behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:42:30 -05:00
54f909c80c Revise default hidden categories to reduce noise in session view
Change the default-hidden message categories from [thinking,
hook_progress] to [tool_result, system_message, hook_progress,
file_snapshot]. This hides the verbose machine-oriented categories
by default while keeping thinking blocks visible — they contain
useful reasoning context that users typically want to see.

Also rename the "summary" category label from "Summaries" to
"Compactions" to better reflect what Claude's summary messages
actually represent (context-window compaction artifacts).

Tests updated to match the new defaults: the filter test now
asserts that tool_result, system_message, hook_progress, and
file_snapshot are all excluded, producing 5 visible messages
instead of the previous 7.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:42:21 -05:00
2a6186e9ce Rename AGENTS.md header from CLAUDE.md to Agent Instructions
The file was originally scaffolded with the CLAUDE.md boilerplate
header. Update the title and subtitle to reflect that this document
is guidance for any AI agent, not just Claude Code specifically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:42:13 -05:00
b60501e80f Add AGENTS.md with development principles and beads workflow docs
Project-level guidance for Claude Code including:
- Development principles: simplicity, third-party library preference,
  extensible architecture, loose DRY, clear architecture
- Beads Rust (br) CLI workflow: essential commands, robot mode flags,
  session protocol checklist, and best practices
- Key concepts: dependency tracking, priority levels (P0-P4), issue
  types, and blocking relationships

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:34:35 -05:00
a51c134da7 Harden API layer: encode session IDs and validate export payload
Session fetch (useSession.ts):
- Wrap the session ID in encodeURIComponent before interpolating
  into the fetch URL. Session IDs can contain characters like '+'
  or '/' that would corrupt the path without encoding.

Export route (export.ts):
- Add validation that redactedMessageUuids, when present, is an
  array. Previously only visibleMessageUuids was checked, so a
  malformed redactedMessageUuids value (e.g. a string or object)
  would silently pass validation and potentially cause downstream
  type errors in the HTML exporter.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:34:29 -05:00
4c5d6dd4c8 Extend search to match tool input content, not just message body
The search index (app.tsx) and the per-message match check
(SessionViewer.tsx) now also search msg.toolInput when present.
This means searching for a file path, command, or argument that
appears in a tool call's input will correctly highlight and
navigate to that message, rather than dimming it as a non-match.

Both locations use the same compound condition so the match index
and the visual dimming/focus logic stay in sync.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:34:20 -05:00
54 changed files with 7276 additions and 460 deletions

11
.beads/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Database
*.db
*.db-shm
*.db-wal
# Lock files
*.lock
# Temporary
last-touched
*.tmp

4
.beads/config.yaml Normal file
View File

@@ -0,0 +1,4 @@
# Beads Project Configuration
# issue_prefix: bd
# default_priority: 2
# default_type: task

12
.beads/issues.jsonl Normal file
View File

@@ -0,0 +1,12 @@
{"id":"bd-10w","title":"CP3: Unit tests for Tier 1 index validation","description":"## Background\nTier 1 validation has specific edge cases around timestamp normalization and stale detection that must be tested independently from the full discovery pipeline.\n\n## Approach\nAdd tests to tests/unit/session-discovery.test.ts under describe \"Tier 1 index validation\". Tests create temp project directories with both .jsonl files and sessions-index.json, then verify Tier 1 behavior.\n\nTest cases:\n1. Uses index messageCount/summary/firstPrompt when index modified matches file mtime within 1s\n2. Rejects stale index entries when mtime differs by > 1s (falls through to Tier 3)\n3. Handles missing modified field in index entry (falls through to Tier 2/3)\n4. SessionEntry.created and .modified always from stat even when Tier 1 is trusted\n5. Missing sessions-index.json: all sessions still discovered via Tier 2/3\n6. Corrupt sessions-index.json (invalid JSON): warning logged, all sessions still discovered\n7. Legacy index format (raw array, no version wrapper): still parsed correctly\n\n## Acceptance Criteria\n- [ ] All 7 test cases pass\n- [ ] Tests use temp directories with controlled file timestamps\n- [ ] npm run test -- session-discovery passes\n\n## Files\n- MODIFY: tests/unit/session-discovery.test.ts (add tests in \"Tier 1 index validation\" describe block)\n\n## TDD Loop\nRED: Write tests (fail until bd-3g5 Tier 1 implementation is done)\nGREEN: Tests pass after Tier 1 implementation\nVERIFY: npm run test -- session-discovery\n\n## Edge Cases\n- Setting file mtime in tests: use fs.utimes() to control mtime precisely\n- Index with ISO string timestamps vs epoch ms: test both formats","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:48:25.641546Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:27:28.878028Z","closed_at":"2026-02-04T18:27:28.877984Z","close_reason":"Tests already written as part of bd-3g5: 6 tests covering Tier 1 hit, miss, no-modified-field, missing index, corrupt index, stat-derived timestamps.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-10w","depends_on_id":"bd-3g5","type":"blocks","created_at":"2026-02-04T17:49:30.660263Z","created_by":"tayloreernisse"}]}
{"id":"bd-18a","title":"CP2: Implement MetadataCache with dirty-flag write-behind and atomic writes","description":"## Background\nThe persistent metadata cache at ~/.cache/session-viewer/metadata.json avoids re-parsing unchanged JSONL files across server restarts. This is Tier 2 in the tiered lookup — checked after Tier 1 (index) fails, before Tier 3 (full parse).\n\n## Approach\nCreate MetadataCache class in src/server/services/metadata-cache.ts:\n\n```typescript\ninterface CacheEntry {\n mtimeMs: number;\n size: number;\n messageCount: number;\n firstPrompt: string;\n summary: string;\n created: string; // ISO from file birthtime\n modified: string; // ISO from file mtime\n firstTimestamp: string;\n lastTimestamp: string;\n}\n\ninterface CacheFile {\n version: 1;\n entries: Record<string, CacheEntry>; // keyed by absolute file path\n}\n\nexport class MetadataCache {\n constructor(cachePath?: string) // default: ~/.cache/session-viewer/metadata.json\n\n async load(): Promise<void> // Load from disk, graceful on missing/corrupt\n get(filePath: string, mtimeMs: number, size: number): CacheEntry | null // Tier 2 lookup\n set(filePath: string, entry: CacheEntry): void // Mark dirty\n async save(existingPaths?: Set<string>): Promise<void> // Prune stale, atomic write if dirty\n async flush(): Promise<void> // Force write if dirty (for shutdown)\n}\n```\n\nKey behaviors:\n1. load(): Read + JSON.parse cache file. On corrupt/missing: start empty, no error.\n2. get(): Return entry only if mtimeMs AND size match. Otherwise return null (cache miss).\n3. set(): Store entry, set dirty flag to true.\n4. save(existingPaths): If dirty, prune entries whose keys are not in existingPaths, write to temp file then fs.rename (atomic). Reset dirty flag.\n5. flush(): Same as save() but without pruning. Called on shutdown.\n6. Shutdown hooks: Register process.on(\"SIGTERM\") and process.on(\"SIGINT\") handlers that call flush(). Register once in module scope or via an init function.\n\nWrite-behind strategy: discoverSessions() calls save() asynchronously after returning results. The Promise is fire-and-forget but errors are logged.\n\nIntegrate into discoverSessions() in session-discovery.ts:\n- Load cache once on first call (lazy init)\n- Before Tier 3 parse: check cache.get(filePath, stat.mtimeMs, stat.size)\n- After Tier 3 parse: call cache.set(filePath, extractedEntry)\n- After building all entries: fire-and-forget cache.save(discoveredPaths)\n\n## Acceptance Criteria\n- [ ] MetadataCache class exported from src/server/services/metadata-cache.ts\n- [ ] Cache hit returns entry when mtimeMs + size match\n- [ ] Cache miss (returns null) when mtimeMs or size differ\n- [ ] Dirty flag only set when set() is called (not on load/get)\n- [ ] save() is no-op when not dirty\n- [ ] Atomic writes: temp file + rename pattern\n- [ ] Corrupt cache file loads gracefully (empty cache, no throw)\n- [ ] Missing cache file loads gracefully (empty cache, no throw)\n- [ ] Stale entries pruned on save\n- [ ] Shutdown hooks registered for SIGTERM/SIGINT\n- [ ] Cache directory created if it does not exist (mkdir -p equivalent)\n- [ ] npm run test passes\n\n## Files\n- CREATE: src/server/services/metadata-cache.ts\n- MODIFY: src/server/services/session-discovery.ts (integrate cache into tiered lookup)\n\n## TDD Loop\nRED: tests/unit/metadata-cache.test.ts — tests:\n - \"returns null for unknown file path\"\n - \"returns entry when mtimeMs and size match\"\n - \"returns null when mtimeMs differs\"\n - \"returns null when size differs\"\n - \"save is no-op when not dirty\"\n - \"save writes to disk when dirty\"\n - \"save prunes entries not in existingPaths\"\n - \"load handles missing cache file\"\n - \"load handles corrupt cache file\"\n - \"atomic write: file not corrupted on crash\"\nGREEN: Implement MetadataCache\nVERIFY: npm run test -- metadata-cache\n\n## Edge Cases\n- Cache directory does not exist: create with fs.mkdir recursive\n- Cache file locked by another process: log warning, continue without cache\n- Server killed with SIGKILL (hard kill): cache may be lost — acceptable, rebuilt on next cold start\n- Concurrent save() calls: second save waits for first (or coalesce via dirty flag)\n- Very large cache (3000+ entries): JSON serialization should still be < 50ms","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:48:03.919559Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:25:16.365118Z","closed_at":"2026-02-04T18:25:16.365065Z","close_reason":"Implemented MetadataCache class with dirty-flag write-behind, atomic writes (temp+rename), prune stale entries, load/save/flush. Integrated into discoverSessions() as Tier 2 lookup. 13 unit tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-18a","depends_on_id":"bd-34v","type":"blocks","created_at":"2026-02-04T17:49:30.583939Z","created_by":"tayloreernisse"}]}
{"id":"bd-1dn","title":"CP4: Bounded concurrency for stat and parse phases","description":"## Background\nCold start with 3,103 files requires bounded parallelism to avoid file-handle exhaustion and IO thrash. Without limits, Node.js will attempt thousands of concurrent fs.stat() and fs.readFile() calls, potentially hitting EMFILE errors.\n\n## Approach\nAdd a lightweight concurrency limiter. Options:\n- Install p-limit (npm i p-limit) — well-maintained, zero deps, 1.2KB\n- Or hand-roll a simple semaphore (~15 lines)\n\nRecommendation: p-limit for clarity and maintenance. It is ESM-only since v4, so use dynamic import or pin v3.x if the project uses CJS.\n\nImplementation in session-discovery.ts:\n\n```typescript\nimport pLimit from \"p-limit\";\n\nconst STAT_CONCURRENCY = 64;\nconst PARSE_CONCURRENCY = 8;\n\n// In discoverSessions(), per project:\nconst statLimit = pLimit(STAT_CONCURRENCY);\nconst parseLimit = pLimit(PARSE_CONCURRENCY);\n\n// Stat phase: batch all files\nconst statResults = await Promise.all(\n jsonlFiles.map(f => statLimit(() => safeStat(f)))\n);\n\n// Parse phase: only Tier 3 misses\nconst parseResults = await Promise.all(\n tier3Files.map(f => parseLimit(() => safeReadAndExtract(f)))\n);\n```\n\nsafeStat() wraps fs.stat in try/catch, returns null on ENOENT/EACCES (with debug log).\nsafeReadAndExtract() wraps fs.readFile + extractSessionMetadata, returns null on failure.\n\nPerformance targets:\n- Cold start (no cache, no index): < 5s for 3,103 files\n- Warm start (cache exists, few changes): < 1s\n- Incremental (cache + few new files): ~500ms + ~50ms per new file\n\n## Acceptance Criteria\n- [ ] p-limit (or equivalent) added as dependency\n- [ ] Stat phase uses concurrency limit of 64\n- [ ] Parse phase uses concurrency limit of 8\n- [ ] ENOENT and EACCES errors from stat silently handled (debug log, skip file)\n- [ ] Read errors silently handled (debug log, skip file)\n- [ ] No EMFILE errors on cold start with 3000+ files\n- [ ] Warm start < 1s verified on real dataset (manual verification step)\n- [ ] npm run test passes\n\n## Files\n- MODIFY: package.json (add p-limit dependency)\n- MODIFY: src/server/services/session-discovery.ts (wrap stat + parse in concurrency limiters)\n\n## TDD Loop\nRED: Manual performance test — time cold start on real ~/.claude/projects\nGREEN: Add concurrency limits, re-test\nVERIFY: npm run test && manual timing of warm/cold starts\n\n## Edge Cases\n- p-limit v4+ is ESM-only: check if project tsconfig uses \"module\": \"ESNext\" or \"NodeNext\". If CJS, use p-limit@3 or hand-roll.\n- Concurrency limits are per-project. With many small projects, total concurrency could still be high. Consider a global limiter shared across projects if needed.\n- Files actively being written during stat phase: mtime captured at stat time, content may differ at read time. Next discovery pass will re-extract (mtime changed).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:48:36.609991Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:28:36.268754Z","closed_at":"2026-02-04T18:28:36.268402Z","close_reason":"Added mapWithLimit() concurrency limiter (32 concurrent ops per project) to prevent EMFILE on large session directories. Hand-rolled to avoid external dependency. No behavior change to existing tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1dn","depends_on_id":"bd-34v","type":"blocks","created_at":"2026-02-04T17:49:30.683780Z","created_by":"tayloreernisse"}]}
{"id":"bd-1ed","title":"CP1: Unit tests for filesystem-first discovery and metadata extraction","description":"## Background\nDiscovery correctness must be verified with tests covering the key scenarios from the PRD edge cases table. These tests validate the integration of filesystem scanning + metadata extraction.\n\n## Approach\nAdd tests to the existing test file, building on its temp directory pattern (uses os.tmpdir, writes .jsonl fixtures, cleans up).\n\nTest cases:\n1. Discovers all .jsonl files without sessions-index.json present\n2. SessionEntry.created from stat.birthtimeMs, .modified from stat.mtimeMs\n3. Duration computed from first/last JSONL timestamps (not index)\n4. Silently skips files that disappear between readdir and stat (create file, delete before stat mock)\n5. Empty .jsonl file returns messageCount: 0, session still appears in list\n6. extractSessionMetadata().messageCount === parseSessionContent().messages.length on fixture data\n7. Sessions sorted by modified descending\n8. Path traversal in filename rejected (symlink or \"..\" in name)\n9. Multiple project directories scanned and merged\n\n## Acceptance Criteria\n- [ ] All 9+ test cases pass\n- [ ] Tests use temp directories (not real ~/.claude/projects)\n- [ ] Cleanup runs even on test failure (afterEach or try/finally)\n- [ ] npm run test -- session-discovery passes\n\n## Files\n- MODIFY: tests/unit/session-discovery.test.ts (add describe block \"filesystem-first discovery\")\n\n## TDD Loop\nRED: Write tests (will fail until bd-34v discovery rewrite is done)\nGREEN: Tests pass after discovery loop rewrite\nVERIFY: npm run test -- session-discovery\n\n## Edge Cases\n- Test cleanup must handle partially-created temp dirs\n- stat.birthtimeMs may equal 0 on some filesystems (Linux ext4) — test should not hardcode platform-specific birthtimeMs behavior","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:47:52.189987Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:23:46.313213Z","closed_at":"2026-02-04T18:23:46.313152Z","close_reason":"Tests already written as part of bd-34v implementation. 9 tests cover all spec requirements: filesystem discovery, stat timestamps, JSONL duration, TOCTOU resilience, empty files, sorting, extension filtering, multi-project aggregation.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ed","depends_on_id":"bd-34v","type":"blocks","created_at":"2026-02-04T17:49:30.541764Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ed","depends_on_id":"bd-3pr","type":"blocks","created_at":"2026-02-04T17:49:30.559261Z","created_by":"tayloreernisse"}]}
{"id":"bd-1tm","title":"CP0: Extract countMessagesForLine() and classifyLine() shared helpers","description":"## Background\nThe PRD requires exact message counts — list counts must match detail-view counts. The existing extractMessages() function (session-parser.ts:78-233) has non-trivial expansion rules that must be encoded in a shared helper so both the metadata extractor and full parser produce identical counts.\n\n## Approach\nExtract two helpers from extractMessages() logic:\n\n```typescript\nexport type LineClassification =\n | \"user\" | \"assistant\" | \"progress\" | \"file-history-snapshot\"\n | \"summary\" | \"system\" | \"queue-operation\" | \"unknown\";\n\nexport function classifyLine(parsed: RawLine): LineClassification\nexport function countMessagesForLine(parsed: RawLine): number\n```\n\nExpansion rules countMessagesForLine must encode:\n- type=user, string content: 1\n- type=user, array content: count of (tool_result + text) blocks in array\n- user text block containing \"<system-reminder>\": still counts as 1 (reclassified as system_message)\n- type=assistant, string content: 1\n- type=assistant, array content: count of (thinking + text + tool_use) blocks\n- type=progress: 1\n- type=file-history-snapshot: 1\n- type=summary: 1\n- type=system: 0 (skipped)\n- type=queue-operation: 0 (skipped)\n- unknown/missing type: 0\n\nThen refactor extractMessages() to use classifyLine() for its initial type dispatch (the switch on raw.type around line 88). countMessagesForLine() can be validated against extractMessages() output.\n\n## Acceptance Criteria\n- [ ] classifyLine() and countMessagesForLine() exported from session-parser.ts\n- [ ] extractMessages() refactored to use classifyLine() internally\n- [ ] npm run test passes (no behavior change to existing parser)\n- [ ] countMessagesForLine() matches extractMessages(line).length for every message type\n\n## Files\n- MODIFY: src/server/services/session-parser.ts\n\n## TDD Loop\nRED: tests/unit/session-parser.test.ts — add tests:\n - \"countMessagesForLine matches extractMessages length for user string message\"\n - \"countMessagesForLine matches extractMessages length for user array with tool_result and text\"\n - \"countMessagesForLine matches extractMessages length for assistant array with thinking/text/tool_use\"\n - \"countMessagesForLine returns 1 for progress/file-history-snapshot/summary\"\n - \"countMessagesForLine returns 0 for system/queue-operation\"\n - \"classifyLine returns correct classification for each type\"\nGREEN: Implement classifyLine() + countMessagesForLine(), wire into extractMessages()\nVERIFY: npm run test -- session-parser\n\n## Edge Cases\n- User message with empty content array: returns 0 (no expandable blocks)\n- Assistant message with mixed block types (some unrecognized): only count known types\n- Missing type field: classify as unknown, count as 0\n- Null/undefined message.content: count as 0 (not 1)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T17:47:13.654314Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:20:11.248274Z","closed_at":"2026-02-04T18:20:11.248229Z","close_reason":"Implemented classifyLine() and countMessagesForLine() helpers, refactored extractMessages() to use classifyLine(), added 13 unit tests including parity checks","compaction_level":0,"original_size":0}
{"id":"bd-2bj","title":"CP2: Unit tests for MetadataCache","description":"## Background\nCache behavior must be verified to ensure correctness, performance, and graceful degradation. These tests validate MetadataCache in isolation using temp directories.\n\n## Approach\nCreate tests/unit/metadata-cache.test.ts. Use temp directories for cache file location. Test the MetadataCache class directly without involving the full discovery pipeline.\n\nTest cases:\n1. get() returns null for unknown file path\n2. get() returns entry when mtimeMs AND size match exactly\n3. get() returns null when mtimeMs differs (file modified)\n4. get() returns null when size differs (file modified)\n5. save() is no-op when cache is not dirty (verify file not written via stat check)\n6. save() writes to disk when dirty (verify file exists after save)\n7. save() prunes entries whose paths are not in existingPaths set\n8. load() handles missing cache file gracefully (no throw, empty state)\n9. load() handles corrupt JSON gracefully (no throw, empty state)\n10. Roundtrip: set entries, save, create new instance, load, get returns same entries\n11. Dirty flag reset after save (second save is no-op)\n\n## Acceptance Criteria\n- [ ] All 11 test cases pass\n- [ ] Tests use temp directories (not real ~/.cache)\n- [ ] Cleanup in afterEach\n- [ ] npm run test -- metadata-cache passes\n\n## Files\n- CREATE: tests/unit/metadata-cache.test.ts\n\n## TDD Loop\nRED: Write all test cases (fail until bd-18a MetadataCache is implemented)\nGREEN: Tests pass after MetadataCache implementation\nVERIFY: npm run test -- metadata-cache\n\n## Edge Cases\n- Temp dir cleanup must handle missing files (rm with force)\n- Tests should not depend on timing (no race-condition-sensitive assertions)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:48:10.222765Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:25:41.487042Z","closed_at":"2026-02-04T18:25:41.486998Z","close_reason":"Tests already written as part of bd-18a implementation: 13 tests covering get/set, dirty flag, mtimeMs/size matching, prune, persistence, corrupt/missing file handling, flush.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2bj","depends_on_id":"bd-18a","type":"blocks","created_at":"2026-02-04T17:49:30.610149Z","created_by":"tayloreernisse"}]}
{"id":"bd-2jf","title":"CP0: Unit tests for parser parity (forEachJsonlLine, countMessagesForLine)","description":"## Background\nThe parser parity contract is the biggest correctness risk in this feature. These tests prove that the shared helpers produce identical results to the full parser, ensuring list-view counts can never drift from detail-view counts.\n\n## Approach\nAdd a new describe block in the existing test file. Tests should exercise every expansion rule using inline JSONL lines, plus test with the existing fixture files.\n\nTest cases:\n1. forEachJsonlLine skips malformed/truncated JSON lines (missing closing brace)\n2. forEachJsonlLine reports parseErrors count accurately\n3. forEachJsonlLine handles empty content string\n4. countMessagesForLine matches extractMessages().length for user string content\n5. countMessagesForLine matches extractMessages().length for user array with tool_result + text blocks\n6. countMessagesForLine matches extractMessages().length for user text with system-reminder (reclassified)\n7. countMessagesForLine matches extractMessages().length for assistant string content\n8. countMessagesForLine matches extractMessages().length for assistant array (thinking + text + tool_use)\n9. countMessagesForLine returns 1 for progress, file-history-snapshot, summary\n10. countMessagesForLine returns 0 for system, queue-operation\n11. classifyLine returns correct classification for each known type\n12. Integration: extractSessionMetadata().messageCount === parseSessionContent().messages.length on tests/fixtures/sample-session.jsonl\n13. Integration: same check on tests/fixtures/edge-cases.jsonl (has malformed lines)\n\n## Acceptance Criteria\n- [ ] All 13+ test cases pass\n- [ ] Tests cover every message type expansion rule\n- [ ] At least one test uses a malformed/truncated JSONL line\n- [ ] At least one test uses real fixture files for integration verification\n- [ ] npm run test -- session-parser passes\n\n## Files\n- MODIFY: tests/unit/session-parser.test.ts (add describe block \"parser parity: shared helpers\")\n\n## TDD Loop\nRED: Write all test cases first (they will fail until CP0 implementation beads are done)\nGREEN: Tests pass after bd-2og and bd-1tm are implemented\nVERIFY: npm run test -- session-parser\n\n## Edge Cases\n- Fixture files may change over time; tests should assert count equality (meta.messageCount === parsed.messages.length) not hardcoded numbers\n- Truncated JSON at end of file (crash mid-write) must be handled identically by both paths","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:47:22.070948Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:20:50.740098Z","closed_at":"2026-02-04T18:20:50.740034Z","close_reason":"Added 4 additional parity tests: system-reminder reclassification, truncated JSON handling, and 2 fixture-based integration tests proving countMessagesForLine sum equals parseSessionContent length","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2jf","depends_on_id":"bd-1tm","type":"blocks","created_at":"2026-02-04T17:49:30.466873Z","created_by":"tayloreernisse"},{"issue_id":"bd-2jf","depends_on_id":"bd-2og","type":"blocks","created_at":"2026-02-04T17:49:30.447953Z","created_by":"tayloreernisse"}]}
{"id":"bd-2og","title":"CP0: Extract forEachJsonlLine() shared line iterator from session-parser.ts","description":"## Background\nThe metadata extractor and full parser must iterate JSONL lines identically — same malformed-line handling, same error skipping. Currently this logic is inline in parseSessionContent() (session-parser.ts:59-70). Extracting it guarantees the parser parity contract from the PRD.\n\n## Approach\nExtract a shared function from the existing inline loop in parseSessionContent():\n\n```typescript\nexport interface RawLine {\n type?: string; uuid?: string; timestamp?: string;\n parentToolUseID?: string;\n message?: { role?: string; content?: string | ContentBlock[]; };\n data?: Record<string, unknown>;\n summary?: string; snapshot?: Record<string, unknown>;\n subtype?: string;\n}\n\nexport function forEachJsonlLine(\n content: string,\n onLine: (parsed: RawLine, lineIndex: number) => void\n): { parseErrors: number }\n```\n\nImplementation:\n1. Split content by newlines, filter empty/whitespace-only lines\n2. JSON.parse each line inside try/catch\n3. On parse failure: increment parseErrors counter, continue (skip line)\n4. On success: call onLine(parsed, lineIndex)\n5. Return { parseErrors }\n\nThen refactor parseSessionContent() to use forEachJsonlLine() internally — replacing its current inline loop (lines 59-70). No behavior change to parseSessionContent output.\n\n## Acceptance Criteria\n- [ ] forEachJsonlLine() exported from src/server/services/session-parser.ts\n- [ ] RawLine interface exported from src/server/services/session-parser.ts\n- [ ] parseSessionContent() refactored to use forEachJsonlLine() internally\n- [ ] npm run test passes (existing tests unchanged, proving no behavior change)\n- [ ] Malformed JSON lines skipped with parseErrors count incremented\n- [ ] Empty/whitespace-only lines skipped without incrementing parseErrors\n\n## Files\n- MODIFY: src/server/services/session-parser.ts (extract from lines 59-70, export new function + RawLine type)\n\n## TDD Loop\nRED: tests/unit/session-parser.test.ts — add tests:\n - \"forEachJsonlLine skips malformed JSON lines\"\n - \"forEachJsonlLine reports parseErrors count\"\n - \"forEachJsonlLine skips empty and whitespace-only lines\"\nGREEN: Extract forEachJsonlLine(), refactor parseSessionContent() to call it\nVERIFY: npm run test -- session-parser\n\n## Edge Cases\n- Truncated JSON from crash mid-write (common) — must skip, not throw\n- Lines with only whitespace or newlines — skip without error\n- Empty content string — returns { parseErrors: 0 }, onLine never called\n- Content with no trailing newline — last line still processed","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T17:47:00.597480Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:20:09.073002Z","closed_at":"2026-02-04T18:20:09.072954Z","close_reason":"Implemented forEachJsonlLine() with RawLine export, refactored parseSessionContent() to use it, added 5 unit tests","compaction_level":0,"original_size":0}
{"id":"bd-34v","title":"CP1: Rewrite discovery loop — filesystem-first with tiered metadata","description":"## Background\nCurrent discovery in session-discovery.ts (114 lines) relies exclusively on sessions-index.json. This misses 17% of sessions. The rewrite makes the filesystem the primary source — every .jsonl file is a session, regardless of index state.\n\n## Approach\nRewrite discoverSessions() in session-discovery.ts:\n\n```typescript\nexport async function discoverSessions(\n projectsDir: string = CLAUDE_PROJECTS_DIR\n): Promise<SessionEntry[]>\n```\n\nNew flow per project directory:\n1. fs.readdir() to list all *.jsonl files (filter by .jsonl extension)\n2. Batch fs.stat() all files (initially unbounded; CP4 adds concurrency limits)\n3. Silently skip files that fail stat (ENOENT from TOCTOU race, EACCES) with debug log\n4. For each successfully statted file, get metadata via tiered lookup:\n - Tier 3 only in this checkpoint (Tier 1 and 2 added in CP3 and CP2)\n - Read file content, call extractSessionMetadata() from session-metadata.ts\n - Silently skip files that fail read (TOCTOU between stat and read)\n5. Build SessionEntry:\n - id: path.basename(file, \".jsonl\")\n - project: decoded project directory name\n - path: absolute path to .jsonl file\n - created: new Date(stat.birthtimeMs).toISOString()\n - modified: new Date(stat.mtimeMs).toISOString()\n - messageCount, firstPrompt, summary: from metadata\n - duration: computed from (lastTimestamp - firstTimestamp) in ms, or undefined\n6. Sort all entries by modified descending (stat-derived, never index-derived)\n\nSecurity validations (preserved from current implementation):\n- Reject paths containing \"..\" (traversal)\n- Reject non-.jsonl extensions\n- Reject absolute paths outside projectsDir (containment check)\n\nThe existing 30s in-memory cache in routes/sessions.ts and ?refresh=1 are NOT modified — they wrap discoverSessions() and continue working.\n\n## Acceptance Criteria\n- [ ] All .jsonl sessions appear regardless of whether sessions-index.json exists\n- [ ] SessionEntry.created and .modified always come from fs.stat\n- [ ] List is sorted by modified descending\n- [ ] TOCTOU: files disappearing between readdir/stat silently skipped\n- [ ] TOCTOU: files disappearing between stat/read silently skipped\n- [ ] Path traversal protection applied to filesystem-discovered files\n- [ ] Duration computed from JSONL timestamps (not index)\n- [ ] Existing route-level caching unmodified and working\n- [ ] npm run test passes\n\n## Files\n- MODIFY: src/server/services/session-discovery.ts (rewrite discoverSessions)\n- USES: src/server/services/session-metadata.ts (extractSessionMetadata)\n\n## TDD Loop\nRED: tests/unit/session-discovery.test.ts — add/update tests:\n - \"discovers sessions from .jsonl files without index\"\n - \"timestamps come from stat, not index\"\n - \"silently skips files deleted between readdir and stat\"\n - \"rejects path traversal in filenames\"\n - \"duration computed from JSONL timestamps\"\nGREEN: Rewrite discoverSessions()\nVERIFY: npm run test -- session-discovery\n\n## Edge Cases\n- Project directory with no .jsonl files: returns empty array for that project\n- Project directory that disappears during scan: silently skipped\n- .jsonl file with 0 bytes: extractSessionMetadata returns messageCount 0, session still listed\n- Very long project directory names (URL-encoded paths): handled by existing decoding logic\n- Concurrent discoverSessions() calls: no shared mutable state in this checkpoint (cache added in CP2)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T17:47:44.866319Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:23:23.803724Z","closed_at":"2026-02-04T18:23:23.803676Z","close_reason":"Rewrote discoverSessions() to be filesystem-first: scans .jsonl files directly, uses extractSessionMetadata() for parser parity, timestamps from stat(), TOCTOU-safe. 9 tests covering discovery, stat-based timestamps, TOCTOU, aggregation, sorting, duration, empty files, extension filtering.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-34v","depends_on_id":"bd-3pr","type":"blocks","created_at":"2026-02-04T17:49:30.523977Z","created_by":"tayloreernisse"}]}
{"id":"bd-3g5","title":"CP3: Implement Tier 1 index validation and fast path","description":"## Background\nsessions-index.json is unreliable but when valid, it saves parsing work. Tier 1 uses it as a fast-path optimization. The index format varies: modern ({ version: 1, entries: [...] }) or legacy (raw array). The existing parsing logic in session-discovery.ts already handles both formats.\n\n## Approach\nAdd Tier 1 lookup to discoverSessions() before Tier 2 (cache) and Tier 3 (parse):\n\n```typescript\ninterface IndexEntry {\n sessionId: string;\n summary?: string;\n firstPrompt?: string;\n created?: string;\n modified?: string;\n messageCount?: number;\n fullPath?: string;\n projectPath?: string;\n}\n```\n\nPer-project flow:\n1. Try to read and parse sessions-index.json into Map<sessionId, IndexEntry>\n - Handle both modern (version:1 wrapper) and legacy (raw array) formats\n - On missing file: continue silently (common case, 13 projects have none)\n - On corrupt JSON: log warning, continue with empty map\n2. For each .jsonl file, after stat:\n a. Derive sessionId: path.basename(file, \".jsonl\")\n b. Look up sessionId in index map\n c. If found AND entry.modified exists:\n - Compare new Date(entry.modified).getTime() vs stat.mtimeMs\n - If difference <= 1000ms: Tier 1 HIT — use entry.messageCount, entry.summary, entry.firstPrompt\n - If difference > 1000ms: Tier 1 MISS (stale) — fall through to Tier 2/3\n d. If found but no modified field: skip Tier 1, fall through\n e. If not found: skip Tier 1, fall through\n3. IMPORTANT: SessionEntry.created and .modified ALWAYS from stat, even on Tier 1 hit\n\n## Acceptance Criteria\n- [ ] Tier 1 used when index entry modified matches stat mtime within 1s tolerance\n- [ ] Tier 1 rejected when mtime mismatch > 1s\n- [ ] Tier 1 skipped when entry has no modified field\n- [ ] Missing sessions-index.json does not break discovery\n- [ ] Corrupt sessions-index.json does not break discovery (logged, skipped)\n- [ ] SessionEntry timestamps always from stat, never from index\n- [ ] Both modern and legacy index formats handled\n- [ ] npm run test passes\n\n## Files\n- MODIFY: src/server/services/session-discovery.ts (add Tier 1 logic before Tier 2/3)\n\n## TDD Loop\nRED: tests/unit/session-discovery.test.ts — add describe block \"Tier 1 index validation\":\n - \"uses index data when modified matches stat mtime within 1s\"\n - \"rejects index data when mtime mismatch > 1s\"\n - \"skips Tier 1 when entry has no modified field\"\n - \"handles missing sessions-index.json\"\n - \"handles corrupt sessions-index.json\"\n - \"timestamps always from stat even on Tier 1 hit\"\nGREEN: Implement Tier 1 logic\nVERIFY: npm run test -- session-discovery\n\n## Edge Cases\n- Index modified as ISO string vs numeric timestamp: normalize both via new Date().getTime()\n- Index with extra/unknown fields: ignored (only read known fields)\n- Multiple index entries with same sessionId: last one wins (Map.set overwrites)\n- Extremely old index (years stale): rejected by mtime check, no special handling needed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T17:48:20.640825Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:27:21.126761Z","closed_at":"2026-02-04T18:27:21.126714Z","close_reason":"Implemented Tier 1 index validation fast path in discoverSessions(). Reads sessions-index.json per project, validates mtime within 1s tolerance, falls through to Tier 2/3 on miss. 6 new tests for hit/miss/no-modified/missing/corrupt/stat-timestamps.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3g5","depends_on_id":"bd-34v","type":"blocks","created_at":"2026-02-04T17:49:30.633475Z","created_by":"tayloreernisse"}]}
{"id":"bd-3pr","title":"CP1: Implement extractSessionMetadata() using shared helpers","description":"## Background\nThe lightweight metadata extractor reads JSONL and extracts only what the list view needs, without building full message content strings. It must use the shared helpers from CP0 to guarantee parser parity (list counts match detail counts).\n\n## Approach\nCreate in a new file src/server/services/session-metadata.ts:\n\n```typescript\nexport interface SessionMetadata {\n messageCount: number;\n firstPrompt: string; // first non-system-reminder user message, truncated to 200 chars\n summary: string; // summary field from last type=summary line\n firstTimestamp: string; // ISO from first JSONL line with timestamp field\n lastTimestamp: string; // ISO from last JSONL line with timestamp field\n parseErrors: number; // from forEachJsonlLine\n}\n\nexport function extractSessionMetadata(content: string): SessionMetadata\n```\n\nImplementation:\n1. Call forEachJsonlLine(content, onLine) from session-parser.ts\n2. In onLine callback:\n a. Accumulate messageCount via countMessagesForLine(parsed)\n b. Track firstTimestamp (first parsed.timestamp seen) and lastTimestamp (latest)\n c. For firstPrompt: first user message where content is string and does not contain \"<system-reminder>\", truncated to 200 chars\n d. For summary: overwrite on each type=summary line (keeps last)\n3. Return SessionMetadata with all fields\n\nNo string building, no JSON.stringify, no markdown processing. Just counting + timestamp capture + first-match extraction.\n\n## Acceptance Criteria\n- [ ] extractSessionMetadata() exported from src/server/services/session-metadata.ts\n- [ ] SessionMetadata interface exported\n- [ ] extractSessionMetadata(content).messageCount === parseSessionContent(content, id).messages.length on fixture files\n- [ ] firstPrompt skips system-reminder user messages\n- [ ] firstPrompt truncated to 200 chars\n- [ ] summary captures the LAST summary line (not first)\n- [ ] firstTimestamp and lastTimestamp correctly captured\n- [ ] Empty JSONL content returns messageCount: 0, empty strings for text fields\n\n## Files\n- CREATE: src/server/services/session-metadata.ts\n- MODIFY: src/server/services/session-parser.ts (ensure forEachJsonlLine, countMessagesForLine, RawLine are exported)\n\n## TDD Loop\nRED: tests/unit/session-metadata.test.ts — tests:\n - \"messageCount matches parseSessionContent on sample-session.jsonl\"\n - \"messageCount matches parseSessionContent on edge-cases.jsonl\"\n - \"firstPrompt skips system-reminder messages\"\n - \"firstPrompt truncated to 200 chars\"\n - \"summary captures last summary line\"\n - \"timestamps captured from first and last lines\"\n - \"empty content returns zero counts\"\nGREEN: Implement extractSessionMetadata()\nVERIFY: npm run test -- session-metadata\n\n## Edge Cases\n- JSONL with no user messages: firstPrompt is empty string\n- JSONL with no summary lines: summary is empty string\n- JSONL with no timestamps: firstTimestamp and lastTimestamp are empty strings\n- All user messages are system-reminders: firstPrompt is empty string\n- Single-line JSONL: firstTimestamp === lastTimestamp","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T17:47:32.534319Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:21:48.880124Z","closed_at":"2026-02-04T18:21:48.880075Z","close_reason":"Implemented extractSessionMetadata() in new file session-metadata.ts with 12 unit tests. Uses forEachJsonlLine/countMessagesForLine/classifyLine for parser parity. Fixture integration tests confirm messageCount matches parseSessionContent.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3pr","depends_on_id":"bd-1tm","type":"blocks","created_at":"2026-02-04T17:49:30.505649Z","created_by":"tayloreernisse"},{"issue_id":"bd-3pr","depends_on_id":"bd-2og","type":"blocks","created_at":"2026-02-04T17:49:30.484195Z","created_by":"tayloreernisse"}]}
{"id":"bd-sks","title":"Epic: JSONL-First Session Discovery","description":"## Background\nEpic tracking the JSONL-First Session Discovery feature. Claude Code sessions-index.json is unreliable (17% loss rate: 542 unindexed JSONL files). The .jsonl files are the source of truth; the index is an unreliable convenience cache.\n\n## Scope\n- CP0: Parser parity foundations (shared line iterator + counting helpers)\n- CP1: Filesystem-first discovery with tiered metadata lookup\n- CP2: Persistent metadata cache (~/.cache/session-viewer/metadata.json)\n- CP3: Tier 1 index validation fast path\n- CP4: Bounded concurrency for performance targets\n\n## Acceptance Criteria\n- [ ] All .jsonl sessions appear in session list regardless of index state\n- [ ] Message counts in list view match detail view exactly (parser parity)\n- [ ] Warm start < 1s, cold start < 5s\n- [ ] Existing 30s in-memory cache and ?refresh=1 continue working\n- [ ] Zero config, works out of the box\n\n## PRD Reference\ndocs/prd-jsonl-first-discovery.md","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-04T17:46:50.724897Z","created_by":"tayloreernisse","updated_at":"2026-02-04T18:28:42.868428Z","closed_at":"2026-02-04T18:28:42.868380Z","close_reason":"All checkpoints complete: CP0 (parser parity helpers), CP1 (filesystem-first discovery + metadata extraction), CP2 (persistent MetadataCache), CP3 (Tier 1 index validation), CP4 (bounded concurrency). 297 tests passing.","compaction_level":0,"original_size":0}

4
.beads/metadata.json Normal file
View File

@@ -0,0 +1,4 @@
{
"database": "beads.db",
"jsonl_export": "issues.jsonl"
}

3
.gitignore vendored
View File

@@ -14,3 +14,6 @@ dist/
*~ *~
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# bv (beads viewer) local config and caches
.bv/

399
AGENTS.md Normal file
View File

@@ -0,0 +1,399 @@
# Agent Instructions
Guidance for Claude Code and other AI agents working in this repository.
### Development Principles
Find the simplest solution that meets all acceptance criteria.
Use third party libraries whenever there's a well-maintained, active, and widely adopted solution (for example, date-fns for TS date math)
Build extensible pieces of logic that can easily be integrated with other pieces.
DRY principles should be loosely held.
Architecture MUST be clear and well thought-out. Ask the user for clarification whenever ambiguity is discovered around architecture, or you think a better approach than planned exists.
---
## Issue Tracking with br (beads_rust)
All issue tracking goes through **br** (beads_rust). No other TODO systems.
Key invariants:
- `.beads/` is authoritative state and **must always be committed** with code changes.
- Do not edit `.beads/*.jsonl` directly; only via `br`.
- **br is non-invasive**: it NEVER executes git commands. You must manually commit `.beads/` changes.
### Basics
Check ready work:
```bash
br ready --json
```
Create issues:
```bash
br create "Issue title" -t bug|feature|task -p 0-4 --json
br create "Issue title" -p 1 --deps discovered-from:bv-123 --json
```
Update:
```bash
br update bv-42 --status in_progress --json
br update bv-42 --priority 1 --json
```
Complete:
```bash
br close bv-42 --reason "Completed" --json
```
Types:
- `bug`, `feature`, `task`, `epic`, `chore`
Priorities:
- `0` critical (security, data loss, broken builds)
- `1` high
- `2` medium (default)
- `3` low
- `4` backlog
Agent workflow:
1. `br ready` to find unblocked work.
2. Claim: `br update <id> --status in_progress`.
3. Implement + test.
4. If you discover new work, create a new bead with `discovered-from:<parent-id>`.
5. Close when done.
6. Sync and commit:
```bash
br sync --flush-only # Export to JSONL (no git ops)
git add .beads/ # Stage beads changes
git commit -m "..." # Commit with code changes
```
Never:
- Use markdown TODO lists.
- Use other trackers.
- Duplicate tracking.
---
## Using bv as an AI Sidecar
bv is a graph-aware triage engine for Beads projects (.beads/beads.jsonl). Instead of parsing JSONL or hallucinating graph traversal, use robot flags for deterministic, dependency-aware outputs with precomputed metrics (PageRank, betweenness, critical path, cycles, HITS, eigenvector, k-core).
**Scope boundary:** bv handles *what to work on* (triage, priority, planning). For multi-agent coordination (messaging, work claiming, file reservations), see the optional MCP Agent Mail section above.
**⚠️ CRITICAL: Use ONLY `--robot-*` flags. Bare `bv` launches an interactive TUI that blocks your session.**
### The Workflow: Start With Triage
**`bv --robot-triage` is your single entry point.** It returns everything you need in one call:
- `quick_ref`: at-a-glance counts + top 3 picks
- `recommendations`: ranked actionable items with scores, reasons, unblock info
- `quick_wins`: low-effort high-impact items
- `blockers_to_clear`: items that unblock the most downstream work
- `project_health`: status/type/priority distributions, graph metrics
- `commands`: copy-paste shell commands for next steps
```bash
bv --robot-triage # THE MEGA-COMMAND: start here
bv --robot-next # Minimal: just the single top pick + claim command
```
### Other Commands
**Planning:**
| Command | Returns |
|---------|---------|
| `--robot-plan` | Parallel execution tracks with `unblocks` lists |
| `--robot-priority` | Priority misalignment detection with confidence |
**Graph Analysis:**
| Command | Returns |
|---------|---------|
| `--robot-insights` | Full metrics: PageRank, betweenness, HITS, eigenvector, critical path, cycles, k-core, articulation points, slack |
| `--robot-label-health` | Per-label health: `health_level` (healthy\|warning\|critical), `velocity_score`, `staleness`, `blocked_count` |
| `--robot-label-flow` | Cross-label dependency: `flow_matrix`, `dependencies`, `bottleneck_labels` |
| `--robot-label-attention [--attention-limit=N]` | Attention-ranked labels by: (pagerank × staleness × block_impact) / velocity |
**History & Change Tracking:**
| Command | Returns |
|---------|---------|
| `--robot-history` | Bead-to-commit correlations: `stats`, `histories` (per-bead events/commits/milestones), `commit_index` |
| `--robot-diff --diff-since <ref>` | Changes since ref: new/closed/modified issues, cycles introduced/resolved |
**Other Commands:**
| Command | Returns |
|---------|---------|
| `--robot-burndown <sprint>` | Sprint burndown, scope changes, at-risk items |
| `--robot-forecast <id\|all>` | ETA predictions with dependency-aware scheduling |
| `--robot-alerts` | Stale issues, blocking cascades, priority mismatches |
| `--robot-suggest` | Hygiene: duplicates, missing deps, label suggestions, cycle breaks |
| `--robot-graph [--graph-format=json\|dot\|mermaid]` | Dependency graph export |
| `--export-graph <file.html>` | Self-contained interactive HTML visualization |
### Scoping & Filtering
```bash
bv --robot-plan --label backend # Scope to label's subgraph
bv --robot-insights --as-of HEAD~30 # Historical point-in-time
bv --recipe actionable --robot-plan # Pre-filter: ready to work (no blockers)
bv --recipe high-impact --robot-triage # Pre-filter: top PageRank scores
bv --robot-triage --robot-triage-by-track # Group by parallel work streams
bv --robot-triage --robot-triage-by-label # Group by domain
```
### Understanding Robot Output
**All robot JSON includes:**
- `data_hash` — Fingerprint of source beads.jsonl (verify consistency across calls)
- `status` — Per-metric state: `computed|approx|timeout|skipped` + elapsed ms
- `as_of` / `as_of_commit` — Present when using `--as-of`; contains ref and resolved SHA
**Two-phase analysis:**
- **Phase 1 (instant):** degree, topo sort, density — always available immediately
- **Phase 2 (async, 500ms timeout):** PageRank, betweenness, HITS, eigenvector, cycles — check `status` flags
**For large graphs (>500 nodes):** Some metrics may be approximated or skipped. Always check `status`.
### jq Quick Reference
```bash
bv --robot-triage | jq '.quick_ref' # At-a-glance summary
bv --robot-triage | jq '.recommendations[0]' # Top recommendation
bv --robot-plan | jq '.plan.summary.highest_impact' # Best unblock target
bv --robot-insights | jq '.status' # Check metric readiness
bv --robot-insights | jq '.Cycles' # Circular deps (must fix!)
bv --robot-label-health | jq '.results.labels[] | select(.health_level == "critical")'
```
**Performance:** Phase 1 instant, Phase 2 async (500ms timeout). Prefer `--robot-plan` over `--robot-insights` when speed matters. Results cached by data hash. Use `bv --profile-startup` for diagnostics.
Use bv instead of parsing beads.jsonl—it computes PageRank, critical paths, cycles, and parallel tracks deterministically.
---
### Hybrid Semantic Search (CLI)
`bv --search` supports hybrid ranking (text + graph metrics).
```bash
# Default (text-only)
bv --search "login oauth"
# Hybrid mode with preset
bv --search "login oauth" --search-mode hybrid --search-preset impact-first
# Hybrid with custom weights
bv --search "login oauth" --search-mode hybrid \
--search-weights '{"text":0.4,"pagerank":0.2,"status":0.15,"impact":0.1,"priority":0.1,"recency":0.05}'
# Robot JSON output (adds mode/preset/weights + component_scores for hybrid)
bv --search "login oauth" --search-mode hybrid --robot-search
```
Env defaults:
- `BV_SEARCH_MODE` (text|hybrid)
- `BV_SEARCH_PRESET` (default|bug-hunting|sprint-planning|impact-first|text-only)
- `BV_SEARCH_WEIGHTS` (JSON string, overrides preset)
---
### Static Site Export for Stakeholder Reporting
Generate a static dashboard for non-technical stakeholders:
```bash
# Interactive wizard (recommended)
bv --pages
# Or export locally
bv --export-pages ./dashboard --pages-title "Sprint 42 Status"
```
The output is a self-contained HTML/JS bundle that:
- Shows triage recommendations (from --robot-triage)
- Visualizes dependencies
- Supports full-text search (FTS5)
- Works offline after initial load
- Requires no installation to view
**Deployment options:**
- `bv --pages` → Interactive wizard for GitHub Pages deployment
- `bv --export-pages ./dir` → Local export for custom hosting
- `bv --preview-pages ./dir` → Preview bundle locally
**For CI/CD integration:**
```bash
bv --export-pages ./bv-pages --pages-title "Nightly Build"
# Then deploy ./bv-pages to your hosting of choice
```
---
## ast-grep vs ripgrep
**Use `ast-grep` when structure matters.** It parses code and matches AST nodes, so results ignore comments/strings, understand syntax, and can safely rewrite code.
- Refactors/codemods: rename APIs, change patterns
- Policy checks: enforce patterns across a repo
**Use `ripgrep` when text is enough.** Fastest way to grep literals/regex.
- Recon: find strings, TODOs, config values
- Pre-filter: narrow candidates before precise pass
**Go-specific examples:**
```bash
# Find all error returns without wrapping
ast-grep run -l Go -p 'return err'
# Find all fmt.Println (should use structured logging)
ast-grep run -l Go -p 'fmt.Println($$$)'
# Quick grep for a function name
rg -n 'func.*LoadConfig' -t go
# Combine: find files then match precisely
rg -l -t go 'sync.Mutex' | xargs ast-grep run -l Go -p 'mu.Lock()'
```
---
## Morph Warp Grep — AI-Powered Code Search
**Use `mcp__morph-mcp__warp_grep` for exploratory "how does X work?" questions.** An AI search agent automatically expands your query into multiple search patterns, greps the codebase, reads relevant files, and returns precise line ranges.
**Use `ripgrep` for targeted searches.** When you know exactly what you're looking for.
| Scenario | Tool |
|----------|------|
| "How is graph analysis implemented?" | `warp_grep` |
| "Where is PageRank computed?" | `warp_grep` |
| "Find all uses of `NewAnalyzer`" | `ripgrep` |
| "Rename function across codebase" | `ast-grep` |
**warp_grep usage:**
```
mcp__morph-mcp__warp_grep(
repoPath: "/path/to/beads_viewer",
query: "How does the correlation package detect orphan commits?"
)
```
**Anti-patterns:**
- ❌ Using `warp_grep` to find a known function name → use `ripgrep`
- ❌ Using `ripgrep` to understand architecture → use `warp_grep`
---
## UBS Quick Reference
UBS = "Ultimate Bug Scanner" — static analysis for catching bugs early.
**Golden Rule:** `ubs <changed-files>` before every commit. Exit 0 = safe. Exit >0 = fix & re-run.
```bash
ubs file.go file2.go # Specific files (< 1s)
ubs $(git diff --name-only --cached) # Staged files
ubs --only=go pkg/ # Go files only
ubs . # Whole project
```
**Output Format:**
```
⚠️ Category (N errors)
file.go:42:5 Issue description
💡 Suggested fix
Exit code: 1
```
**Fix Workflow:**
1. Read finding → understand the issue
2. Navigate `file:line:col` → view context
3. Verify real issue (not false positive)
4. Fix root cause
5. Re-run `ubs <file>` → exit 0
6. Commit
**Bug Severity (Go-specific):**
- **Critical**: nil dereference, division by zero, race conditions, resource leaks
- **Important**: error handling, type assertions without check
- **Contextual**: TODO/FIXME, unused variables
---
## cass — Cross-Agent Session Search
`cass` indexes prior agent conversations (Claude Code, Codex, Cursor, Gemini, ChatGPT, Aider, etc.) into a unified, searchable index so you can reuse solved problems.
**NEVER run bare `cass`** — it launches an interactive TUI. Always use `--robot` or `--json`.
### Quick Start
```bash
# Check if index is healthy (exit 0=ok, 1=run index first)
cass health
# Search across all agent histories
cass search "authentication error" --robot --limit 5
# View a specific result (from search output)
cass view /path/to/session.jsonl -n 42 --json
# Expand context around a line
cass expand /path/to/session.jsonl -n 42 -C 3 --json
# Learn the full API
cass capabilities --json # Feature discovery
cass robot-docs guide # LLM-optimized docs
```
### Key Flags
| Flag | Purpose |
|------|---------|
| `--robot` / `--json` | Machine-readable JSON output (required!) |
| `--fields minimal` | Reduce payload: `source_path`, `line_number`, `agent` only |
| `--limit N` | Cap result count |
| `--agent NAME` | Filter to specific agent (claude, codex, cursor, etc.) |
| `--days N` | Limit to recent N days |
**stdout = data only, stderr = diagnostics. Exit 0 = success.**
### Robot Mode Etiquette
- Prefer `cass --robot-help` and `cass robot-docs <topic>` for machine-first docs
- The CLI is forgiving: globals placed before/after subcommand are auto-normalized
- If parsing fails, follow the actionable errors with examples
- Use `--color=never` in non-TTY automation for ANSI-free output
### Pre-Flight Health Check
```bash
cass health --json
```
Returns in <50ms:
- **Exit 0:** Healthy—proceed with queries
- **Exit 1:** Unhealthy—run `cass index --full` first
### Exit Codes
| Code | Meaning | Retryable |
|------|---------|-----------|
| 0 | Success | N/A |
| 1 | Health check failed | Yes—run `cass index --full` |
| 2 | Usage/parsing error | No—fix syntax |
| 3 | Index/DB missing | Yes—run `cass index --full` |
Treat cass as a way to avoid re-solving problems other agents already handled.

127
README.md Normal file
View File

@@ -0,0 +1,127 @@
# Session Viewer
Browse, filter, redact, and export Claude Code sessions as self-contained HTML files.
Session Viewer reads session data from `~/.claude/projects/` and presents it in a dark-themed web interface with full-text search, message filtering, sensitive data redaction, and one-click HTML export.
## Quick Start
```bash
# Install dependencies
npm install
# Run in development mode (starts both server and client with hot reload)
npm run dev
# Or run from the CLI entry point (opens browser automatically)
node bin/session-viewer.js
```
The API server runs on `http://localhost:3848` and the Vite dev server on `http://localhost:3847` (proxying API requests to the backend).
## Features
### Session Navigation
- **Project grouping** -- Sessions are organized by their originating project directory.
- **Session metadata** -- Each session displays its summary, first prompt, message count, duration, and last-modified date.
- **Sorted by recency** -- Most recently modified sessions appear first.
### Message Display
- **Nine message categories** -- `user_message`, `assistant_text`, `thinking`, `tool_call`, `tool_result`, `system_message`, `hook_progress`, `file_snapshot`, and `summary`, each with a distinct color indicator.
- **Collapsible sections** -- Thinking blocks, tool calls, and tool results collapse by default to reduce noise.
- **Diff rendering** -- Git-style diffs are auto-detected and rendered with line-level syntax coloring.
- **Code blocks** -- Fenced code blocks display a language label and a copy-to-clipboard button.
- **Time gap indicators** -- Gaps of more than five minutes between messages are shown as labeled dividers.
- **Hash anchor links** -- Each message has a copyable anchor link (`#msg-{uuid}`) for deep linking.
### Search
- **Full-text search** across message content and tool input.
- **Match cycling** -- Navigate between matches with `Enter`/`Shift+Enter` or `Ctrl+G`/`Ctrl+Shift+G`.
- **Match counter** -- Displays current position and total matches (e.g., "3/12").
- **Minimap** -- A scrollbar overlay shows match positions as yellow ticks with a viewport indicator. Click a tick to jump to that match.
- **Dimming** -- Non-matching messages are visually dimmed while search is active.
- **Keyboard shortcut** -- Press `/` to focus the search bar; `Escape` to clear and blur.
### Filtering
- **Category toggles** -- Show or hide any of the nine message categories. Thinking and hook progress are hidden by default.
- **Filters compose with search** -- Category filters and search operate independently.
### Redaction
- **Manual redaction** -- Click the eye icon on any message to select it, then confirm to redact. Redacted messages are replaced by a visual divider.
- **Auto-redaction** -- Toggle automatic detection of sensitive data (API keys, tokens, secrets, PII) using 37 regex patterns derived from gitleaks. Matching content is replaced with `[REDACTED]` inline.
- **Export-aware** -- Both manual and auto-redaction states are respected during export.
### Export
- **Self-contained HTML** -- Export the current session view as a standalone HTML file with embedded CSS and syntax highlighting.
- **Respects filters** -- The export includes only visible messages and applies the current redaction state.
- **Clean filenames** -- Output files are named `session-{id}.html` with sanitized IDs.
## Architecture
```
src/
client/ React + Vite frontend
components/ UI components (SessionList, SessionViewer, MessageBubble, etc.)
hooks/ useSession, useFilters
lib/ Markdown rendering, sensitive redactor, types, utilities
styles/ CSS custom properties, Tailwind, design tokens
server/ Express backend
routes/ /api/sessions, /api/export
services/ Session discovery, JSONL parser, HTML exporter
shared/ Types shared between client and server
bin/ CLI entry point
tests/
unit/ Vitest unit tests
e2e/ Playwright end-to-end tests
fixtures/ Test data
```
### Tech Stack
| Layer | Technology |
| -------- | --------------------------------------- |
| Frontend | React 18, Tailwind CSS, Vite |
| Backend | Express 4, Node.js |
| Markdown | marked + marked-highlight + highlight.js|
| Testing | Vitest (unit), Playwright (e2e) |
| Language | TypeScript (strict mode) |
### API Endpoints
| Method | Path | Description |
| ------ | --------------------- | -------------------------------------- |
| GET | `/api/health` | Health check |
| GET | `/api/sessions` | List all sessions (30s server cache) |
| GET | `/api/sessions/:id` | Get session detail with parsed messages|
| POST | `/api/export` | Generate self-contained HTML export |
## Scripts
| Script | Description |
| ---------------- | ---------------------------------------------- |
| `npm run dev` | Start server + client with hot reload |
| `npm run build` | TypeScript compile + Vite production build |
| `npm run test` | Run unit tests (Vitest) |
| `npm run test:e2e` | Run end-to-end tests (Playwright) |
| `npm run typecheck` | TypeScript type checking |
| `npm run lint` | ESLint |
## Configuration
| Variable | Default | Description |
| -------------------------- | -------- | --------------------------------- |
| `PORT` | `3848` | API server port |
| `SESSION_VIEWER_OPEN_BROWSER` | unset | Set to `1` to auto-open browser on start |
## Security
- **Path traversal protection** -- Session discovery validates all paths stay within `~/.claude/projects/`.
- **HTML escaping** -- All user content is escaped before interpolation in exports.
- **Filename sanitization** -- Export filenames strip non-alphanumeric characters.
- **Localhost binding** -- Both the API server and Vite dev server bind to `127.0.0.1` only.

View File

@@ -0,0 +1,313 @@
# PRD: JSONL-First Session Discovery
## Status: Ready for Implementation
## Context
Session viewer relies exclusively on `sessions-index.json` files that Claude Code maintains. These indexes are unreliable — a known, widespread bug with multiple open GitHub issues ([#22030](https://github.com/anthropics/claude-code/issues/22030), [#21610](https://github.com/anthropics/claude-code/issues/21610), [#18619](https://github.com/anthropics/claude-code/issues/18619), [#22114](https://github.com/anthropics/claude-code/issues/22114)).
### Root cause
Claude Code updates `sessions-index.json` only at session end. If a session crashes, is killed, or is abandoned, the JSONL file is written but the index is never updated. Multiple concurrent Claude instances can also corrupt the index (last-write-wins on a single JSON file). There is no reindex command and no background repair process.
### Impact on this system
- **542 unindexed JSONL files** across all projects (87 MB total)
- **48 unindexed in last 7 days** (30.8 MB)
- **13 projects** have JSONL session files but no index at all
- **Zero sessions from today** (Feb 4, 2026) appear in any index
- **3,103 total JSONL files** vs **2,563 indexed entries** = 17% loss rate
### Key insight
The `.jsonl` files are the source of truth. The index is an unreliable convenience cache. The session viewer must treat it that way.
## Requirements
### Must have
1. **All sessions with a `.jsonl` file must appear in the session list**, regardless of whether they're in `sessions-index.json`
2. **Exact message counts** — no estimates, no approximations. Contract: Tier 3 extraction MUST reuse the same line-classification logic as `parseSessionContent` (shared helper), so list counts cannot drift from detail parsing.
3. **Performance**: Warm start (cache exists, few changes) must complete under 1 second. Cold start (no cache) is acceptable up to 5 seconds for first request
4. **Correctness over speed** — never show stale metadata if the file has been modified
5. **Zero config** — works out of the box with no setup or external dependencies
### Should have
6. Session `summary` extracted from the last `type="summary"` line in the JSONL
7. Session `firstPrompt` extracted from the first non-system-reminder user message
8. Session `duration` MUST be derivable without relying on `sessions-index.json` — extract first and last timestamps from JSONL when index is missing or stale
9. Persistent metadata cache survives server restarts
### Won't have (this iteration)
- Real-time push updates (sessions appearing in UI without refresh)
- Background file watcher daemon
- Integration with `cass` as a search/indexing backend
- Rebuilding Claude Code's `sessions-index.json`
## Technical Design
### Architecture: Filesystem-primary with tiered metadata lookup
```
discoverSessions()
|
+-- For each project directory under ~/.claude/projects/:
| |
| +-- fs.readdir() --> list all *.jsonl files
| +-- Read sessions-index.json (optional, used as pre-populated cache)
| |
| +-- Batch stat all .jsonl files (bounded concurrency)
| | Files that disappeared between readdir and stat are silently skipped (TOCTOU race)
| |
| +-- For each .jsonl file:
| | |
| | +-- Tier 1: Check index
| | | Entry exists AND normalize(index.modified) matches stat mtime?
| | | --> Use index content data (messageCount, summary, firstPrompt)
| | | --> Use stat-derived timestamps for created/modified (always)
| | |
| | +-- Tier 2: Check persistent metadata cache
| | | path + mtimeMs + size match?
| | | --> Use cached metadata (fast path)
| | |
| | +-- Tier 3: Extract metadata from JSONL content
| | Read file, lightweight parse using shared line iterator + counting helper
| | --> Cache result for future lookups
| |
| +-- Collect SessionEntry[] for this project
|
+-- Merge all projects
+-- Sort by modified (descending) — always stat-derived, never index-derived
+-- Async: persist metadata cache to disk (if dirty)
```
### Tier explanation
| Tier | Source | Speed | When used | Trusts from source |
|------|--------|-------|-----------|--------------------|
| 1 | `sessions-index.json` | Instant (in-memory lookup) | Index exists, entry present, `normalize(modified)` matches actual file mtime | `messageCount`, `summary`, `firstPrompt` only. Timestamps always from stat. |
| 2 | Persistent metadata cache | Instant (in-memory lookup) | Index missing/stale, but file hasn't changed since last extraction (mtimeMs + size match) | All cached fields |
| 3 | JSONL file parse | ~5-50ms/file | New or modified file, not in any cache | Extracted fresh |
Tier 1 reuses Claude's index when it's valid — no wasted work. The index `modified` field (ISO string) is normalized to milliseconds and compared against the real file `stat.mtimeMs`. If the index is missing or corrupt, discovery continues with Tier 2 and 3 without error. Even when Tier 1 is valid, `created` and `modified` timestamps on the `SessionEntry` always come from `fs.stat` — the index is a content cache only.
### Tier 1: Index validation details
The actual `sessions-index.json` format has `created` and `modified` as ISO strings, not a `fileMtime` field. Tier 1 validation must:
1. Map JSONL filename to sessionId: `sessionId := path.basename(jsonlFile, '.jsonl')`
2. Look up `sessionId` in the index `Map<string, IndexEntry>`
3. Compare `new Date(entry.modified).getTime()` against `stat.mtimeMs` — reject if they differ by more than 1000ms (accounts for ISO string → filesystem mtime rounding)
4. If the index entry has no `modified` field, skip Tier 1 (fall through to Tier 2)
5. When Tier 1 is valid, trust only content fields (`messageCount`, `summary`, `firstPrompt`). The `created`/`modified` on the resulting `SessionEntry` must come from `stat.birthtimeMs`/`stat.mtimeMs` respectively — this ensures list ordering is never stale even within the 1s mtime tolerance window.
### Shared line-iteration and counting (parser parity contract)
The biggest correctness risk in this design is duplicating any JSONL processing logic. The real parser in `session-parser.ts` has non-trivial expansion rules:
- User array content: expands `tool_result` and `text` blocks into separate messages
- `system-reminder` detection reclassifies user `text` blocks as `system_message`
- Assistant array content: `thinking`, `text`, and `tool_use` each become separate messages
- `progress`, `file-history-snapshot`, `summary` → 1 message each
- `system`, `queue-operation` → 0 (skipped)
It also has error-handling behavior: malformed/truncated JSON lines are skipped (common when sessions crash mid-write). If the metadata extractor and the full parser handle malformed lines differently, counts will drift.
Rather than reimplementing any of these rules, extract shared helpers at two levels:
```typescript
// In session-parser.ts (or a shared module):
// Level 1: Line iteration with consistent error handling
// Splits content by newlines, JSON.parse each, skips malformed lines identically
// to how parseSessionContent handles them. Returns parse error count for diagnostics.
export function forEachJsonlLine(
content: string,
onLine: (parsed: RawLine, lineIndex: number) => void
): { parseErrors: number }
// Level 2: Classification and counting (called per parsed line)
export function countMessagesForLine(parsed: RawLine): number
export function classifyLine(parsed: RawLine): LineClassification
```
Both `extractSessionMetadata()` and `parseSessionContent()` use `forEachJsonlLine()` for iteration, ensuring identical malformed-line handling. Both use `countMessagesForLine()` for counting. This two-level sharing guarantees that list counts can never drift from detail-view counts, regardless of future parser changes or edge cases in error handling.
### Metadata extraction (Tier 3)
A lightweight `extractSessionMetadata()` function reads the JSONL file and extracts only what the list view needs, without building full message content strings:
```typescript
export function extractSessionMetadata(content: string): SessionMetadata
```
Implementation:
1. Iterate lines via `forEachJsonlLine(content, ...)` — the shared iterator with identical malformed-line handling as the main parser
2. Call `countMessagesForLine(parsed)` per line — the shared helper that uses the **same classification rules** as `parseSessionContent` in `session-parser.ts`
3. Extract `firstPrompt`: content of the first user message that isn't a `<system-reminder>`, truncated to 200 characters
4. Extract `summary`: the `summary` field from the last `type="summary"` line
5. Capture first and last `timestamp` fields for duration computation
No string building, no `JSON.stringify`, no markdown processing — just counting, timestamp capture, and first-match extraction. This is exact (matches `parseSessionContent().length` via shared helpers) but 2-3x faster than full parsing.
### Persistent metadata cache
**Location:** `~/.cache/session-viewer/metadata.json`
```typescript
interface CacheFile {
version: 1;
entries: Record<string, { // keyed by absolute file path
mtimeMs: number;
size: number;
messageCount: number;
firstPrompt: string;
summary: string;
created: string; // ISO string from file birthtime
modified: string; // ISO string from file mtime
firstTimestamp: string; // ISO from first JSONL line with timestamp
lastTimestamp: string; // ISO from last JSONL line with timestamp
}>;
}
```
Behavior:
- Loaded once on first `discoverSessions()` call
- Entries validated by `(mtimeMs, size)` — if either changes, entry is re-extracted via Tier 3
- Written to disk asynchronously using a dirty-flag write-behind strategy: only when cache has new/updated entries, coalescing multiple discovery passes, non-blocking
- Flush any pending write on process exit (`SIGTERM`, `SIGINT`) and graceful server shutdown — prevents losing cache updates when the server stops before the async write fires
- Corrupt or missing cache file triggers graceful fallback (all files go through Tier 3, cache rebuilt)
- Atomic writes: write to temp file, then rename (prevents corruption from crashes during write)
- Stale entries (file no longer exists on disk) are pruned on save
### Concurrency model
Cold start with 3,103 files requires bounded parallelism to avoid file-handle exhaustion and IO thrash, while still meeting the <5s target:
- **Stat phase**: Batch all `fs.stat()` calls with concurrency limit (e.g., 64). This classifies each file into Tier 1/2 (cache hit) or Tier 3 (needs parse). Files that fail stat (ENOENT from deletion race, EACCES) are silently skipped with a debug log.
- **Parse phase**: Process Tier-3 misses with bounded concurrency (e.g., 8). Each parse reads + iterates via shared `forEachJsonlLine()` + shared counter. With max file size 4.5MB, each parse is ~5-50ms.
- Use a simple async work queue (e.g., `p-limit` or hand-rolled semaphore). No worker threads needed for this IO-bound workload.
### Performance expectations
| Scenario | Estimated time |
|----------|---------------|
| Cold start (no cache, no index) | ~3-5s for 3,103 files (~500MB), bounded concurrency: stat@64, parse@8 |
| Warm start (cache exists, few changes) | ~300-500ms (stat all files at bounded concurrency, in-memory lookups) |
| Incremental (cache + few new sessions) | ~500ms + ~50ms per new file |
| Subsequent API calls within 30s TTL | <1ms (in-memory session list cache) |
### Existing infrastructure leveraged
- **30-second in-memory cache** in `sessions.ts` (`getCachedSessions()`) — unchanged, provides the fast path for repeated API calls
- **`?refresh=1` query parameter** — forces cache invalidation, unchanged
- **Concurrent request deduplication** via `cachePromise` pattern — unchanged
- **Security validations** — path traversal rejection, containment checks, `.jsonl` extension enforcement — applied to filesystem-discovered files identically
## Implementation scope
### Checkpoints
#### CP0 — Parser parity foundations
- Extract `forEachJsonlLine()` shared line iterator from existing parser
- Extract `countMessagesForLine()` and `classifyLine()` shared helpers
- Refactor `extractMessages()` to use these internally (no behavior change to parseSessionContent)
- Tests verify identical behavior on malformed/truncated lines
#### CP1 — Filesystem-first correctness
- All `.jsonl` sessions appear even with missing/corrupt index
- `extractSessionMetadata()` uses shared line iterator + counting helpers; exact counts verified by tests
- Stat-derived `created`/`modified` are the single source for SessionEntry timestamps and list ordering
- Duration computed from JSONL timestamps, not index
- TOCTOU races (readdir/stat, stat/read) handled gracefully — disappeared files silently skipped
#### CP2 — Persistent cache
- Atomic writes with dirty-flag write-behind; prune stale entries
- Invalidation keyed on `(mtimeMs, size)`
- Flush pending writes on process exit / server shutdown
#### CP3 — Index fast path (Tier 1)
- Parse index into Map; normalize `modified` ISO → ms; validate against stat mtime with 1s tolerance
- sessionId mapping: `basename(file, '.jsonl')`
- Tier 1 trusts content fields only; timestamps always from stat
#### CP4 — Performance hardening
- Bounded concurrency for stat + parse phases
- Warm start <1s verified on real dataset
### Modified files
**`src/server/services/session-parser.ts`**
1. Extract `forEachJsonlLine(content, onLine): { parseErrors: number }` — shared line iterator with consistent malformed-line handling
2. Extract `countMessagesForLine(parsed: RawLine): number` — shared counting helper
3. Extract `classifyLine(parsed: RawLine): LineClassification` — shared classification
4. Refactor `extractMessages()` to use these shared helpers internally (no behavior change to parseSessionContent)
**`src/server/services/session-discovery.ts`**
1. Add `extractSessionMetadata(content: string): SessionMetadata` — lightweight JSONL metadata extractor using shared line iterator + counting helper
2. Add `MetadataCache` class — persistent cache with load/get/set/save, dirty-flag write-behind, shutdown flush
3. Rewrite per-project discovery loop — filesystem-first, tiered metadata lookup with bounded concurrency
4. Read `sessions-index.json` as optimization only — parse into `Map<sessionId, IndexEntry>`, normalize `modified` to ms, validate against stat mtime before trusting
5. Register shutdown hooks for cache flush on `SIGTERM`/`SIGINT`
### Unchanged files
- `src/server/routes/sessions.ts` — existing caching layer works as-is
- `src/shared/types.ts``SessionEntry` type already has `duration?: number`
- All client components — no changes needed
### New tests
- Unit test: `forEachJsonlLine()` skips malformed lines identically to how `parseSessionContent` handles them
- Unit test: `forEachJsonlLine()` reports parse error count for truncated/corrupted lines
- Unit test: `countMessagesForLine()` matches actual `extractMessages()` output length on sample lines
- Unit test: `extractSessionMetadata()` output matches `parseSessionContent().length` on sample fixtures (including malformed/truncated lines)
- Unit test: Duration extracted from JSONL timestamps matches expected values
- Unit test: SessionEntry `created`/`modified` always come from stat, even when Tier 1 index data is trusted
- Unit test: Tier 1 validation rejects stale index entries (mtime mismatch beyond 1s tolerance)
- Unit test: Tier 1 handles missing `modified` field gracefully (falls through to Tier 2)
- Unit test: Discovery works with no `sessions-index.json` present
- Unit test: Discovery silently skips files that disappear between readdir and stat (TOCTOU)
- Unit test: Cache hit/miss/invalidation behavior (mtimeMs + size)
- Unit test: Cache dirty-flag only triggers write when entries changed
## Edge cases
| Scenario | Behavior |
|----------|----------|
| File actively being written | mtime changes between stat and read. Next discovery pass re-extracts. Partial JSONL handled gracefully (malformed lines skipped via shared `forEachJsonlLine`, same behavior as real parser). |
| Deleted session files | File in cache but gone from disk. Entry silently dropped, pruned from cache on next save. |
| File disappears between readdir and stat | TOCTOU race. Stat failure (ENOENT) silently skipped with debug log. |
| File disappears between stat and read | Read failure silently skipped; file excluded from results. Next pass re-discovers if it reappears. |
| Index entry with wrong mtime | Tier 1 validation rejects it (>1s tolerance). Falls through to Tier 2/3. |
| Index entry with no `modified` field | Tier 1 skips it. Falls through to Tier 2/3. |
| Index `modified` in seconds vs milliseconds | Normalization handles both ISO strings and numeric timestamps. |
| Cache file locked or unwritable | Extraction still works, just doesn't persist. Warning logged to stderr. |
| Very large files | 4.5MB max observed. Tier 3 parse ~50ms. Acceptable. |
| Concurrent server restarts | Cache writes are atomic (temp file + rename). |
| Server killed before async cache write | Shutdown hooks flush pending writes on SIGTERM/SIGINT. Hard kills (SIGKILL) may lose updates — acceptable, cache rebuilt on next cold start. |
| Empty JSONL files | Returns `messageCount: 0`, empty `firstPrompt`, `summary`, and timestamps. Duration: 0. |
| Projects with no index file | Discovery proceeds normally via Tier 2/3. Common case (13 projects). |
| Non-JSONL files in project dirs | Filtered out by `.jsonl` extension check in `readdir` results. |
| File handle exhaustion | Bounded concurrency (stat@64, parse@8) prevents opening thousands of handles. |
| Future parser changes (new message types) | Shared line iterator + counting helper in session-parser.ts means Tier 3 automatically stays in sync. |
| Malformed JSONL lines (crash mid-write) | Shared `forEachJsonlLine()` skips identically in both metadata extraction and full parsing — no count drift. |
## Verification plan
1. Start dev server, confirm today's sessions appear immediately in the session list
2. Compare message counts for indexed sessions: Tier 1 data vs Tier 3 extraction (should match)
3. Verify duration is shown for sessions that have no index entry (JSONL-only sessions)
4. Delete a `sessions-index.json`, refresh — verify all sessions for that project still appear with correct counts and durations
5. Run existing test suite: `npm test`
6. Run new unit tests for shared line iterator, counting helper, `extractSessionMetadata()`, and `MetadataCache`
7. Verify `created`/`modified` in session list come from stat, not index (compare with `ls -l` output)
8. Verify cold start performance: delete `~/.cache/session-viewer/metadata.json`, time the first API request
9. Verify warm start performance: time a subsequent server start with cache in place
10. Verify cache dirty-flag: repeated refreshes with no file changes should not write cache to disk
11. Kill server with SIGTERM, restart — verify cache was flushed (no full re-parse on restart)

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

@@ -5,9 +5,55 @@ import { FilterPanel } from "./components/FilterPanel";
import { SearchBar } from "./components/SearchBar"; import { SearchBar } from "./components/SearchBar";
import { SearchMinimap } from "./components/SearchMinimap"; import { SearchMinimap } from "./components/SearchMinimap";
import { ExportButton } from "./components/ExportButton"; import { ExportButton } from "./components/ExportButton";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { Menu, LayoutRows } from "./components/Icons";
import { useSession } from "./hooks/useSession"; import { useSession } from "./hooks/useSession";
import { useFilters } from "./hooks/useFilters"; import { useFilters } from "./hooks/useFilters";
import { countSensitiveMessages } from "../shared/sensitive-redactor";
import type { SessionEntry } from "./lib/types";
type Density = "comfortable" | "compact";
function useDensity(): [Density, (d: Density) => void] {
const [density, setDensityState] = useState<Density>(() => {
try {
const stored = localStorage.getItem("session-viewer-density");
if (stored === "compact" || stored === "comfortable") return stored;
} catch { /* localStorage unavailable */ }
return "comfortable";
});
const setDensity = useCallback((d: Density) => {
setDensityState(d);
try { localStorage.setItem("session-viewer-density", d); } catch { /* noop */ }
}, []);
return [density, setDensity];
}
function NavSessionInfo({ sessionId, project, sessions }: {
sessionId: string;
project: string;
sessions: SessionEntry[];
}): React.ReactElement {
const entry = sessions.find((s) => s.id === sessionId);
const title = entry?.summary || entry?.firstPrompt || "Session";
return (
<div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
{project && (
<span className="text-caption text-foreground-muted whitespace-nowrap truncate max-w-[200px]" title={project}>
{project}
</span>
)}
{project && (
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
)}
<span className="text-body font-medium text-foreground truncate">
{title}
</span>
</div>
);
}
export function App() { export function App() {
const { const {
@@ -16,21 +62,71 @@ export function App() {
currentSession, currentSession,
sessionLoading, sessionLoading,
loadSession, loadSession,
refreshSessions,
} = useSession(); } = useSession();
const filters = useFilters(); const filters = useFilters();
const [density, setDensity] = useDensity();
const [sidebarOpen, setSidebarOpen] = useState(true);
// Close sidebar on mobile after selecting a session
const handleSelectSession = useCallback((id: string) => {
loadSession(id);
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
}, [loadSession]);
// URL-driven session selection: sync session ID with URL search params
const hasRestoredFromUrl = useRef(false);
// On initial load (once sessions are available), restore session from URL
useEffect(() => {
if (hasRestoredFromUrl.current || sessionsLoading || sessions.length === 0) return;
hasRestoredFromUrl.current = true;
const params = new URLSearchParams(window.location.search);
const sessionId = params.get("session");
if (sessionId && sessions.some((s) => s.id === sessionId)) {
loadSession(sessionId);
}
}, [sessionsLoading, sessions, loadSession]);
// Update URL when session changes
useEffect(() => {
if (!currentSession) return;
const params = new URLSearchParams(window.location.search);
if (params.get("session") !== currentSession.id) {
params.set("session", currentSession.id);
const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`;
window.history.replaceState(null, "", newUrl);
}
}, [currentSession]);
const filteredMessages = useMemo(() => { const filteredMessages = useMemo(() => {
if (!currentSession) return []; if (!currentSession) return [];
return filters.filterMessages(currentSession.messages); return filters.filterMessages(currentSession.messages);
}, [currentSession, filters.filterMessages]); }, [currentSession, filters.filterMessages]);
const progressEnabled = filters.enabledCategories.has("hook_progress");
// Count across all session messages (not just filtered) — recompute only on session change.
const sensitiveCount = useMemo(
() => countSensitiveMessages(currentSession?.messages || []),
[currentSession?.messages]
);
// Track which filtered-message indices match the search query // Track which filtered-message indices match the search query
const matchIndices = useMemo(() => { const matchIndices = useMemo(() => {
if (!filters.searchQuery) return []; if (!filters.searchQuery) return [];
const lq = filters.searchQuery.toLowerCase(); const lq = filters.searchQuery.toLowerCase();
return filteredMessages.reduce<number[]>((acc, msg, i) => { return filteredMessages.reduce<number[]>((acc, msg, i) => {
if (msg.content.toLowerCase().includes(lq)) acc.push(i); if (
msg.content.toLowerCase().includes(lq) ||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq))
) {
acc.push(i);
}
return acc; return acc;
}, []); }, []);
}, [filteredMessages, filters.searchQuery]); }, [filteredMessages, filters.searchQuery]);
@@ -62,6 +158,46 @@ export function App() {
const focusedMessageIndex = const focusedMessageIndex =
currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1; currentMatchPosition >= 0 ? matchIndices[currentMatchPosition] : -1;
// Keyboard navigation: j/k to move between messages
const [keyboardFocusIndex, setKeyboardFocusIndex] = useState(-1);
// Reset keyboard focus when session or filters change
useEffect(() => {
setKeyboardFocusIndex(-1);
}, [filteredMessages]);
// Combined focus: search focus takes precedence over keyboard focus
const activeFocusIndex =
focusedMessageIndex >= 0 ? focusedMessageIndex : keyboardFocusIndex;
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
// Don't intercept when typing in an input
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
return;
}
if (e.key === "j" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
setKeyboardFocusIndex((prev) => {
const max = filteredMessages.length - 1;
if (max < 0) return -1;
return prev < max ? prev + 1 : max;
});
}
if (e.key === "k" && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
setKeyboardFocusIndex((prev) => {
if (filteredMessages.length === 0) return -1;
return prev > 0 ? prev - 1 : 0;
});
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [filteredMessages.length]);
const visibleUuids = useMemo( const visibleUuids = useMemo(
() => filteredMessages.map((m) => m.uuid), () => filteredMessages.map((m) => m.uuid),
[filteredMessages] [filteredMessages]
@@ -99,14 +235,47 @@ export function App() {
updateViewport(); updateViewport();
}, [filteredMessages, updateViewport]); }, [filteredMessages, updateViewport]);
const isCompact = density === "compact";
return ( return (
<div className="flex h-screen" style={{ background: "var(--color-canvas)" }}> <div className="flex h-screen" style={{ background: "var(--color-canvas)" }}>
{/* Sidebar backdrop — visible on mobile when sidebar is open */}
{sidebarOpen && (
<div
className="fixed inset-0 z-20 bg-black/50 md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */} {/* Sidebar */}
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col"> <div
className={`
flex-shrink-0 border-r border-border bg-surface-raised flex flex-col
fixed inset-y-0 left-0 z-30 w-80
transform transition-transform duration-200 ease-out
md:relative md:translate-x-0
${sidebarOpen ? "translate-x-0" : "-translate-x-full"}
`}
>
<div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}> <div className="px-5 py-4 border-b border-border" style={{ background: "linear-gradient(180deg, var(--color-surface-overlay) 0%, var(--color-surface-raised) 100%)" }}>
<div className="flex items-center justify-between">
<h1 className="text-heading font-semibold text-foreground tracking-tight"> <h1 className="text-heading font-semibold text-foreground tracking-tight">
Session Viewer Session Viewer
</h1> </h1>
<button
onClick={refreshSessions}
disabled={sessionsLoading}
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors disabled:opacity-40"
title="Refresh sessions"
>
<svg
className={`w-4 h-4 ${sessionsLoading ? "animate-spin" : ""}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
</button>
</div>
<p className="text-caption text-foreground-muted mt-0.5"> <p className="text-caption text-foreground-muted mt-0.5">
Browse and export Claude sessions Browse and export Claude sessions
</p> </p>
@@ -116,22 +285,44 @@ export function App() {
sessions={sessions} sessions={sessions}
loading={sessionsLoading} loading={sessionsLoading}
selectedId={currentSession?.id} selectedId={currentSession?.id}
onSelect={loadSession} onSelect={handleSelectSession}
/> />
</div> </div>
<div className="flex-shrink-0">
<FilterPanel <FilterPanel
enabledCategories={filters.enabledCategories} enabledCategories={filters.enabledCategories}
onToggle={filters.toggleCategory} onToggle={filters.toggleCategory}
autoRedactEnabled={filters.autoRedactEnabled} autoRedactEnabled={filters.autoRedactEnabled}
onAutoRedactToggle={filters.setAutoRedactEnabled} onAutoRedactToggle={filters.setAutoRedactEnabled}
sensitiveCount={sensitiveCount}
/> />
</div> </div>
</div>
{/* Main */} {/* Main */}
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<div className="glass flex items-center px-5 py-2.5 border-b border-border z-10"> <div className="glass flex items-center px-5 py-3 border-b border-border z-10 gap-3">
{/* Left spacer — mirrors right side width to keep search centered */} {/* Mobile sidebar toggle */}
<div className="flex-1 min-w-0" /> <button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center w-8 h-8 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors md:hidden flex-shrink-0"
aria-label="Toggle sidebar"
>
<Menu />
</button>
{/* Left — session info or app title */}
<div className="flex-1 min-w-0">
{currentSession ? (
<NavSessionInfo
sessionId={currentSession.id}
project={currentSession.project}
sessions={sessions}
/>
) : (
<span className="text-body font-medium text-foreground-secondary hidden md:block">Session Viewer</span>
)}
</div>
{/* Center — search bar + contextual redaction controls */} {/* Center — search bar + contextual redaction controls */}
<div className="flex items-center gap-3 flex-shrink-0"> <div className="flex items-center gap-3 flex-shrink-0">
@@ -144,7 +335,7 @@ export function App() {
onPrev={goToPrevMatch} onPrev={goToPrevMatch}
/> />
{filters.selectedForRedaction.size > 0 && ( {filters.selectedForRedaction.size > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in"> <div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/8 border border-red-500/15 animate-fade-in">
<span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap"> <span className="text-caption text-red-400 font-medium tabular-nums whitespace-nowrap">
{filters.selectedForRedaction.size} selected {filters.selectedForRedaction.size} selected
</span> </span>
@@ -164,8 +355,19 @@ export function App() {
)} )}
</div> </div>
{/* Right — export button, right-justified */} {/* Right — density toggle + export button */}
<div className="flex-1 min-w-0 flex justify-end"> <div className="flex-1 min-w-0 flex items-center justify-end gap-2">
<button
onClick={() => setDensity(isCompact ? "comfortable" : "compact")}
className={`flex items-center justify-center w-8 h-8 rounded-md transition-colors flex-shrink-0 ${
isCompact
? "text-accent bg-accent-light"
: "text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60"
}`}
title={isCompact ? "Switch to comfortable density" : "Switch to compact density"}
>
<LayoutRows size="w-4 h-4" />
</button>
{currentSession && ( {currentSession && (
<ExportButton <ExportButton
session={currentSession} session={currentSession}
@@ -179,6 +381,7 @@ export function App() {
{/* Scroll area wrapper — relative so minimap can position over the right edge */} {/* Scroll area wrapper — relative so minimap can position over the right edge */}
<div className="flex-1 relative min-h-0"> <div className="flex-1 relative min-h-0">
<div ref={scrollRef} className="h-full overflow-y-auto"> <div ref={scrollRef} className="h-full overflow-y-auto">
<ErrorBoundary>
<SessionViewer <SessionViewer
messages={filteredMessages} messages={filteredMessages}
allMessages={currentSession?.messages || []} allMessages={currentSession?.messages || []}
@@ -188,8 +391,14 @@ export function App() {
selectedForRedaction={filters.selectedForRedaction} selectedForRedaction={filters.selectedForRedaction}
onToggleRedactionSelection={filters.toggleRedactionSelection} onToggleRedactionSelection={filters.toggleRedactionSelection}
autoRedactEnabled={filters.autoRedactEnabled} autoRedactEnabled={filters.autoRedactEnabled}
focusedIndex={focusedMessageIndex} focusedIndex={activeFocusIndex}
toolProgress={currentSession?.toolProgress}
progressEnabled={progressEnabled}
sessionId={currentSession?.id}
project={currentSession?.project}
compact={isCompact}
/> />
</ErrorBoundary>
</div> </div>
<SearchMinimap <SearchMinimap
matchIndices={matchIndices} matchIndices={matchIndices}

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

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { ErrorBoundary } from "./ErrorBoundary";
function ThrowingComponent({ shouldThrow }: { shouldThrow: boolean }) {
if (shouldThrow) {
throw new Error("Test render error");
}
return <div>Child content</div>;
}
describe("ErrorBoundary", () => {
beforeEach(() => {
// Suppress React error boundary console errors in test output
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("renders children when no error occurs", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText("Child content")).toBeInTheDocument();
});
it("renders error UI when child throws", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByText("Test render error")).toBeInTheDocument();
});
it("shows try again button in error state", () => {
render(
<ErrorBoundary>
<ThrowingComponent shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
expect(screen.getByText("Try again")).toBeInTheDocument();
expect(screen.getByText("An error occurred while rendering this view.")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,57 @@
import React from "react";
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (!this.state.hasError) {
return this.props.children;
}
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md animate-fade-in">
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-red-500/20"
style={{ background: "linear-gradient(135deg, rgba(239,68,68,0.1), rgba(239,68,68,0.05))" }}
>
<svg className="w-7 h-7 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<p className="text-subheading font-medium text-foreground">Something went wrong</p>
<p className="text-body text-foreground-muted mt-1.5 mb-4">
An error occurred while rendering this view.
</p>
{this.state.error && (
<pre className="text-caption text-red-400/80 bg-red-500/5 border border-red-500/10 rounded-lg p-3 mb-4 text-left overflow-x-auto max-h-32">
{this.state.error.message}
</pre>
)}
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="btn btn-sm bg-surface-overlay border border-border-muted text-foreground hover:bg-surface-inset transition-colors"
>
Try again
</button>
</div>
</div>
);
}
}

View File

@@ -2,15 +2,17 @@ import React, { useState } from "react";
import type { MessageCategory } from "../lib/types"; import type { MessageCategory } from "../lib/types";
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types"; import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants"; import { CATEGORY_COLORS } from "../lib/constants";
import { Tooltip } from "./Tooltip";
interface Props { interface Props {
enabledCategories: Set<MessageCategory>; enabledCategories: Set<MessageCategory>;
onToggle: (cat: MessageCategory) => void; onToggle: (cat: MessageCategory) => void;
autoRedactEnabled: boolean; autoRedactEnabled: boolean;
onAutoRedactToggle: (enabled: boolean) => void; onAutoRedactToggle: (enabled: boolean) => void;
sensitiveCount: number;
} }
export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle }: Props) { export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, onAutoRedactToggle, sensitiveCount }: Props) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const enabledCount = enabledCategories.size; const enabledCount = enabledCategories.size;
@@ -69,6 +71,11 @@ export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, on
</div> </div>
<div className="mt-3 pt-3 border-t border-border-muted"> <div className="mt-3 pt-3 border-t border-border-muted">
<Tooltip
content="Automatically detect and replace sensitive content (API keys, tokens, passwords, emails, IPs, etc.) with placeholder labels"
side="top"
delayMs={150}
>
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors"> <label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
<input <input
type="checkbox" type="checkbox"
@@ -79,8 +86,14 @@ export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, on
<svg className="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3.5 h-3.5 text-red-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg> </svg>
<span className="text-body text-foreground-secondary">Auto-redact sensitive</span> <span className="text-body text-foreground-secondary flex-1">Auto-redact sensitive</span>
{sensitiveCount > 0 && (
<span className="text-caption tabular-nums px-1.5 py-0.5 rounded-full bg-red-500/10 text-red-400 border border-red-500/20 ml-auto">
{sensitiveCount}
</span>
)}
</label> </label>
</Tooltip>
</div> </div>
</div> </div>
)} )}

View File

@@ -0,0 +1,116 @@
import React from "react";
interface IconProps {
size?: string;
strokeWidth?: number;
className?: string;
}
const defaults = { size: "w-4 h-4", strokeWidth: 1.5 };
function icon(
d: string | string[],
defaultStrokeWidth = defaults.strokeWidth
): React.FC<IconProps> {
const paths = Array.isArray(d) ? d : [d];
return function Icon({
size = defaults.size,
strokeWidth = defaultStrokeWidth,
className = "",
}: IconProps) {
return (
<svg
className={`${size} ${className}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={strokeWidth}
>
{paths.map((p, i) => (
<path key={i} strokeLinecap="round" strokeLinejoin="round" d={p} />
))}
</svg>
);
};
}
export const ChevronRight = icon(
"M8.25 4.5l7.5 7.5-7.5 7.5",
2
);
export const ChevronLeft = icon(
"M15.75 19.5L8.25 12l7.5-7.5",
2
);
export const ChevronDown = icon(
"M19.5 8.25l-7.5 7.5-7.5-7.5",
2
);
export const ChevronUp = icon(
"M4.5 15.75l7.5-7.5 7.5 7.5",
2
);
export const Search = icon(
"M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z",
2
);
export const X = icon(
"M6 18L18 6M6 6l12 12",
2
);
export const Copy = icon(
"M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m0 0a2.625 2.625 0 115.25 0H12m-3.75 0h3.75"
);
export const Check = icon(
"M4.5 12.75l6 6 9-13.5",
2
);
export const Refresh = icon(
[
"M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182",
]
);
export const EyeSlash = icon(
"M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
);
export const Shield = icon(
"M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z",
2
);
export const Filter = icon(
"M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
);
export const Chat = icon(
"M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
);
export const Download = icon(
"M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3",
2
);
export const AlertCircle = icon(
"M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z",
2
);
export const Clipboard = icon(
"M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
);
export const ChatBubble = icon(
"M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
);
export const Menu = icon(
"M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5",
2
);
export const LayoutRows = icon(
"M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z"
);
/** Spinner icon — uses fill, not stroke */
export function Spinner({ size = "w-3.5 h-3.5", className = "" }: Omit<IconProps, "strokeWidth">): React.ReactElement {
return (
<svg className={`${size} animate-spin ${className}`} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
);
}

View File

@@ -5,6 +5,8 @@ import { CATEGORY_COLORS } from "../lib/constants";
import { renderMarkdown, highlightSearchText } from "../lib/markdown"; import { renderMarkdown, highlightSearchText } from "../lib/markdown";
import { redactMessage } from "../../shared/sensitive-redactor"; import { redactMessage } from "../../shared/sensitive-redactor";
import { escapeHtml } from "../../shared/escape-html"; import { escapeHtml } from "../../shared/escape-html";
import { ProgressBadge } from "./ProgressBadge";
import { ChevronRight, Copy, Check, EyeSlash } from "./Icons";
interface Props { interface Props {
message: ParsedMessage; message: ParsedMessage;
@@ -13,6 +15,9 @@ interface Props {
selectedForRedaction: boolean; selectedForRedaction: boolean;
onToggleRedactionSelection: () => void; onToggleRedactionSelection: () => void;
autoRedactEnabled: boolean; autoRedactEnabled: boolean;
progressEvents?: ParsedMessage[];
progressEnabled?: boolean;
compact?: boolean;
} }
/** /**
@@ -29,6 +34,9 @@ export function MessageBubble({
selectedForRedaction, selectedForRedaction,
onToggleRedactionSelection, onToggleRedactionSelection,
autoRedactEnabled, autoRedactEnabled,
progressEvents,
progressEnabled,
compact = false,
}: Props) { }: Props) {
const colors = CATEGORY_COLORS[message.category]; const colors = CATEGORY_COLORS[message.category];
const label = CATEGORY_LABELS[message.category]; const label = CATEGORY_LABELS[message.category];
@@ -36,7 +44,7 @@ export function MessageBubble({
// Collapsible state for thinking blocks and tool calls/results // Collapsible state for thinking blocks and tool calls/results
const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result"; const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result";
const [collapsed, setCollapsed] = useState(isCollapsible); const [collapsed, setCollapsed] = useState(isCollapsible);
const [linkCopied, setLinkCopied] = useState(false); const [contentCopied, setContentCopied] = useState(false);
const renderedHtml = useMemo(() => { const renderedHtml = useMemo(() => {
// Skip expensive rendering when content is collapsed and not visible // Skip expensive rendering when content is collapsed and not visible
@@ -46,16 +54,15 @@ export function MessageBubble({
if (msg.category === "tool_call") { if (msg.category === "tool_call") {
const inputHtml = msg.toolInput const inputHtml = msg.toolInput
? `<pre class="hljs mt-2"><code>${escapeHtml(msg.toolInput)}</code></pre>` ? `<pre class="hljs mt-2"><code>${escapeHtml(tryPrettyJson(msg.toolInput))}</code></pre>`
: ""; : "";
const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`; const html = `<div class="font-medium ${colors.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
} }
// Structured data categories: render as preformatted text, not markdown. // Structured data categories: render as preformatted text, not markdown.
// Avoids expensive marked.parse() on large JSON/log blobs.
if (msg.category === "hook_progress" || msg.category === "file_snapshot") { if (msg.category === "hook_progress" || msg.category === "file_snapshot") {
const html = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`; const html = `<pre class="hljs"><code>${escapeHtml(tryPrettyJson(msg.content))}</code></pre>`;
return searchQuery ? highlightSearchText(html, searchQuery) : html; return searchQuery ? highlightSearchText(html, searchQuery) : html;
} }
@@ -94,11 +101,28 @@ export function MessageBubble({
? formatTimestamp(message.timestamp) ? formatTimestamp(message.timestamp)
: null; : null;
// Content is sourced from local user-owned JSONL files (~/.claude/projects/), not untrusted input
const contentEl = !collapsed ? (
<div
className={`prose-message text-body text-foreground max-w-none break-words overflow-hidden ${
compact ? "px-3 pb-2 pt-0.5" : "px-5 pb-4 pt-1"
}`}
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
) : null;
const collapsedPreviewEl = collapsed && message.category === "thinking" && collapsedPreview ? (
<div className={compact ? "px-3 pb-2 pt-0.5" : "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>
) : null;
return ( return (
<div <div
className={` className={`
group rounded-xl border bg-surface-raised group rounded-xl border-l-[3px] border bg-surface-raised
transition-all duration-200 relative overflow-hidden transition-all duration-200
${colors.accentBorder}
${colors.border} ${colors.border}
${dimmed ? "message-dimmed" : ""} ${dimmed ? "message-dimmed" : ""}
${selectedForRedaction ? "redaction-selected" : ""} ${selectedForRedaction ? "redaction-selected" : ""}
@@ -106,23 +130,19 @@ export function MessageBubble({
shadow-card shadow-card
`} `}
> >
{/* Category accent strip */}
<div className={`absolute left-0 top-0 bottom-0 w-[3px] rounded-l-xl ${colors.dot}`} />
{/* Header bar */} {/* Header bar */}
<div className="flex items-center gap-2 px-4 pl-5 h-10"> <div className={`flex items-center gap-1.5 ${compact ? "px-3 min-h-8 py-1.5" : "px-5 min-h-10 py-2.5"}`}>
{isCollapsible && ( {isCollapsible && (
<button <button
onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }} onClick={(e) => { e.stopPropagation(); setCollapsed(!collapsed); }}
className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0" className="flex items-center justify-center w-5 h-5 text-foreground-muted hover:text-foreground transition-colors flex-shrink-0"
aria-label={collapsed ? "Expand" : "Collapse"} aria-label={collapsed ? "Expand" : "Collapse"}
> >
<svg <ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`} size="w-3.5 h-3.5"
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5} strokeWidth={2.5}
> className={`transition-transform duration-150 ${collapsed ? "" : "rotate-90"}`}
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> />
</svg>
</button> </button>
)} )}
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} /> <span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${colors.dot}`} />
@@ -131,7 +151,7 @@ export function MessageBubble({
</span> </span>
{timestamp && ( {timestamp && (
<> <>
<span className="text-border leading-none">·</span> <span className="text-border leading-none">&middot;</span>
<span className="text-caption text-foreground-muted tabular-nums leading-none"> <span className="text-caption text-foreground-muted tabular-nums leading-none">
{timestamp} {timestamp}
</span> </span>
@@ -139,7 +159,7 @@ export function MessageBubble({
)} )}
{isCollapsible && collapsed && collapsedPreview && ( {isCollapsible && collapsed && collapsedPreview && (
<> <>
<span className="text-border leading-none">·</span> <span className="text-border leading-none">&middot;</span>
<span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none"> <span className="text-caption text-foreground-muted truncate max-w-[300px] leading-none">
{message.category === "thinking" && collapsedPreview.totalLines > 2 {message.category === "thinking" && collapsedPreview.totalLines > 2
? `${collapsedPreview.totalLines} lines` ? `${collapsedPreview.totalLines} lines`
@@ -153,23 +173,21 @@ export function MessageBubble({
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const url = `${window.location.origin}${window.location.pathname}#msg-${message.uuid}`; const text = message.category === "tool_call"
navigator.clipboard.writeText(url).then(() => { ? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}`
setLinkCopied(true); : message.content;
setTimeout(() => setLinkCopied(false), 1500); navigator.clipboard.writeText(text).then(() => {
setContentCopied(true);
setTimeout(() => setContentCopied(false), 1500);
}).catch(() => {}); }).catch(() => {});
}} }}
className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors" className="flex items-center justify-center w-7 h-7 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
title="Copy link to message" title="Copy message content"
> >
{linkCopied ? ( {contentCopied ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <Check size="w-4 h-4" className="text-green-400" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : ( ) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Copy />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m0 0a2.625 2.625 0 115.25 0H12m-3.75 0h3.75" />
</svg>
)} )}
</button> </button>
<button <button
@@ -184,24 +202,15 @@ export function MessageBubble({
}`} }`}
title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"} title={selectedForRedaction ? "Deselect for redaction" : "Select for redaction"}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <EyeSlash />
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
</button> </button>
</div> </div>
</div> </div>
{/* Content — sourced from local user-owned JSONL files, not external/untrusted input */} {contentEl}
{!collapsed && ( {collapsedPreviewEl}
<div {message.category === "tool_call" && progressEnabled && progressEvents && progressEvents.length > 0 && (
className="prose-message text-body text-foreground px-4 pl-5 pb-4 pt-1 max-w-none break-words overflow-hidden" <ProgressBadge events={progressEvents} />
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
{collapsed && message.category === "thinking" && collapsedPreview && (
<div className="px-4 pl-5 pb-3 pt-1">
<pre className="text-caption text-foreground-muted whitespace-pre-wrap line-clamp-2 font-mono">{collapsedPreview.preview}</pre>
</div>
)} )}
</div> </div>
); );
@@ -220,8 +229,6 @@ function isDiffContent(content: string): boolean {
diffLines++; diffLines++;
} }
} }
// Require at least one hunk header AND some +/- lines to avoid false positives
// on YAML lists, markdown lists, or other content with leading dashes
return hunkHeaders >= 1 && diffLines >= 2; return hunkHeaders >= 1 && diffLines >= 2;
} }
@@ -258,3 +265,13 @@ function formatTimestamp(ts: string): string {
second: "2-digit", second: "2-digit",
}); });
} }
function tryPrettyJson(text: string): string {
const trimmed = text.trimStart();
if (trimmed[0] !== "{" && trimmed[0] !== "[") return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}

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

@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom";
import { SearchBar } from "./SearchBar";
const defaultProps = {
query: "",
onQueryChange: vi.fn(),
matchCount: 0,
currentMatchPosition: -1,
onNext: vi.fn(),
onPrev: vi.fn(),
};
function renderSearchBar(overrides: Partial<typeof defaultProps> = {}) {
return render(<SearchBar {...defaultProps} {...overrides} />);
}
describe("SearchBar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("empty state visibility", () => {
it("does not render match count or navigation when query is empty", () => {
const { container } = renderSearchBar();
// No match count badge should be visible
expect(container.querySelector("[data-testid='match-count']")).toBeNull();
// No navigation arrows should be visible
expect(container.querySelector("[aria-label='Previous match']")).toBeNull();
expect(container.querySelector("[aria-label='Next match']")).toBeNull();
});
it("shows keyboard hint when no query is entered", () => {
const { container } = renderSearchBar();
const kbd = container.querySelector("kbd");
expect(kbd).toBeInTheDocument();
expect(kbd?.textContent).toBe("/");
});
});
describe("active search state", () => {
it("shows match count when query has results", () => {
const { container } = renderSearchBar({
query: "test",
matchCount: 5,
currentMatchPosition: 2,
});
const badge = container.querySelector("[data-testid='match-count']");
expect(badge).toBeInTheDocument();
expect(badge?.textContent).toContain("3");
expect(badge?.textContent).toContain("5");
});
it("shows 'No results' when query has no matches", () => {
const { container } = renderSearchBar({
query: "nonexistent",
matchCount: 0,
currentMatchPosition: -1,
});
const badge = container.querySelector("[data-testid='match-count']");
expect(badge).toBeInTheDocument();
expect(badge?.textContent).toContain("No results");
});
it("shows navigation arrows when there are results", () => {
const { container } = renderSearchBar({
query: "test",
matchCount: 3,
currentMatchPosition: 0,
});
expect(container.querySelector("[aria-label='Previous match']")).toBeInTheDocument();
expect(container.querySelector("[aria-label='Next match']")).toBeInTheDocument();
});
it("shows clear button when input has text", () => {
const { container } = renderSearchBar({
query: "test",
matchCount: 1,
currentMatchPosition: 0,
});
expect(container.querySelector("[aria-label='Clear search']")).toBeInTheDocument();
});
});
describe("arrow key navigation", () => {
it("calls onNext when ArrowDown is pressed in the input", () => {
const onNext = vi.fn();
const { container } = renderSearchBar({
query: "test",
matchCount: 3,
currentMatchPosition: 0,
onNext,
});
const input = container.querySelector("input")!;
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(onNext).toHaveBeenCalledTimes(1);
});
it("calls onPrev when ArrowUp is pressed in the input", () => {
const onPrev = vi.fn();
const { container } = renderSearchBar({
query: "test",
matchCount: 3,
currentMatchPosition: 1,
onPrev,
});
const input = container.querySelector("input")!;
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(onPrev).toHaveBeenCalledTimes(1);
});
});
describe("navigation button clicks", () => {
it("calls onPrev when previous button is clicked", () => {
const onPrev = vi.fn();
const { container } = renderSearchBar({
query: "test",
matchCount: 3,
currentMatchPosition: 1,
onPrev,
});
fireEvent.click(container.querySelector("[aria-label='Previous match']")!);
expect(onPrev).toHaveBeenCalledTimes(1);
});
it("calls onNext when next button is clicked", () => {
const onNext = vi.fn();
const { container } = renderSearchBar({
query: "test",
matchCount: 3,
currentMatchPosition: 0,
onNext,
});
fireEvent.click(container.querySelector("[aria-label='Next match']")!);
expect(onNext).toHaveBeenCalledTimes(1);
});
});
describe("clear button", () => {
it("clears query when clear button is clicked", () => {
const onQueryChange = vi.fn();
const { container } = renderSearchBar({
query: "test",
matchCount: 1,
currentMatchPosition: 0,
onQueryChange,
});
fireEvent.click(container.querySelector("[aria-label='Clear search']")!);
expect(onQueryChange).toHaveBeenCalledWith("");
});
});
});

View File

@@ -90,13 +90,24 @@ export function SearchBar({
onNext(); onNext();
} }
} }
// Arrow keys navigate between matches while in the search input
if (e.key === "ArrowDown") {
e.preventDefault();
onNext();
}
if (e.key === "ArrowUp") {
e.preventDefault();
onPrev();
}
} }
const hasResults = query && matchCount > 0; const hasResults = query && matchCount > 0;
const hasNoResults = query && matchCount === 0; const hasNoResults = query && matchCount === 0;
const showControls = !!localQuery || !!query;
return ( return (
<div className="w-80 sm:w-96"> <div className="min-w-80 max-w-md w-full">
{/* Unified search container */} {/* Unified search container */}
<div <div
className={` className={`
@@ -126,37 +137,53 @@ export function SearchBar({
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
placeholder="Search messages..." placeholder="Search messages..."
className="flex-1 min-w-0 bg-transparent px-2.5 py-2 text-body text-foreground className="flex-1 min-w-0 bg-transparent pl-3 pr-2.5 py-2 text-body text-foreground
placeholder:text-foreground-muted placeholder:text-foreground-muted
focus:outline-none" focus:outline-none"
/> />
{/* Right-side controls — all inside the unified bar */} {/* Right-side controls — only rendered when there's content */}
<div className="flex items-center gap-0.5 pr-2 flex-shrink-0"> {showControls ? (
<div className="flex items-center gap-1 pr-2.5 flex-shrink-0 animate-fade-in">
{/* Match count badge */} {/* Match count badge */}
{query && ( {query && (
<div className={` <div
flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5 data-testid="match-count"
className={`
flex items-center px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap
transition-colors duration-150
${hasNoResults ${hasNoResults
? "text-red-400 bg-red-500/10" ? "text-red-400 bg-red-500/10"
: "text-foreground-muted bg-surface-overlay/50" : "text-foreground-muted bg-surface-overlay/50"
} }
`}> `}
>
{hasNoResults ? ( {hasNoResults ? (
<span>No results</span> <span>No results</span>
) : ( ) : (
<span>{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}<span className="text-foreground-muted/50 mx-0.5">/</span>{matchCount}</span> <span>
<span className="text-foreground-secondary font-medium">
{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}
</span>
<span className="text-foreground-muted/40 mx-0.5">/</span>
<span>{matchCount}</span>
</span>
)} )}
</div> </div>
)} )}
{/* Navigation arrows — only when there are results */} {/* Navigation arrows — only when there are results */}
{hasResults && ( {hasResults && (
<div className="flex items-center border-l border-border-muted/60 ml-1 pl-1"> <div className="flex items-center gap-0.5 ml-0.5">
<button <button
onClick={onPrev} onClick={onPrev}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors" className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Previous match" aria-label="Previous match"
tabIndex={-1}
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
@@ -164,8 +191,13 @@ export function SearchBar({
</button> </button>
<button <button
onClick={onNext} onClick={onNext}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors" className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Next match" aria-label="Next match"
tabIndex={-1}
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
@@ -174,27 +206,38 @@ export function SearchBar({
</div> </div>
)} )}
{/* Clear button or keyboard hint */} {/* Divider between nav and clear */}
{localQuery ? ( {hasResults && (
<div className="w-px h-4 bg-border-muted/50 mx-0.5" />
)}
{/* Clear button */}
<button <button
onClick={() => { onClick={() => {
setLocalQuery(""); setLocalQuery("");
onQueryChange(""); onQueryChange("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors ml-0.5" className="flex items-center justify-center w-6 h-6 rounded-md
text-foreground-muted hover:text-foreground
hover:bg-surface-overlay/60
active:bg-surface-overlay/80 active:scale-95
transition-all duration-100"
aria-label="Clear search" aria-label="Clear search"
tabIndex={-1}
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div>
) : ( ) : (
<kbd className="hidden sm:inline-flex items-center px-1.5 py-0.5 text-caption text-foreground-muted/70 bg-surface-overlay/40 border border-border-muted rounded font-mono leading-none"> <div className="pr-2.5 flex-shrink-0">
<kbd className="hidden sm:inline-flex items-center justify-center w-5 h-5 text-[11px] text-foreground-secondary bg-surface-overlay border border-border rounded font-mono">
/ /
</kbd> </kbd>
)}
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import type { SessionEntry } from "../lib/types"; import type { SessionEntry } from "../lib/types";
import { ChevronRight, ChevronLeft, ChatBubble } from "./Icons";
interface Props { interface Props {
sessions: SessionEntry[]; sessions: SessionEntry[];
@@ -11,13 +12,16 @@ interface Props {
export function SessionList({ sessions, loading, selectedId, onSelect }: Props) { export function SessionList({ sessions, loading, selectedId, onSelect }: Props) {
const [selectedProject, setSelectedProject] = useState<string | null>(null); const [selectedProject, setSelectedProject] = useState<string | null>(null);
// Group by project // Group by project (memoized to avoid recomputing on unrelated rerenders)
const grouped = new Map<string, SessionEntry[]>(); const grouped = useMemo(() => {
const map = new Map<string, SessionEntry[]>();
for (const session of sessions) { for (const session of sessions) {
const group = grouped.get(session.project) || []; const group = map.get(session.project) || [];
group.push(session); group.push(session);
grouped.set(session.project, group); map.set(session.project, group);
} }
return map;
}, [sessions]);
// Auto-select project when selectedId changes // Auto-select project when selectedId changes
useEffect(() => { useEffect(() => {
@@ -46,9 +50,7 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
return ( return (
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <div className="flex flex-col items-center justify-center py-12 px-6 text-center">
<div className="w-10 h-10 rounded-xl bg-surface-inset flex items-center justify-center mb-3"> <div className="w-10 h-10 rounded-xl bg-surface-inset flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <ChatBubble size="w-5 h-5" className="text-foreground-muted" />
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
</svg>
</div> </div>
<p className="text-body font-medium text-foreground-secondary">No sessions found</p> <p className="text-body font-medium text-foreground-secondary">No sessions found</p>
<p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p> <p className="text-caption text-foreground-muted mt-1">Sessions will appear here once created</p>
@@ -65,15 +67,17 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
onClick={() => setSelectedProject(null)} onClick={() => setSelectedProject(null)}
className="w-full text-left px-4 py-2.5 text-caption font-medium text-accent hover:text-accent-dark hover:bg-surface-overlay flex items-center gap-1.5 border-b border-border-muted transition-colors" className="w-full text-left px-4 py-2.5 text-caption font-medium text-accent hover:text-accent-dark hover:bg-surface-overlay flex items-center gap-1.5 border-b border-border-muted transition-colors"
> >
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <ChevronLeft size="w-3.5 h-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
<span>All Projects</span> <span>All Projects</span>
</button> </button>
<div className="px-4 py-2 text-caption font-semibold text-foreground-muted uppercase tracking-wider border-b border-border-muted" style={{ background: "var(--color-surface-inset)" }}> <div
{formatProjectName(selectedProject)} 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)" }}
title={formatProjectName(selectedProject)}
>
{truncateProjectName(selectedProject)}
</div> </div>
<div className="py-1"> <div className="py-1 px-2">
{projectSessions.map((session, idx) => { {projectSessions.map((session, idx) => {
const isSelected = selectedId === session.id; const isSelected = selectedId === session.id;
return ( return (
@@ -81,24 +85,24 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
key={session.id} key={session.id}
onClick={() => onSelect(session.id)} onClick={() => onSelect(session.id)}
className={` className={`
w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg transition-all duration-200 w-full text-left my-0.5 px-3 py-2.5 rounded-lg transition-all duration-200
${isSelected ${isSelected
? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25" ? "bg-accent-light shadow-glow-accent ring-1 ring-accent/25"
: "hover:bg-surface-overlay" : "hover:bg-surface-overlay"
} }
`} `}
style={{ width: "calc(100% - 1rem)", animationDelay: `${idx * 30}ms` }} style={{ animationDelay: `${Math.min(idx, 15) * 30}ms` }}
> >
<div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}> <div className={`text-body font-medium truncate ${isSelected ? "text-accent-dark" : "text-foreground"}`}>
{session.summary || session.firstPrompt || "Untitled Session"} {session.summary || session.firstPrompt || "Untitled Session"}
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted"> <div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
<span>{formatDate(session.modified || session.created)}</span> <span>{formatRelativeTime(session.modified || session.created)}</span>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span className="tabular-nums">{session.messageCount} msgs</span> <span className="tabular-nums">{session.messageCount} msgs</span>
{session.duration && session.duration > 0 && ( {session.duration && session.duration > 0 && (
<> <>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span className="tabular-nums">{formatSessionDuration(session.duration)}</span> <span className="tabular-nums">{formatSessionDuration(session.duration)}</span>
</> </>
)} )}
@@ -113,31 +117,32 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
// Project list // Project list
return ( return (
<div className="py-1 animate-fade-in"> <div className="py-1 px-2 animate-fade-in">
{[...grouped.entries()].map(([project, projectSessions]) => { {[...grouped.entries()].map(([project, projectSessions]) => {
const latest = projectSessions.reduce((a, b) => const latest = projectSessions.reduce((a, b) =>
(a.modified || a.created) > (b.modified || b.created) ? a : b (a.modified || a.created) > (b.modified || b.created) ? a : b
); );
const count = projectSessions.length; const count = projectSessions.length;
const totalMessages = projectSessions.reduce((sum, s) => sum + s.messageCount, 0);
return ( return (
<button <button
key={project} key={project}
onClick={() => setSelectedProject(project)} onClick={() => setSelectedProject(project)}
className="w-full text-left mx-2 my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group" className="w-full text-left my-0.5 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-all duration-200 group"
style={{ width: "calc(100% - 1rem)" }} title={formatProjectName(project)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-body font-medium text-foreground truncate"> <div className="text-body font-medium text-foreground truncate">
{formatProjectName(project)} {truncateProjectName(project)}
</div> </div>
<svg className="w-4 h-4 text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <ChevronRight size="w-4 h-4" className="text-foreground-muted opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted"> <div className="flex items-center gap-1.5 mt-1 text-caption text-foreground-muted">
<span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span> <span className="tabular-nums">{count} {count === 1 ? "session" : "sessions"}</span>
<span className="text-border">·</span> <span className="text-border">&middot;</span>
<span>{formatDate(latest.modified || latest.created)}</span> <span>{formatRelativeTime(latest.modified || latest.created)}</span>
<span className="text-border">&middot;</span>
<span className="tabular-nums">{totalMessages} msgs</span>
</div> </div>
</button> </button>
); );
@@ -148,10 +153,6 @@ export function SessionList({ sessions, loading, selectedId, onSelect }: Props)
/** /**
* Best-effort decode of Claude Code's project directory name back to a path. * Best-effort decode of Claude Code's project directory name back to a path.
* Claude encodes project paths by replacing '/' with '-', but this is lossy:
* a path like /home/user/my-cool-app encodes as -home-user-my-cool-app and
* decodes as /home/user/my/cool/app (hyphens in the original name are lost).
* There is no way to distinguish path separators from literal hyphens.
*/ */
function formatProjectName(project: string): string { function formatProjectName(project: string): string {
if (project.startsWith("-")) { if (project.startsWith("-")) {
@@ -160,6 +161,14 @@ function formatProjectName(project: string): string {
return project; return project;
} }
/** Show last 2 path segments for compact display. */
function truncateProjectName(project: string): string {
const full = formatProjectName(project);
const segments = full.split("/").filter(Boolean);
if (segments.length <= 2) return full;
return segments.slice(-2).join("/");
}
function formatSessionDuration(ms: number): string { function formatSessionDuration(ms: number): string {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
if (minutes < 1) return "<1m"; if (minutes < 1) return "<1m";
@@ -170,14 +179,27 @@ function formatSessionDuration(ms: number): string {
return `${hours}h ${rem}m`; return `${hours}h ${rem}m`;
} }
function formatDate(dateStr: string): string { function formatRelativeTime(dateStr: string): string {
if (!dateStr) return ""; if (!dateStr) return "";
const d = new Date(dateStr); const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr; if (isNaN(d.getTime())) return dateStr;
return d.toLocaleDateString(undefined, {
month: "short", const now = Date.now();
day: "numeric", const diffMs = now - d.getTime();
hour: "2-digit",
minute: "2-digit", if (diffMs < 0) return "just now";
}); if (diffMs < 60_000) return "just now";
if (diffMs < 3_600_000) {
const mins = Math.floor(diffMs / 60_000);
return `${mins}m ago`;
}
if (diffMs < 86_400_000) {
const hours = Math.floor(diffMs / 3_600_000);
return `${hours}h ago`;
}
if (diffMs < 604_800_000) {
const days = Math.floor(diffMs / 86_400_000);
return `${days}d ago`;
}
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} }

View File

@@ -1,7 +1,8 @@
import React, { useRef, useEffect, useMemo } from "react"; import React, { useRef, useEffect, useMemo, useState } from "react";
import type { ParsedMessage } from "../lib/types"; import type { ParsedMessage } from "../lib/types";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { RedactedDivider } from "./RedactedDivider"; import { RedactedDivider } from "./RedactedDivider";
import { Chat, Filter } from "./Icons";
interface Props { interface Props {
messages: ParsedMessage[]; messages: ParsedMessage[];
@@ -13,6 +14,11 @@ interface Props {
onToggleRedactionSelection: (uuid: string) => void; onToggleRedactionSelection: (uuid: string) => void;
autoRedactEnabled: boolean; autoRedactEnabled: boolean;
focusedIndex?: number; focusedIndex?: number;
toolProgress?: Record<string, ParsedMessage[]>;
progressEnabled?: boolean;
sessionId?: string;
project?: string;
compact?: boolean;
} }
function MessageSkeleton({ delay = 0 }: { delay?: number }) { function MessageSkeleton({ delay = 0 }: { delay?: number }) {
@@ -44,6 +50,11 @@ export function SessionViewer({
onToggleRedactionSelection, onToggleRedactionSelection,
autoRedactEnabled, autoRedactEnabled,
focusedIndex = -1, focusedIndex = -1,
toolProgress,
progressEnabled,
sessionId,
project,
compact = false,
}: Props) { }: Props) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -58,10 +69,21 @@ export function SessionViewer({
} }
}, [focusedIndex]); }, [focusedIndex]);
// Auto-scroll to hash anchor on load // Auto-scroll to hash anchor on initial session load only.
// Track whether we've already scrolled for this session to avoid
// re-triggering when filter toggles change messages.length.
const hashScrolledRef = useRef(false);
// Reset the flag when the underlying session data changes (new session loaded)
useEffect(() => { useEffect(() => {
hashScrolledRef.current = false;
}, [allMessages]);
useEffect(() => {
if (hashScrolledRef.current) return;
const hash = window.location.hash; const hash = window.location.hash;
if (hash && hash.startsWith("#msg-") && messages.length > 0) { if (hash && hash.startsWith("#msg-") && messages.length > 0) {
hashScrolledRef.current = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = document.getElementById(hash.slice(1)); const el = document.getElementById(hash.slice(1));
if (el) { if (el) {
@@ -79,6 +101,7 @@ export function SessionViewer({
if (messages.length === 0) return []; if (messages.length === 0) return [];
const visibleUuids = new Set(messages.map((m) => m.uuid)); const visibleUuids = new Set(messages.map((m) => m.uuid));
const items: Array< const items: Array<
| { type: "message"; message: ParsedMessage; messageIndex: number } | { type: "message"; message: ParsedMessage; messageIndex: number }
| { type: "redacted_divider"; key: string } | { type: "redacted_divider"; key: string }
@@ -88,14 +111,17 @@ export function SessionViewer({
let prevWasRedactedGap = false; let prevWasRedactedGap = false;
let prevTimestamp: string | undefined; let prevTimestamp: string | undefined;
let messageIndex = 0; let messageIndex = 0;
for (const msg of allMessages) { for (const msg of allMessages) {
if (redactedUuids.has(msg.uuid)) { if (redactedUuids.has(msg.uuid)) {
prevWasRedactedGap = true; prevWasRedactedGap = true;
continue; continue;
} }
if (!visibleUuids.has(msg.uuid)) { if (!visibleUuids.has(msg.uuid)) {
continue; continue;
} }
if (prevWasRedactedGap) { if (prevWasRedactedGap) {
items.push({ items.push({
type: "redacted_divider", type: "redacted_divider",
@@ -143,12 +169,25 @@ export function SessionViewer({
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted" className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }} style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
> >
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Chat size="w-7 h-7" className="text-foreground-muted" />
<path strokeLinecap="round" strokeLinejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
</div> </div>
<p className="text-subheading font-medium text-foreground">Select a session</p> <p className="text-subheading font-medium text-foreground">Select a session</p>
<p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p> <p className="text-body text-foreground-muted mt-1.5">Choose a session from the sidebar to view its messages</p>
<div className="mt-5 flex flex-col gap-2 text-caption text-foreground-muted">
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">j</kbd>
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">k</kbd>
<span>Navigate messages</span>
</div>
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center w-5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">/</kbd>
<span>Search</span>
</div>
<div className="flex items-center justify-center gap-2">
<kbd className="inline-flex items-center justify-center px-1.5 h-5 text-[11px] bg-surface-overlay border border-border rounded font-mono">Esc</kbd>
<span>Clear search</span>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -162,9 +201,7 @@ export function SessionViewer({
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted" className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-4 border border-border-muted"
style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }} style={{ background: "linear-gradient(135deg, var(--color-surface-overlay), var(--color-surface-inset))" }}
> >
<svg className="w-7 h-7 text-foreground-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <Filter size="w-7 h-7" className="text-foreground-muted" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
</div> </div>
<p className="text-subheading font-medium text-foreground">No matching messages</p> <p className="text-subheading font-medium text-foreground">No matching messages</p>
<p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p> <p className="text-body text-foreground-muted mt-1.5">Try adjusting your filters or search query</p>
@@ -174,20 +211,36 @@ export function SessionViewer({
} }
return ( return (
<div className="max-w-6xl mx-auto px-6 py-5"> <div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-4"> <div className="sticky top-0 z-10 -mx-6 px-6 py-3 mb-4 glass-subtle border-b border-border-muted">
<span className="text-caption text-foreground-muted tabular-nums"> <div className="flex items-center justify-between gap-4 min-w-0">
<div className="flex items-center gap-2 min-w-0 overflow-x-auto scrollbar-none">
{project && (
<>
<span className="text-caption text-foreground-muted whitespace-nowrap">{project}</span>
<span className="text-foreground-muted opacity-40 flex-shrink-0">/</span>
</>
)}
{sessionId && (
<div className="flex items-center gap-1.5 min-w-0">
<code className="text-caption text-foreground-muted font-mono whitespace-nowrap">{sessionId}</code>
<CopyIdButton value={sessionId} />
</div>
)}
</div>
<span className="text-caption text-foreground-muted tabular-nums whitespace-nowrap flex-shrink-0">
{messages.length} message{messages.length !== 1 ? "s" : ""} {messages.length} message{messages.length !== 1 ? "s" : ""}
</span> </span>
</div> </div>
<div ref={containerRef} className="space-y-3"> </div>
<div ref={containerRef} className={compact ? "space-y-1.5" : "space-y-3"}>
{displayItems.map((item, idx) => { {displayItems.map((item, idx) => {
if (item.type === "redacted_divider") { if (item.type === "redacted_divider") {
return <RedactedDivider key={item.key} />; return <RedactedDivider key={item.key} />;
} }
if (item.type === "time_gap") { if (item.type === "time_gap") {
return ( return (
<div key={item.key} className="flex items-center gap-3 py-2"> <div key={item.key} className="flex items-center gap-3 py-3">
<div className="flex-1 h-px bg-border-muted" /> <div className="flex-1 h-px bg-border-muted" />
<span className="text-caption text-foreground-muted tabular-nums flex-shrink-0"> <span className="text-caption text-foreground-muted tabular-nums flex-shrink-0">
{item.duration} later {item.duration} later
@@ -197,18 +250,25 @@ export function SessionViewer({
); );
} }
const msg = item.message; const msg = item.message;
const lq = searchQuery ? searchQuery.toLowerCase() : "";
const isMatch = const isMatch =
searchQuery && searchQuery &&
msg.content.toLowerCase().includes(searchQuery.toLowerCase()); (msg.content.toLowerCase().includes(lq) ||
(msg.toolInput && msg.toolInput.toLowerCase().includes(lq)));
const isDimmed = searchQuery && !isMatch; const isDimmed = searchQuery && !isMatch;
const isFocused = item.messageIndex === focusedIndex; const isFocused = item.messageIndex === focusedIndex;
// Look up progress events for this tool_call
const progressEvents =
msg.category === "tool_call" && msg.toolUseId && toolProgress
? toolProgress[msg.toolUseId]
: undefined;
return ( return (
<div <div
key={msg.uuid} key={msg.uuid}
id={`msg-${msg.uuid}`} id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex} data-msg-index={item.messageIndex}
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`} className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }} style={idx < 20 ? { animationDelay: `${Math.min(idx, 15) * 20}ms`, animationFillMode: "backwards" } : undefined}
> >
<MessageBubble <MessageBubble
message={msg} message={msg}
@@ -219,6 +279,9 @@ export function SessionViewer({
onToggleRedactionSelection(msg.uuid) onToggleRedactionSelection(msg.uuid)
} }
autoRedactEnabled={autoRedactEnabled} autoRedactEnabled={autoRedactEnabled}
progressEvents={progressEvents}
progressEnabled={progressEnabled}
compact={compact}
/> />
</div> </div>
); );
@@ -228,6 +291,35 @@ export function SessionViewer({
); );
} }
function CopyIdButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
function handleCopy(): void {
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}
return (
<button
onClick={handleCopy}
className="flex-shrink-0 p-0.5 rounded text-foreground-muted opacity-50 hover:opacity-100 transition-opacity"
title="Copy session ID"
>
{copied ? (
<svg className="w-3.5 h-3.5 text-category-assistant" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
)}
</button>
);
}
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
const minutes = Math.floor(ms / 60000); const minutes = Math.floor(ms / 60000);
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;

View File

@@ -0,0 +1,81 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
interface Props {
content: React.ReactNode;
children: React.ReactElement;
delayMs?: number;
side?: "top" | "bottom";
}
export function Tooltip({ content, children, delayMs = 150, side = "top" }: Props) {
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const show = useCallback(() => {
timerRef.current = setTimeout(() => setVisible(true), delayMs);
}, [delayMs]);
const hide = useCallback(() => {
clearTimeout(timerRef.current);
setVisible(false);
}, []);
// Clean up timer on unmount
useEffect(() => () => clearTimeout(timerRef.current), []);
// Recompute position when visible
useEffect(() => {
if (!visible || !triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPosition({
x: rect.left + rect.width / 2,
y: side === "top" ? rect.top : rect.bottom,
});
}, [visible, side]);
// Nudge tooltip horizontally if it overflows the viewport
useEffect(() => {
if (!visible || !tooltipRef.current || !position) return;
const el = tooltipRef.current;
const tooltipRect = el.getBoundingClientRect();
const pad = 8;
if (tooltipRect.left < pad) {
el.style.transform = `translateX(${pad - tooltipRect.left}px)`;
} else if (tooltipRect.right > window.innerWidth - pad) {
el.style.transform = `translateX(${window.innerWidth - pad - tooltipRect.right}px)`;
} else {
el.style.transform = "";
}
}, [visible, position]);
return (
<>
{React.cloneElement(children, {
ref: triggerRef,
onMouseEnter: show,
onMouseLeave: hide,
onFocus: show,
onBlur: hide,
})}
{visible && position && (
<div
ref={tooltipRef}
role="tooltip"
className="tooltip-popup"
style={{
position: "fixed",
left: position.x,
top: side === "top" ? position.y - 12 : position.y + 12,
zIndex: 9999,
}}
data-side={side}
>
{content}
</div>
)}
</>
);
}

View File

@@ -1,7 +1,40 @@
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import type { MessageCategory, ParsedMessage } from "../lib/types"; import type { MessageCategory, ParsedMessage } from "../lib/types";
import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types"; import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../lib/types";
const STORAGE_KEY_CATEGORIES = "session-viewer:enabledCategories";
const STORAGE_KEY_AUTOREDACT = "session-viewer:autoRedact";
function loadEnabledCategories(): Set<MessageCategory> {
try {
const stored = localStorage.getItem(STORAGE_KEY_CATEGORIES);
if (stored) {
const arr = JSON.parse(stored) as string[];
const valid = arr.filter((c) =>
ALL_CATEGORIES.includes(c as MessageCategory)
) as MessageCategory[];
if (valid.length > 0) return new Set(valid);
}
} catch {
// Fall through to default
}
const set = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
set.delete(cat);
}
return set;
}
function loadAutoRedact(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY_AUTOREDACT);
if (stored !== null) return stored === "true";
} catch {
// Fall through to default
}
return false;
}
interface FilterState { interface FilterState {
enabledCategories: Set<MessageCategory>; enabledCategories: Set<MessageCategory>;
toggleCategory: (cat: MessageCategory) => void; toggleCategory: (cat: MessageCategory) => void;
@@ -20,13 +53,7 @@ interface FilterState {
export function useFilters(): FilterState { export function useFilters(): FilterState {
const [enabledCategories, setEnabledCategories] = useState< const [enabledCategories, setEnabledCategories] = useState<
Set<MessageCategory> Set<MessageCategory>
>(() => { >(loadEnabledCategories);
const set = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
set.delete(cat);
}
return set;
});
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [redactedUuids, setRedactedUuids] = useState<Set<string>>(new Set()); const [redactedUuids, setRedactedUuids] = useState<Set<string>>(new Set());
@@ -34,7 +61,28 @@ export function useFilters(): FilterState {
Set<string> Set<string>
>(new Set()); >(new Set());
const [autoRedactEnabled, setAutoRedactEnabled] = useState(false); const [autoRedactEnabled, setAutoRedactEnabled] = useState(loadAutoRedact);
// Persist enabledCategories to localStorage
useEffect(() => {
try {
localStorage.setItem(
STORAGE_KEY_CATEGORIES,
JSON.stringify([...enabledCategories])
);
} catch {
// Ignore storage errors
}
}, [enabledCategories]);
// Persist autoRedact to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY_AUTOREDACT, String(autoRedactEnabled));
} catch {
// Ignore storage errors
}
}, [autoRedactEnabled]);
const toggleCategory = useCallback((cat: MessageCategory) => { const toggleCategory = useCallback((cat: MessageCategory) => {
setEnabledCategories((prev) => { setEnabledCategories((prev) => {

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"; import type { SessionEntry, SessionDetailResponse } from "../lib/types";
interface SessionState { interface SessionState {
@@ -9,6 +9,7 @@ interface SessionState {
sessionLoading: boolean; sessionLoading: boolean;
sessionError: string | null; sessionError: string | null;
loadSessions: () => Promise<void>; loadSessions: () => Promise<void>;
refreshSessions: () => Promise<void>;
loadSession: (id: string) => Promise<void>; loadSession: (id: string) => Promise<void>;
} }
@@ -21,11 +22,15 @@ export function useSession(): SessionState {
const [sessionLoading, setSessionLoading] = useState(false); const [sessionLoading, setSessionLoading] = useState(false);
const [sessionError, setSessionError] = useState<string | null>(null); const [sessionError, setSessionError] = useState<string | null>(null);
const loadSessions = useCallback(async () => { // Track in-flight session request to prevent stale responses
const sessionAbortRef = useRef<AbortController | null>(null);
const fetchSessions = useCallback(async (refresh = false) => {
setSessionsLoading(true); setSessionsLoading(true);
setSessionsError(null); setSessionsError(null);
try { try {
const res = await fetch("/api/sessions"); const url = refresh ? "/api/sessions?refresh=1" : "/api/sessions";
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
setSessions(data.sessions); setSessions(data.sessions);
@@ -38,21 +43,36 @@ export function useSession(): SessionState {
} }
}, []); }, []);
const loadSessions = useCallback(() => fetchSessions(false), [fetchSessions]);
const refreshSessions = useCallback(() => fetchSessions(true), [fetchSessions]);
const loadSession = useCallback(async (id: string) => { const loadSession = useCallback(async (id: string) => {
// Abort any in-flight request to prevent stale responses
sessionAbortRef.current?.abort();
const controller = new AbortController();
sessionAbortRef.current = controller;
setSessionLoading(true); setSessionLoading(true);
setSessionError(null); setSessionError(null);
try { try {
const res = await fetch(`/api/sessions/${id}`); const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
setCurrentSession(data); setCurrentSession(data);
} catch (err) { } catch (err) {
// Ignore aborted requests — a newer request superseded this one
if (err instanceof DOMException && err.name === "AbortError") return;
setSessionError( setSessionError(
err instanceof Error ? err.message : "Failed to load session" err instanceof Error ? err.message : "Failed to load session"
); );
} finally { } finally {
// Only clear loading if this controller wasn't superseded
if (sessionAbortRef.current === controller) {
setSessionLoading(false); setSessionLoading(false);
} }
}
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -67,6 +87,7 @@ export function useSession(): SessionState {
sessionLoading, sessionLoading,
sessionError, sessionError,
loadSessions, loadSessions,
refreshSessions,
loadSession, loadSession,
}; };
} }

View File

@@ -6,7 +6,8 @@
<title>Session Viewer</title> <title>Session Viewer</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/geist@1/dist/fonts/geist-sans/style.css" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

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

@@ -2,51 +2,60 @@ import type { MessageCategory } from "./types";
export const CATEGORY_COLORS: Record< export const CATEGORY_COLORS: Record<
MessageCategory, MessageCategory,
{ dot: string; border: string; text: string } { dot: string; border: string; text: string; accentBorder: string }
> = { > = {
user_message: { user_message: {
dot: "bg-category-user", dot: "bg-category-user",
border: "border-category-user-border", border: "border-category-user-border",
text: "text-category-user", text: "text-category-user",
accentBorder: "border-l-category-user",
}, },
assistant_text: { assistant_text: {
dot: "bg-category-assistant", dot: "bg-category-assistant",
border: "border-category-assistant-border", border: "border-category-assistant-border",
text: "text-category-assistant", text: "text-category-assistant",
accentBorder: "border-l-category-assistant",
}, },
thinking: { thinking: {
dot: "bg-category-thinking", dot: "bg-category-thinking",
border: "border-category-thinking-border", border: "border-category-thinking-border",
text: "text-category-thinking", text: "text-category-thinking",
accentBorder: "border-l-category-thinking",
}, },
tool_call: { tool_call: {
dot: "bg-category-tool", dot: "bg-category-tool",
border: "border-category-tool-border", border: "border-category-tool-border",
text: "text-category-tool", text: "text-category-tool",
accentBorder: "border-l-category-tool",
}, },
tool_result: { tool_result: {
dot: "bg-category-result", dot: "bg-category-result",
border: "border-category-result-border", border: "border-category-result-border",
text: "text-category-result", text: "text-category-result",
accentBorder: "border-l-category-result",
}, },
system_message: { system_message: {
dot: "bg-category-system", dot: "bg-category-system",
border: "border-category-system-border", border: "border-category-system-border",
text: "text-category-system", text: "text-category-system",
accentBorder: "border-l-category-system",
}, },
hook_progress: { hook_progress: {
dot: "bg-category-hook", dot: "bg-category-hook",
border: "border-category-hook-border", border: "border-category-hook-border",
text: "text-category-hook", text: "text-category-hook",
accentBorder: "border-l-category-hook",
}, },
file_snapshot: { file_snapshot: {
dot: "bg-category-snapshot", dot: "bg-category-snapshot",
border: "border-category-snapshot-border", border: "border-category-snapshot-border",
text: "text-category-snapshot", text: "text-category-snapshot",
accentBorder: "border-l-category-snapshot",
}, },
summary: { summary: {
dot: "bg-category-summary", dot: "bg-category-summary",
border: "border-category-summary-border", border: "border-category-summary-border",
text: "text-category-summary", text: "text-category-summary",
accentBorder: "border-l-category-summary",
}, },
}; };

View File

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

View File

@@ -36,11 +36,10 @@
--color-glow-accent: rgba(91, 156, 245, 0.12); --color-glow-accent: rgba(91, 156, 245, 0.12);
--color-glow-success: rgba(63, 185, 80, 0.12); --color-glow-success: rgba(63, 185, 80, 0.12);
/* Inter font from Google Fonts CDN */ /* Geist font from jsDelivr CDN */
font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: "Geist", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
} }
/* Smooth transitions on all interactive elements */ /* Smooth transitions on all interactive elements */
@@ -247,7 +246,7 @@ mark.search-highlight {
position: absolute; position: absolute;
right: 0; right: 0;
width: 8px; width: 8px;
height: 4px; height: 3px;
background: rgba(254, 240, 138, 0.7); background: rgba(254, 240, 138, 0.7);
border: none; border: none;
padding: 0; padding: 0;
@@ -362,7 +361,7 @@ mark.search-highlight {
.prose-message p { .prose-message p {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
line-height: 1.625; line-height: 1.6;
} }
.prose-message p:first-child { .prose-message p:first-child {
@@ -383,7 +382,7 @@ mark.search-highlight {
.prose-message li { .prose-message li {
margin-top: 0.25rem; margin-top: 0.25rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
line-height: 1.5; line-height: 1.6;
} }
.prose-message code:not(pre code) { .prose-message code:not(pre code) {
@@ -445,6 +444,116 @@ mark.search-highlight {
font-weight: 600; font-weight: 600;
} }
/* ═══════════════════════════════════════════════
Progress markdown (compact variant)
═══════════════════════════════════════════════ */
.prose-message-progress {
font-family: "JetBrains Mono", "Fira Code", "SF Mono", ui-monospace, monospace;
font-size: 11px;
line-height: 1.5;
color: var(--color-foreground-secondary);
word-break: break-word;
}
.prose-message-progress h1,
.prose-message-progress h2,
.prose-message-progress h3,
.prose-message-progress h4,
.prose-message-progress h5,
.prose-message-progress h6 {
font-size: 12px;
font-weight: 600;
color: var(--color-foreground);
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose-message-progress p {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
}
.prose-message-progress p:first-child {
margin-top: 0;
}
.prose-message-progress p:last-child {
margin-bottom: 0;
}
.prose-message-progress ul,
.prose-message-progress ol {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
padding-left: 1rem;
}
.prose-message-progress li {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
line-height: 1.5;
}
.prose-message-progress code:not(pre code) {
font-family: inherit;
font-size: 0.9em;
padding: 0.0625rem 0.25rem;
border-radius: 0.1875rem;
background: var(--color-surface-inset);
border: 1px solid var(--color-border-muted);
color: #c4a1ff;
font-weight: 500;
}
.prose-message-progress pre {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
max-width: 100%;
overflow-x: auto;
}
.prose-message-progress blockquote {
border-left: 2px solid var(--color-border);
padding-left: 0.5rem;
color: var(--color-foreground-secondary);
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.prose-message-progress a {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: rgba(91, 156, 245, 0.3);
transition: text-decoration-color 150ms;
}
.prose-message-progress a:hover {
color: var(--color-accent-dark);
text-decoration-color: rgba(125, 180, 255, 0.6);
}
.prose-message-progress table {
width: 100%;
border-collapse: collapse;
margin-top: 0.375rem;
margin-bottom: 0.375rem;
font-size: 11px;
}
.prose-message-progress th,
.prose-message-progress td {
border: 1px solid var(--color-border-muted);
padding: 0.25rem 0.5rem;
text-align: left;
}
.prose-message-progress th {
background: var(--color-surface-inset);
font-weight: 600;
}
/* ═══════════════════════════════════════════════ /* ═══════════════════════════════════════════════
Focus ring system Focus ring system
═══════════════════════════════════════════════ */ ═══════════════════════════════════════════════ */
@@ -481,10 +590,10 @@ mark.search-highlight {
.custom-checkbox:checked::after { .custom-checkbox:checked::after {
content: ""; content: "";
position: absolute; position: absolute;
top: 1px; top: 6.25%;
left: 4px; left: 25%;
width: 5px; width: 31.25%;
height: 9px; height: 56.25%;
border: solid #0c1017; border: solid #0c1017;
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: rotate(45deg);
@@ -552,3 +661,43 @@ mark.search-highlight {
@apply focus-visible:ring-red-500; @apply focus-visible:ring-red-500;
} }
} }
/* ═══════════════════════════════════════════════
Tooltip
═══════════════════════════════════════════════ */
.tooltip-popup {
pointer-events: none;
max-width: 280px;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-foreground);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.1);
animation: tooltip-in 120ms cubic-bezier(0.16, 1, 0.3, 1);
white-space: normal;
}
.tooltip-popup[data-side="top"] {
transform-origin: bottom center;
translate: -50% -100%;
}
.tooltip-popup[data-side="bottom"] {
transform-origin: top center;
translate: -50% 0;
}
@keyframes tooltip-in {
from {
opacity: 0;
scale: 0.96;
}
to {
opacity: 1;
scale: 1;
}
}

View File

@@ -10,7 +10,9 @@ exportRouter.post("/", async (req, res) => {
if ( if (
!exportReq?.session?.messages || !exportReq?.session?.messages ||
!Array.isArray(exportReq.session.messages) || !Array.isArray(exportReq.session.messages) ||
!Array.isArray(exportReq.visibleMessageUuids) !Array.isArray(exportReq.visibleMessageUuids) ||
(exportReq.redactedMessageUuids !== undefined &&
!Array.isArray(exportReq.redactedMessageUuids))
) { ) {
res.status(400).json({ error: "Invalid export request: missing session, messages, or visibleMessageUuids" }); res.status(400).json({ error: "Invalid export request: missing session, messages, or visibleMessageUuids" });
return; return;

View File

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

View File

@@ -1,7 +1,7 @@
import { marked } from "marked"; import { marked } from "marked";
import hljs from "highlight.js"; import hljs from "highlight.js";
import { markedHighlight } from "marked-highlight"; import { markedHighlight } from "marked-highlight";
import type { ExportRequest, ParsedMessage } from "../../shared/types.js"; import type { ExportRequest, ParsedMessage, ProgressSubtype } from "../../shared/types.js";
import { CATEGORY_LABELS } from "../../shared/types.js"; import { CATEGORY_LABELS } from "../../shared/types.js";
import { redactMessage } from "../../shared/sensitive-redactor.js"; import { redactMessage } from "../../shared/sensitive-redactor.js";
import { escapeHtml } from "../../shared/escape-html.js"; import { escapeHtml } from "../../shared/escape-html.js";
@@ -22,7 +22,11 @@ marked.use(
// Categories whose content is structured data (JSON, logs, snapshots) — not markdown. // Categories whose content is structured data (JSON, logs, snapshots) — not markdown.
// Rendered as preformatted text to avoid the cost of markdown parsing on large blobs. // Rendered as preformatted text to avoid the cost of markdown parsing on large blobs.
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "tool_result", "file_snapshot"]); // Note: tool_result is handled explicitly in renderMessage() for diff detection.
const PREFORMATTED_CATEGORIES = new Set(["hook_progress", "file_snapshot"]);
// Categories that render collapsed by default
const COLLAPSIBLE_CATEGORIES = new Set(["thinking", "tool_call", "tool_result"]);
// Category dot/border colors matching the client-side design // Category dot/border colors matching the client-side design
const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = { const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: string }> = {
@@ -37,10 +41,19 @@ const CATEGORY_STYLES: Record<string, { dot: string; border: string; text: strin
summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" }, summary: { dot: "#2dd4bf", border: "#1a4d45", text: "#2dd4bf" },
}; };
// Progress subtype colors for the export badge pills
const PROGRESS_SUBTYPE_COLORS: Record<ProgressSubtype, { text: string; bg: string }> = {
hook: { text: "#484f58", bg: "rgba(72,79,88,0.1)" },
bash: { text: "#d29922", bg: "rgba(210,153,34,0.1)" },
mcp: { text: "#8b8cf8", bg: "rgba(139,140,248,0.1)" },
agent: { text: "#bc8cff", bg: "rgba(188,140,255,0.1)" },
};
export async function generateExportHtml( export async function generateExportHtml(
req: ExportRequest req: ExportRequest
): Promise<string> { ): Promise<string> {
const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req; const { session, visibleMessageUuids, redactedMessageUuids, autoRedactEnabled } = req;
const toolProgress = session.toolProgress || {};
const visibleSet = new Set(visibleMessageUuids); const visibleSet = new Set(visibleMessageUuids);
const redactedSet = new Set(redactedMessageUuids); const redactedSet = new Set(redactedMessageUuids);
@@ -65,7 +78,8 @@ export async function generateExportHtml(
lastWasRedacted = false; lastWasRedacted = false;
} }
const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg; const msgToRender = autoRedactEnabled ? redactMessage(msg) : msg;
messageHtmlParts.push(renderMessage(msgToRender)); const progressEvents = msg.toolUseId ? toolProgress[msg.toolUseId] : undefined;
messageHtmlParts.push(renderMessage(msgToRender, progressEvents));
} }
const hljsCss = getHighlightCss(); const hljsCss = getHighlightCss();
@@ -108,6 +122,9 @@ ${hljsCss}
${messageHtmlParts.join("\n ")} ${messageHtmlParts.join("\n ")}
</main> </main>
</div> </div>
<script>
${getExportJs()}
</script>
</body> </body>
</html>`; </html>`;
} }
@@ -123,9 +140,11 @@ function renderRedactedDivider(): string {
</div>`; </div>`;
} }
function renderMessage(msg: ParsedMessage): string { function renderMessage(msg: ParsedMessage, progressEvents?: ParsedMessage[]): string {
const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message; const style = CATEGORY_STYLES[msg.category] || CATEGORY_STYLES.system_message;
const label = CATEGORY_LABELS[msg.category]; const label = CATEGORY_LABELS[msg.category];
const isCollapsible = COLLAPSIBLE_CATEGORIES.has(msg.category);
let bodyHtml: string; let bodyHtml: string;
if (msg.category === "tool_call") { if (msg.category === "tool_call") {
@@ -133,10 +152,11 @@ function renderMessage(msg: ParsedMessage): string {
? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>` ? `<pre class="hljs"><code>${escapeHtml(msg.toolInput)}</code></pre>`
: ""; : "";
bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`; bodyHtml = `<div class="tool-name" style="color: ${style.text}">${escapeHtml(msg.toolName || "Unknown Tool")}</div>${inputHtml}`;
} else if (msg.category === "tool_result") {
bodyHtml = isDiffContent(msg.content)
? renderDiffHtml(msg.content)
: `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
} else if (PREFORMATTED_CATEGORIES.has(msg.category)) { } else if (PREFORMATTED_CATEGORIES.has(msg.category)) {
// These categories contain structured data (JSON, logs, snapshots), not prose.
// Rendering them through marked is both incorrect and extremely slow on large
// content (370KB JSON blobs take ~300ms each in marked.parse).
bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`; bodyHtml = `<pre class="hljs"><code>${escapeHtml(msg.content)}</code></pre>`;
} else { } else {
bodyHtml = renderMarkdown(msg.content); bodyHtml = renderMarkdown(msg.content);
@@ -147,13 +167,116 @@ function renderMessage(msg: ParsedMessage): string {
? `<span class="header-sep">&middot;</span><span class="message-time">${escapeHtml(timestamp)}</span>` ? `<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"> <div class="message-header">
<span class="message-dot" style="background: ${style.dot}"></span> ${chevronHtml}<span class="message-dot" style="background: ${style.dot}"></span>
<span class="message-label">${escapeHtml(label)}</span> <span class="message-label">${escapeHtml(label)}</span>
${timestampHtml} ${timestampHtml}
${previewHtml}
</div> </div>
<div class="message-body prose-message">${bodyHtml}</div> <div class="message-body prose-message">${bodyHtml}</div>
${progressHtml}
</div>`;
}
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>`; </div>`;
} }
@@ -179,6 +302,28 @@ function formatTimestamp(ts: string): string {
}); });
} }
function getExportJs(): string {
return `
document.addEventListener("click", function(e) {
var toggle = e.target.closest(".collapsible-toggle");
if (toggle) {
var msg = toggle.closest(".message");
if (!msg) return;
var collapsed = msg.getAttribute("data-collapsed") === "true";
msg.setAttribute("data-collapsed", collapsed ? "false" : "true");
return;
}
var progressToggle = e.target.closest(".progress-toggle");
if (progressToggle) {
var drawer = progressToggle.nextElementSibling;
if (drawer && drawer.classList.contains("progress-drawer")) {
drawer.style.display = drawer.style.display === "none" ? "block" : "none";
}
}
});
`;
}
function getHighlightCss(): string { function getHighlightCss(): string {
// Dark theme highlight.js (GitHub Dark) matching the client // Dark theme highlight.js (GitHub Dark) matching the client
return ` return `
@@ -259,6 +404,19 @@ body {
border-radius: 0.75rem; border-radius: 0.75rem;
border: 1px solid #30363d; border: 1px solid #30363d;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
position: relative;
overflow: hidden;
}
.message::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
border-radius: 0.75rem 0 0 0.75rem;
background: currentColor;
opacity: 0.5;
} }
.message-header { .message-header {
display: flex; display: flex;
@@ -298,6 +456,97 @@ body {
line-height: 1.5rem; line-height: 1.5rem;
} }
/* Collapsible toggle */
.collapsible-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
background: none;
border: none;
color: #484f58;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: color 0.15s, transform 0.15s;
}
.collapsible-toggle:hover { color: #e6edf3; }
.message[data-collapsed="false"] .collapsible-toggle svg { transform: rotate(90deg); }
.message[data-collapsed="true"] .message-body { display: none; }
.collapsed-preview {
font-size: 0.75rem;
color: #484f58;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
/* Diff highlighting */
.diff-view { font-size: 0.8125rem; line-height: 1.6; }
.diff-add { color: #7ee787; background: rgba(46,160,67,0.15); display: block; }
.diff-del { color: #ffa198; background: rgba(248,81,73,0.15); display: block; }
.diff-hunk { color: #bc8cff; display: block; }
.diff-meta { color: #8b949e; display: block; }
.diff-header { color: #e6edf3; font-weight: 600; display: block; }
/* Progress badge */
.progress-badge {
padding: 0.25rem 1rem 0.75rem;
}
.progress-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-size: 0.6875rem;
}
.progress-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
}
.progress-drawer {
margin-top: 0.5rem;
padding: 0.5rem;
background: #161b22;
border: 1px solid #21262d;
border-radius: 0.5rem;
}
.progress-row {
display: flex;
gap: 0.5rem;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.6875rem;
line-height: 1.4;
padding: 0.125rem 0;
}
.progress-time {
width: 5rem;
flex-shrink: 0;
color: #484f58;
font-variant-numeric: tabular-nums;
}
.progress-subtype {
width: 3.5rem;
flex-shrink: 0;
font-weight: 500;
}
.progress-content {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
color: #8b949e;
}
/* Tool name */ /* Tool name */
.tool-name { font-weight: 500; margin-bottom: 0.5rem; } .tool-name { font-weight: 500; margin-bottom: 0.5rem; }
@@ -405,6 +654,10 @@ body {
body { background: #1c2128; } body { background: #1c2128; }
.session-export { padding: 0; max-width: 100%; } .session-export { padding: 0; max-width: 100%; }
.session-header, .message { box-shadow: none; break-inside: avoid; } .session-header, .message { box-shadow: none; break-inside: avoid; }
.message-body { display: block !important; }
.collapsed-preview { display: none !important; }
.collapsible-toggle { display: none !important; }
.progress-drawer { display: block !important; }
} }
`; `;
} }

View File

@@ -0,0 +1,128 @@
import fs from "fs/promises";
import path from "path";
import os from "os";
export interface CacheEntry {
mtimeMs: number;
size: number;
messageCount: number;
firstPrompt: string;
summary: string;
firstTimestamp: string;
lastTimestamp: string;
}
interface CacheFile {
version: 1;
entries: Record<string, CacheEntry>;
}
const DEFAULT_CACHE_PATH = path.join(
os.homedir(),
".cache",
"session-viewer",
"metadata.json"
);
export class MetadataCache {
private entries: Map<string, CacheEntry> = new Map();
private dirty = false;
private cachePath: string;
private saving: Promise<void> | null = null;
constructor(cachePath: string = DEFAULT_CACHE_PATH) {
this.cachePath = cachePath;
}
async load(): Promise<void> {
try {
const raw = await fs.readFile(this.cachePath, "utf-8");
const parsed: CacheFile = JSON.parse(raw);
if (parsed.version === 1 && parsed.entries) {
this.entries = new Map(Object.entries(parsed.entries));
}
} catch {
// Missing or corrupt — start empty
this.entries = new Map();
}
this.dirty = false;
}
get(filePath: string, mtimeMs: number, size: number): CacheEntry | null {
const entry = this.entries.get(filePath);
if (!entry) return null;
if (entry.mtimeMs !== mtimeMs || entry.size !== size) return null;
return entry;
}
set(filePath: string, entry: CacheEntry): void {
this.entries.set(filePath, entry);
this.dirty = true;
}
isDirty(): boolean {
return this.dirty;
}
async save(existingPaths?: Set<string>): Promise<void> {
if (!this.dirty) return;
// Coalesce concurrent saves
if (this.saving) {
await this.saving;
if (!this.dirty) return;
}
this.saving = this.doSave(existingPaths);
try {
await this.saving;
} finally {
this.saving = null;
}
}
async flush(): Promise<void> {
if (!this.dirty) return;
if (this.saving) {
await this.saving;
if (!this.dirty) return;
}
this.saving = this.doSave();
try {
await this.saving;
} finally {
this.saving = null;
}
}
private async doSave(existingPaths?: Set<string>): Promise<void> {
// Prune stale entries
if (existingPaths) {
for (const key of this.entries.keys()) {
if (!existingPaths.has(key)) {
this.entries.delete(key);
}
}
}
const cacheFile: CacheFile = {
version: 1,
entries: Object.fromEntries(this.entries),
};
const json = JSON.stringify(cacheFile);
// Ensure directory exists
const dir = path.dirname(this.cachePath);
await fs.mkdir(dir, { recursive: true });
// Atomic write: temp file + rename
const tmpPath = this.cachePath + `.tmp.${process.pid}`;
await fs.writeFile(tmpPath, json, "utf-8");
await fs.rename(tmpPath, this.cachePath);
this.dirty = false;
}
}

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

@@ -2,6 +2,54 @@ import fs from "fs/promises";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import type { SessionEntry } from "../../shared/types.js"; import type { SessionEntry } from "../../shared/types.js";
import { extractSessionMetadata } from "./session-metadata.js";
import { MetadataCache } from "./metadata-cache.js";
import type { CacheEntry } from "./metadata-cache.js";
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
const FILE_CONCURRENCY = 32;
let cache: MetadataCache | null = null;
let cacheLoaded = false;
export function setCache(c: MetadataCache | null): void {
cache = c;
cacheLoaded = c !== null;
}
async function ensureCache(): Promise<MetadataCache> {
if (!cache) {
cache = new MetadataCache();
}
if (!cacheLoaded) {
await cache.load();
cacheLoaded = true;
}
return cache;
}
async function mapWithLimit<T, R>(
items: T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < items.length) {
const i = nextIndex++;
results[i] = await fn(items[i]);
}
}
const workers = Array.from(
{ length: Math.min(limit, items.length) },
() => worker()
);
await Promise.all(workers);
return results;
}
interface IndexEntry { interface IndexEntry {
sessionId: string; sessionId: string;
@@ -14,12 +62,14 @@ interface IndexEntry {
projectPath?: string; projectPath?: string;
} }
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects"); const MTIME_TOLERANCE_MS = 1000;
export async function discoverSessions( export async function discoverSessions(
projectsDir: string = CLAUDE_PROJECTS_DIR projectsDir: string = CLAUDE_PROJECTS_DIR
): Promise<SessionEntry[]> { ): Promise<SessionEntry[]> {
const sessions: SessionEntry[] = []; const sessions: SessionEntry[] = [];
const metadataCache = await ensureCache();
const discoveredPaths = new Set<string>();
let projectDirs: string[]; let projectDirs: string[];
try { try {
@@ -28,63 +78,152 @@ export async function discoverSessions(
return sessions; return sessions;
} }
// Parallel I/O: stat + readFile for all project dirs concurrently
const results = await Promise.all( const results = await Promise.all(
projectDirs.map(async (projectDir) => { projectDirs.map(async (projectDir) => {
const projectPath = path.join(projectsDir, projectDir); const projectPath = path.join(projectsDir, projectDir);
const entries: SessionEntry[] = []; const entries: SessionEntry[] = [];
let stat; let dirStat;
try { try {
stat = await fs.stat(projectPath); dirStat = await fs.stat(projectPath);
} catch { } catch {
return entries; return entries;
} }
if (!stat.isDirectory()) return entries; if (!dirStat.isDirectory()) return entries;
const indexPath = path.join(projectPath, "sessions-index.json"); let files: string[];
try { try {
const content = await fs.readFile(indexPath, "utf-8"); files = await fs.readdir(projectPath);
const parsed = JSON.parse(content);
// Handle both formats: raw array or { version, entries: [...] }
const rawEntries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of rawEntries) {
const sessionPath =
entry.fullPath ||
path.join(projectPath, `${entry.sessionId}.jsonl`);
// Validate: reject paths with traversal segments or non-JSONL extensions.
// Check the raw path for ".." before resolving (resolve normalizes them away).
if (sessionPath.includes("..") || !sessionPath.endsWith(".jsonl")) {
continue;
}
const resolved = path.resolve(sessionPath);
// Containment check: reject paths that escape the projects directory.
// A corrupted or malicious index could set fullPath to an arbitrary
// absolute path like "/etc/shadow.jsonl".
if (!resolved.startsWith(projectsDir + path.sep) && resolved !== projectsDir) {
continue;
}
entries.push({
id: entry.sessionId,
summary: entry.summary || "",
firstPrompt: entry.firstPrompt || "",
project: projectDir,
created: entry.created || "",
modified: entry.modified || "",
messageCount: entry.messageCount || 0,
path: resolved,
duration: computeDuration(entry.created, entry.modified),
});
}
} catch { } catch {
// Missing or corrupt index - skip return entries;
}
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
// Tier 1: Load sessions-index.json for this project
const indexMap = await loadProjectIndex(projectPath);
const fileResults = await mapWithLimit(
jsonlFiles,
FILE_CONCURRENCY,
async (filename) => {
const filePath = path.join(projectPath, filename);
// Security: reject traversal
if (filename.includes("..")) return null;
const resolved = path.resolve(filePath);
if (
!resolved.startsWith(projectsDir + path.sep) &&
resolved !== projectsDir
) {
return null;
}
let fileStat;
try {
fileStat = await fs.stat(resolved);
} catch {
return null;
}
discoveredPaths.add(resolved);
const sessionId = path.basename(filename, ".jsonl");
// Tier 1: Check index
const indexEntry = indexMap.get(sessionId);
if (indexEntry?.modified) {
const indexMtimeMs = new Date(indexEntry.modified).getTime();
if (
!isNaN(indexMtimeMs) &&
Math.abs(indexMtimeMs - fileStat.mtimeMs) <= MTIME_TOLERANCE_MS
) {
const duration = computeDuration(
indexEntry.created,
indexEntry.modified
);
return {
id: sessionId,
project: projectDir,
path: resolved,
created: new Date(fileStat.birthtimeMs).toISOString(),
modified: new Date(fileStat.mtimeMs).toISOString(),
messageCount: indexEntry.messageCount || 0,
firstPrompt: indexEntry.firstPrompt || "",
summary: indexEntry.summary || "",
duration: duration > 0 ? duration : undefined,
} satisfies SessionEntry;
}
}
// Tier 2: Check metadata cache
const cached = metadataCache.get(
resolved,
fileStat.mtimeMs,
fileStat.size
);
if (cached) {
const duration = computeDuration(
cached.firstTimestamp,
cached.lastTimestamp
);
return {
id: sessionId,
project: projectDir,
path: resolved,
created: new Date(fileStat.birthtimeMs).toISOString(),
modified: new Date(fileStat.mtimeMs).toISOString(),
messageCount: cached.messageCount,
firstPrompt: cached.firstPrompt,
summary: cached.summary,
duration: duration > 0 ? duration : undefined,
} satisfies SessionEntry;
}
// Tier 3: Full parse
let content: string;
try {
content = await fs.readFile(resolved, "utf-8");
} catch {
return null;
}
const metadata = extractSessionMetadata(content);
// Update cache
const cacheEntry: CacheEntry = {
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
messageCount: metadata.messageCount,
firstPrompt: metadata.firstPrompt,
summary: metadata.summary,
firstTimestamp: metadata.firstTimestamp,
lastTimestamp: metadata.lastTimestamp,
};
metadataCache.set(resolved, cacheEntry);
const duration = computeDuration(
metadata.firstTimestamp,
metadata.lastTimestamp
);
return {
id: sessionId,
project: projectDir,
path: resolved,
created: new Date(fileStat.birthtimeMs).toISOString(),
modified: new Date(fileStat.mtimeMs).toISOString(),
messageCount: metadata.messageCount,
firstPrompt: metadata.firstPrompt,
summary: metadata.summary,
duration: duration > 0 ? duration : undefined,
} satisfies SessionEntry;
}
);
for (const entry of fileResults) {
if (entry) entries.push(entry);
} }
return entries; return entries;
@@ -101,14 +240,47 @@ export async function discoverSessions(
return dateB - dateA; return dateB - dateA;
}); });
// Fire-and-forget cache save
metadataCache.save(discoveredPaths).catch(() => {
// Cache write failure is non-fatal
});
return sessions; return sessions;
} }
function computeDuration(created?: string, modified?: string): number { async function loadProjectIndex(
if (!created || !modified) return 0; projectPath: string
const createdMs = new Date(created).getTime(); ): Promise<Map<string, IndexEntry>> {
const modifiedMs = new Date(modified).getTime(); const indexMap = new Map<string, IndexEntry>();
if (isNaN(createdMs) || isNaN(modifiedMs)) return 0; const indexPath = path.join(projectPath, "sessions-index.json");
const diff = modifiedMs - createdMs;
try {
const raw = await fs.readFile(indexPath, "utf-8");
const parsed = JSON.parse(raw);
const rawEntries: IndexEntry[] = Array.isArray(parsed)
? parsed
: parsed.entries ?? [];
for (const entry of rawEntries) {
if (entry.sessionId) {
indexMap.set(entry.sessionId, entry);
}
}
} catch {
// Missing or corrupt index — continue without Tier 1
}
return indexMap;
}
function computeDuration(
firstTimestamp?: string,
lastTimestamp?: string
): number {
if (!firstTimestamp || !lastTimestamp) return 0;
const firstMs = new Date(firstTimestamp).getTime();
const lastMs = new Date(lastTimestamp).getTime();
if (isNaN(firstMs) || isNaN(lastMs)) return 0;
const diff = lastMs - firstMs;
return diff > 0 ? diff : 0; return diff > 0 ? diff : 0;
} }

View File

@@ -0,0 +1,65 @@
import {
forEachJsonlLine,
countMessagesForLine,
classifyLine,
} from "./session-parser.js";
import type { RawLine } from "./session-parser.js";
export interface SessionMetadata {
messageCount: number;
firstPrompt: string;
summary: string;
firstTimestamp: string;
lastTimestamp: string;
parseErrors: number;
}
const MAX_FIRST_PROMPT_LENGTH = 200;
export function extractSessionMetadata(content: string): SessionMetadata {
let messageCount = 0;
let firstPrompt = "";
let summary = "";
let firstTimestamp = "";
let lastTimestamp = "";
const { parseErrors } = forEachJsonlLine(content, (parsed: RawLine) => {
messageCount += countMessagesForLine(parsed);
if (parsed.timestamp) {
if (!firstTimestamp) {
firstTimestamp = parsed.timestamp;
}
lastTimestamp = parsed.timestamp;
}
if (!firstPrompt && classifyLine(parsed) === "user") {
const msgContent = parsed.message?.content;
if (typeof msgContent === "string" && !isSystemReminder(msgContent)) {
firstPrompt = truncate(msgContent, MAX_FIRST_PROMPT_LENGTH);
}
}
if (parsed.type === "summary" && parsed.summary) {
summary = parsed.summary;
}
});
return {
messageCount,
firstPrompt,
summary,
firstTimestamp,
lastTimestamp,
parseErrors,
};
}
function isSystemReminder(text: string): boolean {
return text.includes("<system-reminder>") || text.includes("</system-reminder>");
}
function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength);
}

View File

@@ -1,5 +1,5 @@
import fs from "fs/promises"; import fs from "fs/promises";
import type { ParsedMessage } from "../../shared/types.js"; import type { ParsedMessage, ProgressSubtype } from "../../shared/types.js";
/** /**
* Real Claude Code JSONL format (verified from actual session files): * Real Claude Code JSONL format (verified from actual session files):
@@ -19,6 +19,7 @@ import type { ParsedMessage } from "../../shared/types.js";
interface ContentBlock { interface ContentBlock {
type: string; type: string;
id?: string;
text?: string; text?: string;
thinking?: string; thinking?: string;
name?: string; name?: string;
@@ -27,10 +28,11 @@ interface ContentBlock {
content?: string | ContentBlock[]; content?: string | ContentBlock[];
} }
interface RawLine { export interface RawLine {
type?: string; type?: string;
uuid?: string; uuid?: string;
timestamp?: string; timestamp?: string;
parentToolUseID?: string;
message?: { message?: {
role?: string; role?: string;
content?: string | ContentBlock[]; content?: string | ContentBlock[];
@@ -41,6 +43,94 @@ interface RawLine {
subtype?: string; subtype?: string;
} }
export type LineClassification =
| "user"
| "assistant"
| "progress"
| "file-history-snapshot"
| "summary"
| "system"
| "queue-operation"
| "unknown";
export function forEachJsonlLine(
content: string,
onLine: (parsed: RawLine, lineIndex: number) => void
): { parseErrors: number } {
let parseErrors = 0;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (!trimmed) continue;
let parsed: RawLine;
try {
parsed = JSON.parse(trimmed);
} catch {
parseErrors++;
continue;
}
onLine(parsed, i);
}
return { parseErrors };
}
export function classifyLine(parsed: RawLine): LineClassification {
const type = parsed.type;
if (type === "progress") return "progress";
if (type === "file-history-snapshot") return "file-history-snapshot";
if (type === "summary") return "summary";
if (type === "system") return "system";
if (type === "queue-operation") return "queue-operation";
if (type === "user" || parsed.message?.role === "user") return "user";
if (type === "assistant" || parsed.message?.role === "assistant") return "assistant";
return "unknown";
}
export function countMessagesForLine(parsed: RawLine): number {
const classification = classifyLine(parsed);
switch (classification) {
case "progress":
case "file-history-snapshot":
case "summary":
return 1;
case "system":
case "queue-operation":
case "unknown":
return 0;
case "user": {
const content = parsed.message?.content;
if (content === undefined || content === null) return 0;
if (typeof content === "string") return 1;
if (Array.isArray(content)) {
return content.filter(
(b: ContentBlock) => b.type === "tool_result" || b.type === "text"
).length;
}
return 0;
}
case "assistant": {
const content = parsed.message?.content;
if (content === undefined || content === null) return 0;
if (typeof content === "string") return 1;
if (Array.isArray(content)) {
return content.filter(
(b: ContentBlock) =>
b.type === "thinking" || b.type === "text" || b.type === "tool_use"
).length;
}
return 0;
}
}
}
export async function parseSession( export async function parseSession(
filePath: string filePath: string
): Promise<ParsedMessage[]> { ): Promise<ParsedMessage[]> {
@@ -56,47 +146,43 @@ export async function parseSession(
export function parseSessionContent(content: string): ParsedMessage[] { export function parseSessionContent(content: string): ParsedMessage[] {
const messages: ParsedMessage[] = []; const messages: ParsedMessage[] = [];
const lines = content.split("\n").filter((l) => l.trim());
for (let i = 0; i < lines.length; i++) { forEachJsonlLine(content, (parsed, lineIndex) => {
let parsed: RawLine; const extracted = extractMessages(parsed, lineIndex);
try {
parsed = JSON.parse(lines[i]);
} catch {
continue; // Skip malformed lines
}
const extracted = extractMessages(parsed, i);
messages.push(...extracted); messages.push(...extracted);
} });
return messages; return messages;
} }
function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] { function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const messages: ParsedMessage[] = []; const messages: ParsedMessage[] = [];
const type = raw.type; const classification = classifyLine(raw);
const uuid = raw.uuid || `generated-${rawIndex}`; const uuid = raw.uuid || `generated-${rawIndex}`;
const timestamp = raw.timestamp; const timestamp = raw.timestamp;
// Progress/hook messages - content is in `data`, not `content` // Progress/hook messages - content is in `data`, not `content`
if (type === "progress") { if (classification === "progress") {
const data = raw.data; const data = raw.data;
const progressText = data const progressText = data
? formatProgressData(data) ? formatProgressData(data)
: "Progress event"; : "Progress event";
const dataType = typeof data?.type === "string" ? data.type : "";
const progressSubtype = deriveProgressSubtype(dataType);
messages.push({ messages.push({
uuid, uuid,
category: "hook_progress", category: "hook_progress",
content: progressText, content: progressText,
timestamp, timestamp,
rawIndex, rawIndex,
parentToolUseId: raw.parentToolUseID,
progressSubtype,
}); });
return messages; return messages;
} }
// File history snapshot // File history snapshot
if (type === "file-history-snapshot") { if (classification === "file-history-snapshot") {
messages.push({ messages.push({
uuid, uuid,
category: "file_snapshot", category: "file_snapshot",
@@ -108,7 +194,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
} }
// Summary message - text is in `summary` field, not `content` // Summary message - text is in `summary` field, not `content`
if (type === "summary") { if (classification === "summary") {
messages.push({ messages.push({
uuid, uuid,
category: "summary", category: "summary",
@@ -120,7 +206,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
} }
// System metadata (turn_duration etc.) - skip, not user-facing // System metadata (turn_duration etc.) - skip, not user-facing
if (type === "system" || type === "queue-operation") { if (classification === "system" || classification === "queue-operation") {
return messages; return messages;
} }
@@ -128,7 +214,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
const role = raw.message?.role; const role = raw.message?.role;
const content = raw.message?.content; const content = raw.message?.content;
if ((type === "user" || role === "user") && content !== undefined) { if (classification === "user" && content !== undefined) {
if (typeof content === "string") { if (typeof content === "string") {
const category = detectSystemReminder(content) const category = detectSystemReminder(content)
? "system_message" ? "system_message"
@@ -155,7 +241,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`, uuid: `${uuid}-tr-${block.tool_use_id || rawIndex}`,
category: "tool_result", category: "tool_result",
content: resultText, content: resultText,
toolName: block.tool_use_id, toolUseId: block.tool_use_id,
timestamp, timestamp,
rawIndex, rawIndex,
}); });
@@ -177,7 +263,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
return messages; return messages;
} }
if ((type === "assistant" || role === "assistant") && content !== undefined) { if (classification === "assistant" && content !== undefined) {
if (typeof content === "string") { if (typeof content === "string") {
messages.push({ messages.push({
uuid, uuid,
@@ -215,6 +301,7 @@ function extractMessages(raw: RawLine, rawIndex: number): ParsedMessage[] {
toolInput: JSON.stringify(block.input, null, 2), toolInput: JSON.stringify(block.input, null, 2),
timestamp, timestamp,
rawIndex, rawIndex,
toolUseId: block.id,
}); });
} }
} }
@@ -239,3 +326,10 @@ function formatProgressData(data: Record<string, unknown>): string {
function detectSystemReminder(text: string): boolean { function detectSystemReminder(text: string): boolean {
return text.includes("<system-reminder>") || text.includes("</system-reminder>"); return text.includes("<system-reminder>") || text.includes("</system-reminder>");
} }
function deriveProgressSubtype(dataType: string): ProgressSubtype {
if (dataType === "bash_progress") return "bash";
if (dataType === "mcp_progress") return "mcp";
if (dataType === "agent_progress") return "agent";
return "hook";
}

View File

@@ -449,3 +449,25 @@ export function redactMessage(msg: ParsedMessage): ParsedMessage {
// toolName is typically safe (e.g. "Bash", "Read") — pass through unchanged // toolName is typically safe (e.g. "Bash", "Read") — pass through unchanged
}; };
} }
/**
* Counts how many messages contain at least one sensitive match.
* Checks both content and toolInput fields.
*/
export function countSensitiveMessages(messages: ParsedMessage[]): number {
let count = 0;
for (const msg of messages) {
const contentResult = redactSensitiveContent(msg.content);
if (contentResult.redactionCount > 0) {
count++;
continue;
}
if (msg.toolInput) {
const inputResult = redactSensitiveContent(msg.toolInput);
if (inputResult.redactionCount > 0) {
count++;
}
}
}
return count;
}

View File

@@ -9,6 +9,8 @@ export type MessageCategory =
| "file_snapshot" | "file_snapshot"
| "summary"; | "summary";
export type ProgressSubtype = "hook" | "bash" | "mcp" | "agent";
export interface ParsedMessage { export interface ParsedMessage {
uuid: string; uuid: string;
category: MessageCategory; category: MessageCategory;
@@ -17,6 +19,9 @@ export interface ParsedMessage {
toolInput?: string; toolInput?: string;
timestamp?: string; timestamp?: string;
rawIndex: number; rawIndex: number;
toolUseId?: string;
parentToolUseId?: string;
progressSubtype?: ProgressSubtype;
} }
export interface SessionEntry { export interface SessionEntry {
@@ -39,6 +44,7 @@ export interface SessionDetailResponse {
id: string; id: string;
project: string; project: string;
messages: ParsedMessage[]; messages: ParsedMessage[];
toolProgress?: Record<string, ParsedMessage[]>;
} }
export interface ExportRequest { export interface ExportRequest {
@@ -69,10 +75,12 @@ export const CATEGORY_LABELS: Record<MessageCategory, string> = {
system_message: "System Messages", system_message: "System Messages",
hook_progress: "Hook/Progress", hook_progress: "Hook/Progress",
file_snapshot: "File Snapshots", file_snapshot: "File Snapshots",
summary: "Summaries", summary: "Compactions",
}; };
export const DEFAULT_HIDDEN_CATEGORIES: MessageCategory[] = [ export const DEFAULT_HIDDEN_CATEGORIES: MessageCategory[] = [
"thinking", "tool_result",
"system_message",
"hook_progress", "hook_progress",
"file_snapshot",
]; ];

View File

@@ -41,7 +41,7 @@ export default {
}, },
fontFamily: { fontFamily: {
sans: [ sans: [
"Inter", "Geist",
"system-ui", "system-ui",
"-apple-system", "-apple-system",
"BlinkMacSystemFont", "BlinkMacSystemFont",

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":"file-history-snapshot","messageId":"snap-1","snapshot":{"messageId":"snap-1","trackedFileBackups":[],"timestamp":"2025-10-15T10:30:00Z"},"isSnapshotUpdate":false}
{"type":"progress","data":{"type":"hook","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"} {"type":"progress","data":{"type":"hook_progress","hookEvent":"SessionStart","hookName":"init_hook","command":"setup"},"uuid":"prog-0","timestamp":"2025-10-15T10:30:00Z"}
{"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"security_check","command":"check"},"uuid":"prog-1","timestamp":"2025-10-15T10:30:01Z"}
{"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"} {"type":"user","message":{"role":"user","content":"Can you help me fix the login bug? Users are getting a 401 error."},"uuid":"msg-1","timestamp":"2025-10-15T10:30:05Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user is reporting a 401 error on login. I should look at the authentication middleware and the login endpoint to understand what's happening."},{"type":"text","text":"I'll investigate the login bug. Let me start by looking at the authentication code."}]},"uuid":"msg-2","timestamp":"2025-10-15T10:30:10Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_read1","name":"Read","input":{"file_path":"/src/auth/login.ts"}}]},"uuid":"msg-3","timestamp":"2025-10-15T10:30:15Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"} {"type":"progress","data":{"type":"hook_progress","hookEvent":"PreToolUse","hookName":"read_hook","status":"callback"},"parentToolUseID":"toolu_read1","uuid":"prog-read-hook","timestamp":"2025-10-15T10:30:15Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read1","content":"export async function login(email: string, password: string) {\n const user = await db.users.findByEmail(email);\n if (!user) throw new UnauthorizedError();\n const valid = await bcrypt.compare(password, user.passwordHash);\n if (!valid) throw new UnauthorizedError();\n return generateToken(user);\n}"}]},"uuid":"msg-4","timestamp":"2025-10-15T10:30:16Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I found the issue. The `login` function looks correct, but let me check the middleware that validates tokens.\n\n```typescript\n// The fix is to update the token validation\nexport function validateToken(token: string) {\n return jwt.verify(token, process.env.JWT_SECRET!);\n}\n```\n\nThe problem was that the JWT_SECRET environment variable wasn't being loaded properly."}]},"uuid":"msg-5","timestamp":"2025-10-15T10:30:20Z"}
{"type":"user","message":{"role":"user","content":"<system-reminder>Remember to check environment variables</system-reminder>"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"} {"type":"user","message":{"role":"user","content":"<system-reminder>Remember to check environment variables</system-reminder>"},"uuid":"msg-6","timestamp":"2025-10-15T10:30:21Z"}
{"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"} {"type":"user","message":{"role":"user","content":"That makes sense! Can you fix it?"},"uuid":"msg-7","timestamp":"2025-10-15T10:31:00Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_edit1","name":"Edit","input":{"file_path":"/src/auth/middleware.ts","old_string":"jwt.verify(token, process.env.JWT_SECRET!)","new_string":"jwt.verify(token, getJwtSecret())"}}]},"uuid":"msg-8","timestamp":"2025-10-15T10:31:05Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_02","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"} {"type":"progress","data":{"type":"bash_progress","status":"running","toolName":"Bash"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-bash","timestamp":"2025-10-15T10:31:05Z"}
{"type":"progress","data":{"type":"mcp_progress","serverName":"morph-mcp","toolName":"edit_file","status":"completed"},"parentToolUseID":"toolu_edit1","uuid":"prog-edit-mcp","timestamp":"2025-10-15T10:31:06Z"}
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_edit1","content":"File edited successfully."}]},"uuid":"msg-9","timestamp":"2025-10-15T10:31:06Z"}
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"} {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"I've fixed the issue by using a `getJwtSecret()` helper that properly loads and caches the secret. The login should work now."}]},"uuid":"msg-10","timestamp":"2025-10-15T10:31:10Z"}
{"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"} {"type":"progress","data":{"type":"tool","status":"running","toolName":"Bash","serverName":"local"},"uuid":"prog-2","timestamp":"2025-10-15T10:31:15Z"}
{"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"} {"type":"summary","summary":"Fixed authentication bug by updating JWT secret loading in middleware.","leafUuid":"msg-10"}

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

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { MessageCategory } from "../../src/shared/types.js";
import { ALL_CATEGORIES, DEFAULT_HIDDEN_CATEGORIES } from "../../src/shared/types.js";
// Test the localStorage persistence logic used by useFilters
const STORAGE_KEY_CATEGORIES = "session-viewer:enabledCategories";
const STORAGE_KEY_AUTOREDACT = "session-viewer:autoRedact";
function loadEnabledCategories(): Set<MessageCategory> {
try {
const stored = localStorage.getItem(STORAGE_KEY_CATEGORIES);
if (stored) {
const arr = JSON.parse(stored) as string[];
const valid = arr.filter((c) =>
ALL_CATEGORIES.includes(c as MessageCategory)
) as MessageCategory[];
if (valid.length > 0) return new Set(valid);
}
} catch {
// Fall through to default
}
const set = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
set.delete(cat);
}
return set;
}
function loadAutoRedact(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY_AUTOREDACT);
if (stored !== null) return stored === "true";
} catch {
// Fall through to default
}
return false;
}
// Mock localStorage
const store: Record<string, string> = {};
const localStorageMock = {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: vi.fn(() => { for (const key in store) delete store[key]; }),
get length() { return Object.keys(store).length; },
key: vi.fn((i: number) => Object.keys(store)[i] ?? null),
};
Object.defineProperty(globalThis, "localStorage", { value: localStorageMock });
describe("filter persistence", () => {
beforeEach(() => {
localStorageMock.clear();
vi.clearAllMocks();
});
describe("loadEnabledCategories", () => {
it("returns default categories when localStorage is empty", () => {
const result = loadEnabledCategories();
const expected = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
expected.delete(cat);
}
expect(result).toEqual(expected);
});
it("restores categories from localStorage", () => {
const saved: MessageCategory[] = ["user_message", "assistant_text"];
store[STORAGE_KEY_CATEGORIES] = JSON.stringify(saved);
const result = loadEnabledCategories();
expect(result).toEqual(new Set(saved));
});
it("filters out invalid category values from localStorage", () => {
const saved = ["user_message", "invalid_category", "thinking"];
store[STORAGE_KEY_CATEGORIES] = JSON.stringify(saved);
const result = loadEnabledCategories();
expect(result.has("user_message")).toBe(true);
expect(result.has("thinking")).toBe(true);
expect(result.size).toBe(2);
});
it("falls back to defaults on corrupted localStorage data", () => {
store[STORAGE_KEY_CATEGORIES] = "not valid json";
const result = loadEnabledCategories();
const expected = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
expected.delete(cat);
}
expect(result).toEqual(expected);
});
it("falls back to defaults when stored array is all invalid", () => {
store[STORAGE_KEY_CATEGORIES] = JSON.stringify(["fake1", "fake2"]);
const result = loadEnabledCategories();
const expected = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
expected.delete(cat);
}
expect(result).toEqual(expected);
});
});
describe("loadAutoRedact", () => {
it("returns false when localStorage is empty", () => {
expect(loadAutoRedact()).toBe(false);
});
it("returns true when stored as 'true'", () => {
store[STORAGE_KEY_AUTOREDACT] = "true";
expect(loadAutoRedact()).toBe(true);
});
it("returns false when stored as 'false'", () => {
store[STORAGE_KEY_AUTOREDACT] = "false";
expect(loadAutoRedact()).toBe(false);
});
it("returns false for any non-'true' string", () => {
store[STORAGE_KEY_AUTOREDACT] = "yes";
expect(loadAutoRedact()).toBe(false);
});
});
});

View File

@@ -48,15 +48,17 @@ describe("filters", () => {
expect(filtered.find((m) => m.category === "thinking")).toBeUndefined(); expect(filtered.find((m) => m.category === "thinking")).toBeUndefined();
}); });
it("default filter state has thinking and hooks disabled", () => { it("default filter state hides tool_result, system, hooks, and snapshots", () => {
const defaultEnabled = new Set(ALL_CATEGORIES); const defaultEnabled = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) { for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
defaultEnabled.delete(cat); defaultEnabled.delete(cat);
} }
const filtered = filterMessages(messages, defaultEnabled); const filtered = filterMessages(messages, defaultEnabled);
expect(filtered.find((m) => m.category === "thinking")).toBeUndefined(); expect(filtered.find((m) => m.category === "tool_result")).toBeUndefined();
expect(filtered.find((m) => m.category === "system_message")).toBeUndefined();
expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined(); expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined();
expect(filtered).toHaveLength(7); expect(filtered.find((m) => m.category === "file_snapshot")).toBeUndefined();
expect(filtered).toHaveLength(5);
}); });
it("all-off filter returns empty array", () => { it("all-off filter returns empty array", () => {
@@ -110,6 +112,19 @@ describe("filters", () => {
expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true); expect(filtered.every((m) => m.category === "tool_call" || m.category === "tool_result")).toBe(true);
}); });
it("hook_progress included when category is enabled", () => {
const enabled = new Set<MessageCategory>(ALL_CATEGORIES);
const filtered = filterMessages(messages, enabled);
expect(filtered.find((m) => m.category === "hook_progress")).toBeDefined();
});
it("hook_progress excluded when category is disabled", () => {
const enabled = new Set<MessageCategory>(ALL_CATEGORIES);
enabled.delete("hook_progress");
const filtered = filterMessages(messages, enabled);
expect(filtered.find((m) => m.category === "hook_progress")).toBeUndefined();
});
it("category counts are computed correctly", () => { it("category counts are computed correctly", () => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const cat of ALL_CATEGORIES) { for (const cat of ALL_CATEGORIES) {

View File

@@ -16,13 +16,15 @@ function makeMessage(
function makeExportRequest( function makeExportRequest(
messages: ParsedMessage[], messages: ParsedMessage[],
visible?: string[], visible?: string[],
redacted?: string[] redacted?: string[],
toolProgress?: Record<string, ParsedMessage[]>
): ExportRequest { ): ExportRequest {
return { return {
session: { session: {
id: "test-session", id: "test-session",
project: "test-project", project: "test-project",
messages, messages,
toolProgress,
}, },
visibleMessageUuids: visible || messages.map((m) => m.uuid), visibleMessageUuids: visible || messages.map((m) => m.uuid),
redactedMessageUuids: redacted || [], redactedMessageUuids: redacted || [],
@@ -118,4 +120,149 @@ describe("html-exporter", () => {
// Verify singular — should NOT contain "1 messages" // Verify singular — should NOT contain "1 messages"
expect(html).not.toMatch(/\b1 messages\b/); expect(html).not.toMatch(/\b1 messages\b/);
}); });
// ─── Collapsible messages ───
it("thinking messages render collapsed by default", async () => {
const msg = makeMessage({
uuid: "think-1",
category: "thinking",
content: "Line one\nLine two\nLine three\nLine four\nLine five",
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain('data-collapsed="true"');
});
it("tool_call messages render collapsed with tool name preview", async () => {
const msg = makeMessage({
uuid: "tc-1",
category: "tool_call",
content: "",
toolName: "Read",
toolInput: '{"path": "/foo/bar.ts"}',
toolUseId: "tu-1",
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain('data-collapsed="true"');
expect(html).toContain("collapsed-preview");
expect(html).toContain("Read");
});
it("tool_result messages render collapsed with content preview", async () => {
const msg = makeMessage({
uuid: "tr-1",
category: "tool_result",
content: "This is the first line of a tool result that should be truncated in the preview display when collapsed",
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain('data-collapsed="true"');
expect(html).toContain("collapsed-preview");
});
it("collapsible messages include toggle button", async () => {
const msg = makeMessage({
uuid: "think-2",
category: "thinking",
content: "Some thoughts",
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("collapsible-toggle");
});
it("non-collapsible messages do not have collapse attributes on their message div", async () => {
const msg = makeMessage({
uuid: "user-1",
category: "user_message",
content: "Hello there",
});
const html = await generateExportHtml(makeExportRequest([msg]));
// Extract the message div — it should NOT have data-collapsed (CSS will have it as a selector)
const messageDiv = html.match(/<div class="message"[^>]*>/);
expect(messageDiv).not.toBeNull();
expect(messageDiv![0]).not.toContain("data-collapsed");
expect(messageDiv![0]).not.toContain("collapsible-toggle");
});
it("export includes toggle JavaScript", async () => {
const msg = makeMessage({ uuid: "msg-js", content: "Hello" });
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("<script>");
expect(html).toContain("collapsible-toggle");
});
it("tool_result with diff content gets diff highlighting", async () => {
const diffContent = [
"diff --git a/foo.ts b/foo.ts",
"--- a/foo.ts",
"+++ b/foo.ts",
"@@ -1,3 +1,3 @@",
" unchanged",
"-old line",
"+new line",
].join("\n");
const msg = makeMessage({
uuid: "tr-diff",
category: "tool_result",
content: diffContent,
});
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("diff-add");
expect(html).toContain("diff-del");
expect(html).toContain("diff-hunk");
});
it("tool_call with progress events renders progress badge", async () => {
const toolMsg = makeMessage({
uuid: "tc-prog",
category: "tool_call",
content: "",
toolName: "Bash",
toolUseId: "tu-prog",
});
const progressEvents: ParsedMessage[] = [
makeMessage({
uuid: "pe-1",
category: "hook_progress",
content: "Running...",
progressSubtype: "bash",
timestamp: "2025-01-01T12:00:00Z",
}),
makeMessage({
uuid: "pe-2",
category: "hook_progress",
content: "Done",
progressSubtype: "bash",
timestamp: "2025-01-01T12:00:01Z",
}),
];
const html = await generateExportHtml(
makeExportRequest([toolMsg], undefined, undefined, { "tu-prog": progressEvents })
);
expect(html).toContain("progress-badge");
expect(html).toContain("progress-drawer");
expect(html).toContain("bash");
expect(html).toContain("2");
});
it("tool_call without progress events has no badge", async () => {
const toolMsg = makeMessage({
uuid: "tc-no-prog",
category: "tool_call",
content: "",
toolName: "Read",
toolUseId: "tu-no-prog",
});
const html = await generateExportHtml(makeExportRequest([toolMsg]));
// The CSS will contain .progress-badge as a selector, but the message HTML should not
// have an actual progress-badge div element
expect(html).not.toContain('<div class="progress-badge">');
});
it("print CSS forces content visible", async () => {
const msg = makeMessage({ uuid: "msg-print", content: "Hello" });
const html = await generateExportHtml(makeExportRequest([msg]));
expect(html).toContain("@media print");
// Should override collapsed hidden state for print
expect(html).toMatch(/\.message-body\s*\{[^}]*display:\s*block/);
});
}); });

View File

@@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { MetadataCache } from "../../src/server/services/metadata-cache.js";
import type { CacheEntry } from "../../src/server/services/metadata-cache.js";
import fs from "fs/promises";
import path from "path";
import os from "os";
function makeCacheEntry(overrides: Partial<CacheEntry> = {}): CacheEntry {
return {
mtimeMs: 1700000000000,
size: 1024,
messageCount: 5,
firstPrompt: "Hello",
summary: "Session summary",
firstTimestamp: "2025-01-01T10:00:00Z",
lastTimestamp: "2025-01-01T11:00:00Z",
...overrides,
};
}
describe("MetadataCache", () => {
let tmpDir: string;
let cachePath: string;
beforeEach(async () => {
tmpDir = path.join(os.tmpdir(), `sv-cache-test-${Date.now()}`);
await fs.mkdir(tmpDir, { recursive: true });
cachePath = path.join(tmpDir, "metadata.json");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("returns null for unknown file path", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
expect(cache.get("/unknown/path.jsonl", 1000, 500)).toBeNull();
});
it("returns entry when mtimeMs and size match", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
cache.set("/test/session.jsonl", entry);
const result = cache.get("/test/session.jsonl", 1000, 500);
expect(result).not.toBeNull();
expect(result!.messageCount).toBe(5);
expect(result!.firstPrompt).toBe("Hello");
});
it("returns null when mtimeMs differs", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
cache.set("/test/session.jsonl", entry);
expect(cache.get("/test/session.jsonl", 2000, 500)).toBeNull();
});
it("returns null when size differs", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
const entry = makeCacheEntry({ mtimeMs: 1000, size: 500 });
cache.set("/test/session.jsonl", entry);
expect(cache.get("/test/session.jsonl", 1000, 999)).toBeNull();
});
it("save is no-op when not dirty", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
await cache.save();
// File should not exist since nothing was set
await expect(fs.access(cachePath)).rejects.toThrow();
});
it("save writes to disk when dirty", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
cache.set("/test/session.jsonl", makeCacheEntry());
await cache.save();
const raw = await fs.readFile(cachePath, "utf-8");
const parsed = JSON.parse(raw);
expect(parsed.version).toBe(1);
expect(parsed.entries["/test/session.jsonl"]).toBeDefined();
expect(parsed.entries["/test/session.jsonl"].messageCount).toBe(5);
});
it("save prunes entries not in existingPaths", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
cache.set("/test/a.jsonl", makeCacheEntry());
cache.set("/test/b.jsonl", makeCacheEntry());
cache.set("/test/c.jsonl", makeCacheEntry());
const existingPaths = new Set(["/test/a.jsonl", "/test/c.jsonl"]);
await cache.save(existingPaths);
const raw = await fs.readFile(cachePath, "utf-8");
const parsed = JSON.parse(raw);
expect(Object.keys(parsed.entries)).toHaveLength(2);
expect(parsed.entries["/test/b.jsonl"]).toBeUndefined();
});
it("load handles missing cache file", async () => {
const cache = new MetadataCache(
path.join(tmpDir, "nonexistent", "cache.json")
);
await cache.load();
expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull();
});
it("load handles corrupt cache file", async () => {
await fs.writeFile(cachePath, "not valid json {{{");
const cache = new MetadataCache(cachePath);
await cache.load();
expect(cache.get("/test/session.jsonl", 1000, 500)).toBeNull();
});
it("persists and reloads across instances", async () => {
const cache1 = new MetadataCache(cachePath);
await cache1.load();
cache1.set("/test/session.jsonl", makeCacheEntry({ mtimeMs: 42, size: 100 }));
await cache1.save();
const cache2 = new MetadataCache(cachePath);
await cache2.load();
const result = cache2.get("/test/session.jsonl", 42, 100);
expect(result).not.toBeNull();
expect(result!.messageCount).toBe(5);
});
it("isDirty returns false initially, true after set", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
expect(cache.isDirty()).toBe(false);
cache.set("/test/session.jsonl", makeCacheEntry());
expect(cache.isDirty()).toBe(true);
});
it("isDirty resets to false after save", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
cache.set("/test/session.jsonl", makeCacheEntry());
expect(cache.isDirty()).toBe(true);
await cache.save();
expect(cache.isDirty()).toBe(false);
});
it("flush writes without pruning", async () => {
const cache = new MetadataCache(cachePath);
await cache.load();
cache.set("/test/a.jsonl", makeCacheEntry());
cache.set("/test/b.jsonl", makeCacheEntry());
await cache.flush();
const raw = await fs.readFile(cachePath, "utf-8");
const parsed = JSON.parse(raw);
// Both should be present (no pruning on flush)
expect(Object.keys(parsed.entries)).toHaveLength(2);
});
});

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

@@ -3,6 +3,7 @@ import {
redactSensitiveContent, redactSensitiveContent,
redactMessage, redactMessage,
redactString, redactString,
countSensitiveMessages,
} from "../../src/shared/sensitive-redactor.js"; } from "../../src/shared/sensitive-redactor.js";
import type { ParsedMessage } from "../../src/shared/types.js"; import type { ParsedMessage } from "../../src/shared/types.js";
@@ -563,4 +564,78 @@ describe("sensitive-redactor", () => {
expect(redacted.toolName).toBeUndefined(); expect(redacted.toolName).toBeUndefined();
}); });
}); });
describe("countSensitiveMessages", () => {
it("returns 0 for empty array", () => {
const result = countSensitiveMessages([]);
expect(result).toBe(0);
});
it("returns 0 when no messages contain sensitive content", () => {
const messages: ParsedMessage[] = [
{
uuid: "m1",
category: "assistant_text",
content: "Hello, how can I help?",
toolName: undefined,
toolInput: undefined,
timestamp: "2025-10-15T10:00:00Z",
rawIndex: 0,
},
];
expect(countSensitiveMessages(messages)).toBe(0);
});
it("counts messages with sensitive content", () => {
const ghToken = "ghp_" + "a".repeat(36);
const messages: ParsedMessage[] = [
{
uuid: "m1",
category: "assistant_text",
content: "Here is your token: " + ghToken,
toolName: undefined,
toolInput: undefined,
timestamp: "2025-10-15T10:00:00Z",
rawIndex: 0,
},
{
uuid: "m2",
category: "assistant_text",
content: "No secrets here",
toolName: undefined,
toolInput: undefined,
timestamp: "2025-10-15T10:01:00Z",
rawIndex: 1,
},
{
uuid: "m3",
category: "tool_call",
content: "Running command",
toolName: "Bash",
toolInput: "export SECRET_KEY=abcdefghijklmnopqrstuvwxyz",
timestamp: "2025-10-15T10:02:00Z",
rawIndex: 2,
},
];
// m1 has a GitHub token in content, m3 has a secret in toolInput
expect(countSensitiveMessages(messages)).toBe(2);
});
it("counts a message only once even if it has multiple sensitive items", () => {
const ghToken = "ghp_" + "a".repeat(36);
const messages: ParsedMessage[] = [
{
uuid: "m1",
category: "assistant_text",
content: ghToken + " and also AKIAIOSFODNN7EXAMPLE",
toolName: undefined,
toolInput: undefined,
timestamp: "2025-10-15T10:00:00Z",
rawIndex: 0,
},
];
// One message with two secrets still counts as 1 message
expect(countSensitiveMessages(messages)).toBe(1);
});
});
}); });

View File

@@ -1,70 +1,122 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, beforeEach } from "vitest";
import { discoverSessions } from "../../src/server/services/session-discovery.js"; import { discoverSessions, setCache } from "../../src/server/services/session-discovery.js";
import { MetadataCache } from "../../src/server/services/metadata-cache.js";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import os from "os"; import os from "os";
/** Helper to write a sessions-index.json in the real { version, entries } format */ function makeJsonlContent(lines: Record<string, unknown>[]): string {
function makeIndex(entries: Record<string, unknown>[]) { return lines.map((l) => JSON.stringify(l)).join("\n");
}
function makeIndex(entries: Record<string, unknown>[]): string {
return JSON.stringify({ version: 1, entries }); return JSON.stringify({ version: 1, entries });
} }
describe("session-discovery", () => { async function makeTmpProject(
it("discovers sessions from { version, entries } format", async () => { suffix: string
const tmpDir = path.join(os.tmpdir(), `sv-test-${Date.now()}`); ): Promise<{ tmpDir: string; projectDir: string; cachePath: string; cleanup: () => Promise<void> }> {
const tmpDir = path.join(os.tmpdir(), `sv-test-${suffix}-${Date.now()}`);
const projectDir = path.join(tmpDir, "test-project"); const projectDir = path.join(tmpDir, "test-project");
const cachePath = path.join(tmpDir, ".cache", "metadata.json");
await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(projectDir, { recursive: true });
return {
tmpDir,
projectDir,
cachePath,
cleanup: () => fs.rm(tmpDir, { recursive: true }),
};
}
const sessionPath = path.join(projectDir, "sess-001.jsonl"); describe("session-discovery", () => {
await fs.writeFile( beforeEach(() => {
path.join(projectDir, "sessions-index.json"), // Reset global cache between tests to prevent cross-contamination
makeIndex([ setCache(new MetadataCache(path.join(os.tmpdir(), `sv-cache-${Date.now()}.json`)));
});
it("discovers sessions from .jsonl files without index", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("no-index");
const content = makeJsonlContent([
{ {
sessionId: "sess-001", type: "user",
fullPath: sessionPath, message: { role: "user", content: "Hello world" },
summary: "Test session", uuid: "u-1",
firstPrompt: "Hello", timestamp: "2025-10-15T10:00:00Z",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
messageCount: 5,
}, },
]) {
); type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "Hi there" }],
},
uuid: "a-1",
timestamp: "2025-10-15T10:01:00Z",
},
]);
await fs.writeFile(path.join(projectDir, "sess-001.jsonl"), content);
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("sess-001"); expect(sessions[0].id).toBe("sess-001");
expect(sessions[0].summary).toBe("Test session");
expect(sessions[0].project).toBe("test-project"); expect(sessions[0].project).toBe("test-project");
expect(sessions[0].messageCount).toBe(5); expect(sessions[0].messageCount).toBe(2);
expect(sessions[0].path).toBe(sessionPath); expect(sessions[0].firstPrompt).toBe("Hello world");
expect(sessions[0].path).toBe(path.join(projectDir, "sess-001.jsonl"));
await fs.rm(tmpDir, { recursive: true }); await cleanup();
}); });
it("also handles legacy raw array format", async () => { it("timestamps come from stat, not JSONL content", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-legacy-${Date.now()}`); const { tmpDir, projectDir, cleanup } = await makeTmpProject("stat-times");
const projectDir = path.join(tmpDir, "legacy-project");
await fs.mkdir(projectDir, { recursive: true });
// Raw array (not wrapped in { version, entries }) const content = makeJsonlContent([
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
JSON.stringify([
{ {
sessionId: "legacy-001", type: "user",
summary: "Legacy format", message: { role: "user", content: "Hello" },
created: "2025-10-15T10:00:00Z", uuid: "u-1",
modified: "2025-10-15T11:00:00Z", timestamp: "2020-01-01T00:00:00Z",
}, },
]) ]);
);
const filePath = path.join(projectDir, "sess-stat.jsonl");
await fs.writeFile(filePath, content);
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("legacy-001");
await fs.rm(tmpDir, { recursive: true }); // created and modified should be from stat (recent), not from the 2020 timestamp
const createdDate = new Date(sessions[0].created);
const now = new Date();
const diffMs = now.getTime() - createdDate.getTime();
expect(diffMs).toBeLessThan(60_000); // within last minute
await cleanup();
});
it("silently skips files deleted between readdir and stat", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("toctou");
// Write a session, discover will find it
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Survives" },
uuid: "u-1",
},
]);
await fs.writeFile(path.join(projectDir, "survivor.jsonl"), content);
// Write and immediately delete another
await fs.writeFile(path.join(projectDir, "ghost.jsonl"), content);
await fs.unlink(path.join(projectDir, "ghost.jsonl"));
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("survivor");
await cleanup();
}); });
it("handles missing projects directory gracefully", async () => { it("handles missing projects directory gracefully", async () => {
@@ -72,21 +124,6 @@ describe("session-discovery", () => {
expect(sessions).toEqual([]); expect(sessions).toEqual([]);
}); });
it("handles corrupt index files gracefully", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-corrupt-${Date.now()}`);
const projectDir = path.join(tmpDir, "corrupt-project");
await fs.mkdir(projectDir, { recursive: true });
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
"not valid json {"
);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toEqual([]);
await fs.rm(tmpDir, { recursive: true });
});
it("aggregates across multiple project directories", async () => { it("aggregates across multiple project directories", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-multi-${Date.now()}`); const tmpDir = path.join(os.tmpdir(), `sv-test-multi-${Date.now()}`);
const proj1 = path.join(tmpDir, "project-a"); const proj1 = path.join(tmpDir, "project-a");
@@ -94,14 +131,25 @@ describe("session-discovery", () => {
await fs.mkdir(proj1, { recursive: true }); await fs.mkdir(proj1, { recursive: true });
await fs.mkdir(proj2, { recursive: true }); await fs.mkdir(proj2, { recursive: true });
await fs.writeFile( const contentA = makeJsonlContent([
path.join(proj1, "sessions-index.json"), {
makeIndex([{ sessionId: "a-001", created: "2025-01-01T00:00:00Z", modified: "2025-01-01T00:00:00Z" }]) type: "user",
); message: { role: "user", content: "Project A" },
await fs.writeFile( uuid: "u-a",
path.join(proj2, "sessions-index.json"), timestamp: "2025-01-01T00:00:00Z",
makeIndex([{ sessionId: "b-001", created: "2025-01-02T00:00:00Z", modified: "2025-01-02T00:00:00Z" }]) },
); ]);
const contentB = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Project B" },
uuid: "u-b",
timestamp: "2025-01-02T00:00:00Z",
},
]);
await fs.writeFile(path.join(proj1, "a-001.jsonl"), contentA);
await fs.writeFile(path.join(proj2, "b-001.jsonl"), contentB);
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(2); expect(sessions).toHaveLength(2);
@@ -112,93 +160,299 @@ describe("session-discovery", () => {
await fs.rm(tmpDir, { recursive: true }); await fs.rm(tmpDir, { recursive: true });
}); });
it("rejects paths with traversal segments", async () => { it("ignores non-.jsonl files in project directories", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-traversal-${Date.now()}`); const { tmpDir, projectDir, cleanup } = await makeTmpProject("filter-ext");
const projectDir = path.join(tmpDir, "traversal-project");
await fs.mkdir(projectDir, { recursive: true });
const goodPath = path.join(projectDir, "good-001.jsonl"); const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
},
]);
await fs.writeFile(path.join(projectDir, "session.jsonl"), content);
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
'{"version":1,"entries":[]}'
);
await fs.writeFile(path.join(projectDir, "notes.txt"), "notes");
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("session");
await cleanup();
});
it("duration computed from JSONL timestamps", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("duration");
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Start" },
uuid: "u-1",
timestamp: "2025-10-15T10:00:00Z",
},
{
type: "assistant",
message: {
role: "assistant",
content: [{ type: "text", text: "End" }],
},
uuid: "a-1",
timestamp: "2025-10-15T10:30:00Z",
},
]);
await fs.writeFile(path.join(projectDir, "sess-dur.jsonl"), content);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
// 30 minutes = 1800000 ms
expect(sessions[0].duration).toBe(1_800_000);
await cleanup();
});
it("handles empty .jsonl files", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("empty");
await fs.writeFile(path.join(projectDir, "empty.jsonl"), "");
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("empty");
expect(sessions[0].messageCount).toBe(0);
expect(sessions[0].firstPrompt).toBe("");
await cleanup();
});
it("sorts by modified descending", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("sort");
const content1 = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "First" },
uuid: "u-1",
},
]);
const content2 = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Second" },
uuid: "u-2",
},
]);
await fs.writeFile(path.join(projectDir, "older.jsonl"), content1);
// Small delay to ensure different mtime
await new Promise((r) => setTimeout(r, 50));
await fs.writeFile(path.join(projectDir, "newer.jsonl"), content2);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(2);
expect(sessions[0].id).toBe("newer");
expect(sessions[1].id).toBe("older");
await cleanup();
});
describe("Tier 1 index validation", () => {
it("uses index data when modified matches stat mtime within 1s", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-hit");
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
timestamp: "2025-10-15T10:00:00Z",
},
]);
const filePath = path.join(projectDir, "sess-idx.jsonl");
await fs.writeFile(filePath, content);
// Get the actual mtime from the file
const stat = await fs.stat(filePath);
const mtimeIso = new Date(stat.mtimeMs).toISOString();
// Write an index with the matching modified timestamp and different metadata
await fs.writeFile( await fs.writeFile(
path.join(projectDir, "sessions-index.json"), path.join(projectDir, "sessions-index.json"),
makeIndex([ makeIndex([
{ {
sessionId: "evil-001", sessionId: "sess-idx",
fullPath: "/home/ubuntu/../../../etc/passwd", summary: "Index summary",
created: "2025-10-15T10:00:00Z", firstPrompt: "Index prompt",
modified: "2025-10-15T11:00:00Z", messageCount: 99,
}, modified: mtimeIso,
{ created: "2025-10-15T09:00:00Z",
sessionId: "evil-002",
fullPath: "/home/ubuntu/sessions/not-a-jsonl.txt",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
},
{
sessionId: "good-001",
fullPath: goodPath,
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
}, },
]) ])
); );
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1); expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe("good-001"); // Should use index data (Tier 1 hit)
expect(sessions[0].messageCount).toBe(99);
expect(sessions[0].summary).toBe("Index summary");
expect(sessions[0].firstPrompt).toBe("Index prompt");
await fs.rm(tmpDir, { recursive: true }); await cleanup();
}); });
it("rejects absolute paths outside the projects directory", async () => { it("rejects index data when mtime mismatch > 1s", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-containment-${Date.now()}`); const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-miss");
const projectDir = path.join(tmpDir, "contained-project");
await fs.mkdir(projectDir, { recursive: true });
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Real content" },
uuid: "u-1",
timestamp: "2025-10-15T10:00:00Z",
},
]);
await fs.writeFile(path.join(projectDir, "sess-stale.jsonl"), content);
// Write an index with a very old modified timestamp (stale)
await fs.writeFile( await fs.writeFile(
path.join(projectDir, "sessions-index.json"), path.join(projectDir, "sessions-index.json"),
makeIndex([ makeIndex([
{ {
sessionId: "escaped-001", sessionId: "sess-stale",
fullPath: "/etc/shadow.jsonl", summary: "Stale index summary",
created: "2025-10-15T10:00:00Z", firstPrompt: "Stale prompt",
modified: "2025-10-15T11:00:00Z", messageCount: 99,
}, modified: "2020-01-01T00:00:00Z",
{ created: "2020-01-01T00:00:00Z",
sessionId: "escaped-002",
fullPath: "/tmp/other-dir/secret.jsonl",
created: "2025-10-15T10:00:00Z",
modified: "2025-10-15T11:00:00Z",
}, },
]) ])
); );
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(0); expect(sessions).toHaveLength(1);
// Should NOT use index data (Tier 1 miss) — falls through to Tier 3
expect(sessions[0].messageCount).toBe(1); // Actual parse count
expect(sessions[0].firstPrompt).toBe("Real content");
await fs.rm(tmpDir, { recursive: true }); await cleanup();
}); });
it("uses fullPath from index entry", async () => { it("skips Tier 1 when entry has no modified field", async () => {
const tmpDir = path.join(os.tmpdir(), `sv-test-fp-${Date.now()}`); const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-no-mod");
const projectDir = path.join(tmpDir, "fp-project");
await fs.mkdir(projectDir, { recursive: true }); const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Real content" },
uuid: "u-1",
},
]);
await fs.writeFile(path.join(projectDir, "sess-nomod.jsonl"), content);
const sessionPath = path.join(projectDir, "fp-001.jsonl");
await fs.writeFile( await fs.writeFile(
path.join(projectDir, "sessions-index.json"), path.join(projectDir, "sessions-index.json"),
makeIndex([ makeIndex([
{ {
sessionId: "fp-001", sessionId: "sess-nomod",
fullPath: sessionPath, summary: "Index summary",
created: "2025-10-15T10:00:00Z", messageCount: 99,
modified: "2025-10-15T11:00:00Z", // No modified field
}, },
]) ])
); );
const sessions = await discoverSessions(tmpDir); const sessions = await discoverSessions(tmpDir);
expect(sessions[0].path).toBe(sessionPath); expect(sessions).toHaveLength(1);
// Falls through to Tier 3 parse
expect(sessions[0].messageCount).toBe(1);
await fs.rm(tmpDir, { recursive: true }); await cleanup();
});
it("handles missing sessions-index.json", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-missing");
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "No index" },
uuid: "u-1",
},
]);
await fs.writeFile(path.join(projectDir, "sess-noindex.jsonl"), content);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
expect(sessions[0].firstPrompt).toBe("No index");
await cleanup();
});
it("handles corrupt sessions-index.json", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-corrupt");
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Corrupt index" },
uuid: "u-1",
},
]);
await fs.writeFile(path.join(projectDir, "sess-corrupt.jsonl"), content);
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
"not valid json {"
);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
expect(sessions[0].firstPrompt).toBe("Corrupt index");
await cleanup();
});
it("timestamps always from stat even on Tier 1 hit", async () => {
const { tmpDir, projectDir, cleanup } = await makeTmpProject("tier1-stat-ts");
const content = makeJsonlContent([
{
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
},
]);
const filePath = path.join(projectDir, "sess-ts.jsonl");
await fs.writeFile(filePath, content);
const stat = await fs.stat(filePath);
const mtimeIso = new Date(stat.mtimeMs).toISOString();
await fs.writeFile(
path.join(projectDir, "sessions-index.json"),
makeIndex([
{
sessionId: "sess-ts",
messageCount: 1,
modified: mtimeIso,
created: "1990-01-01T00:00:00Z",
},
])
);
const sessions = await discoverSessions(tmpDir);
expect(sessions).toHaveLength(1);
// created/modified should be from stat (recent), not from index's 1990 date
const createdDate = new Date(sessions[0].created);
const now = new Date();
expect(now.getTime() - createdDate.getTime()).toBeLessThan(60_000);
await cleanup();
});
}); });
}); });

View File

@@ -0,0 +1,192 @@
import { describe, it, expect } from "vitest";
import { extractSessionMetadata } from "../../src/server/services/session-metadata.js";
import { parseSessionContent } from "../../src/server/services/session-parser.js";
import fs from "fs/promises";
import path from "path";
describe("session-metadata", () => {
it("messageCount matches parseSessionContent on sample-session.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/sample-session.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const meta = extractSessionMetadata(content);
const parsed = parseSessionContent(content);
expect(meta.messageCount).toBe(parsed.length);
});
it("messageCount matches parseSessionContent on edge-cases.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/edge-cases.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const meta = extractSessionMetadata(content);
const parsed = parseSessionContent(content);
expect(meta.messageCount).toBe(parsed.length);
});
it("firstPrompt skips system-reminder messages", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>hook output</system-reminder>" },
uuid: "u-sr",
timestamp: "2025-01-01T00:00:00Z",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "What is the project structure?" },
uuid: "u-real",
timestamp: "2025-01-01T00:00:01Z",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("What is the project structure?");
});
it("firstPrompt truncated to 200 chars", () => {
const longMessage = "a".repeat(300);
const content = JSON.stringify({
type: "user",
message: { role: "user", content: longMessage },
uuid: "u-long",
timestamp: "2025-01-01T00:00:00Z",
});
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toHaveLength(200);
expect(meta.firstPrompt).toBe("a".repeat(200));
});
it("summary captures the LAST summary line", () => {
const content = [
JSON.stringify({ type: "summary", summary: "First summary", uuid: "s-1" }),
JSON.stringify({
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
}),
JSON.stringify({ type: "summary", summary: "Last summary", uuid: "s-2" }),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.summary).toBe("Last summary");
});
it("timestamps captured from first and last lines with timestamps", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "Hello" },
uuid: "u-1",
timestamp: "2025-01-01T10:00:00Z",
}),
JSON.stringify({
type: "assistant",
message: { role: "assistant", content: "Hi" },
uuid: "a-1",
timestamp: "2025-01-01T10:05:00Z",
}),
JSON.stringify({
type: "summary",
summary: "Session done",
uuid: "s-1",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstTimestamp).toBe("2025-01-01T10:00:00Z");
expect(meta.lastTimestamp).toBe("2025-01-01T10:05:00Z");
});
it("empty content returns zero counts and empty strings", () => {
const meta = extractSessionMetadata("");
expect(meta.messageCount).toBe(0);
expect(meta.firstPrompt).toBe("");
expect(meta.summary).toBe("");
expect(meta.firstTimestamp).toBe("");
expect(meta.lastTimestamp).toBe("");
expect(meta.parseErrors).toBe(0);
});
it("JSONL with no user messages returns empty firstPrompt", () => {
const content = [
JSON.stringify({ type: "summary", summary: "No user", uuid: "s-1" }),
JSON.stringify({ type: "progress", data: { type: "hook" }, uuid: "p-1" }),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("");
});
it("JSONL with all system-reminder users returns empty firstPrompt", () => {
const content = [
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>r1</system-reminder>" },
uuid: "u-1",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "<system-reminder>r2</system-reminder>" },
uuid: "u-2",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("");
});
it("single-line JSONL: firstTimestamp equals lastTimestamp", () => {
const content = JSON.stringify({
type: "user",
message: { role: "user", content: "solo" },
uuid: "u-solo",
timestamp: "2025-06-15T12:00:00Z",
});
const meta = extractSessionMetadata(content);
expect(meta.firstTimestamp).toBe("2025-06-15T12:00:00Z");
expect(meta.lastTimestamp).toBe("2025-06-15T12:00:00Z");
});
it("reports parseErrors from malformed lines", () => {
const content = [
"broken json",
JSON.stringify({
type: "user",
message: { role: "user", content: "ok" },
uuid: "u-1",
}),
"{truncated",
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.parseErrors).toBe(2);
expect(meta.messageCount).toBe(1);
});
it("skips array user content for firstPrompt (only captures string content)", () => {
const content = [
JSON.stringify({
type: "user",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "t1", content: "result" },
],
},
uuid: "u-arr",
}),
JSON.stringify({
type: "user",
message: { role: "user", content: "Second prompt as string" },
uuid: "u-str",
}),
].join("\n");
const meta = extractSessionMetadata(content);
expect(meta.firstPrompt).toBe("Second prompt as string");
});
});

View File

@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { parseSessionContent } from "../../src/server/services/session-parser.js"; import {
parseSessionContent,
forEachJsonlLine,
classifyLine,
countMessagesForLine,
} from "../../src/server/services/session-parser.js";
import type { RawLine } from "../../src/server/services/session-parser.js";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@@ -216,4 +222,358 @@ describe("session-parser", () => {
expect(msgs[0].category).toBe("user_message"); expect(msgs[0].category).toBe("user_message");
expect(msgs[1].category).toBe("assistant_text"); expect(msgs[1].category).toBe("assistant_text");
}); });
it("extracts toolUseId from tool_use blocks with id field", () => {
const line = JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", id: "toolu_abc123", name: "Read", input: { file_path: "/src/index.ts" } },
],
},
uuid: "a-tu-1",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].category).toBe("tool_call");
expect(msgs[0].toolUseId).toBe("toolu_abc123");
});
it("toolUseId is undefined when tool_use block has no id field", () => {
const line = JSON.stringify({
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "tool_use", name: "Read", input: { file_path: "/src/index.ts" } },
],
},
uuid: "a-tu-2",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].toolUseId).toBeUndefined();
});
it("extracts parentToolUseId and progressSubtype from hook_progress", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "hook_progress", hookEvent: "PreToolUse", hookName: "check" },
parentToolUseID: "toolu_abc123",
uuid: "p-linked",
timestamp: "2025-10-15T10:00:00Z",
});
const msgs = parseSessionContent(line);
expect(msgs).toHaveLength(1);
expect(msgs[0].category).toBe("hook_progress");
expect(msgs[0].parentToolUseId).toBe("toolu_abc123");
expect(msgs[0].progressSubtype).toBe("hook");
});
it("derives progressSubtype 'bash' from bash_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "bash_progress", status: "running" },
parentToolUseID: "toolu_bash1",
uuid: "p-bash",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("bash");
expect(msgs[0].parentToolUseId).toBe("toolu_bash1");
});
it("derives progressSubtype 'mcp' from mcp_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "mcp_progress", serverName: "morph-mcp" },
parentToolUseID: "toolu_mcp1",
uuid: "p-mcp",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("mcp");
});
it("derives progressSubtype 'agent' from agent_progress data type", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "agent_progress", status: "started" },
parentToolUseID: "toolu_agent1",
uuid: "p-agent",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("agent");
});
it("parentToolUseId is undefined when progress has no parentToolUseID", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "hook_progress", hookEvent: "SessionStart" },
uuid: "p-orphan",
});
const msgs = parseSessionContent(line);
expect(msgs[0].parentToolUseId).toBeUndefined();
expect(msgs[0].progressSubtype).toBe("hook");
});
it("progressSubtype defaults to 'hook' for unknown data types", () => {
const line = JSON.stringify({
type: "progress",
data: { type: "unknown_thing", status: "ok" },
uuid: "p-unknown",
});
const msgs = parseSessionContent(line);
expect(msgs[0].progressSubtype).toBe("hook");
});
describe("forEachJsonlLine", () => {
it("skips malformed JSON lines and reports parseErrors count", () => {
const content = [
"not valid json",
JSON.stringify({ type: "user", message: { role: "user", content: "Hello" } }),
"{broken}",
].join("\n");
const lines: RawLine[] = [];
const result = forEachJsonlLine(content, (parsed) => {
lines.push(parsed);
});
expect(lines).toHaveLength(1);
expect(result.parseErrors).toBe(2);
});
it("skips empty and whitespace-only lines without incrementing parseErrors", () => {
const content = [
"",
" ",
JSON.stringify({ type: "summary", summary: "test" }),
"\t",
"",
].join("\n");
const lines: RawLine[] = [];
const result = forEachJsonlLine(content, (parsed) => {
lines.push(parsed);
});
expect(lines).toHaveLength(1);
expect(result.parseErrors).toBe(0);
});
it("returns parseErrors 0 for empty content", () => {
const lines: RawLine[] = [];
const result = forEachJsonlLine("", (parsed) => {
lines.push(parsed);
});
expect(lines).toHaveLength(0);
expect(result.parseErrors).toBe(0);
});
it("processes content without trailing newline", () => {
const content = JSON.stringify({ type: "summary", summary: "no trailing newline" });
const lines: RawLine[] = [];
forEachJsonlLine(content, (parsed) => {
lines.push(parsed);
});
expect(lines).toHaveLength(1);
expect(lines[0].summary).toBe("no trailing newline");
});
it("passes correct lineIndex to callback", () => {
const content = [
JSON.stringify({ type: "user", message: { role: "user", content: "first" } }),
"",
JSON.stringify({ type: "summary", summary: "third" }),
].join("\n");
const indices: number[] = [];
forEachJsonlLine(content, (_parsed, lineIndex) => {
indices.push(lineIndex);
});
expect(indices).toEqual([0, 2]);
});
});
describe("classifyLine", () => {
it("returns correct classification for each type", () => {
expect(classifyLine({ type: "progress" })).toBe("progress");
expect(classifyLine({ type: "file-history-snapshot" })).toBe("file-history-snapshot");
expect(classifyLine({ type: "summary" })).toBe("summary");
expect(classifyLine({ type: "system" })).toBe("system");
expect(classifyLine({ type: "queue-operation" })).toBe("queue-operation");
expect(classifyLine({ type: "user", message: { role: "user" } })).toBe("user");
expect(classifyLine({ type: "assistant", message: { role: "assistant" } })).toBe("assistant");
expect(classifyLine({})).toBe("unknown");
});
it("classifies by message.role when type is missing", () => {
expect(classifyLine({ message: { role: "user" } })).toBe("user");
expect(classifyLine({ message: { role: "assistant" } })).toBe("assistant");
});
it("returns unknown for missing type and no role", () => {
expect(classifyLine({ message: {} })).toBe("unknown");
expect(classifyLine({ uuid: "orphan" })).toBe("unknown");
});
});
describe("countMessagesForLine", () => {
it("returns 1 for user string message", () => {
const line: RawLine = {
type: "user",
message: { role: "user", content: "Hello" },
};
expect(countMessagesForLine(line)).toBe(1);
});
it("matches extractMessages length for user array with tool_result and text", () => {
const line: RawLine = {
type: "user",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "t1", content: "result" },
{ type: "text", text: "description" },
],
},
uuid: "u-arr",
};
const msgs = parseSessionContent(JSON.stringify(line));
expect(countMessagesForLine(line)).toBe(msgs.length);
expect(countMessagesForLine(line)).toBe(2);
});
it("matches extractMessages length for assistant array with thinking/text/tool_use", () => {
const line: RawLine = {
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "hmm" },
{ type: "text", text: "response" },
{ type: "tool_use", name: "Read", input: { file_path: "/x" } },
],
},
uuid: "a-arr",
};
const msgs = parseSessionContent(JSON.stringify(line));
expect(countMessagesForLine(line)).toBe(msgs.length);
expect(countMessagesForLine(line)).toBe(3);
});
it("returns 1 for progress/file-history-snapshot/summary", () => {
expect(countMessagesForLine({ type: "progress", data: { type: "hook" } })).toBe(1);
expect(countMessagesForLine({ type: "file-history-snapshot", snapshot: {} })).toBe(1);
expect(countMessagesForLine({ type: "summary", summary: "test" })).toBe(1);
});
it("returns 0 for system/queue-operation", () => {
expect(countMessagesForLine({ type: "system", subtype: "turn_duration" })).toBe(0);
expect(countMessagesForLine({ type: "queue-operation" })).toBe(0);
});
it("returns 0 for unknown type", () => {
expect(countMessagesForLine({})).toBe(0);
expect(countMessagesForLine({ type: "something-new" })).toBe(0);
});
it("returns 0 for user message with empty content array", () => {
const line: RawLine = {
type: "user",
message: { role: "user", content: [] },
};
expect(countMessagesForLine(line)).toBe(0);
});
it("returns 0 for user message with undefined content", () => {
const line: RawLine = {
type: "user",
message: { role: "user" },
};
expect(countMessagesForLine(line)).toBe(0);
});
it("only counts known block types in assistant arrays", () => {
const line: RawLine = {
type: "assistant",
message: {
role: "assistant",
content: [
{ type: "thinking", thinking: "hmm" },
{ type: "unknown_block" },
{ type: "text", text: "hi" },
],
},
};
expect(countMessagesForLine(line)).toBe(2);
});
it("returns 1 for assistant string content", () => {
const line: RawLine = {
type: "assistant",
message: { role: "assistant", content: "direct string" },
};
expect(countMessagesForLine(line)).toBe(1);
});
it("counts user text with system-reminder as 1 (reclassified but still counted)", () => {
const line: RawLine = {
type: "user",
message: { role: "user", content: "<system-reminder>Some reminder</system-reminder>" },
uuid: "u-sr-parity",
};
const msgs = parseSessionContent(JSON.stringify(line));
expect(countMessagesForLine(line)).toBe(msgs.length);
expect(countMessagesForLine(line)).toBe(1);
});
it("handles truncated JSON (crash mid-write)", () => {
const content = [
JSON.stringify({ type: "user", message: { role: "user", content: "ok" }, uuid: "u-ok" }),
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"trun',
].join("\n");
const lines: RawLine[] = [];
const result = forEachJsonlLine(content, (parsed) => {
lines.push(parsed);
});
expect(lines).toHaveLength(1);
expect(result.parseErrors).toBe(1);
});
});
describe("parser parity: fixture integration", () => {
it("countMessagesForLine sum matches parseSessionContent on sample-session.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/sample-session.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const parsedMessages = parseSessionContent(content);
let countSum = 0;
forEachJsonlLine(content, (parsed) => {
countSum += countMessagesForLine(parsed);
});
expect(countSum).toBe(parsedMessages.length);
});
it("countMessagesForLine sum matches parseSessionContent on edge-cases.jsonl", async () => {
const fixturePath = path.join(__dirname, "../fixtures/edge-cases.jsonl");
const content = await fs.readFile(fixturePath, "utf-8");
const parsedMessages = parseSessionContent(content);
let countSum = 0;
forEachJsonlLine(content, (parsed) => {
countSum += countMessagesForLine(parsed);
});
expect(countSum).toBe(parsedMessages.length);
});
});
}); });