Compare commits
10 Commits
b030734915
...
150cd0c686
| Author | SHA1 | Date | |
|---|---|---|---|
| 150cd0c686 | |||
| 4ec186d45b | |||
| 957f9bc744 | |||
| 10f23ccecc | |||
| b0b330e0ba | |||
| e5c5e470b0 | |||
| 89ee0cb313 | |||
| 6681f07fc0 | |||
| 4027dd65be | |||
| a8b602fbde |
440
AGENTS.md
440
AGENTS.md
@@ -9,89 +9,391 @@ Use third party libraries whenever there's a well-maintained, active, and widely
|
|||||||
Build extensible pieces of logic that can easily be integrated with other pieces.
|
Build extensible pieces of logic that can easily be integrated with other pieces.
|
||||||
DRY principles should be loosely held.
|
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.
|
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.
|
||||||
Isolate development CLAUDE files/skills/agents/etc from the tools and prompts that will be used by the app. There should be no pollution of external Claude config with what the Ghost AI Assistant sees and uses.
|
|
||||||
|
|
||||||
## Beads Rust Workflow Integration
|
---
|
||||||
|
|
||||||
This project uses [beads_viewer](https://github.com/Dicklesworthstone/beads_viewer) for issue tracking. Issues are stored in `.beads/` and tracked in git.
|
## Issue Tracking with br (beads_rust)
|
||||||
|
|
||||||
### Essential Commands
|
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
|
```bash
|
||||||
# View issues (launches TUI - NOT FOR AGENT USE, human only)
|
br ready --json
|
||||||
bv
|
|
||||||
|
|
||||||
# CLI commands for agents (use --json for machine-readable output)
|
|
||||||
br ready --json # Show issues ready to work (no blockers)
|
|
||||||
br list --status=open --json # All open issues
|
|
||||||
br show <id> --json # Full issue details with dependencies
|
|
||||||
br create --title="..." --type=task --priority=2
|
|
||||||
br update <id> --status=in_progress
|
|
||||||
br close <id> --reason="Completed"
|
|
||||||
br close <id1> <id2> # Close multiple issues at once
|
|
||||||
br sync # Commit and push changes
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Robot Mode (Agent-Optimized bv Commands)
|
Create issues:
|
||||||
|
|
||||||
Use `bv --robot-*` flags for structured JSON output optimized for AI agents:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Essential robot commands
|
br create "Issue title" -t bug|feature|task -p 0-4 --json
|
||||||
bv --robot-triage # THE MEGA-COMMAND: unified analysis, recommendations, health
|
br create "Issue title" -p 1 --deps discovered-from:bv-123 --json
|
||||||
bv --robot-next # Single top recommendation (minimal output)
|
|
||||||
bv --robot-plan # Dependency-respecting execution plan
|
|
||||||
bv --robot-priority # Priority recommendations with reasoning
|
|
||||||
bv --robot-insights # Deep graph analysis (PageRank, bottlenecks, etc.)
|
|
||||||
|
|
||||||
# File impact analysis (check before editing)
|
|
||||||
bv --robot-impact <file> # Risk assessment for modifying files
|
|
||||||
bv --robot-file-beads <path> # What beads have touched this file?
|
|
||||||
bv --robot-file-hotspots # High-churn files (conflict zones)
|
|
||||||
bv --robot-related <bead-id> # Find related beads
|
|
||||||
|
|
||||||
# Filtering options (work with most robot commands)
|
|
||||||
bv --robot-triage --robot-by-label=backend
|
|
||||||
bv --robot-priority --robot-min-confidence=0.7
|
|
||||||
bv --robot-insights --label=api # Scope to label subgraph
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `bv -robot-help` for complete robot mode documentation.
|
Update:
|
||||||
|
|
||||||
### Workflow Pattern
|
|
||||||
|
|
||||||
1. **Start**: Run `br ready` to find actionable work
|
|
||||||
2. **Claim**: Use `br update <id> --status=in_progress`
|
|
||||||
3. **Work**: Implement the task
|
|
||||||
4. **Complete**: Use `br close <id>`
|
|
||||||
5. **Sync**: Always run `br sync` at session end
|
|
||||||
|
|
||||||
### Key Concepts
|
|
||||||
|
|
||||||
- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
|
|
||||||
- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
|
|
||||||
- **Types**: task, bug, feature, epic, question, docs
|
|
||||||
- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
|
|
||||||
|
|
||||||
### Session Protocol
|
|
||||||
|
|
||||||
**Before ending any session, run this checklist:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git status # Check what changed
|
br update bv-42 --status in_progress --json
|
||||||
git add <files> # Stage code changes
|
br update bv-42 --priority 1 --json
|
||||||
br sync # Commit beads changes
|
|
||||||
git commit -m "..." # Commit code
|
|
||||||
br sync # Commit any new beads changes
|
|
||||||
git push # Push to remote
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Best Practices
|
Complete:
|
||||||
|
|
||||||
- Check `br ready` at session start to find available work
|
```bash
|
||||||
- Update status as you work (in_progress → closed)
|
br close bv-42 --reason "Completed" --json
|
||||||
- Create new issues with `br create` when you discover tasks
|
```
|
||||||
- Use descriptive titles and set appropriate priority/type
|
|
||||||
- Always `br sync` before ending session
|
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.
|
||||||
|
|
||||||
<!-- end-br-agent-instructions -->
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ 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 { 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";
|
||||||
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -16,15 +18,47 @@ export function App() {
|
|||||||
currentSession,
|
currentSession,
|
||||||
sessionLoading,
|
sessionLoading,
|
||||||
loadSession,
|
loadSession,
|
||||||
|
refreshSessions,
|
||||||
} = useSession();
|
} = useSession();
|
||||||
|
|
||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
|
|
||||||
|
// 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 sensitiveCount = useMemo(
|
||||||
|
() => countSensitiveMessages(filteredMessages),
|
||||||
|
[filteredMessages]
|
||||||
|
);
|
||||||
|
|
||||||
// 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 [];
|
||||||
@@ -67,6 +101,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]
|
||||||
@@ -109,9 +183,24 @@ export function App() {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
<div className="w-80 flex-shrink-0 border-r border-border bg-surface-raised flex flex-col">
|
||||||
<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%)" }}>
|
||||||
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
<div className="flex items-center justify-between">
|
||||||
Session Viewer
|
<h1 className="text-heading font-semibold text-foreground tracking-tight">
|
||||||
</h1>
|
Session Viewer
|
||||||
|
</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>
|
||||||
@@ -129,6 +218,7 @@ export function App() {
|
|||||||
onToggle={filters.toggleCategory}
|
onToggle={filters.toggleCategory}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
onAutoRedactToggle={filters.setAutoRedactEnabled}
|
||||||
|
sensitiveCount={sensitiveCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,17 +274,19 @@ 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">
|
||||||
<SessionViewer
|
<ErrorBoundary>
|
||||||
messages={filteredMessages}
|
<SessionViewer
|
||||||
allMessages={currentSession?.messages || []}
|
messages={filteredMessages}
|
||||||
redactedUuids={filters.redactedUuids}
|
allMessages={currentSession?.messages || []}
|
||||||
loading={sessionLoading}
|
redactedUuids={filters.redactedUuids}
|
||||||
searchQuery={filters.searchQuery}
|
loading={sessionLoading}
|
||||||
selectedForRedaction={filters.selectedForRedaction}
|
searchQuery={filters.searchQuery}
|
||||||
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
selectedForRedaction={filters.selectedForRedaction}
|
||||||
autoRedactEnabled={filters.autoRedactEnabled}
|
onToggleRedactionSelection={filters.toggleRedactionSelection}
|
||||||
focusedIndex={focusedMessageIndex}
|
autoRedactEnabled={filters.autoRedactEnabled}
|
||||||
/>
|
focusedIndex={activeFocusIndex}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
<SearchMinimap
|
<SearchMinimap
|
||||||
matchIndices={matchIndices}
|
matchIndices={matchIndices}
|
||||||
|
|||||||
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,18 +71,29 @@ 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">
|
||||||
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
|
<Tooltip
|
||||||
<input
|
content="Automatically detect and replace sensitive content (API keys, tokens, passwords, emails, IPs, etc.) with placeholder labels"
|
||||||
type="checkbox"
|
side="top"
|
||||||
checked={autoRedactEnabled}
|
delayMs={150}
|
||||||
onChange={(e) => onAutoRedactToggle(e.target.checked)}
|
>
|
||||||
className="custom-checkbox checkbox-danger"
|
<label className="flex items-center gap-2.5 py-1.5 px-2 rounded-md cursor-pointer hover:bg-surface-inset transition-colors">
|
||||||
/>
|
<input
|
||||||
<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}>
|
type="checkbox"
|
||||||
<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" />
|
checked={autoRedactEnabled}
|
||||||
</svg>
|
onChange={(e) => onAutoRedactToggle(e.target.checked)}
|
||||||
<span className="text-body text-foreground-secondary">Auto-redact sensitive</span>
|
className="custom-checkbox checkbox-danger"
|
||||||
</label>
|
/>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<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>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -36,7 +36,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,7 +46,7 @@ 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;
|
||||||
@@ -55,7 +55,7 @@ export function MessageBubble({
|
|||||||
// 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.
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,14 +157,14 @@ export function MessageBubble({
|
|||||||
? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}`
|
? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}`
|
||||||
: message.content;
|
: message.content;
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
setLinkCopied(true);
|
setContentCopied(true);
|
||||||
setTimeout(() => setLinkCopied(false), 1500);
|
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 message content"
|
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}>
|
<svg className="w-4 h-4 text-green-400" 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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -260,3 +260,14 @@ function formatTimestamp(ts: string): string {
|
|||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** If the string is valid JSON, return it pretty-printed; otherwise return as-is. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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,10 +90,21 @@ 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="w-80 sm:w-96">
|
||||||
@@ -131,70 +142,102 @@ export function SearchBar({
|
|||||||
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 ? (
|
||||||
{/* Match count badge */}
|
<div className="flex items-center gap-1 pr-2.5 flex-shrink-0 animate-fade-in">
|
||||||
{query && (
|
{/* Match count badge */}
|
||||||
<div className={`
|
{query && (
|
||||||
flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5
|
<div
|
||||||
${hasNoResults
|
data-testid="match-count"
|
||||||
? "text-red-400 bg-red-500/10"
|
className={`
|
||||||
: "text-foreground-muted bg-surface-overlay/50"
|
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"
|
||||||
<span>No results</span>
|
: "text-foreground-muted bg-surface-overlay/50"
|
||||||
) : (
|
}
|
||||||
<span>{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}<span className="text-foreground-muted/50 mx-0.5">/</span>{matchCount}</span>
|
`}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Navigation arrows — only when there are results */}
|
|
||||||
{hasResults && (
|
|
||||||
<div className="flex items-center border-l border-border-muted/60 ml-1 pl-1">
|
|
||||||
<button
|
|
||||||
onClick={onPrev}
|
|
||||||
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
|
||||||
aria-label="Previous match"
|
|
||||||
>
|
>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
{hasNoResults ? (
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
<span>No results</span>
|
||||||
</svg>
|
) : (
|
||||||
</button>
|
<span>
|
||||||
<button
|
<span className="text-foreground-secondary font-medium">
|
||||||
onClick={onNext}
|
{currentMatchPosition >= 0 ? currentMatchPosition + 1 : 0}
|
||||||
className="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
|
</span>
|
||||||
aria-label="Next match"
|
<span className="text-foreground-muted/40 mx-0.5">/</span>
|
||||||
>
|
<span>{matchCount}</span>
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
</span>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
)}
|
||||||
</svg>
|
</div>
|
||||||
</button>
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear button or keyboard hint */}
|
{/* Navigation arrows — only when there are results */}
|
||||||
{localQuery ? (
|
{hasResults && (
|
||||||
|
<div className="flex items-center gap-0.5 ml-0.5">
|
||||||
|
<button
|
||||||
|
onClick={onPrev}
|
||||||
|
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"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNext}
|
||||||
|
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"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider between nav and clear */}
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -209,8 +209,8 @@ export function SessionViewer({
|
|||||||
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: `${idx * 20}ms`, animationFillMode: "backwards" } : undefined}
|
||||||
>
|
>
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
message={msg}
|
message={msg}
|
||||||
|
|||||||
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 - 8 : position.y + 8,
|
||||||
|
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) => {
|
||||||
|
|||||||
@@ -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,12 @@ 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 () => {
|
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,6 +40,9 @@ 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) => {
|
||||||
setSessionLoading(true);
|
setSessionLoading(true);
|
||||||
setSessionError(null);
|
setSessionError(null);
|
||||||
@@ -67,6 +72,7 @@ export function useSession(): SessionState {
|
|||||||
sessionLoading,
|
sessionLoading,
|
||||||
sessionError,
|
sessionError,
|
||||||
loadSessions,
|
loadSessions,
|
||||||
|
refreshSessions,
|
||||||
loadSession,
|
loadSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -552,3 +552,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ async function getCachedSessions(): Promise<SessionEntry[]> {
|
|||||||
return cachedSessions;
|
return cachedSessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionsRouter.get("/", async (_req, res) => {
|
sessionsRouter.get("/", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
if (req.query.refresh === "1") {
|
||||||
|
cacheTimestamp = 0;
|
||||||
|
}
|
||||||
const sessions = await getCachedSessions();
|
const sessions = await getCachedSessions();
|
||||||
res.json({ sessions });
|
res.json({ sessions });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user