Compare commits

...

10 Commits

Author SHA1 Message Date
150cd0c686 Wire up ErrorBoundary, URL session sync, j/k navigation, and refresh
Wrap SessionViewer in an ErrorBoundary so render errors show a
recovery UI instead of a white screen.

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

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

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

Pass sensitiveCount to FilterPanel for the new badge display.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:34:59 -05:00
17 changed files with 1291 additions and 164 deletions

440
AGENTS.md
View File

@@ -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.
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.
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
# View issues (launches TUI - NOT FOR AGENT USE, human only)
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
br ready --json
```
### Robot Mode (Agent-Optimized bv Commands)
Use `bv --robot-*` flags for structured JSON output optimized for AI agents:
Create issues:
```bash
# Essential robot commands
bv --robot-triage # THE MEGA-COMMAND: unified analysis, recommendations, health
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
br create "Issue title" -t bug|feature|task -p 0-4 --json
br create "Issue title" -p 1 --deps discovered-from:bv-123 --json
```
Run `bv -robot-help` for complete robot mode documentation.
### 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:**
Update:
```bash
git status # Check what changed
git add <files> # Stage code changes
br sync # Commit beads changes
git commit -m "..." # Commit code
br sync # Commit any new beads changes
git push # Push to remote
br update bv-42 --status in_progress --json
br update bv-42 --priority 1 --json
```
### Best Practices
Complete:
- Check `br ready` at session start to find available work
- Update status as you work (in_progress → closed)
- Create new issues with `br create` when you discover tasks
- Use descriptive titles and set appropriate priority/type
- Always `br sync` before ending session
```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.
<!-- end-br-agent-instructions -->

View File

@@ -5,8 +5,10 @@ import { FilterPanel } from "./components/FilterPanel";
import { SearchBar } from "./components/SearchBar";
import { SearchMinimap } from "./components/SearchMinimap";
import { ExportButton } from "./components/ExportButton";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { useSession } from "./hooks/useSession";
import { useFilters } from "./hooks/useFilters";
import { countSensitiveMessages } from "../shared/sensitive-redactor";
export function App() {
@@ -16,15 +18,47 @@ export function App() {
currentSession,
sessionLoading,
loadSession,
refreshSessions,
} = useSession();
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(() => {
if (!currentSession) return [];
return filters.filterMessages(currentSession.messages);
}, [currentSession, filters.filterMessages]);
const sensitiveCount = useMemo(
() => countSensitiveMessages(filteredMessages),
[filteredMessages]
);
// Track which filtered-message indices match the search query
const matchIndices = useMemo(() => {
if (!filters.searchQuery) return [];
@@ -67,6 +101,46 @@ export function App() {
const focusedMessageIndex =
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(
() => filteredMessages.map((m) => m.uuid),
[filteredMessages]
@@ -109,9 +183,24 @@ export function App() {
{/* Sidebar */}
<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%)" }}>
<h1 className="text-heading font-semibold text-foreground tracking-tight">
Session Viewer
</h1>
<div className="flex items-center justify-between">
<h1 className="text-heading font-semibold text-foreground tracking-tight">
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">
Browse and export Claude sessions
</p>
@@ -129,6 +218,7 @@ export function App() {
onToggle={filters.toggleCategory}
autoRedactEnabled={filters.autoRedactEnabled}
onAutoRedactToggle={filters.setAutoRedactEnabled}
sensitiveCount={sensitiveCount}
/>
</div>
@@ -184,17 +274,19 @@ export function App() {
{/* Scroll area wrapper — relative so minimap can position over the right edge */}
<div className="flex-1 relative min-h-0">
<div ref={scrollRef} className="h-full overflow-y-auto">
<SessionViewer
messages={filteredMessages}
allMessages={currentSession?.messages || []}
redactedUuids={filters.redactedUuids}
loading={sessionLoading}
searchQuery={filters.searchQuery}
selectedForRedaction={filters.selectedForRedaction}
onToggleRedactionSelection={filters.toggleRedactionSelection}
autoRedactEnabled={filters.autoRedactEnabled}
focusedIndex={focusedMessageIndex}
/>
<ErrorBoundary>
<SessionViewer
messages={filteredMessages}
allMessages={currentSession?.messages || []}
redactedUuids={filters.redactedUuids}
loading={sessionLoading}
searchQuery={filters.searchQuery}
selectedForRedaction={filters.selectedForRedaction}
onToggleRedactionSelection={filters.toggleRedactionSelection}
autoRedactEnabled={filters.autoRedactEnabled}
focusedIndex={activeFocusIndex}
/>
</ErrorBoundary>
</div>
<SearchMinimap
matchIndices={matchIndices}

View File

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

View File

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

View File

@@ -2,15 +2,17 @@ import React, { useState } from "react";
import type { MessageCategory } from "../lib/types";
import { ALL_CATEGORIES, CATEGORY_LABELS } from "../lib/types";
import { CATEGORY_COLORS } from "../lib/constants";
import { Tooltip } from "./Tooltip";
interface Props {
enabledCategories: Set<MessageCategory>;
onToggle: (cat: MessageCategory) => void;
autoRedactEnabled: boolean;
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 enabledCount = enabledCategories.size;
@@ -69,18 +71,29 @@ export function FilterPanel({ enabledCategories, onToggle, autoRedactEnabled, on
</div>
<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">
<input
type="checkbox"
checked={autoRedactEnabled}
onChange={(e) => onAutoRedactToggle(e.target.checked)}
className="custom-checkbox checkbox-danger"
/>
<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">Auto-redact sensitive</span>
</label>
<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">
<input
type="checkbox"
checked={autoRedactEnabled}
onChange={(e) => onAutoRedactToggle(e.target.checked)}
className="custom-checkbox checkbox-danger"
/>
<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>
)}

View File

@@ -36,7 +36,7 @@ export function MessageBubble({
// Collapsible state for thinking blocks and tool calls/results
const isCollapsible = message.category === "thinking" || message.category === "tool_call" || message.category === "tool_result";
const [collapsed, setCollapsed] = useState(isCollapsible);
const [linkCopied, setLinkCopied] = useState(false);
const [contentCopied, setContentCopied] = useState(false);
const renderedHtml = useMemo(() => {
// Skip expensive rendering when content is collapsed and not visible
@@ -46,7 +46,7 @@ export function MessageBubble({
if (msg.category === "tool_call") {
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}`;
return searchQuery ? highlightSearchText(html, searchQuery) : html;
@@ -55,7 +55,7 @@ export function MessageBubble({
// 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") {
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;
}
@@ -157,14 +157,14 @@ export function MessageBubble({
? `${message.toolName || "Tool Call"}\n${message.toolInput || ""}`
: message.content;
navigator.clipboard.writeText(text).then(() => {
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 1500);
setContentCopied(true);
setTimeout(() => setContentCopied(false), 1500);
}).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"
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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
@@ -260,3 +260,14 @@ function formatTimestamp(ts: string): string {
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;
}
}

View File

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

View File

@@ -90,10 +90,21 @@ export function SearchBar({
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 hasNoResults = query && matchCount === 0;
const showControls = !!localQuery || !!query;
return (
<div className="w-80 sm:w-96">
@@ -131,70 +142,102 @@ export function SearchBar({
focus:outline-none"
/>
{/* Right-side controls — all inside the unified bar */}
<div className="flex items-center gap-0.5 pr-2 flex-shrink-0">
{/* Match count badge */}
{query && (
<div className={`
flex items-center gap-1 px-2 py-0.5 rounded-md text-caption tabular-nums whitespace-nowrap mr-0.5
${hasNoResults
? "text-red-400 bg-red-500/10"
: "text-foreground-muted bg-surface-overlay/50"
}
`}>
{hasNoResults ? (
<span>No results</span>
) : (
<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"
{/* Right-side controls — only rendered when there's content */}
{showControls ? (
<div className="flex items-center gap-1 pr-2.5 flex-shrink-0 animate-fade-in">
{/* Match count badge */}
{query && (
<div
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
? "text-red-400 bg-red-500/10"
: "text-foreground-muted bg-surface-overlay/50"
}
`}
>
<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="p-1 rounded-md text-foreground-muted hover:text-foreground hover:bg-surface-overlay/60 transition-colors"
aria-label="Next match"
>
<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>
)}
{hasNoResults ? (
<span>No results</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>
)}
{/* Clear button or keyboard hint */}
{localQuery ? (
{/* Navigation arrows — only when there are results */}
{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
onClick={() => {
setLocalQuery("");
onQueryChange("");
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"
tabIndex={-1}
>
<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" />
</svg>
</button>
) : (
<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>
) : (
<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>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@@ -209,8 +209,8 @@ export function SessionViewer({
key={msg.uuid}
id={`msg-${msg.uuid}`}
data-msg-index={item.messageIndex}
className={`animate-fade-in ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={{ animationDelay: `${Math.min(idx * 20, 300)}ms`, animationFillMode: "backwards" }}
className={`${idx < 20 ? "animate-fade-in" : ""} ${isFocused ? "search-match-focused rounded-xl" : ""}`}
style={idx < 20 ? { animationDelay: `${idx * 20}ms`, animationFillMode: "backwards" } : undefined}
>
<MessageBubble
message={msg}

View File

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

View File

@@ -1,7 +1,40 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useEffect } from "react";
import type { MessageCategory, ParsedMessage } from "../lib/types";
import { 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 {
enabledCategories: Set<MessageCategory>;
toggleCategory: (cat: MessageCategory) => void;
@@ -20,13 +53,7 @@ interface FilterState {
export function useFilters(): FilterState {
const [enabledCategories, setEnabledCategories] = useState<
Set<MessageCategory>
>(() => {
const set = new Set(ALL_CATEGORIES);
for (const cat of DEFAULT_HIDDEN_CATEGORIES) {
set.delete(cat);
}
return set;
});
>(loadEnabledCategories);
const [searchQuery, setSearchQuery] = useState("");
const [redactedUuids, setRedactedUuids] = useState<Set<string>>(new Set());
@@ -34,7 +61,28 @@ export function useFilters(): FilterState {
Set<string>
>(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) => {
setEnabledCategories((prev) => {

View File

@@ -9,6 +9,7 @@ interface SessionState {
sessionLoading: boolean;
sessionError: string | null;
loadSessions: () => Promise<void>;
refreshSessions: () => Promise<void>;
loadSession: (id: string) => Promise<void>;
}
@@ -21,11 +22,12 @@ export function useSession(): SessionState {
const [sessionLoading, setSessionLoading] = useState(false);
const [sessionError, setSessionError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
const fetchSessions = useCallback(async (refresh = false) => {
setSessionsLoading(true);
setSessionsError(null);
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}`);
const data = await res.json();
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) => {
setSessionLoading(true);
setSessionError(null);
@@ -67,6 +72,7 @@ export function useSession(): SessionState {
sessionLoading,
sessionError,
loadSessions,
refreshSessions,
loadSession,
};
}

View File

@@ -552,3 +552,43 @@ mark.search-highlight {
@apply focus-visible:ring-red-500;
}
}
/* ═══════════════════════════════════════════════
Tooltip
═══════════════════════════════════════════════ */
.tooltip-popup {
pointer-events: none;
max-width: 280px;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-foreground);
background: var(--color-surface-overlay);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.1);
animation: tooltip-in 120ms cubic-bezier(0.16, 1, 0.3, 1);
white-space: normal;
}
.tooltip-popup[data-side="top"] {
transform-origin: bottom center;
translate: -50% -100%;
}
.tooltip-popup[data-side="bottom"] {
transform-origin: top center;
translate: -50% 0;
}
@keyframes tooltip-in {
from {
opacity: 0;
scale: 0.96;
}
to {
opacity: 1;
scale: 1;
}
}

View File

@@ -19,8 +19,11 @@ async function getCachedSessions(): Promise<SessionEntry[]> {
return cachedSessions;
}
sessionsRouter.get("/", async (_req, res) => {
sessionsRouter.get("/", async (req, res) => {
try {
if (req.query.refresh === "1") {
cacheTimestamp = 0;
}
const sessions = await getCachedSessions();
res.json({ sessions });
} catch (err) {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import {
redactSensitiveContent,
redactMessage,
redactString,
countSensitiveMessages,
} from "../../src/shared/sensitive-redactor.js";
import type { ParsedMessage } from "../../src/shared/types.js";
@@ -563,4 +564,78 @@ describe("sensitive-redactor", () => {
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);
});
});
});