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