commit 648fea8dcb41b5d20b5055832542ac7abc0a69c8 Author: nntrivi2001 Date: Wed Jul 29 17:07:45 2026 +0700 feat: Claude Code Monitor — lanes, pipelines and a merged workspace Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure. diff --git a/.agents/skills/mcp-maintainer/SKILL.md b/.agents/skills/mcp-maintainer/SKILL.md new file mode 100644 index 0000000..2a522dd --- /dev/null +++ b/.agents/skills/mcp-maintainer/SKILL.md @@ -0,0 +1,21 @@ +--- +name: mcp-maintainer +description: Operate and maintain the local MCP server for this repository. Use for MCP tool updates, policy-guard changes, host configuration, and MCP runtime troubleshooting. +--- + +# MCP Maintainer Skill + +## Workflow +- Confirm dashboard API availability (`/api/health`). +- Inspect affected MCP domain modules under `mcp/src/tools/domains/`. +- Preserve safety gates in `mcp/src/policy/tool-guards.ts`. +- Validate with `npm run mcp:typecheck` and `npm run mcp:build`. + +## Safety rules +- Keep loopback-only target checks enabled. +- Keep mutating and destructive tools behind explicit flags. +- Do not log protocol data to stdout. + +## References +- `references/tool-domain-map.md` +- `references/operations-runbook.md` diff --git a/.agents/skills/mcp-maintainer/agents/openai.yaml b/.agents/skills/mcp-maintainer/agents/openai.yaml new file mode 100644 index 0000000..5afeecf --- /dev/null +++ b/.agents/skills/mcp-maintainer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "MCP Maintainer" + short_description: "Maintain MCP tools, policy gates, and host integration." + default_prompt: "Use mcp-maintainer to update MCP tooling safely and verify runtime integrity." diff --git a/.agents/skills/mcp-maintainer/references/operations-runbook.md b/.agents/skills/mcp-maintainer/references/operations-runbook.md new file mode 100644 index 0000000..d55b47c --- /dev/null +++ b/.agents/skills/mcp-maintainer/references/operations-runbook.md @@ -0,0 +1,14 @@ +# MCP Operations Runbook + +## Modes +- Read-only: + - `MCP_DASHBOARD_ALLOW_MUTATIONS=false` + - `MCP_DASHBOARD_ALLOW_DESTRUCTIVE=false` +- Admin: + - Set mutations true for controlled maintenance operations. +- Destructive: + - Set both true and require `confirmation_token = CLEAR_ALL_DATA`. + +## Verification +- `npm run mcp:typecheck` +- `npm run mcp:build` diff --git a/.agents/skills/mcp-maintainer/references/tool-domain-map.md b/.agents/skills/mcp-maintainer/references/tool-domain-map.md new file mode 100644 index 0000000..97afc00 --- /dev/null +++ b/.agents/skills/mcp-maintainer/references/tool-domain-map.md @@ -0,0 +1,8 @@ +# MCP Tool Domain Map + +- `observability-tools.ts`: health, stats, analytics, snapshots, export. +- `session-tools.ts`: list/get/create/update sessions. +- `agent-tools.ts`: list/get/create/update agents. +- `event-tools.ts`: event listing and hook ingestion. +- `pricing-tools.ts`: pricing CRUD and cost calculations. +- `maintenance-tools.ts`: cleanup, reimport, reinstall hooks, destructive clear. diff --git a/.agents/skills/release-guard/SKILL.md b/.agents/skills/release-guard/SKILL.md new file mode 100644 index 0000000..274a626 --- /dev/null +++ b/.agents/skills/release-guard/SKILL.md @@ -0,0 +1,21 @@ +--- +name: release-guard +description: Run release-readiness checks for this repository. Use when validating docs, scripts, verification coverage, and operational safety before merge or release. +--- + +# Release Guard Skill + +## Workflow +- Check command consistency across docs and `package.json`. +- Verify architecture docs align with current code paths. +- Validate that safety controls are still documented and enforced. +- Report pass/fail with concrete file references. + +## Focus areas +- Hook flow and failure behavior. +- Session/agent lifecycle semantics. +- MCP safety gates and host setup instructions. +- Troubleshooting accuracy. + +## References +- `references/release-checklist.md` diff --git a/.agents/skills/release-guard/agents/openai.yaml b/.agents/skills/release-guard/agents/openai.yaml new file mode 100644 index 0000000..6925db8 --- /dev/null +++ b/.agents/skills/release-guard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Guard" + short_description: "Audit release readiness across code, docs, and safety controls." + default_prompt: "Use release-guard to audit this branch for release readiness and report concrete findings." diff --git a/.agents/skills/release-guard/references/release-checklist.md b/.agents/skills/release-guard/references/release-checklist.md new file mode 100644 index 0000000..a3b6520 --- /dev/null +++ b/.agents/skills/release-guard/references/release-checklist.md @@ -0,0 +1,7 @@ +# Release Checklist + +- Commands in docs exist in root `package.json`. +- Validation steps are documented for backend, frontend, and MCP. +- Behavior-changing diffs mention migration/compatibility impacts. +- Safety-sensitive operations remain guarded by explicit flags. +- Troubleshooting sections reflect the current architecture. diff --git a/.agents/skills/repo-onboarding/SKILL.md b/.agents/skills/repo-onboarding/SKILL.md new file mode 100644 index 0000000..91bb4f6 --- /dev/null +++ b/.agents/skills/repo-onboarding/SKILL.md @@ -0,0 +1,21 @@ +--- +name: repo-onboarding +description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning. +--- + +# Repo Onboarding Skill + +## Workflow +- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`. +- Determine target layer: `server/`, `client/`, `mcp/`, or docs. +- Identify the minimal file set needed for the task. +- Select verification commands before editing. + +## Verification defaults +- Backend: `npm run test:server` +- Frontend: `npm run test:client` +- MCP: `npm run mcp:typecheck` and `npm run mcp:build` + +## References +- `references/module-map.md` +- `references/verification-map.md` diff --git a/.agents/skills/repo-onboarding/agents/openai.yaml b/.agents/skills/repo-onboarding/agents/openai.yaml new file mode 100644 index 0000000..0938e09 --- /dev/null +++ b/.agents/skills/repo-onboarding/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Repo Onboarding" + short_description: "Map architecture, ownership, and verification strategy before coding." + default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task." diff --git a/.agents/skills/repo-onboarding/references/module-map.md b/.agents/skills/repo-onboarding/references/module-map.md new file mode 100644 index 0000000..14db1e4 --- /dev/null +++ b/.agents/skills/repo-onboarding/references/module-map.md @@ -0,0 +1,9 @@ +# Module Map + +- `server/index.js`: app startup and route mounting. +- `server/routes/*.js`: API contracts and route behavior. +- `server/db.js`: schema and statement layer. +- `server/websocket.js`: live update broadcast path. +- `client/src/pages/`: route-level UI. +- `client/src/components/`: reusable UI primitives. +- `mcp/src/tools/domains/`: MCP tool families. diff --git a/.agents/skills/repo-onboarding/references/verification-map.md b/.agents/skills/repo-onboarding/references/verification-map.md new file mode 100644 index 0000000..95ce1c8 --- /dev/null +++ b/.agents/skills/repo-onboarding/references/verification-map.md @@ -0,0 +1,11 @@ +# Verification Map + +- Backend changes: + - `npm run test:server` +- Frontend changes: + - `npm run test:client` +- MCP changes: + - `npm run mcp:typecheck` + - `npm run mcp:build` +- Docs-only changes: + - validate command consistency against root `package.json` diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..3647f8e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,72 @@ +{ + "name": "claude-code-agent-monitor-plugins", + "description": "Official plugin marketplace for Claude Code Agent Monitor — 10 plugins for analytics, cost guardrails, productivity, developer tools, AI insights, session forensics, workflow/fleet intelligence, reliability/SLOs, config & memory governance, and dashboard connectivity. Every plugin is powered by the local Agent Monitor API.", + "owner": { + "name": "smartgift", + "url": "https://git.smartgift.io.vn/Smartgift-AI" + }, + "homepage": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor", + "repository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor", + "plugins": [ + { + "name": "ccam-analytics", + "path": "plugins/ccam-analytics", + "description": "Deep analytics on Claude Code sessions — token usage (total_input/total_output/total_cache_read/total_cache_write with compaction baselines pre-summed), cost breakdowns via the pricing engine, usage trends over 365 days, cache efficiency, model mix, and productivity scoring.", + "tags": ["analytics", "tokens", "cost", "productivity"] + }, + { + "name": "ccam-cost-guard", + "path": "plugins/ccam-cost-guard", + "description": "Budget guardrails — set spend limits, forecast week/month-end cost from daily trends, surface cost-threshold alerts via the alert-rule API, and quantify model-routing savings.", + "tags": ["budget", "cost", "forecast", "alerts"] + }, + { + "name": "ccam-productivity", + "path": "plugins/ccam-productivity", + "description": "Productivity automation — daily standups, weekly/monthly reviews, sprint summaries, time-of-day focus analysis, and workflow optimization using the workflow intelligence API's 11 datasets.", + "tags": ["productivity", "reports", "workflows", "standup"] + }, + { + "name": "ccam-devtools", + "path": "plugins/ccam-devtools", + "description": "Developer tools for the Agent Monitor — session debugging with full event-chain inspection, data-integrity inspection, event tracing, transcript search, hook diagnostics, data export (JSON/CSV/Markdown), and system health checks.", + "tags": ["devtools", "debug", "diagnostics", "export"] + }, + { + "name": "ccam-insights", + "path": "plugins/ccam-insights", + "description": "AI-powered insights — pattern detection via workflow intelligence (tool flow transitions, agent co-occurrence), anomaly alerting, trend forecasting, regression watch, benchmarking, optimization recommendations, and session comparison.", + "tags": ["insights", "patterns", "anomaly", "optimization"] + }, + { + "name": "ccam-sessions", + "path": "plugins/ccam-sessions", + "description": "Session forensics — search by project/model/status, reconstruct event timelines, replay transcripts turn-by-turn, roll up activity per working directory, and review stale sessions before cleanup.", + "tags": ["sessions", "search", "transcript", "replay"] + }, + { + "name": "ccam-workflows", + "path": "plugins/ccam-workflows", + "description": "Multi-agent orchestration & fleet intelligence — map the subagent DAG, audit model delegation and effectiveness, report concurrency lanes, trace error propagation by depth, and review Workflow-tool fleet runs.", + "tags": ["workflows", "orchestration", "subagents", "fleet"] + }, + { + "name": "ccam-quality", + "path": "plugins/ccam-quality", + "description": "Reliability & SLOs — scan for API errors and tool failures, audit hook delivery health (PreToolUse/PostToolUse balance), track completion/error-rate SLOs with error budgets, and alert on reliability regressions.", + "tags": ["reliability", "errors", "slo", "quality"] + }, + { + "name": "ccam-config", + "path": "plugins/ccam-config", + "description": "Claude Code config & memory governance — audit skills/subagents/commands/MCP servers/hooks/settings via the Config Explorer API and curate the file-based memory store (CLAUDE.md + per-project auto-memory files).", + "tags": ["config", "memory", "governance", "mcp"] + }, + { + "name": "ccam-dashboard", + "path": "plugins/ccam-dashboard", + "description": "Dashboard connector — quick status checks, one-line metrics, live activity watch, endpoint probing, and MCP server integration for direct tool access to the Agent Monitor API.", + "tags": ["dashboard", "status", "mcp", "connector"] + } + ] +} diff --git a/.claude/agents/backend-reviewer.md b/.claude/agents/backend-reviewer.md new file mode 100644 index 0000000..00c7fae --- /dev/null +++ b/.claude/agents/backend-reviewer.md @@ -0,0 +1,20 @@ +--- +name: backend-reviewer +description: Review backend route and hook logic for regressions, data integrity risks, and missing tests. +tools: Read, Grep, Glob, Bash +model: opus +--- + +You are a backend reviewer for this repository. + +Focus on: +- Hook event lifecycle correctness. +- Session/agent state-machine regressions. +- API contract compatibility. +- Transaction and persistence correctness. +- Missing or weak verification coverage. + +Output: +- Prioritized findings. +- File references. +- Reproduction or validation notes. diff --git a/.claude/agents/frontend-reviewer.md b/.claude/agents/frontend-reviewer.md new file mode 100644 index 0000000..72b2e26 --- /dev/null +++ b/.claude/agents/frontend-reviewer.md @@ -0,0 +1,20 @@ +--- +name: frontend-reviewer +description: Review React UI changes for behavior regressions, state consistency, and UX breakage. +tools: Read, Grep, Glob, Bash +model: sonnet +--- + +You are a frontend reviewer for this repository. + +Focus on: +- Routing and navigation consistency. +- State updates from websocket and API responses. +- Loading/empty/error state correctness. +- Breaking visual or interaction regressions. +- Missing tests for changed UI behavior. + +Output: +- Prioritized findings. +- File references. +- Suggested verification steps. diff --git a/.claude/agents/mcp-reviewer.md b/.claude/agents/mcp-reviewer.md new file mode 100644 index 0000000..851406f --- /dev/null +++ b/.claude/agents/mcp-reviewer.md @@ -0,0 +1,20 @@ +--- +name: mcp-reviewer +description: Review MCP server changes for tool safety, schema quality, and host integration correctness. +tools: Read, Grep, Glob, Bash +model: opus +--- + +You are an MCP-focused reviewer for this repository. + +Focus on: +- Tool naming and schema strictness. +- Safety gate enforcement for mutating/destructive operations. +- API client timeout/retry/error handling. +- Stdio protocol safety (stderr-only logs). +- Host configuration and runbook documentation accuracy. + +Output: +- Prioritized findings. +- File references. +- Verification commands to run. diff --git a/.claude/rules/backend-node.md b/.claude/rules/backend-node.md new file mode 100644 index 0000000..13d3d8f --- /dev/null +++ b/.claude/rules/backend-node.md @@ -0,0 +1,14 @@ +--- +paths: + - "server/**/*.js" + - "scripts/**/*.js" +--- + +# Backend and Hook Rules + +- Keep API responses backward-compatible unless a breaking change is explicitly requested. +- Maintain deterministic, non-blocking hook ingestion behavior. +- Preserve transaction boundaries and data integrity in event processing logic. +- For route changes, validate input thoroughly and return structured errors. +- Prefer prepared-statement usage patterns already established in `server/db.js`. +- If touching status transitions, verify session and agent lifecycle state machines still make sense. diff --git a/.claude/rules/docs-markdown.md b/.claude/rules/docs-markdown.md new file mode 100644 index 0000000..64573b8 --- /dev/null +++ b/.claude/rules/docs-markdown.md @@ -0,0 +1,12 @@ +--- +paths: + - "**/*.md" +--- + +# Documentation Rules + +- Keep command examples executable and aligned with actual scripts. +- Use absolute or clearly rooted paths when discussing project files. +- When adding architecture claims, reflect current code behavior. +- Update all affected docs together (`README`, `ARCHITECTURE`, `SETUP`, `INSTALL`, `mcp/README`) when workflows change. +- Prefer concise sections and concrete troubleshooting steps. diff --git a/.claude/rules/file-headers.md b/.claude/rules/file-headers.md new file mode 100644 index 0000000..1c39615 --- /dev/null +++ b/.claude/rules/file-headers.md @@ -0,0 +1,6 @@ +# File Header Rules (binding for every coding agent) + +- Every applicable source file (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css` — excluding `node_modules/`, `dist/`, `data/`, minified/vendored, snapshots) MUST start with the header comment: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ `. +- Creating a new applicable file → write the header before any code (after the shebang in scripts). +- Editing a file that lacks the header → add it in the same change; if the edit changes the file's purpose, update the overview. +- Formats and the repo-wide audit script live in `.claude/skills/file-headers/` (`bash .claude/skills/file-headers/scripts/check-headers.sh` must exit 0). diff --git a/.claude/rules/frontend-react.md b/.claude/rules/frontend-react.md new file mode 100644 index 0000000..2085051 --- /dev/null +++ b/.claude/rules/frontend-react.md @@ -0,0 +1,14 @@ +--- +paths: + - "client/src/**/*.{ts,tsx,css}" + - "client/public/**/*.tsx" +--- + +# Frontend Rules + +- Preserve existing UI information hierarchy unless redesign is requested. +- Keep component props and API typing explicit; avoid implicit `any`. +- Match existing page/component patterns for loading, empty, and error states. +- When adding UI behavior, ensure it degrades safely if websocket updates are delayed. +- Keep routes consistent with current navigation model. +- Prefer focused UI diffs over broad stylistic rewrites. diff --git a/.claude/rules/mcp-typescript.md b/.claude/rules/mcp-typescript.md new file mode 100644 index 0000000..87fd924 --- /dev/null +++ b/.claude/rules/mcp-typescript.md @@ -0,0 +1,17 @@ +--- +paths: + - "mcp/src/**/*.ts" + - "mcp/package.json" + - "mcp/tsconfig.json" + - "mcp/.env.example" + - "mcp/README.md" +--- + +# MCP Server Rules + +- Keep MCP tool names stable and descriptive. +- Keep destructive operations behind explicit guardrails. +- Route all logs to stderr only; never write protocol logs to stdout. +- Keep tool input schemas strict and bounded. +- Preserve loopback-only API target enforcement unless security posture changes by request. +- Verify MCP with `npm run mcp:typecheck` and `npm run mcp:build` after code edits. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..18e7130 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run:*)", + "Bash(cd client:*)", + "Bash(git add:*)", + "Bash(git:*)", + "Bash(node --test server/lib/__tests__/transcript-cache.test.js)", + "Bash(node -e \"const r = require\\(''''./server/routes/hooks''''\\); console.log\\(''''router type:'''', typeof r\\); console.log\\(''''transcriptCache exists:'''', !!r.transcriptCache\\); console.log\\(''''transcriptCache has extract:'''', typeof r.transcriptCache?.extract\\);\")", + "Bash(node -e \"const r = require\\(''''./server/routes/hooks''''\\); console.log\\(''''router type:'''', typeof r\\); console.log\\(''''transcriptCache exists:'''', Boolean\\(r.transcriptCache\\)\\); console.log\\(''''transcriptCache has extract:'''', typeof \\(r.transcriptCache && r.transcriptCache.extract\\)\\);\")", + "Bash(node -e \"const { transcriptCache } = require\\(''./server/routes/hooks''\\); console.log\\(''transcriptCache exists:'', Boolean\\(transcriptCache\\)\\); console.log\\(''has extract:'', typeof \\(transcriptCache && transcriptCache.extract\\)\\); console.log\\(''has extractCompactions:'', typeof \\(transcriptCache && transcriptCache.extractCompactions\\)\\); console.log\\(''has invalidate:'', typeof \\(transcriptCache && transcriptCache.invalidate\\)\\); console.log\\(''has stats:'', typeof \\(transcriptCache && transcriptCache.stats\\)\\);\")", + "Bash(npx tsc:*)", + "Bash(npx --prefix client tsc --noEmit -p client/tsconfig.json)", + "Bash(node -e \":*)", + "Bash(wc:*)", + "Bash(grep -E \"\\\\.js$\")", + "Bash(npm install:*)", + "Bash(./node_modules/.bin/tsc --noEmit)", + "Bash(npx vite:*)", + "Bash(node -e \"const p = require\\(''./package.json''\\); console.log\\(''deps:'', Object.keys\\(p.dependencies || {}\\).length\\); console.log\\(Object.keys\\(p.dependencies || {}\\).join\\('', ''\\)\\)\")", + "Bash(npx prettier:*)", + "Bash(node scripts/seed.js)", + "Bash(lsof -ti:4820)", + "Bash(xargs kill:*)", + "Bash(lsof -ti:5173)", + "Bash(lsof -ti:5174)", + "Bash(curl -s http://localhost:4820/api/health)", + "Bash(node -e \"require\\('./server/lib/transcript-cache'\\); console.log\\('transcript-cache OK'\\)\")", + "Bash(python3:*)", + "Bash(node -c scripts/import-history.js)", + "Bash(node -e \"const m = require\\('./scripts/import-history'\\); console.log\\(Object.keys\\(m\\).sort\\(\\).join\\(', '\\)\\)\")", + "Bash(node -e \"const TC = require\\('./server/lib/transcript-cache'\\); const tc = new TC\\(\\); console.log\\('transcript-cache loaded OK, methods:', Object.getOwnPropertyNames\\(TC.prototype\\).filter\\(n => n !== 'constructor'\\).join\\(', '\\)\\)\")", + "Bash(node -e \"try { require\\('./server/routes/hooks'\\); } catch\\(e\\) { console.log\\('Expected error \\(db not available in isolation\\):', e.message.slice\\(0, 100\\)\\); }\")", + "Bash(node --check server/routes/hooks.js)", + "Bash(node --check server/lib/transcript-cache.js)", + "Bash(node -e ':*)", + "Bash(node -e \"require\\('./server/lib/transcript-cache'\\); require\\('./scripts/import-history'\\); console.log\\('All modules load OK'\\)\")", + "Bash(claude --version)", + "Bash(gh pr *)" + ] + } +} diff --git a/.claude/skills/debug-live-issue/SKILL.md b/.claude/skills/debug-live-issue/SKILL.md new file mode 100644 index 0000000..c35743b --- /dev/null +++ b/.claude/skills/debug-live-issue/SKILL.md @@ -0,0 +1,23 @@ +--- +name: debug-live-issue +description: Debug production-like issues in this repository with disciplined evidence gathering. Use when fixing failing workflows, regressions, flaky behavior, or data inconsistencies across hooks, API, DB, websocket, and UI. +--- + +# Debug Live Issue + +Use this workflow for incident-style debugging. + +## Steps +- Capture symptom, expected behavior, and reproducible path. +- Isolate subsystem first: hook ingestion, API route, DB state, websocket, or UI rendering. +- Reproduce with minimal surface area. +- Prove root cause before changing code. +- Apply minimal fix and re-verify. + +## Evidence standards +- Prefer direct logs, API responses, DB state checks, and deterministic repro steps. +- Avoid speculative fixes without root-cause evidence. +- If not fully reproducible, state uncertainty and strongest hypothesis. + +## References +- Investigation template: `references/investigation-template.md` diff --git a/.claude/skills/debug-live-issue/references/investigation-template.md b/.claude/skills/debug-live-issue/references/investigation-template.md new file mode 100644 index 0000000..89ee2c2 --- /dev/null +++ b/.claude/skills/debug-live-issue/references/investigation-template.md @@ -0,0 +1,32 @@ +# Investigation Template + +## Problem statement +- Symptom: +- Expected behavior: +- First observed: +- Scope: + +## Reproduction +- Preconditions: +- Exact steps: +- Actual result: + +## Evidence +- Logs: +- API responses: +- Database observations: +- Websocket behavior: +- UI behavior: + +## Root cause +- Confirmed cause: +- Why it happens: + +## Fix +- Change summary: +- Why this fix is minimal and safe: + +## Verification +- Commands/tests run: +- Manual verification: +- Residual risk: diff --git a/.claude/skills/file-headers/SKILL.md b/.claude/skills/file-headers/SKILL.md new file mode 100644 index 0000000..fd1143e --- /dev/null +++ b/.claude/skills/file-headers/SKILL.md @@ -0,0 +1,116 @@ +--- +name: file-headers +description: MANDATORY for every coding agent (Claude Code, Codex, or any other) on every change-set — every applicable source file the agent creates or updates MUST start with the project's copyright/authorship header (file overview + exact author line). Use automatically whenever writing a new file or editing an existing one; do not wait to be asked. Covers JS/TS/TSX/CJS/MJS, Python, shell, and CSS. Includes the audit script to verify repo-wide compliance. +--- + +# File Headers — Copyright Comment + File Overview + +Every applicable source file in this repository starts with a header comment +containing a **file overview** and the **exact author line**: + +``` +@author Nguyễn Ngọc Trí Vĩ +``` + +The name and email must be exactly as above — no variations, no substitutions, +no other names. This applies to **every coding agent** working in this repo +(Claude Code, Codex, or any other tool): when you **create** a new applicable +file, write the header first; when you **update** an existing applicable file +that is missing the header, add it as part of the same change. + +## Applicable files + +| Included | Excluded | +| -------- | -------- | +| `*.js`, `*.ts`, `*.tsx`, `*.cjs`, `*.mjs` | anything under `node_modules/`, `dist/`, `build/`, `data/` | +| `*.py`, `*.sh` | vendored/minified files (`*.min.js`) | +| `*.css` | generated files (they carry their own AUTO-GENERATED banner) | +| | snapshots (`__snapshots__/`), lockfiles, JSON/YAML/Markdown | + +## Header formats by file type + +**JS / TS / TSX — server & scripts style** (overview inline in `@file`): + +```js +/** + * @file One-to-few-sentence overview of what this file does and why it + * exists. Mention the key contracts or invariants the file owns. + * @author Nguyễn Ngọc Trí Vĩ + */ +``` + +**JS / TS / TSX — client style** (`@file` name + `@description` overview), used +under `client/src/`: + +```ts +/** + * @file ComponentName.tsx + * @description What the component/module renders or provides and how it fits + * into the app. + * @author Nguyễn Ngọc Trí Vĩ + */ +``` + +**CSS** (same block-comment shape as `client/src/index.css`): + +```css +/** + * @file file.css + * @description What these styles cover. + * @author Nguyễn Ngọc Trí Vĩ + */ +``` + +**Shell** (`#` block right after the shebang; existing overview comments count — +just make sure the `@author` line is in the block): + +```bash +#!/usr/bin/env bash +# script-name.sh — what the script does, one to few lines. +# @author Nguyễn Ngọc Trí Vĩ +``` + +**Python** (inside the module docstring): + +```python +""" +module.py — what the module does. + +@author Nguyễn Ngọc Trí Vĩ +""" +``` + +## Rules + +1. **New file → header first.** Any applicable file you create starts with the + header before any code (after the shebang for scripts). +2. **Touched file missing header → add it.** If you edit a file that lacks the + header, add one in the same commit. Write a real overview — describe what + the file actually does; never a placeholder like "TODO" or "utility file". +3. **Exact author line.** `@author Nguyễn Ngọc Trí Vĩ ` — + byte-exact, in every file type (shell and Python use it inside `#` / docstring + comments). +4. **Don't churn existing headers.** If a file already has a compliant header, + leave it alone unless the file's purpose changed (then update the overview). +5. **Overviews must stay truthful.** When an edit changes what a file does, + update its `@file`/`@description` overview in the same change. + +## Audit + +Run the bundled checker to list any applicable file missing the header: + +```bash +bash .claude/skills/file-headers/scripts/check-headers.sh +``` + +Exit code `0` = fully compliant; `1` = the printed files are missing headers. +Run it before finishing any change-set that adds files, and during reviews. + +On every pull request, GitHub Actions runs +`.claude/skills/file-headers/scripts/check-headers-pr.sh` against only the +files changed in the PR diff (added, copied, renamed, or modified). Test locally +before pushing: + +```bash +bash .claude/skills/file-headers/scripts/check-headers-pr.sh origin/master HEAD +``` diff --git a/.claude/skills/file-headers/scripts/check-headers-pr.sh b/.claude/skills/file-headers/scripts/check-headers-pr.sh new file mode 100755 index 0000000..7dd6c98 --- /dev/null +++ b/.claude/skills/file-headers/scripts/check-headers-pr.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# check-headers-pr.sh — verify that applicable files touched in a git diff carry +# the mandatory copyright/authorship header. Used locally before opening a PR and +# by the file-headers GitHub Actions workflow on every pull request. +# +# Usage: +# check-headers-pr.sh [ ] +# +# When omitted, compares the current branch against origin/master (or master). +# @author Nguyễn Ngọc Trí Vĩ + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +AUTHOR_MARK="@author Nguyễn Ngọc Trí Vĩ" +AUTHOR_EMAIL="vinnt@smartgift.vn" + +usage() { + cat <<'EOF' +Usage: check-headers-pr.sh [ ] + +Checks only added/copied/renamed/modified files in the diff between base and +head. Applicable extensions: .js .ts .tsx .cjs .mjs .py .sh .css + +The author line must appear in the file header using the syntax for that type: + JS/TS/CSS — block comment (/** ... @author ... */) + Shell — # comment after the shebang + Python — module docstring (""" ... @author ... """) +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +BASE_SHA="${1:-}" +HEAD_SHA="${2:-}" + +cd "$ROOT" + +if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then + if git show-ref --verify --quiet refs/remotes/origin/master; then + BASE_SHA="$(git merge-base HEAD origin/master)" + elif git show-ref --verify --quiet refs/heads/master; then + BASE_SHA="$(git merge-base HEAD master)" + else + echo "error: could not resolve base ref; pass " >&2 + exit 1 + fi + HEAD_SHA="HEAD" +fi + +# Return 0 when the path is subject to the header policy (keep in sync with +# check-headers.sh exclusions). +is_applicable_file() { + local f="$1" + + case "$f" in + */node_modules/*|*/dist/*|*/build/*|*/.git/*|*/data/*) + return 1 + ;; + */monitoring/.bin/*|*/monitoring/.data/*|*/__snapshots__/*) + return 1 + ;; + esac + + case "$f" in + esac + + case "$f" in + *.js|*.ts|*.tsx|*.cjs|*.mjs|*.py|*.sh|*.css) + return 0 + ;; + *) + return 1 + ;; + esac +} + +# Best-effort hint for contributors when a file fails. +header_hint_for() { + local f="$1" + case "$f" in + *.py) + echo ' expected: module docstring with @author Nguyễn Ngọc Trí Vĩ ' + ;; + *.sh) + echo ' expected: # block after shebang with @author Nguyễn Ngọc Trí Vĩ ' + ;; + *.css) + echo ' expected: /** @file ... @author Nguyễn Ngọc Trí Vĩ */' + ;; + *) + echo ' expected: /** @file ... @author Nguyễn Ngọc Trí Vĩ */' + ;; + esac +} + +# Require the exact author mark anywhere in the file (same rule as check-headers.sh). +has_author_header() { + local f="$1" + grep -q "$AUTHOR_MARK" "$f" && grep -q "$AUTHOR_EMAIL" "$f" +} + +BASE_SHORT="$(git rev-parse --short "${BASE_SHA}" 2>/dev/null || echo "${BASE_SHA}")" +HEAD_SHORT="$(git rev-parse --short "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}")" + +checked=0 +missing=0 +skipped=0 + +echo "Checking authorship headers for files changed between ${BASE_SHORT}..${HEAD_SHORT}" + +while IFS= read -r f; do + [[ -z "$f" ]] && continue + + if ! is_applicable_file "$f"; then + skipped=$((skipped + 1)) + continue + fi + + if [[ ! -f "$f" ]]; then + echo "SKIP (missing on disk): $f" + skipped=$((skipped + 1)) + continue + fi + + checked=$((checked + 1)) + + if ! has_author_header "$f"; then + echo "MISSING HEADER: $f" + header_hint_for "$f" + missing=1 + fi +done < <(git diff --name-only --diff-filter=ACMR "${BASE_SHA}" "${HEAD_SHA}") + +if [[ "$checked" -eq 0 ]]; then + echo "✔ No applicable source files changed in this diff (skipped ${skipped} path(s))." + exit 0 +fi + +if [[ "$missing" -eq 0 ]]; then + echo "✔ All ${checked} applicable changed file(s) carry the authorship header." + exit 0 +fi + +echo +echo "Add the project header to each file listed above." +echo "See .claude/skills/file-headers/SKILL.md for per-type examples." +exit 1 diff --git a/.claude/skills/file-headers/scripts/check-headers.sh b/.claude/skills/file-headers/scripts/check-headers.sh new file mode 100755 index 0000000..18e3f9a --- /dev/null +++ b/.claude/skills/file-headers/scripts/check-headers.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# check-headers.sh — audit the repo for applicable source files missing the +# mandatory copyright/authorship header (see .claude/skills/file-headers). +# Prints each non-compliant file; exits 0 when fully compliant, 1 otherwise. +# @author Nguyễn Ngọc Trí Vĩ + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +AUTHOR_MARK="@author Nguyễn Ngọc Trí Vĩ" + +missing=0 +while IFS= read -r f; do + if ! grep -q "$AUTHOR_MARK" "$f"; then + echo "MISSING HEADER: ${f#"$ROOT"/}" + missing=1 + fi +done < <( + find "$ROOT" \ + \( -name node_modules -o -name dist -o -name build -o -name .git \ + -o -path "$ROOT/data" -o -path "$ROOT/monitoring/.bin" \ + -o -path "$ROOT/monitoring/.data" -o -name "__snapshots__" \) -prune -o \ + -type f \( -name "*.js" -o -name "*.ts" -o -name "*.tsx" -o -name "*.cjs" \ + -o -name "*.mjs" -o -name "*.py" -o -name "*.sh" -o -name "*.css" \) \ + ! -name "*.min.js" -print +) + +if [ "$missing" -eq 0 ]; then + echo "✔ All applicable files carry the authorship header." +fi +exit "$missing" diff --git a/.claude/skills/mcp-operations/SKILL.md b/.claude/skills/mcp-operations/SKILL.md new file mode 100644 index 0000000..c668df3 --- /dev/null +++ b/.claude/skills/mcp-operations/SKILL.md @@ -0,0 +1,27 @@ +--- +name: mcp-operations +description: Operate and maintain the local MCP server for this project. Use when creating MCP host config, troubleshooting tool connectivity, modifying tool domains, or adjusting safety policy flags. +--- + +# MCP Operations + +Use this skill whenever work touches `mcp/` behavior or MCP host integration. + +## Core workflow +- Confirm dashboard API is running (`/api/health`). +- Confirm MCP server build status. +- Validate env flags for mutation/destructive modes. +- Verify host configuration path and command. + +## Safe operations policy +- Default to read-only mode (`MCP_DASHBOARD_ALLOW_MUTATIONS=false`). +- Enable mutations only for explicit admin tasks. +- Enable destructive mode only transiently and require explicit confirmation token. + +## Required verification for code changes +- `npm run mcp:typecheck` +- `npm run mcp:build` + +## References +- Host config examples: `references/host-config.md` +- Operations runbook: `references/runbook.md` diff --git a/.claude/skills/mcp-operations/references/host-config.md b/.claude/skills/mcp-operations/references/host-config.md new file mode 100644 index 0000000..96fe3ca --- /dev/null +++ b/.claude/skills/mcp-operations/references/host-config.md @@ -0,0 +1,18 @@ +# MCP Host Config + +## Command +- `node` + +## Args +- Absolute path to `mcp/build/index.js` + +## Example env +- `MCP_DASHBOARD_BASE_URL=http://127.0.0.1:4820` +- `MCP_DASHBOARD_ALLOW_MUTATIONS=false` +- `MCP_DASHBOARD_ALLOW_DESTRUCTIVE=false` +- `MCP_LOG_LEVEL=info` + +## Common mistakes +- Relative path to MCP build entry. +- Dashboard not running while MCP starts. +- Mutating tools used while mutation flag is false. diff --git a/.claude/skills/mcp-operations/references/runbook.md b/.claude/skills/mcp-operations/references/runbook.md new file mode 100644 index 0000000..45d51ff --- /dev/null +++ b/.claude/skills/mcp-operations/references/runbook.md @@ -0,0 +1,19 @@ +# MCP Runbook + +## Read-only daily mode +- Keep both mutation flags false. +- Use read tools for observability and reporting. + +## Admin mode +- Set `MCP_DASHBOARD_ALLOW_MUTATIONS=true`. +- Run maintenance/pricing operations. +- Reset mutation flag to false after completion. + +## Destructive mode +- Set both mutation and destructive flags true. +- Execute destructive command only with explicit confirmation token. +- Immediately disable destructive flag after operation. + +## Verification commands +- `npm run mcp:typecheck` +- `npm run mcp:build` diff --git a/.claude/skills/repo-onboarding/SKILL.md b/.claude/skills/repo-onboarding/SKILL.md new file mode 100644 index 0000000..40f71cd --- /dev/null +++ b/.claude/skills/repo-onboarding/SKILL.md @@ -0,0 +1,26 @@ +--- +name: repo-onboarding +description: Onboard quickly to this repository. Use when asked to understand architecture, locate ownership, choose the right module, or identify the correct commands and verification strategy before coding. +--- + +# Repo Onboarding + +Use this workflow when a task begins with discovery. + +## Steps +- Read `README.md` and `ARCHITECTURE.md` for system-level context. +- Identify target layer: + - `server/` for API, hooks, DB, websocket + - `client/` for UI and routing + - `mcp/` for local MCP tools and policy gates +- Select the smallest set of files required to answer the task. +- Confirm verification commands before implementation. + +## Verification defaults +- Backend: `npm run test:server` +- Frontend: `npm run test:client` +- MCP: `npm run mcp:typecheck` and `npm run mcp:build` + +## References +- Module map: `references/module-map.md` +- Command map: `references/command-map.md` diff --git a/.claude/skills/repo-onboarding/references/command-map.md b/.claude/skills/repo-onboarding/references/command-map.md new file mode 100644 index 0000000..5a93543 --- /dev/null +++ b/.claude/skills/repo-onboarding/references/command-map.md @@ -0,0 +1,25 @@ +# Command Map + +## Root commands +- `npm run setup` +- `npm run dev` +- `npm run build` +- `npm start` +- `npm run test:server` +- `npm run test:client` +- `npm run install-hooks` +- `npm run import-history` +- `npm run clear-data` + +## MCP helper commands (root) +- `npm run mcp:install` +- `npm run mcp:build` +- `npm run mcp:start` +- `npm run mcp:dev` +- `npm run mcp:typecheck` + +## Direct MCP package commands +- `npm --prefix mcp install` +- `npm --prefix mcp run build` +- `npm --prefix mcp run start` +- `npm --prefix mcp run typecheck` diff --git a/.claude/skills/repo-onboarding/references/module-map.md b/.claude/skills/repo-onboarding/references/module-map.md new file mode 100644 index 0000000..c30a48b --- /dev/null +++ b/.claude/skills/repo-onboarding/references/module-map.md @@ -0,0 +1,20 @@ +# Module Map + +## Backend +- `server/index.js`: app composition, startup behavior, periodic maintenance. +- `server/db.js`: schema and prepared statement ownership. +- `server/routes/*.js`: endpoint contracts by domain. +- `server/websocket.js`: WS lifecycle and broadcast behavior. + +## Frontend +- `client/src/pages/`: route-level screens. +- `client/src/components/`: reusable UI building blocks. +- `client/src/lib/api.ts`: client API access patterns. +- `client/src/hooks/useWebSocket.ts`: live update pipeline. + +## MCP +- `mcp/src/index.ts`: runtime entrypoint. +- `mcp/src/server.ts`: MCP assembly. +- `mcp/src/tools/domains/`: domain tool registration. +- `mcp/src/clients/dashboard-api-client.ts`: resilient API bridge. +- `mcp/src/policy/tool-guards.ts`: mutation/destructive gates. diff --git a/.claude/skills/ship-feature/SKILL.md b/.claude/skills/ship-feature/SKILL.md new file mode 100644 index 0000000..9b0be68 --- /dev/null +++ b/.claude/skills/ship-feature/SKILL.md @@ -0,0 +1,28 @@ +--- +name: ship-feature +description: Implement a feature safely end-to-end in this repository. Use when adding or changing functionality across backend, frontend, or MCP with required verification and documentation updates. +--- + +# Ship Feature + +Use this workflow for medium or large implementation tasks. + +## Steps +- Explore impacted modules first. +- Write a short implementation plan before editing. +- Implement smallest coherent diff that satisfies requirements. +- Run relevant verification commands. +- Update docs when commands, paths, architecture, or behavior changed. + +## Required quality checks +- Keep API and websocket contracts stable unless intentionally changed. +- Keep destructive operations behind explicit guardrails. +- Avoid broad refactors in feature tickets unless requested. + +## Finish checklist +- Tests/build/typecheck completed or explicitly reported as not run. +- Changed file set is scoped and intentional. +- User-facing docs updated if behavior changed. + +## References +- Checklist template: `references/feature-checklist.md` diff --git a/.claude/skills/ship-feature/references/feature-checklist.md b/.claude/skills/ship-feature/references/feature-checklist.md new file mode 100644 index 0000000..43d0012 --- /dev/null +++ b/.claude/skills/ship-feature/references/feature-checklist.md @@ -0,0 +1,22 @@ +# Feature Checklist + +- Scope + - Problem and success criteria are explicit. + - Impacted layers identified (server/client/mcp/docs/scripts). + +- Implementation + - Input validation and error handling are explicit. + - Existing behavior preserved where not in scope. + - Safety controls preserved. + +- Verification + - Backend: `npm run test:server` when backend changed. + - Frontend: `npm run test:client` when UI changed. + - MCP: `npm run mcp:typecheck` + `npm run mcp:build` when MCP changed. + +- Documentation + - `README.md`, `ARCHITECTURE.md`, `SETUP.md`, `INSTALL.md`, `mcp/README.md` updated as needed. + - Commands in docs match `package.json`. + +- Delivery + - Known risks and unrun checks are clearly stated. diff --git a/.claude/skills/update-project-docs/SKILL.md b/.claude/skills/update-project-docs/SKILL.md new file mode 100644 index 0000000..99cbfea --- /dev/null +++ b/.claude/skills/update-project-docs/SKILL.md @@ -0,0 +1,63 @@ +--- +name: update-project-docs +description: MANDATORY for every coding agent (Claude Code, Codex, or any other) — keep this repository's documentation in sync after any change to behavior, configuration, interfaces, events, schema, or features. Use automatically (without being asked) at the end of ANY change-set that adds or alters an env var, event type, hook behavior, session/agent state transition, API route or response shape, DB schema, WebSocket message, MCP tool, CLI command, or user-facing feature — and whenever the user asks to "update the docs / README / architecture". Knows the full doc surface (README, ARCHITECTURE, server/client READMEs, docs/*) and which docs each kind of change touches. +--- + +# Update Project Docs + +This repository keeps a large doc set and docs drift silently, because one change often belongs in several files at once. This skill encodes **which docs exist, which change-types touch which docs, and how to propagate consistently**. This build ships English only — the translated READMEs, the wiki and the root landing page were removed; do not recreate them. + +Authoritative inventory with exact section anchors lives in [`references/doc-map.md`](references/doc-map.md) — read it when deciding where a specific change lands. The repo rule [`.claude/rules/docs-markdown.md`](../../rules/docs-markdown.md) ("update all affected docs together") is binding. + +## When to update (including without being asked) + +Update docs **in the same change-set (PR/commit) as the code**, before claiming done — do not wait for the user to ask — whenever the change is observable from outside the module: + +- **New/changed env var** → every env-var table + `.env.example`. +- **New event type** (e.g. an `events.event_type` value) → every event-type list/table. +- **New/changed hook behavior or session/agent state transition** → hook docs + every state-machine diagram. +- **New/changed API route or response shape** → API docs + route tables + OpenAPI. +- **DB schema change** (table/column/index) → database docs + ERD. +- **New WebSocket message type** → client/server WS docs. +- **New MCP tool** → MCP docs. +- **New CLI command / script / renamed file referenced in docs** → command lists + onboarding guides. +- **New user-facing feature / page / background service** → feature tables + architecture. + +**Do NOT** auto-update for: pure internal refactors with no observable/interface/config change, test-only changes, comment/typo fixes, or work the user explicitly scoped as "no docs". When unsure whether a change is observable, check the mapping below; if it touches any row, update. + +## Change → docs mapping + +| Change type | Docs to update | +|---|---| +| **Env var** | `README.md` (env table), `ARCHITECTURE.md` (inline), `server/README.md`, `.env.example` | +| **Event type** | `README.md`+VN+CN (hook-event table), `ARCHITECTURE.md` (Event types line), `docs/PLUGINS.md`, + i18n, `docs/DATABASE.md` (if it enumerates types) | +| **Hook behavior / state transition** | `docs/HOOKS.md`, state-machine **mermaid** diagrams in `README.md`+VN+CN + `server/README.md` + `docs/DATABASE.md` + , `ARCHITECTURE.md` (hooks.js row) | +| **API route / response** | `docs/API.md`, `server/README.md` (routes), `ARCHITECTURE.md` (routes row), `server/openapi*.js` (code) | +| **DB schema** | `docs/DATABASE.md`, `ARCHITECTURE.md` (ERD/schema) | +| **WebSocket message** | `client/README.md` (Event Types), `server/README.md`, | +| **MCP tool** | `mcp/README.md`, `docs/MCP.md` | +| **Feature / page / background service** | `README.md` (feature table + data-flow list), `ARCHITECTURE.md` (module table), `server/README.md` or `client/README.md` | +| **CLI command / script** | `README.md` commands, `CLAUDE.md` / `AGENTS.md`, `INSTALL.md` / `SETUP.md` | +| **New language** | `docs/I18N.md`, `client/src/i18n/locales//*`, `client/src/i18n/index.ts` (add to `supportedLngs` AND the `resources` map) | + +## Procedure + +1. **Classify** the change against the table above. A change can hit multiple rows (a new feature with a new env var hits both). +2. **Write the canonical English version first** — usually `README.md` and/or `ARCHITECTURE.md`. Get the wording right there; it anchors everything else. +6. **Area READMEs / docs/**: update `server/README.md`, `client/README.md`, and the relevant `docs/*.md` per the mapping. +7. **Diagrams**: when a state transition changes, edit every mermaid `stateDiagram-v2` block that models it (they are duplicated across README, server/README and docs/DATABASE). Keep transition labels consistent. + +## Verify (do not skip) + +- **Coverage**: run `scripts/doc-coverage.sh [...]` (e.g. the new env var / event type / identifier) and confirm every doc the mapping flags shows a HIT. The matrix is advisory — not every term belongs in every file — but a flagged doc reading `0` is a miss to fix. +- **Tables**: markdown tables stay pipe-balanced (header column count == every row). +- **Mermaid**: each edited block still parses (valid `source --> target: label`). +- **i18n**: every new English string has a `vi` entry in `client/src/i18n/locales/vi/`. +- **Format/tests**: run `npm run format` (or `prettier --check` on touched files); for any code touched, run the verification from `CLAUDE.md` (`npm run test:server` / `test:client` / `mcp:typecheck`). +- State exactly which docs were updated and which were intentionally skipped (with reason), mirroring the repo's verification policy. + +## Tips + +- The fastest way to find where something already lives: `grep -n "" ` (e.g. grep an adjacent env var to find the env table). `references/doc-map.md` lists the stable anchors per file. +- Parallelize translations + HTML across subagents when the change is large, but write the canonical English edit yourself first so the translations have a faithful source. +- One language/area per subagent keeps edits reviewable and tables un-corrupted. diff --git a/.claude/skills/update-project-docs/references/doc-map.md b/.claude/skills/update-project-docs/references/doc-map.md new file mode 100644 index 0000000..16ad250 --- /dev/null +++ b/.claude/skills/update-project-docs/references/doc-map.md @@ -0,0 +1,65 @@ +# Documentation Map + +Authoritative inventory of this repository's documentation surface: every doc that must be kept in sync, what each contains, and the stable anchors to grep for when placing an edit. Section line numbers drift — grep the anchor strings, don't trust line numbers. + +## Tier 1 — primary, always consider + +### `README.md` (English, canonical) +The source of truth most other docs mirror. Key sections: +- **Feature table** — rows like `**Kanban Board**`, `**Transcript Cache**`, `**Pre-Existing Session Detection**`, `**Continuous Project Sync**`. Grep a neighboring row label. +- **Data-flow numbered list** — bullets describing hook ingestion, the watchdog, periodic sweep, continuous sync. Grep `Error detection watchdog` / `periodic server sweep`. +- **Agent State Machine** + **Session State Machine** — two `mermaid stateDiagram-v2` blocks. Grep `stateDiagram-v2`. +- **Hook Events table** — `| Hook Type | Trigger | Dashboard Action |`. Lists `SessionStart`…`SessionEnd`, plus synthetic `Compaction`, `APIError`, `TurnDuration`, `ToolError`, `Interrupted`. Grep `## Hook Events`. +- **Configuration / Environment Variables table** — `| Environment Variable | Default | Description |`. Grep `DASHBOARD_PORT` or `DASHBOARD_HOST`. + +### Translations + +This build ships English only. `README-VN.md`, `README-CN.md` and `README-KO.md` were removed, as were the `zh` and `ko` UI locales — do not recreate them. +Standalone full translations of `README.md`. **Every** README change must be mirrored here at the corresponding section. Conventions: +- Keep in English/code: identifiers, env-var names, event-type names, `awaiting_input_since`, `pendingInterrupt`, "watchdog", `fs.watch`, model IDs, mermaid transition labels. +- Translate prose. "Waiting" → **Đang chờ** (vi) / **等待中** (zh) / **대기 중** (ko). "watchdog" often kept; in zh sometimes 看门狗. + +### `ARCHITECTURE.md` +- **Module responsibility table** — one row per source file (`scripts/import-history.js`, `lib/transcript-cache.js`, `routes/hooks.js`, `server/index.js`, …). Update the row whose file you changed. Grep the file path. +- **Data-flow + sequence diagrams**, **state machines**, **Continuous background sync** prose block (grep `Continuous background sync`). +- **Event types line** — grep `| Event types |`. +- **ERD / schema** mermaid + `event_type "PreToolUse|PostToolUse|Stop|etc"`. + +### `server/README.md` +Backend reference: routes table, **Error Detection Watchdog** / **User-Interrupt (Esc) Recovery** / **Continuous Project Sync** sections, Agent/Session lifecycle mermaid diagrams, Environment Variables bash block under `## Deployment`. Update for any backend behavior, route, state, env var, or background service. + +### `client/README.md` +Frontend reference: component list, **Event Types** table (WebSocket broadcast message types like `session_created`, `agent_updated`), session/agent status TypeScript unions. Update for new WS message types or client-facing behavior. NOT needed for server-only changes the UI already renders generically. + +### `docs/HOOKS.md` +Per-hook deep reference (`### 1. SessionStart` … `### 8. SessionEnd`), the `awaiting_input_since` overlay rules, the "User interrupts (Esc) — no hook fires" section, transcript-derived sync. Update for any hook semantics or state behavior. + +### `docs/DATABASE.md` +Schema reference: `sessions` / `agents` / `events` tables, column docs, status CHECK constraints, lifecycle mermaid diagrams. Update for schema or state-machine changes. + +### `docs/API.md` +REST API reference (endpoints, params, example responses). Update for route/response changes. Pair with `server/openapi*.js` (code, not docs). + +### `docs/PLUGINS.md` +Plugin/marketplace docs incl. an **Event Types** enumeration line — keep it in sync with the canonical event-type list. + +### `docs/MCP.md` + `mcp/README.md` +MCP server + tool reference. Update for new/changed MCP tools. + +### `docs/I18N.md` +i18n architecture: **Supported languages** list, `supportedLngs`, the 15 namespaces. Update when adding a language or namespace. Client UI strings live in `client/src/i18n/locales/{en,zh,vi}/*.json` (code). + +## Tier 3 — situational + +- `.env.example` — every env var belongs here with a sane default + comment. +- `INSTALL.md`, `SETUP.md`, `DEPLOYMENT.md`, `docs/DEPLOYMENT.md` — install/run/deploy commands. +- `CLAUDE.md`, `AGENTS.md` — agent working guides; update when commands, file locations, or workflows change. +- `docs/README.md` — docs index; add a link when a new `docs/*.md` is created. +- `desktop/README.md`, `vscode-extension/README.md`, `statusline/README.md` — surface-specific; update only when that surface changes. + +## Consistency invariants + +- The **event-type set** must match across: `README` hook table, `ARCHITECTURE` Event types line, `docs/PLUGINS.md`. When adding one, grep the existing set (e.g. `TurnDuration`) across all and add everywhere it appears. +- **Env-var set** must match across: README tables, `server/README.md`, `.env.example`, and any inline `ARCHITECTURE` mention. +- **State-machine diagrams** are duplicated across README, `server/README.md` and `docs/DATABASE.md`. A transition change touches all of them. +- Run `scripts/doc-coverage.sh ` to confirm a new identifier/var/event reached every doc that should mention it. diff --git a/.claude/skills/update-project-docs/scripts/doc-coverage.sh b/.claude/skills/update-project-docs/scripts/doc-coverage.sh new file mode 100755 index 0000000..ed7e5fc --- /dev/null +++ b/.claude/skills/update-project-docs/scripts/doc-coverage.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# doc-coverage.sh — verify that one or more terms (a new env var, event type, +# route, identifier, feature name, …) are documented across this repo's +# canonical doc surface. Prints a HIT/miss matrix so a docs update can be +# checked for "full coverage" before finishing. +# +# Usage: +# .claude/skills/update-project-docs/scripts/doc-coverage.sh DASHBOARD_SESSION_SYNC_MS +# .claude/skills/update-project-docs/scripts/doc-coverage.sh Interrupted pendingInterrupt +# +# Run from the repo root. Exit code is non-zero if any term is missing from a +# doc that the change-type mapping (see references/doc-map.md) says it belongs +# in — but treat the matrix as advisory: not every term belongs in every file. +# @author Nguyễn Ngọc Trí Vĩ + +set -u + +# The canonical doc set kept in sync. Translations + HTML + per-area READMEs. +DOCS=( + "README.md" + "ARCHITECTURE.md" + "server/README.md" + "client/README.md" + "docs/HOOKS.md" + "docs/DATABASE.md" + "docs/API.md" + "docs/PLUGINS.md" + "docs/MCP.md" + "mcp/README.md" + "docs/I18N.md" + ".env.example" +) + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [term2 ...]" >&2 + exit 2 +fi + +missing_any=0 +for term in "$@"; do + echo "── coverage for: $term ──────────────────────────────" + for doc in "${DOCS[@]}"; do + if [ ! -f "$doc" ]; then + printf " %-26s (absent)\n" "$doc" + continue + fi + n=$(grep -Fc -- "$term" "$doc" 2>/dev/null || true) + n=${n:-0} + if [ "$n" -gt 0 ]; then + printf " ✅ %-26s %s\n" "$doc" "$n" + else + printf " · %-26s 0\n" "$doc" + fi + done + echo +done + +exit $missing_any diff --git a/.codex/README.md b/.codex/README.md new file mode 100644 index 0000000..7ac3d3c --- /dev/null +++ b/.codex/README.md @@ -0,0 +1,29 @@ +# Codex Agent Setup + +This directory contains all project-scoped Codex extensions: + +- instruction baseline via root [`AGENTS.md`](../AGENTS.md) +- execution policy rules in [`rules/default.rules`](./rules/default.rules) +- custom subagent definitions in [`agents/`](./agents) +- reusable skills in [`skills/`](./skills) +- runtime configuration in [`config.toml`](./config.toml) + +## What Codex reads + +- `AGENTS.md` from repository root +- `.codex/config.toml` for runtime settings +- `.codex/agents/*.toml` for custom agents +- `.codex/skills/*/SKILL.md` for project skills +- `.codex/rules/*.rules` for execution policy + +## Included custom agents + +- `reviewer`: read-only, high-rigor review agent +- `implementer`: workspace-write implementation agent +- `release_auditor`: read-only release readiness checker + +## Included skills + +- `repo-onboarding` — architecture discovery and verification selection +- `mcp-maintainer` — MCP server operations and troubleshooting +- `release-guard` — release readiness checks diff --git a/.codex/agents/implementer.toml b/.codex/agents/implementer.toml new file mode 100644 index 0000000..cf539c9 --- /dev/null +++ b/.codex/agents/implementer.toml @@ -0,0 +1,12 @@ +name = "implementer" +description = "Execution-focused agent for contained feature and bug-fix implementation." +model = "gpt-5.3-codex-spark" +model_reasoning_effort = "medium" +sandbox_mode = "workspace-write" +developer_instructions = """ +Implement requested changes with minimal scope and strong validation. +Preserve existing contracts unless change is explicitly requested. +Run targeted verification for modified areas and report what was run. +Avoid unrelated refactors. +""" +nickname_candidates = ["Nova", "Forge", "Kite"] diff --git a/.codex/agents/release-auditor.toml b/.codex/agents/release-auditor.toml new file mode 100644 index 0000000..54f97da --- /dev/null +++ b/.codex/agents/release-auditor.toml @@ -0,0 +1,12 @@ +name = "release_auditor" +description = "Read-only release gate checker for docs, scripts, tests, and risk reporting." +model = "gpt-5.4-mini" +model_reasoning_effort = "medium" +sandbox_mode = "read-only" +developer_instructions = """ +Audit release readiness for this repository. +Check command consistency between docs and package scripts. +Look for missing verification, stale architecture notes, and risky behavior changes. +Produce a concise pass/fail summary with exact file references. +""" +nickname_candidates = ["Lumen", "Harbor", "Beacon"] diff --git a/.codex/agents/reviewer.toml b/.codex/agents/reviewer.toml new file mode 100644 index 0000000..7220c72 --- /dev/null +++ b/.codex/agents/reviewer.toml @@ -0,0 +1,12 @@ +name = "reviewer" +description = "Read-only reviewer focused on correctness, regressions, security, and missing tests." +model = "gpt-5.4" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Review like an owner. +Prioritize behavior regressions, correctness, security risks, and missing tests. +Lead with concrete findings and file references. +Avoid style-only suggestions unless they hide a functional risk. +""" +nickname_candidates = ["Atlas", "Delta", "Echo"] diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..3870900 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,7 @@ +project_doc_fallback_filenames = ["TEAM_GUIDE.md", ".agents.md"] +project_doc_max_bytes = 65536 + +[agents] +max_threads = 6 +max_depth = 1 +job_max_runtime_seconds = 1800 diff --git a/.codex/rules/default.rules b/.codex/rules/default.rules new file mode 100644 index 0000000..7ed570d --- /dev/null +++ b/.codex/rules/default.rules @@ -0,0 +1,71 @@ +# Default execution policy rules for this repository. + +# Safe, routine read-only git inspection can run with prompt. +prefix_rule( + pattern = ["git", ["status", "diff", "log", "show"]], + decision = "prompt", + justification = "Git inspection is allowed with approval.", + match = [ + "git status", + "git diff", + "git log --oneline -20", + "git show HEAD~1", + ], + not_match = [ + "git checkout -b feature/new-branch", + ], +) + +# Destructive reset-style operations are blocked. +prefix_rule( + pattern = ["git", "reset", "--hard"], + decision = "forbidden", + justification = "Hard reset is blocked to prevent data loss. Use explicit file edits or safe restore strategies.", + match = [ + "git reset --hard", + "git reset --hard HEAD~1", + ], + not_match = [ + "git reset --soft HEAD~1", + ], +) + +# Installing dependencies should always require approval. +prefix_rule( + pattern = ["npm", "install"], + decision = "prompt", + justification = "Dependency installation changes lockfiles and runtime behavior; require explicit approval.", + match = [ + "npm install", + "npm install some-package", + ], + not_match = [ + "npm run build", + ], +) + +# Potentially destructive filesystem deletes are blocked. +prefix_rule( + pattern = ["rm", "-rf"], + decision = "forbidden", + justification = "Recursive force deletion is blocked. Use targeted edits or safer deletion commands.", + match = [ + "rm -rf /tmp/test-folder", + ], + not_match = [ + "rm -r ./tmp", + ], +) + +# Network fetch commands should be reviewed each time. +prefix_rule( + pattern = ["curl"], + decision = "prompt", + justification = "Network access should be explicitly reviewed per command.", + match = [ + "curl https://example.com", + ], + not_match = [ + "cat README.md", + ], +) diff --git a/.codex/skills/mcp-maintainer/SKILL.md b/.codex/skills/mcp-maintainer/SKILL.md new file mode 100644 index 0000000..2a522dd --- /dev/null +++ b/.codex/skills/mcp-maintainer/SKILL.md @@ -0,0 +1,21 @@ +--- +name: mcp-maintainer +description: Operate and maintain the local MCP server for this repository. Use for MCP tool updates, policy-guard changes, host configuration, and MCP runtime troubleshooting. +--- + +# MCP Maintainer Skill + +## Workflow +- Confirm dashboard API availability (`/api/health`). +- Inspect affected MCP domain modules under `mcp/src/tools/domains/`. +- Preserve safety gates in `mcp/src/policy/tool-guards.ts`. +- Validate with `npm run mcp:typecheck` and `npm run mcp:build`. + +## Safety rules +- Keep loopback-only target checks enabled. +- Keep mutating and destructive tools behind explicit flags. +- Do not log protocol data to stdout. + +## References +- `references/tool-domain-map.md` +- `references/operations-runbook.md` diff --git a/.codex/skills/mcp-maintainer/agents/openai.yaml b/.codex/skills/mcp-maintainer/agents/openai.yaml new file mode 100644 index 0000000..5afeecf --- /dev/null +++ b/.codex/skills/mcp-maintainer/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "MCP Maintainer" + short_description: "Maintain MCP tools, policy gates, and host integration." + default_prompt: "Use mcp-maintainer to update MCP tooling safely and verify runtime integrity." diff --git a/.codex/skills/mcp-maintainer/references/operations-runbook.md b/.codex/skills/mcp-maintainer/references/operations-runbook.md new file mode 100644 index 0000000..d55b47c --- /dev/null +++ b/.codex/skills/mcp-maintainer/references/operations-runbook.md @@ -0,0 +1,14 @@ +# MCP Operations Runbook + +## Modes +- Read-only: + - `MCP_DASHBOARD_ALLOW_MUTATIONS=false` + - `MCP_DASHBOARD_ALLOW_DESTRUCTIVE=false` +- Admin: + - Set mutations true for controlled maintenance operations. +- Destructive: + - Set both true and require `confirmation_token = CLEAR_ALL_DATA`. + +## Verification +- `npm run mcp:typecheck` +- `npm run mcp:build` diff --git a/.codex/skills/mcp-maintainer/references/tool-domain-map.md b/.codex/skills/mcp-maintainer/references/tool-domain-map.md new file mode 100644 index 0000000..97afc00 --- /dev/null +++ b/.codex/skills/mcp-maintainer/references/tool-domain-map.md @@ -0,0 +1,8 @@ +# MCP Tool Domain Map + +- `observability-tools.ts`: health, stats, analytics, snapshots, export. +- `session-tools.ts`: list/get/create/update sessions. +- `agent-tools.ts`: list/get/create/update agents. +- `event-tools.ts`: event listing and hook ingestion. +- `pricing-tools.ts`: pricing CRUD and cost calculations. +- `maintenance-tools.ts`: cleanup, reimport, reinstall hooks, destructive clear. diff --git a/.codex/skills/release-guard/SKILL.md b/.codex/skills/release-guard/SKILL.md new file mode 100644 index 0000000..274a626 --- /dev/null +++ b/.codex/skills/release-guard/SKILL.md @@ -0,0 +1,21 @@ +--- +name: release-guard +description: Run release-readiness checks for this repository. Use when validating docs, scripts, verification coverage, and operational safety before merge or release. +--- + +# Release Guard Skill + +## Workflow +- Check command consistency across docs and `package.json`. +- Verify architecture docs align with current code paths. +- Validate that safety controls are still documented and enforced. +- Report pass/fail with concrete file references. + +## Focus areas +- Hook flow and failure behavior. +- Session/agent lifecycle semantics. +- MCP safety gates and host setup instructions. +- Troubleshooting accuracy. + +## References +- `references/release-checklist.md` diff --git a/.codex/skills/release-guard/agents/openai.yaml b/.codex/skills/release-guard/agents/openai.yaml new file mode 100644 index 0000000..6925db8 --- /dev/null +++ b/.codex/skills/release-guard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Guard" + short_description: "Audit release readiness across code, docs, and safety controls." + default_prompt: "Use release-guard to audit this branch for release readiness and report concrete findings." diff --git a/.codex/skills/release-guard/references/release-checklist.md b/.codex/skills/release-guard/references/release-checklist.md new file mode 100644 index 0000000..a3b6520 --- /dev/null +++ b/.codex/skills/release-guard/references/release-checklist.md @@ -0,0 +1,7 @@ +# Release Checklist + +- Commands in docs exist in root `package.json`. +- Validation steps are documented for backend, frontend, and MCP. +- Behavior-changing diffs mention migration/compatibility impacts. +- Safety-sensitive operations remain guarded by explicit flags. +- Troubleshooting sections reflect the current architecture. diff --git a/.codex/skills/repo-onboarding/SKILL.md b/.codex/skills/repo-onboarding/SKILL.md new file mode 100644 index 0000000..91bb4f6 --- /dev/null +++ b/.codex/skills/repo-onboarding/SKILL.md @@ -0,0 +1,21 @@ +--- +name: repo-onboarding +description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning. +--- + +# Repo Onboarding Skill + +## Workflow +- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`. +- Determine target layer: `server/`, `client/`, `mcp/`, or docs. +- Identify the minimal file set needed for the task. +- Select verification commands before editing. + +## Verification defaults +- Backend: `npm run test:server` +- Frontend: `npm run test:client` +- MCP: `npm run mcp:typecheck` and `npm run mcp:build` + +## References +- `references/module-map.md` +- `references/verification-map.md` diff --git a/.codex/skills/repo-onboarding/agents/openai.yaml b/.codex/skills/repo-onboarding/agents/openai.yaml new file mode 100644 index 0000000..0938e09 --- /dev/null +++ b/.codex/skills/repo-onboarding/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Repo Onboarding" + short_description: "Map architecture, ownership, and verification strategy before coding." + default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task." diff --git a/.codex/skills/repo-onboarding/references/module-map.md b/.codex/skills/repo-onboarding/references/module-map.md new file mode 100644 index 0000000..14db1e4 --- /dev/null +++ b/.codex/skills/repo-onboarding/references/module-map.md @@ -0,0 +1,9 @@ +# Module Map + +- `server/index.js`: app startup and route mounting. +- `server/routes/*.js`: API contracts and route behavior. +- `server/db.js`: schema and statement layer. +- `server/websocket.js`: live update broadcast path. +- `client/src/pages/`: route-level UI. +- `client/src/components/`: reusable UI primitives. +- `mcp/src/tools/domains/`: MCP tool families. diff --git a/.codex/skills/repo-onboarding/references/verification-map.md b/.codex/skills/repo-onboarding/references/verification-map.md new file mode 100644 index 0000000..95ce1c8 --- /dev/null +++ b/.codex/skills/repo-onboarding/references/verification-map.md @@ -0,0 +1,11 @@ +# Verification Map + +- Backend changes: + - `npm run test:server` +- Frontend changes: + - `npm run test:client` +- MCP changes: + - `npm run mcp:typecheck` + - `npm run mcp:build` +- Docs-only changes: + - validate command consistency against root `package.json` diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..00253c2 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,23 @@ +# Optional dev-container image for Claude Code Agent Monitor. +# Node 22 to match the production Dockerfile and the project's engines. +# Adds the native-addon toolchain (better-sqlite3 builds via node-gyp) plus +# Python 3 (statusline.py and helper scripts) and the sqlite3 CLI. +# +# This image is ONLY used by Dev Containers / Codespaces. Host-based development +# (npm run dev / npm start) is unaffected. +# +# Author: Nguyễn Ngọc Trí Vĩ +FROM mcr.microsoft.com/devcontainers/javascript-node:22 + +# node-gyp needs python3 + a C/C++ toolchain to compile better-sqlite3. +# python-is-python3 makes `python` resolve to python3 for node-gyp. +# sqlite3 is handy for inspecting the dashboard DB during development. +RUN export DEBIAN_FRONTEND=noninteractive \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + python3 \ + python-is-python3 \ + sqlite3 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..0a04a89 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,70 @@ +# Dev Container (optional) + +A ready-to-use, **opt-in** development environment for Claude Code Agent Monitor. +It is used **only** when you explicitly choose it — it changes nothing about +host-based development (`npm run dev` / `npm start` still work exactly as before). + +## When to use it + +Use it if you want a consistent, batteries-included toolchain without installing +Node, build tools, or Python on your machine — or if you're on a GitHub Codespace. + +## How to open it + +- **VS Code:** install the *Dev Containers* extension, then run + **"Dev Containers: Reopen in Container"** (Command Palette). +- **GitHub Codespaces:** *Code → Create codespace on this branch*. + +The first build runs `.devcontainer/post-create.sh`, which installs all workspace +dependencies (`npm run setup`) and builds the MCP server (`npm run mcp:install`, +`npm run mcp:build`). + +## What's inside + +| Component | Detail | +| ---------------- | ------------------------------------------------------------------- | +| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) | +| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles | +| Python | `python3` / `python` for `statusline.py` and helper scripts | +| sqlite3 CLI | inspect the dashboard DB during development | +| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) | +| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) | +| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind | + +## Everyday commands + +```bash +npm run dev # server on :4820 + Vite client on :5173 +npm start # production-style server (serves client/dist) +npm run test:server # node --test +npm run test:client # vitest +npm run test:mcp # MCP server tests +npm run openapi:yaml # regenerate openapi.yaml from the live spec +``` + +## Claude Code hooks are HOST-side (important — issue #193) + +Claude Code runs on your **host**, so its hooks must point at a handler path that +exists on the host. This container therefore: + +- does **not** bind-mount `~/.claude`, and +- does **not** install hooks — `scripts/install-hooks.js` **refuses to run inside + a container** (it would write a container-internal handler path into your host + settings and break every host hook with `MODULE_NOT_FOUND`). + +Install hooks **on your host** instead: + +```bash +npm run install-hooks # on the HOST +``` + +The host hook handler POSTs to `http://localhost:4820`, which this container +forwards — so a host-installed hook reaches the containerized dashboard. + +> Escape hatch: if you genuinely run Claude Code *inside* this same container, +> set `CCAM_ALLOW_CONTAINER_HOOKS=1` before `npm run install-hooks`. + +## Not supported in the container + +Electron desktop builds (`npm run desktop:*`) need a host with a display and are +host-only. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a7685aa --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,81 @@ +{ + // ───────────────────────────────────────────────────────────────────────── + // Optional, opt-in dev environment for Claude Code Agent Monitor. + // Used only when you choose "Dev Containers: Reopen in Container" (VS Code) or + // open the repo in a GitHub Codespace. It changes nothing for host-based dev. + // + // Covers the full project: the Express server (4820), the React/Vite client + // (5173), the MCP server, and the VS Code extension. The native `better-sqlite3` + // addon builds here (build-essential + python3 are installed in the Dockerfile). + // + // NOTE (issue #193): Claude Code hooks are a HOST-side concern. This container + // intentionally does NOT bind-mount ~/.claude and does NOT install hooks — the + // installer refuses to run inside a container. Run `npm run install-hooks` on + // your host so hooks POST to http://localhost:4820 (forwarded from here). + // ───────────────────────────────────────────────────────────────────────── + "name": "Claude Code Agent Monitor", + "build": { + "dockerfile": "Dockerfile", + "context": ".." + }, + + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + // Lets you build/run the project's own production Dockerfile + docker-compose + // from inside the dev container (e.g. to reproduce issue #193 deliberately). + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + + // Server (API + WebSocket) and the Vite client dev server. VS Code forwards + // these from the container's localhost, so the secure loopback bind is fine. + "forwardPorts": [4820, 5173], + "portsAttributes": { + "4820": { + "label": "Dashboard server (API + WebSocket)", + "onAutoForward": "notify" + }, + "5173": { + "label": "Vite dev client", + "onAutoForward": "openBrowser" + } + }, + + // Install root + client + vscode-extension deps and build the MCP server. + // Never installs Claude Code hooks (host-only — see note above). + "postCreateCommand": "bash .devcontainer/post-create.sh", + "waitFor": "postCreateCommand", + + "remoteUser": "node", + + // Dev defaults. NODE_ENV=development so `npm run dev` runs API-only with the + // Vite client on 5173 (production mode would serve the prebuilt client/dist). + "remoteEnv": { + "NODE_ENV": "development" + }, + + "customizations": { + "vscode": { + "extensions": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "vitest.explorer", + "ms-azuretools.vscode-docker", + "redhat.vscode-yaml", + "yzhang.markdown-all-in-one", + "ms-python.python", + "bradlc.vscode-tailwindcss", + "GitHub.vscode-pull-request-github" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], + "files.eol": "\n", + "terminal.integrated.defaultProfile.linux": "bash" + } + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..272fb2e --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Dev-container bootstrap. Installs all workspace dependencies and builds the +# MCP server. Runs once, after the container is created. +# +# Deliberately does NOT install Claude Code hooks: hooks are a host-side concern +# (issue #193) and `scripts/install-hooks.js` refuses to run inside a container. +# @author Nguyễn Ngọc Trí Vĩ +set -euo pipefail + +echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…" +npm run setup + +echo "▶ Installing and building the MCP server…" +npm run mcp:install +npm run mcp:build + +cat <<'EOF' + +✅ Dev environment ready. + + Develop: + npm run dev # server on :4820 + Vite client on :5173 + npm start # production-style server (serves client/dist) + + Test: + npm run test:server # node --test + npm run test:client # vitest + npm run test:mcp # MCP server tests + npm run mcp:typecheck # MCP type check + + Docs: + npm run openapi:yaml # regenerate openapi.yaml from the live spec + +⚠ Claude Code hooks are HOST-side. Do NOT run `npm run install-hooks` in this + container — it is refused on purpose (issue #193). Run it on your HOST so the + hook handler path exists there and POSTs to http://localhost:4820 (forwarded + from this container). + + Electron desktop builds (npm run desktop:*) also need a host with a display + and are not supported inside this container. +EOF diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8e9e6d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules/ +client/node_modules/ +client/dist/ +data/ +.git/ +.github/ +*.md +!README.md +images/ +wiki/ +index.html +og-image.svg +favicon.svg +.prettierrc +.prettierignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..761a1fb --- /dev/null +++ b/.env.example @@ -0,0 +1,111 @@ +# Claude Code home directory (default: ~/.claude) +# Override this if your Claude Code data is in a different location, e.g.: +# CLAUDE_HOME=~/.codefuse/engine/cc +# CLAUDE_HOME=~/.claude + +# ── Server / network ──────────────────────────────────────────────────────── +# Port to listen on (default: 4820) +# DASHBOARD_PORT=4820 + +# Interface to bind. SECURITY: defaults to 127.0.0.1 (loopback) so the dashboard +# is NOT reachable from the network out of the box (GHSA-gr74-4xfh-6jw9). The +# server reads transcripts, exports all data, and can spawn `claude`, so binding +# it to the network without auth exposes all of that. Only widen the bind if you +# understand the risk — and set DASHBOARD_TOKEN when you do. +# DASHBOARD_HOST=0.0.0.0 # bind all interfaces (LAN-reachable) + +# Optional auth token. When set, every /api/* request and the WebSocket must +# present it (Authorization: Bearer , x-dashboard-token header, or +# ?token=). Strongly recommended whenever DASHBOARD_HOST is non-loopback. Unset +# by default — the loopback bind is the trust boundary. +# DASHBOARD_TOKEN=change-me-to-a-long-random-string + +# Extra Host-header names allowed besides loopback (comma-separated). Needed when +# you bind to a LAN and reach the dashboard by hostname/IP — entries here pass +# the anti-DNS-rebinding Host allowlist. Also required when Prometheus-in-Docker +# scrapes a host-native dashboard: +# DASHBOARD_ALLOWED_HOSTS=host.docker.internal +# DASHBOARD_ALLOWED_HOSTS=dashboard.internal,192.168.1.50 + +# ── Background sweeps / sync ───────────────────────────────────────────────── +# Minutes of inactivity before an active session is marked "abandoned" by the +# periodic maintenance sweep (default: 180 = 3h). +# DASHBOARD_STALE_MINUTES=180 + +# Idle-working timeout (seconds) for recovering a turn cancelled with Esc BEFORE +# any output (which leaves no transcript marker). When the main agent has been +# "working" with no tool in flight and neither a hook nor the transcript has +# advanced for this long, the watchdog moves the session to Waiting (default: 120). +# DASHBOARD_WORKING_IDLE_SECONDS=120 + +# Dead-session liveness reap. The watchdog lists running `claude` CLI processes +# (ps + lsof on macOS, /proc on Linux) and completes any active session whose +# cwd has no live claude process — recovering sessions whose SessionEnd hook +# was lost because the dashboard was down when the user quit (e.g. Ctrl+C). +# Set to 0 to disable (do this when hooks arrive from another machine); it is +# auto-disabled on Windows and inside containers (default: enabled). +# DASHBOARD_LIVENESS_PROBE=1 + +# Idle gate (seconds) for WATCHDOG-TICK liveness reaps: a session is only +# completed when its transcript hasn't been written for at least this long +# (the last hook write is the fallback clock when no transcript exists on +# disk), so mid-turn or just-resumed sessions never flicker out on a transient +# probe miss. The startup passes skip this gate entirely — at boot the probe +# alone decides, so sessions quit moments before launch clear immediately +# (default: 60). +# DASHBOARD_LIVENESS_IDLE_SECONDS=60 + +# Poll interval (ms) for the continuous ~/.claude/projects sync that surfaces +# projects added after startup whose sessions never flow through hooks. The +# fs.watch watcher fires near-instantly regardless; this poll is the safety net. +# Set to 0 to disable the poll while leaving the watcher running (default: 30000). +# DASHBOARD_SESSION_SYNC_MS=30000 + +# ── Remote Data Sources (SSH multi-machine collection) ─────────────────────── +# The dashboard can pull Claude Code history from other machines over SSH: +# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging +# dir, feeds it through the same importer used for local history, and tags the +# imported sessions with the source. Authentication defers entirely to the host's +# own SSH stack (~/.ssh/config, ssh-agent, keys, known_hosts) — no passwords or +# secrets are stored here. + +# Poll interval (ms) for the background poller that syncs each enabled remote +# source. Set to 0 to disable the poller (manual / on-demand syncs still work) +# (default: 15000 = 15s). +# DASHBOARD_REMOTE_SYNC_MS=15000 + +# Per-source timeout (ms) for a single remote sync (rsync pull + import) before +# it is aborted (default: 600000 = 10min). +# DASHBOARD_REMOTE_SYNC_TIMEOUT_MS=600000 + +# Timeout (ms) for the Test SSH probe that verifies a remote source is +# reachable (default: 15000 = 15s). +# DASHBOARD_REMOTE_TEST_TIMEOUT_MS=15000 + +# Freshness window (ms) for a remote source session's live status. On each sync, +# a remote session whose mirrored transcript changed within this window is kept +# active; once the mirror stops advancing for longer, it is reconciled to +# completed. Remote sessions get no live hooks, so this replaces the local +# liveness/stale sweeps (which skip them). (default: 600000 = 10min). +# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000 + +# ── Lanes (durable parallel-work units and managed git worktrees) ──────────── +# Directory dashboard-managed worktrees are provisioned under. It is also the +# boundary safety check 2 resolves every destructive lane path against: nothing +# outside it can be reset or removed. Keep it readable only by the service user +# (default: ~/.claude/ccam-lanes). +# LANES_ROOT=~/.claude/ccam-lanes + +# Default base branch a new managed worktree resolves against when the request +# omits `base`. An explicit --base / request body `base` always wins +# (default: main). +# LANE_BASE_BRANCH=main + +# Prefix for a new managed worktree's feature branch name, as +# (default: feat/). +# LANE_BRANCH_PREFIX=feat/ + +# Seconds of silence after which a lane whose stage matches /watch|poll/i flips +# from `active` to `dead` liveness. A silent *idle* lane is at rest, not dead +# (default: 300 = 5min). +# LANE_DEAD_SEC=300 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..278b126 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Force LF line endings for all text files, regardless of OS +* text=auto eol=lf + +# Explicitly mark binary files to prevent corruption +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.db binary + +# ── GitHub Linguist overrides — keep language stats honest ────────────────── +# wiki/mermaid.min.js is a vendored, minified third-party library (Mermaid); +# it is not project source code and must not count toward language stats. +wiki/mermaid.min.js linguist-vendored + +# wiki/i18n-content.js is machine-assembled translation data (see its header: +# "AUTO-GENERATED wiki body-content translations ... Do not hand-edit"). +# It is a data bundle keyed by English innerHTML, not hand-written JavaScript. +wiki/i18n-content.js linguist-generated diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..3738720 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# commit-msg hook – runs Commitlint +npx --no-install commitlint --edit "$1" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bfad93c --- /dev/null +++ b/.gitignore @@ -0,0 +1,77 @@ +# Dependency directories +node_modules/ +jspm_packages/ + +# Build artifacts +dist/ +build/ +client/dist/ +mcp/build/ +desktop/out/ +desktop/release/ +desktop/assets/icon.iconset/ +*.tsbuildinfo + +# Database and data +/data/ +*.db +*.db-wal +*.db-shm + +# Environment variables +.env +.env.local +.env.*.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +*.log + +# OS artifacts +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDEs/Editors +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea/workspace.xml +.vs/ +.superpowers/ +.omc/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace +.helm/ + +# Coverage and testing +coverage/ +*.lcov + +# Temporary files +.tmp/ +.temp/ + +# Local-only remote data source test playbook (not shipped) +scripts/remote-test-commands.txt + +# Playwright MCP +.playwright_mcp/ +.playwright-mcp/ +__pycache__/ + +# JetBrains IDE config — machine-specific, never shared +.idea/ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..aa79af1 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,26 @@ +#!/usr/bin/env sh +# +# Commit-msg: lightweight, dependency-free Conventional Commits advisory. +# This is intentionally NON-blocking — it only prints a hint when the subject +# doesn't look conventional. Commits are gated on tests (see pre-commit), not +# on the message format. + +MSG_FILE="$1" +SUBJECT="$(head -n1 "$MSG_FILE" 2>/dev/null)" + +# Skip merge/revert/fixup/squash commits — git generates those subjects. +case "$SUBJECT" in + Merge*|Revert*|fixup!*|squash!*) exit 0 ;; +esac + +# type(scope)?!: description — e.g. "feat(tray): poll /api/stats" +PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .+' + +if ! printf '%s' "$SUBJECT" | grep -Eq "$PATTERN"; then + echo "💡 Tip: commit subjects work best in Conventional Commits form:" + echo " (optional-scope): " + echo " e.g. feat(tray): poll /api/stats for live counts" + echo " (advisory only — your commit will still proceed)" +fi + +exit 0 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..9ee4222 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,65 @@ +#!/usr/bin/env sh +# +# Pre-commit: auto-format staged files, then run the full test suite. +# The commit is ABORTED unless every test passes. +# +# Activated via `core.hooksPath=.husky` (set by the package.json "prepare" +# script on `npm install`). No husky/lint-staged runtime required. + +# Abort the commit on the first failing command (formatting error or failing test). +set -e + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# ── 1. Format staged files with Prettier, then re-stage them ──────────────── +# Only Added/Copied/Modified/Renamed paths in the index. --ignore-unknown lets +# Prettier silently skip anything it can't parse, so we can hand it every staged +# path without filtering by extension. NUL-delimited (-z / -0) so paths with +# spaces are handled correctly. +# +# NOTE: this re-adds the *whole* file. If you intentionally staged only part of +# a file, review the result after the auto-format. +STAGED="$(git diff --cached --name-only --diff-filter=ACMR)" +if [ -n "$STAGED" ]; then + PRETTIER="$ROOT/node_modules/.bin/prettier" + if [ -x "$PRETTIER" ]; then + set -- "$PRETTIER" + else + set -- npx --no-install prettier + fi + + echo "🎨 Formatting staged files with Prettier..." + git diff --cached --name-only --diff-filter=ACMR -z | xargs -0 "$@" --write --ignore-unknown + git diff --cached --name-only --diff-filter=ACMR -z | xargs -0 git add +else + echo "🎨 No staged files to format." +fi + +# ── 2. Run tests — commit is blocked unless all pass ──────────────────────── +# Each suite is retried once on failure. The full run executes dozens of test +# files concurrently, each starting its own server (plus the CLI suite's +# spawned child processes with hard kill timeouts), so a loaded machine can +# produce a one-off timing failure unrelated to the commit. A real regression +# is deterministic: it fails both runs and still blocks the commit. This keeps +# the gate strict without making commits a dice roll on machine load. +run_suite() { + # $1 = human label, $2 = npm script + echo "🧪 Running $1 tests..." + if npm run "$2"; then + return 0 + fi + echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..." + if npm run "$2"; then + echo "✅ $1 suite passed on retry — treating the first failure as a load flake." + echo " If this keeps happening, the flaky test above deserves a real fix." + return 0 + fi + echo "❌ $1 suite failed twice — genuine failure, aborting the commit." + return 1 +} + +run_suite backend test:server +run_suite frontend test:client + +echo "✅ Formatting applied and all tests passed — proceeding with commit." diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1546f69 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,26 @@ +node_modules/ +data/ +client/dist/ +client/node_modules/ +*.db +*.db-wal +*.db-shm +package-lock.json +client/package-lock.json +.vs/ +*.md +index.html +wiki/index.html +*.yml +*.yaml +mcp/build/ +desktop/out/ +desktop/release/ +desktop/node_modules/ +desktop/package-lock.json +desktop/assets/*.svg +desktop/assets/*.png +desktop/assets/*.icns +desktop/assets/icon.iconset/ +wiki/mermaid.min.js +fonts/*.woff2 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1bfc886 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "printWidth": 100, + "tabWidth": 2, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/.superpowers/README.md b/.superpowers/README.md new file mode 100644 index 0000000..616b09d --- /dev/null +++ b/.superpowers/README.md @@ -0,0 +1,130 @@ +# Superpowers Workspace + +This directory contains project-specific configuration and artifacts for the Superpowers workflow. + +It acts as the working memory layer for agent-driven development, enabling structured planning, execution, and iteration across sessions. + +--- + +## Purpose + +Superpowers transforms coding agents into structured, process-driven collaborators. Instead of jumping straight into code, the agent: + +1. Clarifies intent +2. Produces a design +3. Breaks work into executable steps +4. Executes via subagents +5. Verifies and iterates + +This directory stores the outputs of that workflow so progress is persistent, inspectable, and reproducible. + +--- + +## Directory Structure + +Typical contents may include: + +``` +.superpowers/ +├── brainstorm/ # Design explorations and refined specs +├── plans/ # Task breakdowns and execution plans +├── reviews/ # Code review outputs and feedback +├── runs/ # Execution logs or agent traces +└── README.md # This file +``` + +> Exact structure may evolve depending on which skills are triggered. + +--- + +## Workflow Overview + +Superpowers operates through automatic skill activation: + +### 1. Brainstorming + +* Refines vague ideas into concrete specs +* Explores alternatives +* Produces structured, reviewable design docs + +### 2. Planning + +* Converts approved designs into granular tasks +* Each task is: + + * Small (2–5 min) + * Explicit (exact file paths + code) + * Verifiable + +### 3. Execution + +* Tasks are executed by subagents +* Includes: + + * Spec compliance checks + * Code quality review +* Can run sequentially or in parallel + +### 4. Verification + +* Enforces test-first development (TDD) +* Ensures correctness before completion +* Prevents silent regressions + +### 5. Completion + +* Validates final state +* Offers merge / PR / discard options +* Cleans up working branches + +--- + +## Key Principles + +* **Test-first development** (RED → GREEN → REFACTOR) +* **Small, deterministic tasks** +* **Explicit over implicit** +* **Process over intuition** +* **Verification over assumption** + +--- + +## How to Use + +You don’t interact with this directory directly most of the time. + +Instead: + +* Start a task in your coding agent (Claude, Cursor, etc.) +* Let Superpowers skills activate automatically +* Review outputs when prompted (designs, plans, reviews) + +Artifacts will be written here as the workflow progresses. + +--- + +## When to Look Here + +Check this directory when you want to: + +* Review the current plan +* Inspect prior design decisions +* Debug agent behavior +* Resume interrupted work +* Audit what was executed + +--- + +## Notes + +* Files here are **source-of-truth for agent state** +* Safe to commit (recommended for team workflows) +* Avoid manual edits unless you understand the workflow implications + +--- + +## Related + +* Project root `CLAUDE.md` → global agent context +* `.claude/` → rules, skills, and subagents +* Superpowers upstream docs → diff --git a/.superpowers/brainstorm/1542-1774533799/content/workflows-page-wireframe.html b/.superpowers/brainstorm/1542-1774533799/content/workflows-page-wireframe.html new file mode 100644 index 0000000..33e0e6d --- /dev/null +++ b/.superpowers/brainstorm/1542-1774533799/content/workflows-page-wireframe.html @@ -0,0 +1,2123 @@ + + + + + + Workflows Page — Wireframe + + + + + + + +
+
+
3.2
+
Avg Agent Depth
+
▲ 0.4 this week
+
+
+
5.8
+
Avg Subagents / Session
+
▲ 1.2 this week
+
+
+
87%
+
Agent Success Rate
+
▼ 2% this week
+
+
+
R→E→B
+
Most Common Flow
+
Read → Edit → Bash
+
+
+
2.1
+
Avg Compactions
+
▲ 0.3 this week
+
+
+
4m 32s
+
Avg Session Duration
+
▼ 18s this week
+
+
+ + +
+
+

+ 1 Agent Orchestration Graph ? +

+
+ Aggregate spawning patterns across all sessions · Click a node to filter page +
+
+
+
+ +
+
Origin
+
● Session Start (142)
+
+ +
+
+
+ + +
+
Main Agent
+
■ Main Agent (142)
+
+ +
+
+
+
+
+
+
+ + +
+
Subagent Types
+
◆ Explore (89)
+
◆ code-reviewer (67)
+
+ ◆ general-purpose (54) +
+
◆ Plan (43)
+
◆ tdd-assistant (38)
+
◆ +5 more...
+
+ +
+
+
+
+ + +
+
Nested (Depth 2+)
+
+ ◆ debugger (12) +
+
+ ◆ security-auditor (8) +
+
+ ◆ compaction (23) +
+
+ +
+
+
+
+ + +
+
Outcomes
+
+ ✓ Completed (298) +
+
✗ Error (18)
+
+ ⚠ Abandoned (7) +
+
+
+ +
+
+
+ Session root +
+
+
+ Main agent +
+
+
+ Subagent type +
+
+
+ High frequency +
+
+
+ Low frequency +
+
+ +
+ 💡 + Interactive: Nodes are clickable. Selecting a node filters Sections 2-5 to only show data + involving that agent type. Edge thickness represents spawn frequency. Hover shows detailed + metrics. +
+
+
+ + +
+
+

2 Tool Execution Flow ?

+
How tools chain together · Sankey-style directed flow
+
+
+
+
All Agents
+
Explore
+
code-reviewer
+
general-purpose
+
+ +
+ +
+
+ Read 42% +
+
+ Bash 18% +
+
+ Grep 14% +
+
+ Glob 10% +
+
+ + +
+
+
+
+
+
+
+ Flow bands connect
source → target tools
Band width = transition frequency +
+
+ + +
+
+ Edit 28% +
+
+ Write 22% +
+
+ Bash 18% +
+
+ Read 16% +
+
+ Agent 10% +
+
+
+ +
+ 💡 + Sankey diagram: left column = source tool, right column = next tool in sequence. Band + width shows how often one tool follows another. Filterable by agent type tabs above. +
+
+
+ + +
+ +
+
+

3 Subagent Effectiveness

+
Performance per agent type
+
+
+
+ +
+
+
+ Explore +
+
+ + + + +
92%
+
+
+
+
89
+
Sessions
+
+
+
1.2m
+
Avg Tokens
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ code-reviewer +
+
+ + + + +
85%
+
+
+
+
67
+
Sessions
+
+
+
2.8m
+
Avg Tokens
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ general-purpose +
+
+ + + + +
80%
+
+
+
+
54
+
Sessions
+
+
+
3.4m
+
Avg Tokens
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+ tdd-assistant +
+
+ + + + +
75%
+
+
+
+
38
+
Sessions
+
+
+
1.8m
+
Avg Tokens
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+

4 Detected Workflow Patterns

+
Common agent orchestration sequences
+
+
+
+
+
🚀
+
+
Explore
+
+
Plan
+
+
code-reviewer
+
+
+
34x
+
24% of sessions
+
+
+ +
+
🔧
+
+
Explore
+
+
general-purpose
+
+
tdd-assistant
+
+
+
28x
+
20% of sessions
+
+
+ +
+
🛡
+
+
code-reviewer
+
+
security-auditor
+
+
+
19x
+
13% of sessions
+
+
+ +
+
🐞
+
+
Explore
+
+
debugger
+
+
tdd-assistant
+
+
+
15x
+
11% of sessions
+
+
+ +
+
📑
+
+
+ Solo main +
+
+
+ No subagents +
+
+
+
22x
+
15% of sessions
+
+
+
+ +
+ 💡 + Patterns detected by analyzing subagent spawn sequences within sessions. Click a pattern + to highlight it in the DAG above. +
+
+
+
+ + +
+ +
+
+

5 Model Delegation Flow

+
How models route through agent hierarchies
+
+
+
+
+
Opus 4.6
+
78% of main agents
+
$4.82 avg cost
+
+
+
delegates to
+
+
62%
+
+
+
+
Sonnet 4.6
+
48% of subagents
+
$1.24 avg cost
+
+
+
Haiku 4.5
+
14% of subagents
+
$0.18 avg cost
+
+
+
+
+
+ + +
+
+

6 Error Propagation Map

+
Where errors cluster in agent hierarchy depth
+
+
+
+
+
+
3
+
+
+ Depth 0
Main agent +
+
+
+
+
11
+
+
+ Depth 1
Direct subagent +
+
+
+
+
4
+
+
+ Depth 2
Nested +
+
+
+
+
1
+
+
+ Depth 3+
Deep nested +
+
+
+
+
+ Top error-prone types: +
+
+ general-purpose (6) · + tdd-assistant (5) · + debugger (4) +
+
+
+
+
+ + +
+
+

+ 7 Agent Concurrency Timeline ? +

+
+ Parallel agent execution patterns · Shows how agents overlap in time +
+
+
+
+
Aggregate (avg across sessions)
+
Selected Session
+
+ +
+
+
Main Agent
+
+
+
+
+
+
Explore
+
+
+
+
+
+
+
Plan
+
+
+
+
+
+
code-reviewer
+
+
+
+
+
+
tdd-assistant
+
+
+
+
+
+
+
+
security-auditor
+
+
+
+
+
+
compaction
+
+
+
+
+
+ +
+
0% — Start
+
25%
+
50%
+
75%
+
100% — End
+
+
+ +
+
+
+ Working +
+
+
+ Error +
+
+
+ Compaction event +
+
+
+
+ + +
+ +
+
+

8 Session Complexity Scatter

+
+ Duration vs agent count vs tokens · Bubble size = token usage +
+
+
+
+
+
+
+ Agent Count ↑ +
+
Duration →
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Completed +
+
+
+ Error +
+
+
+ Active +
+
+
+ Abandoned +
+
+
+
+ + +
+
+

9 Compaction Impact Analysis

+
Context compression events and token recovery
+
+
+
+
+
47
+
+ Total Compactions +
+
+
+
38.2M
+
+ Tokens Recovered +
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ Session start + Session end +
+ +
+
+
+ Token growth +
+
+
+ Compaction trigger +
+
+
+ Post-compaction baseline +
+
+
+
+
+ + +
+
+

10 Session Drill-In ?

+
+ Select a session from the dropdown above or click a scatter dot to see its full execution + graph +
+
+
+
+
+
No session selected
+
+ Select a session to see its specific agent hierarchy tree, tool call timeline, and + execution flow as a horizontal node graph. +
+
+
◆ Agent Tree
+
⚙ Tool Timeline
+
☰ Event Sequence
+
+
+ +
+ 💡 + When a session is selected: shows the actual horizontal agent spawn tree for that session + (like the DAG above but for one session), a tool-call swim-lane timeline, and a scrollable + event sequence. All three sub-views in tabs. +
+
+
+ + +
+

✍ Design Notes

+
+
+ UX Principles +
    +
  • Cross-filtering: clicking nodes in DAG filters all other sections
  • +
  • Progressive disclosure: aggregate by default, drill into specifics
  • +
  • Consistent dark theme matching existing app (Tailwind surface levels)
  • +
  • Responsive: 2-col layouts collapse to single column on narrow screens
  • +
  • Tooltips on every metric for raw values and context
  • +
+
+
+ Technical Approach +
    +
  • New /api/workflows endpoint with aggregation queries
  • +
  • D3.js or React Flow for DAG and Sankey rendering
  • +
  • WebSocket updates for real-time active session changes
  • +
  • No new database tables — all derived from existing schema
  • +
  • Server-side computation for patterns and aggregates
  • +
+
+
+
+ + diff --git a/.superpowers/brainstorm/1542-1774533799/state/server-stopped b/.superpowers/brainstorm/1542-1774533799/state/server-stopped new file mode 100644 index 0000000..7f4b634 --- /dev/null +++ b/.superpowers/brainstorm/1542-1774533799/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1774536800234} diff --git a/.superpowers/brainstorm/1542-1774533799/state/server.pid b/.superpowers/brainstorm/1542-1774533799/state/server.pid new file mode 100644 index 0000000..57c7c05 --- /dev/null +++ b/.superpowers/brainstorm/1542-1774533799/state/server.pid @@ -0,0 +1 @@ +1542 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..aba3fe0 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode.vscode-typescript-next", + "bradlc.vscode-tailwindcss", + "dzhavat.mermaid-preview" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..efd8637 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug Server", + "program": "${workspaceFolder}/server/index.js", + "skipFiles": ["/**"], + "env": { + "NODE_ENV": "development", + "DASHBOARD_PORT": "4820" + } + }, + { + "type": "chrome", + "request": "launch", + "name": "Debug Client (Vite)", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/client/src" + } + ], + "compounds": [ + { + "name": "Full Stack Debug", + "configurations": ["Debug Server", "Debug Client (Vite)"] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a544092 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "**/node_modules": true, + "**/dist": true, + "**/build": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..8378791 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "npm: setup", + "type": "npm", + "script": "setup", + "group": "build", + "problemMatcher": [] + }, + { + "label": "npm: dev", + "type": "npm", + "script": "dev", + "isBackground": true, + "group": "none", + "problemMatcher": [ + { + "owner": "typescript", + "fileLocation": ["relative", "${workspaceFolder}/client"], + "pattern": { + "regexp": "^([^\\s].*)\\((\\d+|\\d+,\\d+|\\d+,\\d+,\\d+,\\d+)\\):\\s+(error|warning|info)\\s+(TS\\d+)\\s*:\\s*(.*)$", + "file": 1, + "location": 2, + "severity": 3, + "code": 4, + "message": 5 + }, + "background": { + "activeOnStart": true, + "beginsPattern": "VITE v.* ready in .* ms", + "endsPattern": "ready in .* ms" + } + } + ] + }, + { + "label": "npm: test:server", + "type": "npm", + "script": "test:server", + "group": "test", + "presentation": { + "reveal": "always", + "panel": "new" + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..812397c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Codex Project Instructions + +## Project intent +- Keep this repository a stable, local-first Claude Code monitoring platform. +- Maintain correctness across hooks, API, DB, websocket, UI, and MCP integration. + +## Priorities +- Correctness over cleverness. +- Small, scoped, reversible diffs. +- Preserve existing behavior unless change is requested. +- Update docs whenever workflow or architecture changes — follow `.claude/skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*). +- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ `. See `.claude/skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash .claude/skills/file-headers/scripts/check-headers.sh`. + +## Where to work +- `server/` for API/routes/data processing. +- `client/` for React UI behavior. +- `mcp/` for local MCP server tooling. +- `scripts/` for hook/install/import/cleanup utilities. + +## Validation expectations +- Backend changes: run `npm run test:server` when possible. +- Frontend changes: run `npm run test:client` when possible. +- MCP changes: run `npm run mcp:typecheck` and `npm run mcp:build`. +- If any check is skipped, report it explicitly. + +## Safety expectations +- Keep destructive capabilities behind explicit configuration gates. +- Never broaden destructive behavior without explicit user request. +- Treat hook execution path as fail-safe and non-blocking. + +## Useful commands +- Setup: `npm run setup` +- Dev: `npm run dev` +- Build/start: `npm run build` then `npm start` +- MCP helpers: `npm run mcp:install`, `npm run mcp:build`, `npm run mcp:start` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..205cf79 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,2923 @@ +# Agent Dashboard - System Design and Technical Reference + +Architectural overview and technical reference for the Agent Dashboard system, covering design goals, high-level architecture, data flow, server and client components, database design, WebSocket protocol, hook integration, MCP extension layer, Claude Code plugins & skills, state management, security considerations, performance characteristics, deployment modes, and technology choices. + +![Claude Code](https://img.shields.io/badge/Claude_Code-orange?style=flat-square&logo=claude&logoColor=white) +![Claude Code Plugins](https://img.shields.io/badge/Claude_Code-Plugins_&_Skills-orange?style=flat-square&logo=anthropic&logoColor=white) +![Model Context Protocol](https://img.shields.io/badge/Model_Context_Protocol-1.0-0f766e?style=flat-square&logo=modelcontextprotocol&logoColor=white) +![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?style=flat-square&logo=node.js&logoColor=white) +![Python](https://img.shields.io/badge/Python-%3E%3D3.6-3776AB?style=flat-square&logo=python&logoColor=white) +![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white) +![ws](https://img.shields.io/badge/ws-WebSocket_server-010101?style=flat-square&logo=socketdotio&logoColor=white) +![web-push](https://img.shields.io/badge/web--push-VAPID-3b82f6?style=flat-square&logo=javascript&logoColor=white) +![swagger-ui-express](https://img.shields.io/badge/swagger--ui--express-5.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) +![multer](https://img.shields.io/badge/multer-multipart_upload-FF6B6B?style=flat-square&logo=express&logoColor=white) +![adm-zip](https://img.shields.io/badge/adm--zip-archive_extract-FBBF24?style=flat-square&logo=files&logoColor=white) +![tar](https://img.shields.io/badge/tar-tgz_extract-A78BFA?style=flat-square&logo=gnu&logoColor=white) +![React](https://img.shields.io/badge/React-18.3-61DAFB?style=flat-square&logo=react&logoColor=white) +![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?style=flat-square&logo=typescript&logoColor=white) +![Javascript](https://img.shields.io/badge/JavaScript-ES6-F7DF1E?style=flat-square&logo=javascript&logoColor=white) +![Vite](https://img.shields.io/badge/Vite-6.1-646CFF?style=flat-square&logo=vite&logoColor=white) +![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-3.4-06B6D4?style=flat-square&logo=tailwindcss&logoColor=white) +![PostCSS](https://img.shields.io/badge/PostCSS-8.5-DD3A0A?style=flat-square&logo=postcss&logoColor=white) +![Autoprefixer](https://img.shields.io/badge/Autoprefixer-10.4-DD3735?style=flat-square&logo=autoprefixer&logoColor=white) +![React Router](https://img.shields.io/badge/React_Router-6.28-CA4245?style=flat-square&logo=reactrouter&logoColor=white) +![Lucide](https://img.shields.io/badge/Lucide_Icons-0.474-F56565?style=flat-square&logo=lucide&logoColor=white) +![D3.js](https://img.shields.io/badge/D3.js-7-F9A03C?style=flat-square&logo=d3&logoColor=white) +![Mermaid](https://img.shields.io/badge/Mermaid-10.2-ff3333?style=flat-square&logo=mermaid&logoColor=white) +![i18next](https://img.shields.io/badge/i18next-22.4-7A42FF?style=flat-square&logo=i18next&logoColor=white) +![i18next Language Detector](https://img.shields.io/badge/i18next_Language_Detector-6.1-7A42FF?style=flat-square&logo=i18next&logoColor=white) +![SQLite](https://img.shields.io/badge/SQLite-3-003B57?style=flat-square&logo=sqlite&logoColor=white) +![better--sqlite3](https://img.shields.io/badge/better--sqlite3-11.7-003B57?style=flat-square&logo=sqlite&logoColor=white) +![better-sqlite3 WAL](https://img.shields.io/badge/better--sqlite3-WAL_mode-003B57?style=flat-square&logo=sqlite&logoColor=white) +![WebSocket](https://img.shields.io/badge/WebSocket-RFC_6455-010101?style=flat-square&logo=socketdotio&logoColor=white) +![SSE](https://img.shields.io/badge/SSE-Server_Sent_Events-FF6600?style=flat-square&logo=googlechrome&logoColor=white) +![OpenAPI](https://img.shields.io/badge/OpenAPI-3.0-000000?style=flat-square&logo=openapiinitiative&logoColor=white) +![Swagger](https://img.shields.io/badge/Swagger-3.0-85EA2D?style=flat-square&logo=swagger&logoColor=white) +![VS Code](https://img.shields.io/badge/VS_Code-Extension-007ACC?style=flat-square&logo=vscodium&logoColor=white) +![Electron](https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron&logoColor=white) +![electron-builder](https://img.shields.io/badge/electron--builder-25.1-2c2e3b?style=flat-square&logo=electron&logoColor=white) +![macOS](https://img.shields.io/badge/macOS-Desktop_App-000000?style=flat-square&logo=apple&logoColor=white) +![Windows](https://img.shields.io/badge/Windows-Desktop_App-0078D6?style=flat-square&logo=windows&logoColor=white) +![SMAppService](https://img.shields.io/badge/SMAppService-Login_Items-000000?style=flat-square&logo=apple&logoColor=white) +![macOS DMG](https://img.shields.io/badge/macOS_DMG-arm64_%2B_x64-7c3aed?style=flat-square&logo=apple&logoColor=white) +![Vitest](https://img.shields.io/badge/Vitest-1.0-646CFF?style=flat-square&logo=vitest&logoColor=white) +![React Testing Library](https://img.shields.io/badge/React_Testing_Library-13.0-FF5733?style=flat-square&logo=testinglibrary&logoColor=white) +![ESLint](https://img.shields.io/badge/ESLint-8.44-4B32C3?style=flat-square&logo=eslint&logoColor=white) +![Prettier](https://img.shields.io/badge/Prettier-3.8-F7B93E?style=flat-square&logo=prettier&logoColor=white) +![Docker](https://img.shields.io/badge/Docker-20.10-2496ED?style=flat-square&logo=docker&logoColor=white) +![Podman](https://img.shields.io/badge/Podman-4.0-CC342D?style=flat-square&logo=podman&logoColor=white) +![Terraform](https://img.shields.io/badge/Terraform-%3E%3D1.5-844FBA?style=flat-square&logo=terraform&logoColor=white) +![Kubernetes](https://img.shields.io/badge/Kubernetes-%3E%3D1.24-326CE5?style=flat-square&logo=kubernetes&logoColor=white) +![Helm](https://img.shields.io/badge/Helm-3-0F1689?style=flat-square&logo=helm&logoColor=white) +![Kustomize](https://img.shields.io/badge/Kustomize-5.0-326CE5?style=flat-square&logo=kubernetes&logoColor=white) +![Nginx](https://img.shields.io/badge/Nginx-Ingress-009639?style=flat-square&logo=nginx&logoColor=white) +![Prometheus](https://img.shields.io/badge/Prometheus-2.x-E6522C?style=flat-square&logo=prometheus&logoColor=white) +![Grafana](https://img.shields.io/badge/Grafana-10.x-F46800?style=flat-square&logo=grafana&logoColor=white) +![Coralogix](https://img.shields.io/badge/Coralogix-Observability-1a1a2e?style=flat-square&logo=datadog&logoColor=white) +![OpenTelemetry](https://img.shields.io/badge/OpenTelemetry-Collector-4f46e5?style=flat-square&logo=opentelemetry&logoColor=white) +![AWS](https://img.shields.io/badge/AWS-ECS%20%7C%20RDS-232F3E?style=flat-square&logo=task&logoColor=white) +![Google Cloud](https://img.shields.io/badge/Google_Cloud-GKE%20%7C%20SQL-4285F4?style=flat-square&logo=googlecloud&logoColor=white) +![Azure](https://img.shields.io/badge/Azure-AKS%20%7C%20SQL-0078D4?style=flat-square&logo=cloudflare&logoColor=white) +![Oracle Cloud](https://img.shields.io/badge/Oracle_Cloud-OKE%20%7C%20DB-F80000?style=flat-square&logo=cloudways&logoColor=white) +![GitHub Actions](https://img.shields.io/badge/GitHub_Actions-pipelines-2088FF?style=flat-square&logo=githubactions&logoColor=white) +![GitLab CI](https://img.shields.io/badge/GitLab_CI-pipelines-FC6D26?style=flat-square&logo=gitlab&logoColor=white) +![Make](https://img.shields.io/badge/Make-4.3-000000?style=flat-square&logo=make&logoColor=white) +![Auto Release](https://img.shields.io/badge/CI-auto--release_to_GitHub-22c55e?style=flat-square&logo=githubactions&logoColor=white) + +--- + +## Table of Contents + +- [System Overview](#system-overview) +- [High-Level Architecture](#high-level-architecture) +- [Data Flow](#data-flow) +- [Server Architecture](#server-architecture) +- [Client Architecture](#client-architecture) +- [Internationalization Architecture](#internationalization-architecture) +- [Database Design](#database-design) +- [WebSocket Protocol](#websocket-protocol) +- [Hook Integration](#hook-integration) +- [Import Pipeline](#import-pipeline) +- [Agent Extension Layer](#agent-extension-layer) +- [Plugin Marketplace](#plugin-marketplace) +- [MCP Integration](#mcp-integration) +- [State Management](#state-management) +- [Browser Notification System](#browser-notification-system) +- [Update Notifier Subsystem](#update-notifier-subsystem) +- [Tabby Companion Subsystem](#tabby-companion-subsystem) +- [VS Code Extension Architecture](#vs-code-extension-architecture) +- [Desktop App Architecture (macOS & Windows / Electron)](#desktop-app-architecture-macos--windows--electron) +- [Security Considerations](#security-considerations) +- [Performance Characteristics](#performance-characteristics) +- [Deployment Modes](#deployment-modes) +- [Statusline Utility](#statusline-utility) +- [Technology Choices](#technology-choices) +- [Build & Run Targets](#build--run-targets) + +--- + +## System Overview + +Agent Dashboard is a local-first monitoring platform for Claude Code sessions. It captures agent lifecycle events via Claude Code's native hook system, persists them in SQLite, and presents them through a React dashboard with real-time WebSocket updates. + +> **Cursor sessions (informational):** The same `~/.claude` transcript paths also pick up **Cursor** agent sessions — Cursor happens to use that layout locally alongside Claude Code. CCAM does not distinguish which editor created a session. + +```mermaid +C4Context + title System Context Diagram + + Person(user, "Developer", "Uses Claude Code CLI") + System(claude, "Claude Code", "AI coding assistant with hook system") + System(dashboard, "Agent Dashboard", "Monitoring platform") + SystemDb(sqlite, "SQLite", "Persistent storage") + + Rel(user, claude, "Interacts with") + Rel(claude, dashboard, "Sends hook events via stdin + HTTP") + Rel(user, dashboard, "Views in browser") + Rel(dashboard, sqlite, "Reads/writes") +``` + +**Design goals:** + +- Zero-config operation -- auto-discovers sessions from hook events +- Never block Claude Code -- hooks fail silently with timeouts +- Instant feedback -- WebSocket push, no polling +- Portable -- SQLite, no external services, runs on any OS with Node.js 20+ +- Extensible -- plugin marketplace with 10 plugins (53 skills, 14 agents, 30 slash commands, 3 CLI tools) + +--- + +## High-Level Architecture + +```mermaid +graph TB + subgraph "Claude Code Process" + CC[Claude Code CLI] + H0[SessionStart Hook] + H1[PreToolUse Hook] + H2[PostToolUse Hook] + H3[Stop Hook] + H4[SubagentStop Hook] + H5[Notification Hook] + H6[SessionEnd Hook] + CC --> H0 & H1 & H2 & H3 & H4 & H5 & H6 + end + + subgraph "Plugin Layer" + direction TB + PM["Plugin Marketplace
(10 plugins, 53 skills)"] + PA["ccam-analytics"] + PP["ccam-productivity"] + PD["ccam-devtools"] + PI["ccam-insights"] + PC["ccam-dashboard"] + PG["ccam-cost-guard"] + PS["ccam-sessions"] + PW["ccam-workflows"] + PQ["ccam-quality"] + PF["ccam-config"] + PM --> PA & PP & PD & PI & PC & PG & PS & PW & PQ & PF + end + + subgraph "Hook Layer" + HH["hook-handler.js
(stdin → HTTP)"] + H0 & H1 & H2 & H3 & H4 & H5 & H6 -->|stdin JSON| HH + end + + subgraph "Server Process (port 4820)" + direction TB + EX[Express Server] + HR[Hook Router] + SR[Session Router] + AR[Agent Router] + ER[Event Router] + STR[Stats Router] + ANR[Analytics Router] + WFR[Workflows Router] + PR[Pricing Router] + DB[(SQLite
WAL mode)] + WSS[WebSocket Server] + + EX --> HR & SR & AR & ER & STR & ANR & WFR & PR + HR -->|transaction| DB + SR & AR & ER & STR & ANR & WFR & PR --> DB + HR -->|broadcast| WSS + SR & AR -->|broadcast| WSS + end + + subgraph "Client (Browser)" + direction TB + VITE[Vite Dev Server
or Static Files] + APP[React App] + WS_CLIENT[WebSocket Client] + EB[Event Bus] + PAGES[Pages:
Dashboard / Kanban /
Sessions / Activity /
Analytics / Workflows] + + VITE --> APP + APP --> WS_CLIENT + WS_CLIENT --> EB + EB --> PAGES + PAGES -->|fetch| EX + end + + HH -->|"POST /api/hooks/event"| HR + WSS -->|push messages| WS_CLIENT + PA & PP & PD & PI & PC -->|"curl API"| EX + + style CC fill:#6366f1,stroke:#818cf8,color:#fff + style DB fill:#003B57,stroke:#005f8a,color:#fff + style WSS fill:#10b981,stroke:#34d399,color:#fff + style EB fill:#f59e0b,stroke:#fbbf24,color:#000 + style PM fill:#8b5cf6,stroke:#a78bfa,color:#fff +``` + +--- + +## Data Flow + +### Event Ingestion Pipeline + +```mermaid +sequenceDiagram + participant CC as Claude Code + participant HH as hook-handler.js + participant API as POST /api/hooks/event + participant TX as SQLite Transaction + participant WS as WebSocket.broadcast() + participant UI as React Client + + CC->>HH: stdin: {"session_id":"abc","tool_name":"Bash",...} + Note over HH: Reads stdin, parses JSON,
wraps with hook_type + + HH->>API: POST {"hook_type":"PreToolUse","data":{...}} + Note over API: Validates hook_type + data + + API->>TX: BEGIN TRANSACTION + TX->>TX: ensureSession(session_id) + Note over TX: Creates session + main agent
if first contact. Also persists
data.transcript_path onto the session row
(SQL-guarded, so subsequent events no-op).
Syncs sessions.name from the transcript title
(custom-title > ai-title > first user prompt). + + TX->>TX: Process by hook_type + Note over TX: Dispatches by hook_type. Maintains the agent and
session state machines plus the awaiting_input_since flag.
SubagentStop also triggers a JSONL scan that emits per_tool
events under each subagent. See the hook table below for
the full per_event behaviour. + + TX->>TX: insertEvent(...) + TX->>TX: COMMIT + + API->>WS: broadcast("agent_updated", agent) + API->>WS: broadcast("new_event", event) + + WS->>UI: {"type":"agent_updated","data":{...}} + UI->>UI: eventBus.publish(msg) + UI->>UI: Page re-renders with new data +``` + +### Client Data Loading Pattern + +```mermaid +sequenceDiagram + participant Page as React Page + participant API as api.ts + participant Server as Express + participant EB as eventBus + participant WS as WebSocket + + Note over Page: Component mounts + Page->>API: load() via useEffect + API->>Server: GET /api/sessions (or agents, events, stats) + Server-->>API: JSON response + API-->>Page: setState(data) + + Note over Page: Subscribes to live updates + Page->>EB: eventBus.subscribe(handler) + + loop Real-time updates + WS->>EB: eventBus.publish(msg) + EB->>Page: handler(msg) + Page->>Page: Reload or optimistic update + end + + Note over Page: Component unmounts + Page->>EB: unsubscribe() +``` + +--- + +## Server Architecture + +### Module Dependency Graph + +```mermaid +graph TD + INDEX[server/index.js
Express app + HTTP server] + DB[server/db.js
SQLite + prepared statements
better-sqlite3 → node:sqlite fallback] + WS[server/websocket.js
WS server + broadcast] + HOOKS[routes/hooks.js
Hook event processing] + TC[lib/transcript-cache.js
JSONL cache + incremental reads] + SESSIONS[routes/sessions.js
Session CRUD] + AGENTS[routes/agents.js
Agent CRUD] + EVENTS[routes/events.js
Event listing] + STATS[routes/stats.js
Aggregate queries] + PRICING[routes/pricing.js
Cost calculation + pricing CRUD] + SETTINGS[routes/settings.js
System info + data management] + WORKFLOWS[routes/workflows.js
Workflow visualizations] + ALERTSR[routes/alerts.js
Alert rules CRUD + feed] + ALERTS[lib/alerts.js
Rule evaluation engine] + WEBHOOKSR[routes/webhooks.js
Webhook target CRUD + test] + WEBHOOKS[lib/webhooks.js
Webhook delivery engine] + WEBHOOKPROV[lib/webhook-providers.js
14-provider registry + formatters] + + INDEX --> DB + INDEX --> WS + INDEX --> HOOKS & SESSIONS & AGENTS & EVENTS & STATS & PRICING & SETTINGS & WORKFLOWS & ALERTSR & WEBHOOKSR + + HOOKS --> DB & WS & TC + HOOKS --> ALERTS + ALERTSR --> DB & WS & ALERTS + ALERTS --> DB & WS + ALERTS --> WEBHOOKS + WEBHOOKSR --> DB & WEBHOOKS & WEBHOOKPROV + WEBHOOKS --> DB & WEBHOOKPROV + SETTINGS --> DB & TC + INDEX --> TC + SESSIONS --> DB & WS + AGENTS --> DB & WS + EVENTS --> DB + STATS --> DB & WS + PRICING --> DB + WORKFLOWS --> DB + + style INDEX fill:#6366f1,stroke:#818cf8,color:#fff + style DB fill:#003B57,stroke:#005f8a,color:#fff + style WS fill:#10b981,stroke:#34d399,color:#fff +``` + +### Server Components + +| Module | Responsibility | +|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit | +| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` | +| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (bundled via the `redoc` dependency, never a CDN) so the reference works fully offline / air-gapped | +| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups | +| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE | +| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) | +| `server/websocket.js` | WebSocket server on `/ws` path, 30s heartbeat with ping/pong dead connection detection, typed broadcast function. Upgrades run through the same Host-header allowlist and optional `DASHBOARD_TOKEN` check as the HTTP surface (`isWebSocketAuthorized`) | +| `server/lib/security.js` | Network-hardening module (fix for GHSA-gr74-4xfh-6jw9). `resolveHost()` picks the bind address — `127.0.0.1` by default, widened only by `DASHBOARD_HOST` (logs a warning for non-loopback binds). `hostGuard` rejects requests whose `Host` header isn't in the loopback set or `DASHBOARD_ALLOWED_HOSTS` (DNS-rebinding defense). `corsOptions()` allows only loopback origins while letting No-Origin (curl/CLI) requests through. `tokenGuard` + `isWebSocketAuthorized` enforce the optional `DASHBOARD_TOKEN` on `/api/*` and WebSocket upgrades (accepted as `Authorization: Bearer`, `x-dashboard-token`, or `?token=`; off by default). Exempt paths and token-matching helpers (`tokensMatch`, `extractToken`) live here too | +| `routes/hooks.js` | Core event processing inside a SQLite transaction. Auto-creates sessions/agents. Handles 8 hook types: SessionStart, UserPromptSubmit, PreToolUse, PostToolUse, Stop, SubagentStop, Notification, SessionEnd, plus synthetic `Compaction` events. Manages the agent state machine plus the `awaiting_input_since` overlay (stamped on SessionStart for fresh CLIs — `startup`/`resume`/`clear` only, since a `compact`-source SessionStart fires mid-turn while Claude is working and is deliberately skipped so a working session stays Active — on non-error Stop, and on permission Notifications (which now also set agent status to `waiting`); cleared on UserPromptSubmit / PreToolUse / PostToolUse / SessionStart-resume / SessionEnd; SubagentStop intentionally does NOT clear it; and stamped by the 15 s watchdog on user-interrupt (Esc) recovery — see the `index.js` row — since `Esc` fires no hook). After `res.json()` returns on `SubagentStop`, fires a fire-and-forget `scanAndImportSubagents` (from `scripts/import-history.js`) that parses every `subagents/agent-*.jsonl`, pairs `tool_use` ↔ `tool_result` blocks by `tool_use_id`, and emits per-tool `PreToolUse` + `PostToolUse` events under each subagent's own `agent_id` — closes the gap where subagent-internal tool calls would otherwise never reach the events table. The same scan also reparents nested subagents under their true spawner (see the `import-history.js` row); it returns `{ created, reparented }`, and the follow-up `new_event` refetch nudge fires when **either** is non-zero so a pure re-parent (tree shape changed, no new rows) still refreshes the UI. The same scan attributes each subagent's tokens to **its own model** (resolved from the subagent transcript) and stamps `metadata.model` on the subagent row (issue #185), so a tiered pipeline (Opus orchestrator + Sonnet/Haiku subagents) is priced per real model rather than entirely at the orchestrator's rate; the parent-model bucket is skipped to avoid colliding with the main-transcript token writer's compaction baseline logic. Session reactivation on resume (including Stop/SubagentStop reactivation for imported completed/abandoned sessions), orphaned-session cleanup uses `DASHBOARD_STALE_MINUTES` (default 180). Uses a shared `TranscriptCache` instance (`server/lib/transcript-cache.js`) for extraction of tokens, API errors, turn durations, thinking blocks, and usage extras — stat-based caching with incremental byte-offset reads avoids re-reading entire JSONL files on every event. Detects compaction via `isCompactSummary` in JSONL transcripts and creates compaction agents + events (deduplicated by uuid). Token baselines (`baseline_*` columns) preserve pre-compaction totals so no usage is lost. Cache entries are evicted on SessionEnd. **SessionEnd preserves error state** — but only when the error is still unrecovered at the transcript tail (`isErrorAtTail`: latest API error with no successful turn after it); a transient error the CLI retried past finalizes as `completed` instead of freezing in a stale `error`. **Error recovery**: `UserPromptSubmit` and `PreToolUse` recover a session from `error`; additionally the 15 s watchdog now scans `error` sessions and self-heals one back to `active` when its transcript has progressed past the last API error (`isErrorAtTail` false) — closing the gap where a transient API error left an imported or sweep-monitored session (no live recovery hook) pinned in `error` forever. **Session naming**: on every event, syncs `sessions.name` from the transcript title surfaced by `TranscriptCache` and broadcasts `session_updated` — an explicit `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) always wins, an `ai-title` (auto / plan-accept) only fills a placeholder/auto name (`Session ` or a cwd-folder import name) so a user-chosen name is never clobbered. When neither title exists, the session's **first user prompt** (surfaced by `TranscriptCache` as `firstUserMessage`; tool-result / meta / slash-command plumbing entries skipped) fills the placeholder session name plus the main agent's placeholder name and empty task (issue #201) — a later `ai-title` can still replace a descriptor-filled name, and the agent fill passes the in-flight `current_tool` through (the shared `updateAgent` statement writes that column verbatim) so it is never wiped mid-turn. The guarded `updateSessionName` no-ops on the unchanged case, so the broadcast path stays quiet; the 15 s error-watchdog runs the same sync for idle sessions that fire no hook after a rename. **Dead-session liveness reap**: the same watchdog completes any `active` session whose `cwd` has no running `claude` CLI process (probe via `lib/session-liveness.js`) — recovering a `SessionEnd` lost while the dashboard was down (e.g. Ctrl+C) that previously left the session in Waiting until the 3 h stale sweep; watchdog ticks are gated on the transcript mtime (fallback `updated_at` when no transcript exists) being older than `DASHBOARD_LIVENESS_IDLE_SECONDS` (default 60 s); the boot passes — immediately at startup and again ~5 s later (post-import) — skip the gate so a session quit moments before launch clears at once, disabled via `DASHBOARD_LIVENESS_PROBE=0` / on Windows / in containers, and a false completion self-heals through hook reactivation. Sessions whose `cwd` is not POSIX-absolute (household-hook-forwarded from another machine, e.g. a Windows `D:\…` path a local `/proc`/`lsof` scan can never match) are skipped by the reap — so a mixed local + forwarded deployment stays correct without disabling the whole probe. **Remote Data Source sessions** (`sessions.source` ≠ `local`) are excluded from the reap, the error/interrupt watchdog scan, and both stale sweeps (all gated on `source = 'local'`): their POSIX-absolute `cwd` lives on another machine, so local process/clock heuristics would wrongly terminate a running remote session — their status is reconciled from the SSH mirror by `remote-sync.js` instead | +| `routes/sessions.js` | Standard CRUD with pagination. GET includes agent count via LEFT JOIN. POST is idempotent on session ID. GET `/:id/transcript` also surfaces `custom-title` lines as synthetic `session_event` (rename) messages — deduped, with `ai-title` excluded — so TUI-only `/rename` (which writes no user/assistant turn) is still visible in the conversation viewer. It also surfaces `system`/`local_command` lines: newer Claude Code builds write a local slash command's invocation and captured output (``, ``/`stderr`) as `system`/`local_command` entries with the TUI markup in a top-level `content` string (older builds used `user` messages), so the route re-emits those as user-side text and the client's `tuiSegments` parser renders the command pill + its output (e.g. `/color` → a `/color` pill plus "Session color set to: cyan"). Content-less `local_command` lines (e.g. `/clear`) and every other `system` subtype (`turn_duration`, `stop_hook_summary`, …) are dropped as noise | +| `routes/agents.js` | CRUD with status/session_id filtering. PATCH broadcasts `agent_updated`. Agent-list responses (`GET /api/agents`, `GET /api/sessions/:id/agents`) attach a per-agent `cost` via `pricing.attachAgentCosts` — each subagent's OWN cost, computed from its `metadata.tokens` at current rates (main agents get 0; their cost is the session total), so a subagent card shows only what that subagent spent rather than the session total | +| `routes/events.js` | Read-only event listing with session_id filter and pagination | +| `routes/stats.js` | Single aggregate query returning total/active counts + status distributions | +| `routes/metrics.js` | Prometheus / OpenMetrics text-exposition endpoint (`GET /api/metrics`) — re-exposes the dashboard's live counters (sessions/agents by status, event + token totals, connected WebSocket clients, configured remote sources, process uptime/RSS, build version) in the v0.0.4 text format for scraping into Prometheus / Grafana. Read-only; reads the same `db.js` prepared statements the REST API uses, so numbers match the UI. Status series are enumerated so a gauge never drops out at zero. Mounted under `/api`, so it sits behind the Host-header (DNS-rebinding) guard and the optional `DASHBOARD_TOKEN` guard — a non-loopback scraper needs `DASHBOARD_ALLOWED_HOSTS` (+ token if set). A turnkey Prometheus + Grafana stack with four auto-provisioned dashboards lives in `monitoring/` (`npm run monitoring:up` or `npm run docker:full:up`) | +| `monitoring/` | Optional npm-managed or Docker Compose Prometheus + Grafana stack that scrapes `GET /api/metrics`. Ships four Grafana dashboards (`ccam-overview`, `ccam-sessions-agents`, `ccam-tokens-events`, `ccam-platform`), recording rules (`prometheus/ccam-rules.yml`), a Prometheus 3.x-compatible static HTML console (`prometheus/consoles/index.html`), and lifecycle scripts (`monitoring:install`, `monitoring:up`, `monitoring:verify`). See [`monitoring/README.md`](./monitoring/README.md) | +| `routes/analytics.js` | Extended analytics — token totals, tool usage counts, daily event/session trends, agent type distribution. The client-side analytics heatmap grid is aligned to a Sunday start for correct day-of-week positioning | +| `routes/pricing.js` | Model pricing CRUD (list/upsert/delete) and per-session / global cost calculation with pattern-based model matching. `PUT /api/pricing` upserts a rule and accepts optional time-limited **introductory** rates (`intro_*_per_mtok` + `intro_until`): usage on/before the cutoff date prices at the intro rate, after it at the standard rate — the calculator picks the effective rate per usage day (`ratesForBucket`), so a promo like Sonnet 5's launch discount is correct before AND after the cutoff, retroactively. Intro columns are written only when the caller sends them (a standard-rate edit never disturbs a promo). Cost is computed per token bucket — keyed by (model, speed, inference_geo, service_tier) — applying fast-mode premium, US data-residency (1.1x), and Batch (0.5x) modifiers, the 5m/1h cache-write split, plus server-tool surcharges (web search $10/1k; code execution estimated by container-time with the monthly free-hours allowance; web fetch free). `attachAgentCosts`/`agentOwnCost` reuse the same calculator to price each agent's `metadata.tokens` for the per-agent `cost` on agent-list responses. Feature rates + modifier math live in `lib/pricing-constants.js`; usage normalization in `lib/token-usage.js` | +| `routes/settings.js` | System info (DB size, hook status, server uptime, transcript cache stats), data export as one versioned JSON bundle and matching import/restore (`POST /api/settings/import` via `server/lib/data-transfer.js` — idempotent, session-atomic, non-destructive; consolidates machines), session cleanup (abandon stale, purge old), clear all data (including the fired-alert feed and webhook delivery log; alert *rules* and webhook *targets* are preserved as user configuration), reset pricing, reinstall hooks | +| `routes/alerts.js` | HTTP surface for the rules-based alerting engine: alert-rule CRUD (`GET/POST /api/alerts/rules`, `PATCH/DELETE /api/alerts/rules/:id` — rule_type is immutable after creation, config re-validated against the stored type on PATCH), the fired-alert feed (`GET /api/alerts` with `?unacked=true` + pagination, response carries `total` and `unacked` counts), and acknowledgement (`POST /api/alerts/:id/ack`, `POST /api/alerts/ack-all`, broadcasting `alert_updated`). Every rule mutation calls `invalidateRuleCache()` so the evaluation engine picks up changes immediately | +| `lib/alerts.js` | Rule evaluation engine for the alerting feature. Four rule types: `event_pattern` (match `event_type` / `tool_name` / `summary_contains`, optionally requiring ≥ `count` matching events within `window_minutes` — counted via a dynamically built, statement-cached SQL query), `token_threshold` (session total tokens ≥ `total_tokens`, only evaluated on token-bearing events: PostToolUse / Stop / SubagentStop / SessionEnd), `inactivity` (active session whose `updated_at` — bumped on every ingested event — is older than `minutes`), and `status_duration` (agent stuck in `working`/`waiting` with no activity for `minutes`, joined against active sessions). Event-driven types run via `evaluateEvent()` called from `routes/hooks.js` **after** the ingest transaction commits and the HTTP response is sent — alerting can never slow down or fail hook ingestion, and `evaluateEvent` is itself fully try/catch-guarded per rule. Time-based types run via `sweepTimeRules()` on a 60 s unref'd interval (same pattern as the hooks watchdog). `fireAlert()` applies per-(rule, session, agent) cooldown dedup (`cooldown_seconds`, default 300) by checking the most recent `alert_events` row for the scope, then persists and broadcasts `alert_triggered`. Enabled rules are cached in memory (hook ingest is hot) and invalidated on every CRUD mutation. `validateRuleConfig()` normalizes + validates type-specific config and is shared with the routes. After persisting and broadcasting a fired alert, `fireAlert()` hands it to `lib/webhooks.js` `dispatchAlert()` fire-and-forget (lazy-required to keep the module graph acyclic) — webhook delivery never blocks or fails alert firing | +| `routes/webhooks.js` | HTTP surface for universal webhook targets: target CRUD (`GET/POST /api/webhooks`, `PATCH/DELETE /api/webhooks/:id` — `type` is immutable after creation), a redacted provider catalog (`GET /api/webhooks/providers`, drives the UI form), a synchronous test probe (`POST /api/webhooks/:id/test` — always 200, the `ok` flag carries the downstream delivery result), and a per-target delivery log (`GET /api/webhooks/:id/deliveries`). Validation is registry-driven: required URL (per provider), per-provider config fields, https enforcement, generic-family secret/headers. **Security**: target URLs are masked (host + last 4 chars) and secret config fields + custom-header values are redacted in every response — full URLs, signing secrets, and credentials (routing keys, api keys, bot tokens) are stored server-side and never leave the server. PATCH uses "set-flag" semantics (omit `url`/`secret`/`headers`/`config` to leave unchanged); `config` is merged over the existing config so one field can change without re-sending secrets. Every mutation calls `invalidateWebhookCache()` | +| `lib/webhook-providers.js`| Declarative registry of the 14 first-class providers (+ generic). Each entry declares a `family` (`chat` / `api` / `generic`), a payload `format`ter, URL resolution (`urlFrom(config)` for Telegram/Opsgenie that derive the endpoint, `defaultUrl` for PagerDuty, or a user-supplied URL), optional `authFrom(config)` headers (Opsgenie GenieKey), and the credential `fields` the UI renders + the route validates. Formatters emit each platform's native body: Slack Block Kit, Discord embed, Teams Adaptive Card wrapped in the Power Automate Workflows `{ type: "message", attachments: [...] }` envelope (the legacy O365-connector MessageCard transport was retired May 2026), Google Chat text, Mattermost/Rocket.Chat Slack-style attachments, Telegram sendMessage (HTML), PagerDuty Events API v2 (with `dedup_key`), Opsgenie Alert API, Splunk On-Call/VictorOps, and the generic `{ event, alert }` envelope. A provider may also declare `verifyResponse(body)` to veto a 2xx that actually signals failure (Splunk On-Call returns 200 with `result:"failure"`). `publicProviders()` returns the redacted catalog. Adding a provider = one registry entry + a formatter — no route or delivery changes | +| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint | +| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status. Per-session drill-in endpoint with agent tree, tool timeline, and event details | +| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup | +| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p -d cwd -Fn` on macOS or `/proc//cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` | +| `bin/ccam.js` | Dependency-free umbrella CLI (`ccam `) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agent-dashboard.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `CCAM_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `ccam status` shows a ●/○ up/down indicator and `ccam start` boots a detached production server (waits for /api/health, logs to `data/ccam-server.log`). `ccam repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.ccam_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `ccam` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason | +| `scripts/import-history.js` | Batch history importer used by (a) server startup auto-import, (b) the `/api/import/*` routes, (c) the `import-history` CLI, and (d) live `SubagentStop` ingestion via the exported `scanAndImportSubagents(dbModule, sessionId, transcriptPath)`. Exposes `importAllSessions(dbModule)` for the default `~/.claude/projects` tree, `syncDefaultProjects(dbModule, {mtimeCache})` (the incremental, mtime-fingerprinted re-sweep that backs the continuous background sync — parses only new/changed files and reports `[{sessionId, isNew}]`), and the generalized `importFromDirectory(dbModule, rootDir, {onProgress})` which walks any directory recursively, classifies each `.jsonl` as session vs subagent (with `findSessionSubagents` probing both `//subagents/*` and `/subagents//*` layouts), and funnels everything through the shared `parseSessionFile` + `importSession` pipeline. The durable transcript snapshot (`snapshotTranscript`) additionally preserves **nested** Workflow-tool inner-agent transcripts (`subagents/workflows//agent-*.jsonl`) via the separate `findSessionWorkflowSubagents` probe — mirroring the run subpath so the read route resolves the snapshot identically to the live file, without pulling those nested agents into the flat sub-agent import (no double-count). After each batch imports, `importAllSessions` / `importFromDirectory` also call `ingestWorkflowsForSession` (from `server/lib/workflow-ingest.js`) per session — outside the SQLite transaction, since the ingest is async — so a **Workflow-tool** run whose journal never reached a live server (a headless `claude -p` run, a CI job, or an HPC/cluster node emits no hooks) still links its inner agents to their `run_id` on a plain `ccam import rescan` / `ccam import path`, instead of leaving them orphaned (`workflow_run_id = NULL`) with the run stuck at 1 agent — see "Workflow-Tool Run Ingestion". `parseSubagentFile` extracts ordered `toolEvents` (tool_use + tool_result paired by `tool_use_id`) so `importSubagentFromJsonl` can emit per-tool `PreToolUse` + `PostToolUse` rows under each subagent's own `agent_id`. The importer dedups against live hook-created subagent rows via `findLiveSubagentForJsonl` (session + subagent_type + start-time within 30 s) so backfill never produces parallel `-jsonl-*` rows. It also **skips `importSubagents` entirely when subagent transcripts exist** — the main-transcript `Agent`-block rows (`-subagent-N`) and the transcript rows (`-jsonl-*`) would otherwise both be created and only deduped by a fragile type+timing match, doubling every subagent; when transcripts are present the richer `-jsonl-` rows are authoritative and the `-subagent-N` fallback runs only when there are none. `importSession` now also **persists `transcript_path`** on the session row (via `setSessionTranscriptPath`) so the abandon sweep, compaction scanner, and per-agent cost backfill can locate the transcript later, and **stamps each subagent's own token buckets into `metadata.tokens`** (used for per-agent cost). `backfillSubagentTokenMetadata` (a deferred, self-limiting startup pass) fills `metadata.tokens` on subagents that predate per-agent cost, deriving the transcript path from `//.jsonl` when `transcript_path` is null and covering both subagent-dir layouts — metadata-only, so it never touches session `token_usage`. `classifyJsonl` treats any file under a `subagents/` ancestor at any depth (including the `subagents/workflows//` tree) as a subagent, so workflow inner-agent transcripts are never misimported as top-level sessions. **Nested-subagent hierarchy** is rebuilt by `reconcileSubagentParents`: every subagent is inserted flat under the main agent (no single hook/JSONL carries the spawner id), then `parseSubagentFile` also returns `spawnedChildren` — the child agent ids named on each Task tool result (`toolUseResult.agentId`) — which are inverted to a child→parent map and used to repoint `parent_agent_id` (via the `setAgentParent` statement) so a subagent that spawns its own subagents nests under its true spawner instead of collapsing flat under main; any subagent no other subagent claims stays under main. It resolves both the child and parent to their live-or-jsonl DB id (mirroring `importSubagentFromJsonl`), so it also corrects the live hook heuristic's guesses once the transcripts land. Idempotent and additive (only rewrites `parent_agent_id`), it runs in all three group-import paths (`importSession` ×2, live `scanAndImportSubagents`); `scanAndImportSubagents` returns a `reparented` count alongside `created`. **Re-import is fully incremental**: for each existing session a per-event-type high-water mark (`MAX(created_at) GROUP BY event_type`) is read up-front and only JSONL entries with `ts > cutoff[type]` are inserted for Stop / PostToolUse / TurnDuration / ToolError — so long-running sessions whose transcripts grow across multiple days continue to receive new events on every re-run instead of being blocked by the old "if zero of type X then dump all" check. `sessions.ended_at` is rolled forward to the JSONL's last activity when it surpasses the stored value, and `metadata.user_messages` / `assistant_messages` / `turn_count` are refreshed on every pass. `parseSessionFile` also captures the transcript title (`custom-title` / `ai-title`) and `importSession` prefers it for `sessions.name` over the cwd-folder fallback, backfilling existing auto/placeholder names on re-import (same precedence as the live hook sync). Other idempotency keys are unchanged: `data LIKE '%"tool_use_id":"X"%'` skips any tool event already inserted, compaction agents/events dedup by uuid, API errors dedup by summary, and `baseline_*` columns preserve pre-compaction token totals. Token totals, per-model cost, compactions, subagents, tool events, API errors, and turn durations are identical to live ingestion. Creates `APIError`, `TurnDuration`, and `ToolError` event types during import; subagent tool events carry `imported: true, source: "subagent_jsonl"` in their data payload so analytics can distinguish backfilled rows when needed | +| `server/routes/import.js` | Express router for the Import History feature. Three endpoints funnel into the same pipeline: `POST /api/import/rescan` (default projects dir), `POST /api/import/scan-path` (arbitrary absolute dir with `~` expansion), `POST /api/import/upload` (multer multipart accepting `.jsonl`, `.meta.json`, `.zip`, `.tar`, `.tar.gz`, `.tgz`, `.gz`). `GET /api/import/guide` returns OS-aware instructions + archive command + default-dir stats. Each request uses a per-request temp dir (`req._ccamUploadDir` for multer staging, a separate `workDir` for extraction) that is reclaimed in `finally`. Progress is broadcast as `import.progress` websocket messages throttled at ~150 ms. Limits configurable via `CCAM_IMPORT_MAX_BYTES` / `CCAM_IMPORT_MAX_FILES` | +| `server/lib/data-transfer.js` | Full-dataset export/import ("backup / restore"). `buildExportBundle(db, stmts)` serializes every user-owned table (sessions, agents, events, token_usage, workflows, dashboard_runs, alert_rules, model_pricing) into one versioned JSON bundle (`format: "ccam-export"`); machine-bound/secret tables (push_subscriptions, webhook_targets/deliveries, alert_events) are excluded. `importExportBundle(db, bundle)` restores it session-atomically inside one transaction with `defer_foreign_keys` ON: a session already present (by UUID) is skipped WHOLE (with its agents/events/token_usage/workflows) so re-import and cross-machine merges never duplicate or clobber; events are re-inserted without their non-portable autoincrement id; config rows use `INSERT OR IGNORE` on their natural key. Backs `GET/POST /api/settings/{export,import}` and `ccam import-data` | +| `server/lib/archive.js` | Safe archive extraction: `.zip` via `adm-zip`, `.tar`/`.tar.gz`/`.tgz` via `tar`, plain `.gz` via `zlib` in streaming mode. Every entry is validated through `safeJoin` which rejects absolute paths and `..` traversal before any bytes are written. Enforces a hard extraction cap (`MAX_EXTRACT_BYTES`, default 4 GB, tunable via `CCAM_IMPORT_MAX_EXTRACT_BYTES`) with `ExtractionLimitError` surfaced as HTTP 413 from the upload route — defense against zip/tar/gzip bombs. Also provides `detectKind` for filename-based dispatch and `mkTempDir`/`rmTempDir` helpers | +| `server/lib/remote-sync.js` | **Remote Data Sources** — live remote/multi-machine data collection over SSH. Mirrors the remote's `~/.claude/projects` via **scp** (or `wsl.exe` + `tar` for WSL-hosted Claude on Windows SSH) into a **sandboxed per-source staging dir** under the data dir, then feeds it through the **same** importer used for local history (`importFromDirectory` from `scripts/import-history.js`) and tags every imported session with the source id (`sessions.source`). Also runs the connectivity probe behind the test route. Auth **defers entirely to the host SSH stack** (ssh-agent / `~/.ssh/config` / identity file) — **no secrets are ever stored**; every shell-out uses `execFile`/`spawn` argument arrays (never a shell string), and `StrictHostKeyChecking` is left at its SSH default. Background poller `DASHBOARD_REMOTE_SYNC_MS` (default 15000); add/re-enable triggers immediate pull. Sync timeout `DASHBOARD_REMOTE_SYNC_TIMEOUT_MS` (default 600000); connectivity-test timeout `DASHBOARD_REMOTE_TEST_TIMEOUT_MS` (default 15000). Broadcasts `remote_source.status`, `remote_data.updated`, and per-session `session_created`/`session_updated` on successful sync. After each pull, `reconcileRemoteSessionStatus` sets each source session's live status from the mirror — newest JSONL event timestamp within `DASHBOARD_REMOTE_ACTIVE_WINDOW_MS` (default 600000 = 10 min) ⇒ `active`, otherwise `completed`. Remote sessions get no live hooks and are excluded from every local liveness/stale heuristic (all gated on `source = 'local'`), so this reconciliation is the sole owner of their active/completed lifecycle | +| `server/routes/remote-sources.js` | HTTP surface for **Remote Data Sources**: `GET /api/remote-sources` (list), `POST /api/remote-sources` (create), `PATCH /api/remote-sources/:id`, `DELETE /api/remote-sources/:id` (`?purge=true` also deletes that source's imported sessions), `POST /api/remote-sources/:id/test` (SSH connectivity probe), and `POST /api/remote-sources/:id/sync` (on-demand pull). Delegates the pull/validation to `remote-sync.js`; broadcasts `remote_source.status` on every transition | +| `server/lib/source-filter.js` | Parses the optional `?sources=` query param (comma-separated source ids) into SQL predicates + bind params, shared by the data endpoints (`GET /api/sessions`, `/api/events`, `/api/agents`, `/api/stats`, `/api/analytics`) so a client **data-scope** selection narrows every query consistently. No filter → the existing unscoped queries run unchanged | +| `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | +| `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`/skills//SKILL.md`), subagents (`/agents/*.md`), slash commands (`/commands/*.md`), output styles (`/output-styles/*.md`), plugins (`/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` count by scanning the install dir + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects//memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | +| `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `/cc-config-backups//..bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | +| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | +| `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | +| `lib/stream-json-parser.js` | Newline-delimited JSON line buffer for parsing `claude --output-format stream-json` output. Reassembles arbitrarily chunked stdout into discrete envelopes. Robust: malformed lines are reported via an `onError` callback but never throw | +| `lib/run-spawner.js` | Spawns and supervises `claude` subprocesses for the Run page. Two modes: **headless** (`-p ""` in argv, stdin closed, exits after one turn) and **conversation** (`--input-format stream-json`, prompt + follow-ups piped over stdin, multi-turn). Conversation mode also supports `resumeSessionId` → `--resume `; an empty `prompt` is permitted in this case (the spawner skips the initial stdin write so `claude` idles on the resumed transcript until the user POSTs a follow-up via `/run/:id/message`). The argv builder also passes through an optional `effort` (`low`/`medium`/`high`) → `--effort`. Output is always `--output-format stream-json --verbose --include-partial-messages` so the parser yields character-level deltas (`stream_event` envelopes) the UI can render token-by-token; each envelope is broadcast as `run_stream` over the existing WebSocket. Status transitions broadcast as `run_status`. A failed spawn records an actual-exit timestamp too: no child started, so lane teardown can safely proceed instead of waiting for a nonexistent `exit` event. SIGTERM escalation checks that timestamp rather than Node's delivery-acknowledgement `child.killed`, so a child that ignores SIGTERM still receives SIGKILL after five seconds. Concurrency is effectively uncapped (default ceiling 10000 — matches the terminal TUI which has no cap; the cap is sanity-only to prevent fork-bomb footguns from a buggy client; override with `RUN_MAX_CONCURRENT`, NaN-safe). Per-handle bounded envelope log (cap 500) lets late-attaching clients replay history via `?envelopes=1`. The Run page additionally reconciles this in-memory log against the session's on-disk JSONL transcript on every attach (incl. clicking Resume / View on a row) — when the transcript has more user/assistant messages than the spawner saw (e.g., a resumed run whose prior history never traversed stdout), it supersedes; otherwise the spawner's log wins (it has stream_event deltas the transcript doesn't carry until each turn finalizes). This is what makes leaving a resumed run and coming back show the same chat the user saw initially. Completed handles reaped after 5 min; full transcripts persist via the normal hook ingestion pipeline because every spawned `claude` fires hooks like any other CLI session | +| `routes/run.js` | HTTP surface for the Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). cwd sanitization: must be absolute and exist as a directory. `GET /` lists handles + concurrency state. `GET /binary` probes whether `claude` is on `PATH`. `GET /cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /files?cwd=&q=` powers the Run page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /` spawns (accepts `effort` in body). `POST /:id/message` sends a follow-up turn. `GET /:id` returns the handle; `?envelopes=1` includes the in-memory envelope log for re-attach. `DELETE /:id` SIGTERMs (escalates to SIGKILL after 5 s) | +| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug` and `base_branch` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. | + +### API Documentation + +Both JSDoc and Swagger/OpenAPI 3.0.3 are used for API documentation. JSDoc comments in route handlers provide inline documentation and type hints, while the OpenAPI spec is generated centrally and rendered three ways for interactive and read-optimized API exploration. + +| Layer | Source | Purpose | +|-------|--------|---------| +| Inline code docs | JSDoc blocks in `server/index.js`, `server/db.js`, `server/routes/*.js`, and `server/lib/*.js` | Explain route behavior, lifecycle logic, and internal contracts close to implementation | +| Machine-readable API contract | `server/openapi.js` (`createOpenApiSpec()`) + fragments under `server/openapi-extra/` | Defines OpenAPI 3.0.3 `info`, schemas, parameters, and all documented `/api/*` paths (75 path entries, comprehensive route coverage) | +| Interactive docs | `GET /api/openapi.json` and `GET /api/docs` | Exposes raw OpenAPI JSON and Swagger UI (try-it-out) for exploration and integration testing | +| Read-optimized reference | `GET /api/redoc` (served by `server/lib/redoc.js`) | ReDoc three-panel rendering of the same spec; the ReDoc bundle is self-hosted at `/api/redoc/redoc.standalone.js` (never a CDN) so it works offline / air-gapped | +| Committed spec snapshot | `openapi.yaml` (repo root) | Generated from `server/openapi.js` via `npm run openapi:yaml` — mirrors the live spec, never hand-edited | + +The OpenAPI metadata is grounded in real project data (`package.json` version/license/repository/bugs), and route coverage is enforced in `server/__tests__/api.test.js` by asserting expected paths exist in the spec. +### Request Processing + +```mermaid +flowchart LR + REQ[Incoming
Request] --> CORS[CORS
Middleware] + CORS --> JSON[JSON Body
Parser
1MB limit] + JSON --> ROUTER{Route
Match} + ROUTER -->|/api/hooks| HOOKS[hooks.js] + ROUTER -->|/api/sessions| SESSIONS[sessions.js] + ROUTER -->|/api/agents| AGENTS[agents.js] + ROUTER -->|/api/events| EVENTS[events.js] + ROUTER -->|/api/stats| STATS[stats.js] + ROUTER -->|/api/analytics| ANALYTICS[analytics.js] + ROUTER -->|/api/remote-sources| REMOTE[remote-sources.js] + ROUTER -->|/api/pricing| PRICING[pricing.js] + ROUTER -->|/api/settings| SETTINGS[settings.js] + ROUTER -->|/api/workflows| WORKFLOWS[workflows.js] + ROUTER -->|/api/openapi.json| OPENAPI[OpenAPI JSON] + ROUTER -->|/api/docs| SWAGGER[Swagger UI] + ROUTER -->|/api/health| HEALTH[Health Check] + ROUTER -->|"* (prod)"| STATIC[Static Files
client/dist] + + HOOKS --> DB[(SQLite)] + SESSIONS --> DB + AGENTS --> DB + EVENTS --> DB + STATS --> DB + ANALYTICS --> DB + PRICING --> DB + SETTINGS --> DB + WORKFLOWS --> DB + + HOOKS --> WS[WebSocket
Broadcast] + SESSIONS --> WS + AGENTS --> WS +``` + +--- + +## Client Architecture + +### Component Tree + +```mermaid +graph TD + APP["App.tsx
Router + WebSocket"] + LAYOUT["Layout.tsx
Sidebar + Outlet"] + SIDEBAR["Sidebar.tsx
Nav (scroll-bounded with overflow
chevrons) + Connection Status"] + DASH["Dashboard.tsx"] + KANBAN["KanbanBoard.tsx"] + SESS["Sessions.tsx"] + DETAIL["SessionDetail.tsx"] + ACTIVITY["ActivityFeed.tsx"] + SETTINGS_P["Settings.tsx"] + + ANALYTICS_P["Analytics.tsx"] + WORKFLOWS_P["Workflows.tsx"] + NOTFOUND["NotFound.tsx"] + + APP --> LAYOUT + LAYOUT --> SIDEBAR + LAYOUT --> DASH & KANBAN & SESS & DETAIL & ACTIVITY & ANALYTICS_P & WORKFLOWS_P & SETTINGS_P & NOTFOUND + + DASH --> SC1["StatCard x6
(sessions/agents/subagents/
events today/total events/cost)
3-column grid"] + DASH --> AC1["AgentCard[]
with collapsible subagent hierarchy"] + DASH --> EV1["Event rows"] + DASH --> HEALTH["SystemHealthTab
(health score ring, storage donut,
cache/error/success gauges,
tool bars, subagent effectiveness,
model tokens, compaction stats)"] + + KANBAN --> COL["Agents view: 4 columns
(working/waiting/
completed/error)
Sessions view: 5 columns
(active/waiting/completed/
error/abandoned)"] + COL --> AC2["AgentCard[]"] + + SESS --> TABLE["Session Table
with filters"] + DETAIL --> OVERVIEW["SessionOverview
(stat tiles, top tools,
subagent breakdown,
token flow, event mix)"] + DETAIL --> AC3["AgentCard hierarchy
parent → children tree"] + DETAIL --> CONV["ConversationView
(MarkdownContent + CodeBlock
+ ToolCallBlock per-tool styling)"] + DETAIL --> TL["Event Timeline"] + ACTIVITY --> FEED["Streaming Event List
(click row → expand payload;
Session btn → session detail)"] + WORKFLOWS_P --> WFC["12 D3.js components
(workflows/ directory)"] + + style APP fill:#6366f1,stroke:#818cf8,color:#fff + style LAYOUT fill:#1a1a28,stroke:#2a2a3d,color:#e4e4ed +``` + +### Splash & loading UX + +- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies. +- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts). +- **`workflows/CompactionImpact.tsx`** — redesigned from a one-bar-per-session chart into a "sessions by compaction count" histogram (D3) with axis titles, stat tiles (total / sessions affected / avg / peak), an explanatory help line, a plain-English summary, and rich React-managed hover tooltips (full-height per-bucket hit-area + bar highlight) matching the other charts. +- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover. + +### Self-hosted assets (no external CDN) + +Nothing the dashboard or docs render is fetched from a third-party CDN at runtime — all fonts and scripts are served locally, so every surface works fully offline and leaks nothing to external hosts. + +- **React app fonts** — Inter + JetBrains Mono are imported from `@fontsource` (latin subset) in `client/src/main.tsx`. Vite bundles the per-weight WOFF2 into `client/dist/assets/` with content hashes at build time; there is no Google Fonts ``. Importing the `latin-*` subset entry points keeps the emitted set to one WOFF2 per weight. +- **Static pages (landing + wiki)** — load a self-hosted `@font-face` sheet at the repo-root `fonts/` directory (`fonts/fonts.css` + the `*.woff2` files). The root `index.html` references `fonts/fonts.css`; the wiki references `../fonts/fonts.css` (relative paths resolve under GitHub Pages). +- **Wiki Mermaid** — vendored as `wiki/mermaid.min.js` (the genuine minified `mermaid@10.9.6` from npm, with a provenance banner; `.prettierignore`d) and loaded via a local ` + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..9a1b944 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,5067 @@ +{ + "name": "agent-dashboard-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agent-dashboard-client", + "version": "1.0.0", + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.474.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.4", + "react-router-dom": "^6.28.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", + "@types/d3-sankey": "^0.12.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "jsdom": "^27.0.1", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/d3-sankey/-/d3-sankey-0.12.5.tgz", + "integrity": "sha512-/3RZSew0cLAtzGQ+C89hq/Rp3H20QJuVRSqFy6RKLe7E0B8kd2iOS1oBsodrgds4PcNVpqWhdUEng/SHvBcJ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-shape": "^1" + } + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-path": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-1.0.11.tgz", + "integrity": "sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-sankey/node_modules/@types/d3-shape": { + "version": "1.3.12", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-1.3.12.tgz", + "integrity": "sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "^1" + } + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "license": "ISC", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "license": "ISC", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "license": "ISC", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "license": "ISC", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "license": "ISC", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "license": "ISC", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delaunator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "license": "ISC", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/i18next": { + "version": "26.0.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", + "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.474.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz", + "integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-i18next": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", + "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "license": "Unlicense" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..0ecb3ab --- /dev/null +++ b/client/package.json @@ -0,0 +1,43 @@ +{ + "name": "agent-dashboard-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "d3": "^7.9.0", + "d3-sankey": "^0.12.3", + "i18next": "^26.0.8", + "i18next-browser-languagedetector": "^8.2.1", + "lucide-react": "^0.474.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-i18next": "^17.0.4", + "react-router-dom": "^6.28.2" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", + "@types/d3-sankey": "^0.12.5", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "autoprefixer": "^10.4.20", + "jsdom": "^27.0.1", + "postcss": "^8.5.1", + "tailwindcss": "^3.4.17", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vitest": "^3.2.4" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..57c4afa --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,12 @@ +/** + * @file postcss.config.js + * @description PostCSS pipeline for the client build — Tailwind CSS and Autoprefixer. + * @author Nguyễn Ngọc Trí Vĩ + */ + +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/client/public/favicon.svg b/client/public/favicon.svg new file mode 100644 index 0000000..098f240 --- /dev/null +++ b/client/public/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/manifest.json b/client/public/manifest.json new file mode 100644 index 0000000..6430f21 --- /dev/null +++ b/client/public/manifest.json @@ -0,0 +1,28 @@ +{ + "id": "claude-code-agent-dashboard", + "name": "Agent Dashboard - Claude Code Monitor", + "short_name": "Agent Dashboard", + "description": "Real-time monitoring platform for Claude Code agent activity.", + "author": "Nguyễn Ngọc Trí Vĩ", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#6366f1", + "background_color": "#0f1117", + "orientation": "any", + "categories": ["developer-tools", "productivity"], + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + } + ] +} diff --git a/client/public/og-image.svg b/client/public/og-image.svg new file mode 100644 index 0000000..9c42a6e --- /dev/null +++ b/client/public/og-image.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Agent Dashboard + + Real-time monitoring platform for Claude Code agent activity + + + + + + Node.js 18+ + + React 18 + + TypeScript + + SQLite + + WebSocket + + MIT + + + github.com/SmartGift · smartgift.vn + diff --git a/client/public/sw.js b/client/public/sw.js new file mode 100644 index 0000000..95eb0a2 --- /dev/null +++ b/client/public/sw.js @@ -0,0 +1,99 @@ +/** + * @description Service Worker for caching static assets and handling push notifications. + * @author Nguyễn Ngọc Trí Vĩ + */ + +// Bump this any time the SW logic changes - old clients will install the new +// SW, drop their existing caches in `activate`, and `skipWaiting` so the +// freshly-built bundle starts being served on the very next request. +const CACHE_NAME = "dashboard-v2"; + +self.addEventListener("install", () => { + // No pre-cache: network-first below means the cache fills lazily, and + // there's nothing to "warm" - the v1 SW was pre-caching `/`, which is + // exactly the file most likely to go stale after a rebuild. + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + // Skip API, WebSocket, and Vite HMR endpoints + if ( + url.pathname.startsWith("/api/") || + url.pathname.startsWith("/ws") || + url.pathname.includes("__vite") + ) + return; + + // Hashed bundles under /assets/ are immutable for a given URL - cache-first + // is safe and fast. A new build emits new filenames, so stale entries simply + // don't get re-requested. + if (url.pathname.startsWith("/assets/")) { + event.respondWith( + caches.match(request).then( + (cached) => + cached || + fetch(request).then((response) => { + if (response.ok && response.type === "basic") { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }) + ) + ); + return; + } + + // Everything else (navigations, sw.js, manifest, icons, root /): network-first + // with cache fallback. The user always gets the freshest UI while online and + // a sensible fallback when offline. + event.respondWith( + fetch(request) + .then((response) => { + if (response.ok && response.type === "basic") { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }) + .catch(() => caches.match(request).then((c) => c || caches.match("/"))) + ); +}); + +// --- Push notifications (existing) --- + +self.addEventListener("push", (event) => { + const data = event.data + ? event.data.json() + : { title: "Agent Monitor", body: "New notification" }; + const { title, ...options } = data; + event.waitUntil(self.registration.showNotification(title, { silent: false, ...options })); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + clients.matchAll({ type: "window" }).then((windowClients) => { + for (const client of windowClients) { + if (client.focus) { + return client.focus(); + } + } + }) + ); +}); diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..093a814 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,125 @@ +/** + * @file App.tsx + * @description Top-level React tree for the Claude Code Agent Monitor dashboard. + * Wires together routing, real-time WebSocket ingestion, browser notifications, + * and the splash screen shown on cold load. + * + * ## Data flow + * 1. {@link useWebSocket} connects to the server's `/ws` endpoint. + * 2. Each inbound {@link WSMessage} is published on the in-memory + * {@link eventBus} so any page can subscribe without prop drilling. + * 3. {@link useNotifications} listens for alert-worthy events and surfaces OS + * notifications when permitted. + * + * ## Routing + * All feature pages nest under {@link Layout}, which owns the sidebar and + * passes `wsConnected` for the connection badge. Unknown paths fall through to + * {@link NotFound}. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./components/Layout` + * - `./components/SplashScreen` + * - `./pages/Dashboard` + * - `./pages/KanbanBoard` + * - `./pages/Sessions` + * - `./pages/SessionDetail` + * - `./pages/ActivityFeed` + * - `./pages/Analytics` + * - `./pages/Workflows` + * - `./pages/Settings` + * - `./pages/CcConfig` + * - `./pages/Workspace` + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ + +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { useCallback } from "react"; +import { Layout } from "./components/Layout"; +import { DocumentTitle } from "./components/DocumentTitle"; +import { SplashScreen } from "./components/SplashScreen"; +import { Dashboard } from "./pages/Dashboard"; +import { KanbanBoard } from "./pages/KanbanBoard"; +import { Sessions } from "./pages/Sessions"; +import { SessionDetail } from "./pages/SessionDetail"; +import { ActivityFeed } from "./pages/ActivityFeed"; +import { Analytics } from "./pages/Analytics"; +import { Workflows } from "./pages/Workflows"; +import { Settings } from "./pages/Settings"; +import { CcConfig } from "./pages/CcConfig"; +import { Workspace } from "./pages/Workspace"; +import { NotFound } from "./pages/NotFound"; +import { useWebSocket } from "./hooks/useWebSocket"; +import { useNotifications } from "./hooks/useNotifications"; +import { eventBus } from "./lib/eventBus"; +import type { WSMessage } from "./lib/types"; + +/** + * Application root component mounted by {@link main.tsx}. + * @returns Routed dashboard UI inside `BrowserRouter`. + */ +export default function App() { + const onMessage = useCallback((msg: WSMessage) => { + eventBus.publish(msg); + }, []); + + const { connected } = useWebSocket(onMessage); + useNotifications(); + + return ( + <> + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/client/src/components/AgentCard.tsx b/client/src/components/AgentCard.tsx new file mode 100644 index 0000000..19f9378 --- /dev/null +++ b/client/src/components/AgentCard.tsx @@ -0,0 +1,261 @@ +/** + * @file AgentCard.tsx + * @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked. It also visually distinguishes active agents with a border highlight. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./StatusBadge` + * - `../lib/types` + * - `../lib/format` + * + * ## Public surface + * - `AgentCard` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **AgentCard** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useTranslation } from "react-i18next"; +import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react"; +import { useNavigate } from "react-router-dom"; +import { AgentStatusBadge } from "./StatusBadge"; +import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types"; +import type { Agent, Session } from "../lib/types"; +import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format"; + +/** + * Display name for a main agent, swapping its auto-generated placeholder for the + * real session title when one exists. Main agents are created as + * ` - `, where the placeholder is either `Session ` + * (live hooks) or ` - ` (import / background sync). Replacing + * everything after the first ` - ` covers BOTH formats — the older + * `replace(/Session [0-9a-f]{8}/)` only matched the hook form, so imported + * sessions kept showing ` - ` even after their title was known. + */ +function mainAgentDisplayName(agentName: string, realSessionName: string): string { + if (!realSessionName) return agentName; + const sep = agentName.indexOf(" - "); + return sep >= 0 ? `${agentName.slice(0, sep)} - ${realSessionName}` : agentName; +} + +interface AgentCardProps { + agent: Agent; + /** Optional session data for richer main-agent rendering (model, cwd, + * cost). Subagent display ignores this. When omitted, the card falls + * back to the original minimal layout. */ + session?: Session; + label?: string; + onClick?: () => void; +} + +export function AgentCard({ agent, session, label, onClick }: AgentCardProps) { + const navigate = useNavigate(); + const { t } = useTranslation("kanban"); + const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent); + const status = effectiveAgentStatus(agent); + const isActive = agent.status === "working"; + const isMain = agent.type === "main"; + + // Session-level metadata applies to every card in the session - main and + // subagents alike. Subtitle differs by type: main uses model+cwd (its + // auto-generated name carries no info), subagents stick with their + // subagent_type label (more useful than repeating the session model). + const model = formatModelName(session?.model); + const cwdBase = pathBasename(session?.cwd); + // Cost shown on the card is scoped to what the card represents: a main agent's + // card stands in for the whole session, so it shows the session total; a + // subagent's card shows that subagent's OWN cost (server-computed from its + // token buckets). Showing the session total on a subagent card is misleading — + // it reads as if that one subagent cost the whole session's spend. A subagent + // with no recorded usage shows no cost (the cost > 0 guard below hides it), + // which is truthful rather than misleading. + const cost = isMain + ? typeof session?.cost === "number" + ? session.cost + : 0 + : typeof agent.cost === "number" + ? agent.cost + : 0; + // Real (user-given) session name - the auto-generated "Session " + // fallback carries no extra info next to the ID, so it is suppressed. + const sessionName = session?.name?.trim() || ""; + const realSessionName = /^Session [0-9a-f]{8}$/i.test(sessionName) ? "" : sessionName; + // A subagent's own model lives in its metadata (resolved from its transcript, + // not the parent session's — see issue #185). Use it everywhere this card + // shows a model so a Haiku QA agent under an Opus orchestrator reads as + // Haiku, not Opus. Falls back to the session model only for the main agent. + let subagentModel: string | null = null; + if (!isMain && agent.metadata) { + try { + const parsed = JSON.parse(agent.metadata) as { model?: string }; + subagentModel = parsed?.model ? formatModelName(parsed.model) : null; + } catch { + subagentModel = null; + } + } + // The model badge (footer) must reflect THIS card's agent: the session model + // for main, the subagent's own model for subagents. + const displayModel = isMain ? model : subagentModel; + // Model now lives in the footer badge, so the subtitle carries project + // context instead: main shows cwd + how many agents the session spawned + + // how many turns it has run; subagents show their type + the project they ran + // in. (No model here — that would duplicate the footer badge, which is what + // main cards used to do.) + const agentCount = typeof session?.agent_count === "number" ? session.agent_count : 0; + // agent_count includes the main agent itself. Show how many SUBAGENTS the + // session spawned instead, so this reconciles with the "Active Subagents" + // dashboard stat (which excludes main agents) — otherwise a card reading + // "29 agents" looks like it should equal a 29-subagent stat when the session + // actually has 28 subagents + 1 main. + const subagentCount = Math.max(0, agentCount - 1); + let sessionTurns = 0; + if (isMain && session?.metadata) { + try { + const m = JSON.parse(session.metadata) as { turn_count?: number }; + if (typeof m?.turn_count === "number") sessionTurns = m.turn_count; + } catch { + sessionTurns = 0; + } + } + const subtitle = isMain + ? [ + cwdBase, + subagentCount > 0 ? t("kanban:session.subagentSummary", { count: subagentCount }) : null, + sessionTurns > 0 ? t("kanban:session.turnSummary", { count: sessionTurns }) : null, + ] + .filter(Boolean) + .join(" · ") || null + : [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null; + + function handleClick() { + if (onClick) { + onClick(); + } else { + navigate(`/sessions/${agent.session_id}`); + } + } + + return ( +
+
+
+
+ {isMain ? : } +
+
+

+ {/* Auto-generated main-agent titles (e.g. "Main Agent - Session + 229d93fd" or "Main Agent - work - e3f8e613") swap the + placeholder for the real session name when one exists; custom + (sub)agent names are left untouched. */} + {isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name} +

+ {subtitle &&

{subtitle}

} +
+
+ {/* compact: cards are narrow — inline reason chip would squeeze the + title, so the reason stays hover-tooltip-only here. */} + +
+ + {agent.task && ( +

{agent.task}

+ )} + +
+ {agent.current_tool && ( + + + {agent.current_tool} + + )} + {/* Model badge - shown on every card when no tool is currently + running (avoids clutter on actively-running agents that already + display the running tool name). Uses the agent's OWN model: + session model for main, the subagent's resolved model otherwise. */} + {displayModel && !agent.current_tool && ( + + + {displayModel} + + )} + {cost > 0 && ( + + + {fmtCost(cost)} + + )} + {agent.ended_at ? ( + <> + + + {t("ran")} + {formatDuration(agent.started_at, agent.ended_at)} + + {timeAgo(agent.ended_at)} + + ) : ( + + + {timeAgo(agent.updated_at || agent.started_at)} + + )} + + {realSessionName && {realSessionName} ·} + {agent.session_id.slice(0, 8)} + +
+
+ ); +} diff --git a/client/src/components/AlertsNotifications.tsx b/client/src/components/AlertsNotifications.tsx new file mode 100644 index 0000000..f5b04a5 --- /dev/null +++ b/client/src/components/AlertsNotifications.tsx @@ -0,0 +1,846 @@ +/** + * @file AlertsNotifications.tsx + * @description Unified "Alerts" control center embedded in the Settings page + * (replaces the standalone /alerts route). A segmented tab UI + * combines three concerns that used to be split across a page and a panel: + * • Rules - define what conditions trigger an alert + * • Channels - webhook targets that receive fired alerts (Slack/Discord/…) + * • Activity - the live fired-alert feed with acknowledge controls + * Tab badges reflect live state (rule count, unacked alert count), and the feed + * + counts refetch on alert_triggered / alert_updated WebSocket messages. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../lib/api` + * - `../lib/eventBus` + * - `./EmptyState` + * - `./Skeleton` + * - `./WebhookSettings` + * - `./ConfirmModal` + * - `./Checkbox` + * - `./FieldHelp` + * - `../lib/format` + * - `../lib/types` + * + * ## Public surface + * - `AlertsNotifications` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **AlertsNotifications** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + BellRing, + BellOff, + Check, + CheckCheck, + ChevronDown, + ListChecks, + Plus, + RefreshCw, + Trash2, + Webhook, + X, +} from "lucide-react"; +import { api } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import { EmptyState } from "./EmptyState"; +import { Skeleton } from "./Skeleton"; +import { WebhookSettings } from "./WebhookSettings"; +import { ConfirmModal } from "./ConfirmModal"; +import { Checkbox } from "./Checkbox"; +import { FieldHelp } from "./FieldHelp"; +import { timeAgo } from "../lib/format"; +import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types"; + +const PAGE_SIZE = 25; + +// Example values surfaced in the field-help tooltips so users know what to type. +// These are the Claude Code hook event types and common built-in tool names. +const EVENT_TYPE_EXAMPLES = [ + "PreToolUse", + "PostToolUse", + "Stop", + "SubagentStop", + "Notification", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", +]; +const TOOL_NAME_EXAMPLES = [ + "Bash", + "Read", + "Edit", + "Write", + "Grep", + "Glob", + "Task", + "WebFetch", + "WebSearch", + "TodoWrite", +]; +const SUMMARY_EXAMPLES = ["error", "permission", "timeout", "rate limit", "denied"]; + +const RULE_TYPES: AlertRuleType[] = [ + "event_pattern", + "inactivity", + "status_duration", + "token_threshold", +]; + +type TabKey = "rules" | "channels" | "activity"; + +interface RuleFormState { + name: string; + rule_type: AlertRuleType; + event_type: string; + tool_name: string; + summary_contains: string; + count: string; + window_minutes: string; + minutes: string; + status: "working" | "waiting"; + total_tokens: string; + cooldown_seconds: string; +} + +const EMPTY_FORM: RuleFormState = { + name: "", + rule_type: "event_pattern", + event_type: "", + tool_name: "", + summary_contains: "", + count: "1", + window_minutes: "5", + minutes: "10", + status: "working", + total_tokens: "1000000", + cooldown_seconds: "300", +}; + +function buildConfig(form: RuleFormState): AlertRule["config"] { + switch (form.rule_type) { + case "event_pattern": { + const config: AlertRule["config"] = {}; + if (form.event_type.trim()) config.event_type = form.event_type.trim(); + if (form.tool_name.trim()) config.tool_name = form.tool_name.trim(); + if (form.summary_contains.trim()) config.summary_contains = form.summary_contains.trim(); + const count = parseInt(form.count, 10); + config.count = Number.isFinite(count) && count > 0 ? count : 1; + if (config.count > 1) { + const window = parseFloat(form.window_minutes); + config.window_minutes = Number.isFinite(window) && window > 0 ? window : 5; + } + return config; + } + case "inactivity": + return { minutes: parseFloat(form.minutes) }; + case "status_duration": + return { status: form.status, minutes: parseFloat(form.minutes) }; + case "token_threshold": + return { total_tokens: parseInt(form.total_tokens, 10) }; + } +} + +function describeRule(rule: AlertRule, t: (key: string, opts?: Record) => string) { + const c = rule.config; + switch (rule.rule_type) { + case "event_pattern": { + const parts = [ + c.event_type && `event=${c.event_type}`, + c.tool_name && `tool=${c.tool_name}`, + c.summary_contains && `summary~"${c.summary_contains}"`, + ].filter(Boolean); + const base = parts.join(" · "); + return (c.count ?? 1) > 1 + ? t("ruleDesc.eventPatternCount", { + pattern: base, + count: c.count, + window: c.window_minutes, + }) + : t("ruleDesc.eventPattern", { pattern: base }); + } + case "inactivity": + return t("ruleDesc.inactivity", { minutes: c.minutes }); + case "status_duration": + return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes }); + case "token_threshold": + return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() }); + } +} + +export function AlertsNotifications() { + const { t } = useTranslation("alerts"); + const { t: ts } = useTranslation("settings"); + + const [tab, setTab] = useState("rules"); + + // Rules + const [rules, setRules] = useState([]); + const [loadingRules, setLoadingRules] = useState(true); + const [formOpen, setFormOpen] = useState(false); + const [form, setForm] = useState(EMPTY_FORM); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + const [confirmRule, setConfirmRule] = useState(null); + + // Feed + const [alerts, setAlerts] = useState([]); + const [total, setTotal] = useState(0); + const [unacked, setUnacked] = useState(0); + const [unackedOnly, setUnackedOnly] = useState(false); + const [loadingAlerts, setLoadingAlerts] = useState(true); + + const loadRules = useCallback(async () => { + setLoadingRules(true); + try { + const res = await api.alerts.rules.list(); + setRules(res.rules); + } catch (err) { + console.error("Failed to load alert rules:", err); + } finally { + setLoadingRules(false); + } + }, []); + + const loadAlerts = useCallback(async () => { + setLoadingAlerts(true); + try { + const res = await api.alerts.list({ + unacked: unackedOnly || undefined, + limit: PAGE_SIZE, + offset: 0, + }); + setAlerts(res.alerts); + setTotal(res.total); + setUnacked(res.unacked); + } catch (err) { + console.error("Failed to load alerts:", err); + } finally { + setLoadingAlerts(false); + } + }, [unackedOnly]); + + const loadMore = useCallback(async () => { + try { + const res = await api.alerts.list({ + unacked: unackedOnly || undefined, + limit: PAGE_SIZE, + offset: alerts.length, + }); + setAlerts((prev) => [...prev, ...res.alerts]); + setTotal(res.total); + setUnacked(res.unacked); + } catch (err) { + console.error("Failed to load more alerts:", err); + } + }, [unackedOnly, alerts.length]); + + useEffect(() => { + loadRules(); + }, [loadRules]); + + useEffect(() => { + loadAlerts(); + }, [loadAlerts]); + + // Live updates: any fired/acked alert refreshes the feed + counts regardless + // of which tab is open, so the Activity badge stays accurate. + useEffect(() => { + return eventBus.subscribe((msg: WSMessage) => { + if (msg.type === "alert_triggered" || msg.type === "alert_updated") { + loadAlerts(); + } + }); + }, [loadAlerts]); + + const set = (patch: Partial) => setForm((prev) => ({ ...prev, ...patch })); + + const onCreateRule = async () => { + if (saving) return; + setSaving(true); + setFormError(null); + try { + const cooldown = parseInt(form.cooldown_seconds, 10); + await api.alerts.rules.create({ + name: form.name.trim(), + rule_type: form.rule_type, + config: buildConfig(form), + cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300, + }); + setForm(EMPTY_FORM); + setFormOpen(false); + loadRules(); + } catch (err) { + setFormError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const onToggleRule = async (rule: AlertRule) => { + try { + await api.alerts.rules.update(rule.id, { enabled: !rule.enabled }); + loadRules(); + } catch (err) { + console.error("Failed to toggle alert rule:", err); + } + }; + + const onDeleteRule = async (rule: AlertRule) => { + try { + await api.alerts.rules.remove(rule.id); + setConfirmRule(null); + loadRules(); + loadAlerts(); + } catch (err) { + console.error("Failed to delete alert rule:", err); + } + }; + + const onAck = async (id: number) => { + try { + await api.alerts.ack(id); + loadAlerts(); + } catch (err) { + console.error("Failed to acknowledge alert:", err); + } + }; + + const onAckAll = async () => { + try { + await api.alerts.ackAll(); + loadAlerts(); + } catch (err) { + console.error("Failed to acknowledge alerts:", err); + } + }; + + // Mirror the server-side validation so obviously invalid rules never make it + // to a request. + const minutesVal = parseFloat(form.minutes); + const tokensVal = parseInt(form.total_tokens, 10); + const countVal = parseInt(form.count, 10); + const windowVal = parseFloat(form.window_minutes); + const canSubmit = + form.name.trim().length > 0 && + (form.rule_type !== "event_pattern" || + (Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) && + Number.isFinite(countVal) && + countVal > 0 && + (countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) && + ((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") || + (Number.isFinite(minutesVal) && minutesVal > 0)) && + (form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)); + + const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [ + { + key: "rules", + label: ts("alertsHub.tabRules"), + icon: ListChecks, + badge: rules.length || undefined, + }, + { key: "channels", label: ts("alertsHub.tabChannels"), icon: Webhook }, + { + key: "activity", + label: ts("alertsHub.tabActivity"), + icon: BellRing, + badge: unacked || undefined, + }, + ]; + + return ( +
+ {/* Segmented tab control */} +
+ {TABS.map((tb) => { + const active = tab === tb.key; + const Icon = tb.icon; + return ( + + ); + })} +
+ + {/* ── RULES ── */} + {tab === "rules" && ( +
+
+
+

{t("rules.title")}

+

{ts("alertsHub.rulesHint")}

+
+ +
+ + {formOpen && ( +
+
+ + +
+ +

{t(`ruleTypeHints.${form.rule_type}`)}

+ + {form.rule_type === "event_pattern" && ( +
+ + + + + {parseInt(form.count, 10) > 1 && ( + + )} +
+ )} + + {(form.rule_type === "inactivity" || form.rule_type === "status_duration") && ( +
+ {form.rule_type === "status_duration" && ( + + )} + +
+ )} + + {form.rule_type === "token_threshold" && ( +
+ +
+ )} + +
+ + +
+ {formError &&

{formError}

} +
+ )} + + {loadingRules ? ( +
+ + +
+ ) : rules.length === 0 ? ( + + ) : ( +
    + {rules.map((rule) => ( +
  • +
    +
    + + {rule.name} + + + {t(`ruleTypes.${rule.rule_type}`)} + +
    +

    + {describeRule(rule, t)} ·{" "} + {t("rules.cooldown", { seconds: rule.cooldown_seconds })} +

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ )} + + {/* ── CHANNELS (webhooks) ── */} + {tab === "channels" && } + + {/* ── ACTIVITY (fired-alert feed) ── */} + {tab === "activity" && ( +
+
+

+ {t("feed.title")} + {unacked > 0 && ( + + {t("feed.unackedCount", { count: unacked })} + + )} +

+
+ + + {unacked > 0 && ( + + )} +
+
+ + {loadingAlerts && alerts.length === 0 ? ( +
+ + + +
+ ) : alerts.length === 0 ? ( + + ) : ( + <> +
    + {alerts.map((alert) => ( +
  • +
    +
    + + {alert.message} +
    +

    + {timeAgo(alert.triggered_at)} · {alert.rule_name} + {alert.session_id && ( + <> + {" · "} + + {t("feed.viewSession")} + + + )} +

    +
    + {!alert.acknowledged_at && ( + + )} +
  • + ))} +
+ + {alerts.length < total && ( +
+ +
+ )} + + )} +
+ )} + + setConfirmRule(null)} + onConfirm={() => confirmRule && onDeleteRule(confirmRule)} + /> +
+ ); +} diff --git a/client/src/components/Checkbox.tsx b/client/src/components/Checkbox.tsx new file mode 100644 index 0000000..2ac2dd5 --- /dev/null +++ b/client/src/components/Checkbox.tsx @@ -0,0 +1,114 @@ +/** + * @file Checkbox.tsx + * @description Accessible custom checkbox built on a ` + ); +} diff --git a/client/src/components/ConfirmModal.tsx b/client/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..dab0c20 --- /dev/null +++ b/client/src/components/ConfirmModal.tsx @@ -0,0 +1,185 @@ +/** + * @file ConfirmModal.tsx + * @description Centered confirmation dialog for destructive or irreversible + * actions (delete webhook, remove alert rule, etc.). Replaces `window.confirm` + * with themed UI that matches the dashboard and distinguishes a loading (`busy`) + * confirm button from one refused outright (`disabled`). + * + * ## Dismissal + * Clicking the backdrop, pressing Escape, or clicking the X cancels. The confirm + * button can be styled non-destructive for neutral confirmations. + * + * ## Accessibility + * Focus moves to Cancel on open (safer default), Tab cycles within the dialog, + * Escape cancels, and focus restores to the previously focused element on close. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useId, useRef, type ReactNode } from "react"; +import { AlertTriangle, X } from "lucide-react"; + +/** Props for {@link ConfirmModal}. */ +export interface ConfirmModalProps { + /** When false, nothing is rendered. */ + open: boolean; + /** Dialog heading. */ + title: string; + /** Optional supporting message below the title. */ + message?: string; + /** Primary action label (e.g. "Delete"). */ + confirmLabel: string; + /** Secondary cancel label. */ + cancelLabel: string; + /** When true (default), confirm button uses red destructive styling. */ + destructive?: boolean; + /** Disables confirm while an async delete is in flight. */ + busy?: boolean; + /** + * Disables confirm because the action is not permitted right now (a blocker, an + * unmet checkbox, facts that failed to load). Distinct from `busy`: nothing is + * in flight, so callers must not conflate the two — passing a refusal as `busy` + * makes a blocked action read as perpetually loading. + */ + disabled?: boolean; + /** Optional action-specific facts shown before the confirmation controls. */ + children?: ReactNode; + /** Called when the user confirms. */ + onConfirm: () => void; + /** Called on cancel, backdrop click, Escape, or X. */ + onCancel: () => void; +} + +/** + * Modal confirmation overlay. + * @param props See {@link ConfirmModalProps}. + */ +export function ConfirmModal({ + open, + title, + message, + confirmLabel, + cancelLabel, + destructive = true, + busy = false, + disabled = false, + children, + onConfirm, + onCancel, +}: ConfirmModalProps) { + const titleId = useId(); + const messageId = useId(); + const panelRef = useRef(null); + const cancelRef = useRef(null); + const previouslyFocused = useRef(null); + + useEffect(() => { + if (!open) return; + + previouslyFocused.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + // Prefer Cancel so Enter/activation doesn't immediately destroy data. + const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key !== "Tab" || !panelRef.current) return; + + const focusable = panelRef.current.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + const first = focusable.item(0); + const last = focusable.item(focusable.length - 1); + if (!first || !last) return; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + document.addEventListener("keydown", onKey); + return () => { + window.clearTimeout(focusTimer); + document.removeEventListener("keydown", onKey); + previouslyFocused.current?.focus?.(); + previouslyFocused.current = null; + }; + }, [open, onCancel]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={titleId} + aria-describedby={message ? messageId : undefined} + > +
+ {destructive && ( +
+ +
+ )} +
+

+ {title} +

+ {message && ( +

+ {message} +

+ )} + {children} +
+ +
+
+ + +
+
+
+ ); +} diff --git a/client/src/components/DateTimePicker.tsx b/client/src/components/DateTimePicker.tsx new file mode 100644 index 0000000..319f02b --- /dev/null +++ b/client/src/components/DateTimePicker.tsx @@ -0,0 +1,272 @@ +/** + * @file DateTimePicker.tsx + * @description A React component that provides a user-friendly interface for selecting both date and time. The component displays a button that shows the currently selected date and time in a human-readable format. When the button is clicked, a dropdown appears containing a calendar for date selection and an input for time selection. The component handles edge cases such as invalid dates and ensures that the dropdown is positioned correctly within the viewport. It also allows users to clear their selection easily. This component is designed to be reusable across the application wherever date and time input is required. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `DateTimePicker` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **DateTimePicker** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import React, { useState, useRef, useEffect } from "react"; +import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react"; + +interface DateTimePickerProps { + value: string; // Expected format: YYYY-MM-DDTHH:mm + onChange: (value: string) => void; + placeholder?: string; + className?: string; + "aria-label"?: string; + title?: string; +} + +export function DateTimePicker({ + value, + onChange, + placeholder = "Select date & time", + className = "", + "aria-label": ariaLabel, + title, +}: DateTimePickerProps) { + const [isOpen, setIsOpen] = useState(false); + const [alignment, setAlignment] = useState<"left" | "right">("left"); + const containerRef = useRef(null); + + // Parse value + const dateObj = value ? new Date(value) : null; + const [viewDate, setViewDate] = useState(dateObj || new Date()); + + useEffect(() => { + if (isOpen && containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + if (rect.left + 230 > window.innerWidth) { + setAlignment("right"); + } else { + setAlignment("left"); + } + } + }, [isOpen]); + + useEffect(() => { + if (dateObj && !isNaN(dateObj.getTime())) { + setViewDate(dateObj); + } + }, [value]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const formatDisplay = (d: Date | null) => { + if (!d || isNaN(d.getTime())) return ""; + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + }; + + const handleDateClick = (day: number) => { + const newDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day); + if (dateObj && !isNaN(dateObj.getTime())) { + newDate.setHours(dateObj.getHours(), dateObj.getMinutes()); + } else { + newDate.setHours(0, 0); // Default midnight + } + updateValue(newDate); + }; + + const handleTimeChange = (e: React.ChangeEvent) => { + const timeStr = e.target.value; + if (!timeStr) return; + const parts = timeStr.split(":"); + if (parts.length !== 2) return; + const h = parts[0] || "0"; + const m = parts[1] || "0"; + const newDate = dateObj && !isNaN(dateObj.getTime()) ? new Date(dateObj) : new Date(); + newDate.setHours(parseInt(h, 10), parseInt(m, 10)); + updateValue(newDate); + }; + + const updateValue = (d: Date) => { + const y = d.getFullYear(); + const mo = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + const h = String(d.getHours()).padStart(2, "0"); + const mi = String(d.getMinutes()).padStart(2, "0"); + onChange(`${y}-${mo}-${day}T${h}:${mi}`); + }; + + const clearValue = (e: React.MouseEvent) => { + e.stopPropagation(); + onChange(""); + setIsOpen(false); + }; + + const daysInMonth = new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 0).getDate(); + const firstDayOfMonth = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1).getDay(); + + const days = []; + for (let i = 0; i < firstDayOfMonth; i++) { + days.push(
); + } + for (let i = 1; i <= daysInMonth; i++) { + const isSelected = !!( + dateObj && + dateObj.getDate() === i && + dateObj.getMonth() === viewDate.getMonth() && + dateObj.getFullYear() === viewDate.getFullYear() + ); + const isToday = + new Date().getDate() === i && + new Date().getMonth() === viewDate.getMonth() && + new Date().getFullYear() === viewDate.getFullYear(); + + days.push( + + ); + } + + const timeValue = + dateObj && !isNaN(dateObj.getTime()) + ? `${String(dateObj.getHours()).padStart(2, "0")}:${String(dateObj.getMinutes()).padStart(2, "0")}` + : ""; + + return ( +
+ + + {isOpen && ( +
+ {/* Calendar Header */} +
+ + + {viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })} + + +
+ + {/* Calendar Grid */} +
+
+ {["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => ( +
+ {day} +
+ ))} +
+
{days}
+
+ + {/* Time Picker */} +
+
+ + Time +
+ +
+
+ )} +
+ ); +} diff --git a/client/src/components/DocumentTitle.tsx b/client/src/components/DocumentTitle.tsx new file mode 100644 index 0000000..927daff --- /dev/null +++ b/client/src/components/DocumentTitle.tsx @@ -0,0 +1,47 @@ +/** + * @file DocumentTitle.tsx + * @description Route-aware document title setter nested under BrowserRouter so + * `useLocation` works. Keeps multi-tab window switchers readable. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useLocation } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; + +/** + * Maps the current pathname to a localized browser tab title. + */ +export function DocumentTitle() { + const { t } = useTranslation("nav"); + const location = useLocation(); + + let title = t("dashboard"); + const path = location.pathname; + const sessionId = path.match(/^\/sessions\/([^/]+)/)?.[1]; + + if (sessionId) { + title = `${t("sessions")} · ${sessionId.slice(0, 8)}`; + } else if (path.startsWith("/sessions")) { + title = t("sessions"); + } else if (path.startsWith("/kanban")) { + title = t("agentBoard"); + } else if (path.startsWith("/activity")) { + title = t("activityFeed"); + } else if (path.startsWith("/analytics")) { + title = t("analytics"); + } else if (path.startsWith("/workflows")) { + title = t("workflows"); + } else if (path.startsWith("/cc-config")) { + title = t("ccConfig"); + } else if (path.startsWith("/run")) { + title = t("run"); + } else if (path.startsWith("/settings")) { + title = t("settings"); + } else if (path !== "/") { + title = t("notFound"); + } + + useDocumentTitle(title); + return null; +} diff --git a/client/src/components/EmptyState.tsx b/client/src/components/EmptyState.tsx new file mode 100644 index 0000000..5b5aac6 --- /dev/null +++ b/client/src/components/EmptyState.tsx @@ -0,0 +1,101 @@ +/** + * @file EmptyState.tsx + * @description Centered empty-state panel used whenever a page or section has + * nothing to render yet — no sessions, no events, no search hits, or a feature + * that has not been configured. Keeps the UI from looking broken by giving the + * user a clear icon, title, explanation, and an optional call-to-action slot. + * + * ## When to use + * Prefer this over ad-hoc "No data" paragraphs so every list/table page shares + * the same vertical rhythm, typography, and card chrome. The optional `action` + * slot accepts any React node (usually a `` or ` + )} +
+ +
+ toggle("status", item)} + /> + toggle("event_type", item)} + /> + toggle("tool_name", item)} + /> + {agentOptions && agentOptions.length > 0 && ( + a.id)} + labels={Object.fromEntries(agentOptions.map((a) => [a.id, a.label]))} + selected={value.agent_id} + onToggle={(item) => toggle("agent_id", item)} + /> + )} + {!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && ( + s.id)} + labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))} + selected={value.session_id} + onToggle={(item) => toggle("session_id", item)} + /> + )} +
+ + ); +} + +function ChipGroup({ + label, + options, + selected, + onToggle, + labels, +}: { + label: string; + options: string[]; + selected: string[]; + onToggle: (item: string) => void; + labels?: Record; +}) { + const { t } = useTranslation("common"); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + // Click-outside dismiss. + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const selectedCount = selected.length; + + return ( +
+ + {open && ( +
+ {options.length === 0 ? ( +

+ {t("eventFilters.noOptions")} +

+ ) : ( + options.map((opt) => { + const checked = selected.includes(opt); + const display = labels?.[opt] ?? opt; + return ( + + ); + }) + )} +
+ )} +
+ ); +} diff --git a/client/src/components/EventFiltersInfo.tsx b/client/src/components/EventFiltersInfo.tsx new file mode 100644 index 0000000..5c52a6a --- /dev/null +++ b/client/src/components/EventFiltersInfo.tsx @@ -0,0 +1,177 @@ +/** + * @file EventFiltersInfo.tsx + * @description Collapsible help panel for the Event Timeline filter toolbar. + * Explains agent status badges, Pre/Post hook lifecycle, how filters compose, + * and what each dropdown accepts. Mounted above {@link EventFilters} on both + * Activity Feed and Session Detail so users can self-serve without leaving the + * page. + * + * Built with native `
` / `` for keyboard accessibility without + * a custom popover primitive. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./StatusBadge` + * + * ## Public surface + * - `EventFiltersInfo` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **EventFiltersInfo** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useTranslation } from "react-i18next"; +import { Info } from "lucide-react"; +import { AgentStatusBadge } from "./StatusBadge"; + +/** + * Top-level accordion rendered once per timeline view. + * @returns Expandable help card with nested sections. + */ +export function EventFiltersInfo() { + const { t } = useTranslation("common"); + return ( +
+ + + + {t("eventFilters.help.title")} + + - {t("eventFilters.help.subtitle")} + + +
+
+

{t("eventFilters.help.statusesIntro")}

+
+
+ +
+
{t("eventFilters.help.statusWorkingDesc")}
+
+ +
+
{t("eventFilters.help.statusWaitingDesc")}
+
+ +
+
{t("eventFilters.help.statusCompletedDesc")}
+
+ +
+
{t("eventFilters.help.statusErrorDesc")}
+
+
+ +
+

{t("eventFilters.help.lifecycleDesc")}

+ + {t("eventFilters.help.lifecycleFlow")} + +
+ +
+
    +
  • {t("eventFilters.help.filterTip1")}
  • +
  • {t("eventFilters.help.filterTip2")}
  • +
  • {t("eventFilters.help.filterTipGrouping")}
  • +
  • {t("eventFilters.help.filterTip3")}
  • +
  • {t("eventFilters.help.filterTip4")}
  • +
+
+ +
+
+ + + + + + + +
+
+
+
+ ); +} + +/** Nested collapsible section inside the help panel. */ +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+ + + {title} + +
{children}
+
+ ); +} + +/** Label + description row in the filter-values glossary. */ +function Field({ label, desc }: { label: string; desc: string }) { + return ( + <> +
{label}
+
{desc}
+ + ); +} diff --git a/client/src/components/FieldHelp.tsx b/client/src/components/FieldHelp.tsx new file mode 100644 index 0000000..686d8f7 --- /dev/null +++ b/client/src/components/FieldHelp.tsx @@ -0,0 +1,182 @@ +/** + * @file FieldHelp.tsx + * @description Contextual "(?)" help trigger for dense settings forms. Opens a + * rich popover with title, description, optional example chips, and an optional + * footnote — all portaled to `` and repositioned on scroll/resize so the + * panel never clips inside scrolling cards. + * + * ## Interaction model + * Opens on hover, focus, or click (toggle). Escape dismisses. The popover uses + * `pointer-events-none` so moving the pointer toward it does not accidentally + * close the trigger's hover state mid-read. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `FieldHelpProps` — exported API; see TSDoc on the symbol for behavior. + * - `FieldHelp` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **FieldHelpProps** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **FieldHelp** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; +import { HelpCircle } from "lucide-react"; + +/** Props for {@link FieldHelp}. */ +export interface FieldHelpProps { + /** Optional bold heading inside the popover. */ + title?: string; + /** Main explanatory copy. */ + description: string; + /** Short example values rendered as monospace chips. */ + examples?: string[]; + /** Secondary note shown below examples. */ + note?: string; +} + +/** + * Inline help control for form fields. + * @param props See {@link FieldHelpProps}. + */ +export function FieldHelp({ title, description, examples, note }: FieldHelpProps) { + const { t } = useTranslation("common"); + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const popRef = useRef(null); + const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + + const place = useCallback(() => { + const btn = btnRef.current; + const pop = popRef.current; + if (!btn) return; + const r = btn.getBoundingClientRect(); + const w = pop?.offsetWidth ?? 300; + const h = pop?.offsetHeight ?? 120; + const pad = 10; + let left = r.left + r.width / 2 - w / 2; + if (left < pad) left = pad; + if (left + w > window.innerWidth - pad) left = window.innerWidth - w - pad; + let top = r.bottom + 8; + if (top + h > window.innerHeight - pad) top = r.top - h - 8; // flip up + setPos({ left, top }); + }, []); + + useEffect(() => { + if (!open) return; + place(); + const onScroll = () => place(); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", onScroll); + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", onScroll); + document.removeEventListener("keydown", onKey); + }; + }, [open, place]); + + return ( + + + {open && + createPortal( +
+ {title &&

{title}

} +

{description}

+ {examples && examples.length > 0 && ( +
+

+ {t("examples")} +

+
+ {examples.map((ex) => ( + + {ex} + + ))} +
+
+ )} + {note &&

{note}

} +
, + document.body + )} +
+ ); +} diff --git a/client/src/components/ImportHistory.tsx b/client/src/components/ImportHistory.tsx new file mode 100644 index 0000000..3362506 --- /dev/null +++ b/client/src/components/ImportHistory.tsx @@ -0,0 +1,771 @@ +/** + * @file Import History panel - step-by-step instructions and three import modes + * (rescan default folder, scan any path, upload files/archives). Renders inside + * the Settings page and keeps all I/O isolated behind the api.import.* client. + * + * Robustness notes: + * • Every mode funnels through the same server-side parser used for live + * ingestion, so token counts and per-model cost are computed identically. + * • Re-imports are idempotent: sessions are deduplicated by session ID and + * compaction baselines prevent token double-counting. + * • Archive extraction is guarded against path traversal on the server. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../lib/api` + * - `../lib/eventBus` + * - `../lib/types` + * + * ## Public surface + * - `ImportHistory` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ImportHistory** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useEffect, useRef, useState, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { + FolderOpen, + RefreshCw, + UploadCloud, + FileArchive, + FolderInput, + CheckCircle2, + AlertTriangle, + Loader2, + HardDrive, + ListChecks, + Info, + Copy, + Check, + XCircle, + History, + Terminal, + DatabaseBackup, + RotateCcw, +} from "lucide-react"; +import { api, type ImportResult, type ImportBackupResult } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import type { WSMessage, ImportProgressMessage } from "../lib/types"; + +type Mode = "rescan" | "path" | "upload" | "backup"; + +type GuideResponse = Awaited>; +type Progress = ImportProgressMessage; + +export function ImportHistory() { + const { t } = useTranslation("settings"); + const [mode, setMode] = useState("rescan"); + const [guide, setGuide] = useState(null); + const [folderPath, setFolderPath] = useState(""); + const [files, setFiles] = useState([]); + const [running, setRunning] = useState(false); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const [instructionsOpen, setInstructionsOpen] = useState(true); + const [copied, setCopied] = useState(false); + const [dragging, setDragging] = useState(false); + const fileInputRef = useRef(null); + + // "Restore backup" mode: import a full dashboard export (.json) produced by + // the Export data button — the round-trip for consolidating machines. + const [backupFile, setBackupFile] = useState(null); + const [backupResult, setBackupResult] = useState(null); + const backupInputRef = useRef(null); + + // Load the guide once. If the API isn't reachable, fall back to sensible + // defaults so the UI still explains what to do. + useEffect(() => { + api.import + .guide() + .then(setGuide) + .catch(() => { + setGuide({ + platform: "unknown", + default_projects_dir: "~/.claude/projects", + default_projects_dir_display: "~/.claude/projects", + default_projects_dir_exists: false, + default_projects_dir_stats: { projects: 0, jsonl_files: 0 }, + archive_command: "tar -czf claude-history.tar.gz -C ~/.claude projects", + supported_extensions: [".jsonl", ".meta.json", ".zip", ".tar.gz", ".tgz", ".gz"], + max_upload_bytes: 1024 * 1024 * 1024, + max_upload_files: 2000, + steps: [], + }); + }); + }, []); + + // Stream import progress from the websocket so long-running imports stay + // responsive. We only render the latest snapshot. + useEffect(() => { + return eventBus.subscribe((msg: WSMessage) => { + if (msg.type !== "import.progress") return; + setProgress(msg.data as Progress); + }); + }, []); + + const reset = useCallback(() => { + setErrorMsg(null); + setResult(null); + setBackupResult(null); + setProgress(null); + }, []); + + const handleRescan = async () => { + reset(); + setRunning(true); + try { + const res = await api.import.rescan(); + setResult(res); + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + } finally { + setRunning(false); + setProgress(null); + } + }; + + const handleScanPath = async () => { + reset(); + const trimmed = folderPath.trim(); + if (!trimmed) { + setErrorMsg(t("import.errors.pathRequired")); + return; + } + setRunning(true); + try { + const res = await api.import.scanPath(trimmed); + setResult(res); + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + } finally { + setRunning(false); + setProgress(null); + } + }; + + const handleUpload = async () => { + reset(); + if (files.length === 0) { + setErrorMsg(t("import.errors.noFiles")); + return; + } + setRunning(true); + try { + const res = await api.import.upload(files); + setResult(res); + setFiles([]); + if (fileInputRef.current) fileInputRef.current.value = ""; + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + } finally { + setRunning(false); + setProgress(null); + } + }; + + const handleRestore = async () => { + reset(); + if (!backupFile) { + setErrorMsg(t("import.errors.noFiles")); + return; + } + setRunning(true); + try { + const res = await api.settings.importData(backupFile); + setBackupResult(res); + setBackupFile(null); + if (backupInputRef.current) backupInputRef.current.value = ""; + } catch (err) { + setErrorMsg(err instanceof Error ? err.message : String(err)); + } finally { + setRunning(false); + setProgress(null); + } + }; + + const onSelectFiles = (list: FileList | null) => { + if (!list) return; + const arr = Array.from(list).filter((f) => { + const lower = f.name.toLowerCase(); + return ( + lower.endsWith(".jsonl") || + lower.endsWith(".meta.json") || + lower.endsWith(".zip") || + lower.endsWith(".tar") || + lower.endsWith(".tar.gz") || + lower.endsWith(".tgz") || + lower.endsWith(".gz") + ); + }); + setFiles((prev) => { + const seen = new Set(prev.map((f) => `${f.name}:${f.size}`)); + const next = [...prev]; + for (const f of arr) { + const key = `${f.name}:${f.size}`; + if (!seen.has(key)) next.push(f); + } + return next; + }); + }; + + const copyArchiveCmd = async () => { + if (!guide) return; + try { + await navigator.clipboard.writeText(guide.archive_command); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + /* clipboard unavailable */ + } + }; + + const progressText = (() => { + if (!progress) return null; + if (progress.phase === "scan") return t("import.progress.scan"); + if (progress.phase === "extract") { + return t("import.progress.extract", { + processed: progress.processed ?? 0, + total: progress.total ?? 0, + }); + } + if (progress.phase === "parse") { + return t("import.progress.parse", { + processed: progress.processed ?? 0, + total: progress.total ?? 0, + }); + } + if (progress.phase === "complete") return t("import.progress.complete"); + if (progress.phase === "error") return t("import.progress.error"); + return null; + })(); + + const totalSize = files.reduce((s, f) => s + f.size, 0); + + return ( +
+

+ + {t("import.title")} +

+

{t("import.description")}

+

{t("cursorPathsNote")}

+ +
+ {/* Step-by-step instructions */} +
+ + {instructionsOpen && ( +
+ {/* Default location card */} + {guide && ( +
+ + {t("import.defaultLocation")}: + + {guide.default_projects_dir_display} + + {guide.default_projects_dir_exists ? ( + + + {t("import.locationFound")} + + · {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "} + {guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")} + + + ) : ( + + + {t("import.locationMissing")} + + )} +
+ )} + + {/* Steps */} +
+ + + {guide && ( +
+ + + {guide.archive_command} + + +
+ )} +
+ + +
+ +
+ + {t("import.accuracyNote")} +
+
+ )} +
+ + {/* Mode switcher */} +
+ } + title={t("import.modeRescan")} + desc={t("import.modeRescanDesc")} + onClick={() => setMode("rescan")} + /> + } + title={t("import.modeFolder")} + desc={t("import.modeFolderDesc")} + onClick={() => setMode("path")} + /> + } + title={t("import.modeUpload")} + desc={t("import.modeUploadDesc")} + onClick={() => setMode("upload")} + /> + } + title={t("import.modeBackup")} + desc={t("import.modeBackupDesc")} + onClick={() => setMode("backup")} + /> +
+ + {/* Mode panel */} +
+ {mode === "rescan" && ( +
+
+ + + {guide?.default_projects_dir_display || "~/.claude/projects"} + +
+ +
+ )} + + {mode === "path" && ( +
+
+ setFolderPath(e.target.value)} + placeholder={t("import.folderPlaceholder")} + className="input w-full text-sm font-mono" + spellCheck={false} + /> +

{t("import.folderHelper")}

+
+
+ +
+
+ )} + + {mode === "upload" && ( +
+
fileInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + onSelectFiles(e.dataTransfer.files); + }} + className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ + dragging + ? "border-blue-400 bg-blue-500/5" + : "border-border hover:border-gray-500 bg-surface-1" + }`} + > + +

{t("import.dropzoneHint")}

+

{t("import.dropzoneSub")}

+ onSelectFiles(e.target.files)} + className="hidden" + /> +
+ {files.length > 0 && ( +
+ + + {t("import.filesSelected", { count: files.length })} + ({formatBytes(totalSize)}) + + +
+ )} +
+ +
+
+ )} + + {mode === "backup" && ( +
+
backupInputRef.current?.click()} + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + const f = e.dataTransfer.files?.[0]; + if (f) setBackupFile(f); + }} + className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ + dragging + ? "border-blue-400 bg-blue-500/5" + : "border-border hover:border-gray-500 bg-surface-1" + }`} + > + +

{t("import.backupHint")}

+

{t("import.backupSub")}

+ setBackupFile(e.target.files?.[0] || null)} + className="hidden" + /> +
+ {backupFile && ( +
+ + + {backupFile.name} + ({formatBytes(backupFile.size)}) + + +
+ )} +
+ +
+
+ )} +
+ + {/* In-flight progress */} + {running && progressText && ( +
+ + {progressText} + {progress?.current && ( + + · {progress.current.split("/").slice(-2).join("/")} + + )} +
+ )} + + {/* Errors */} + {errorMsg && ( +
+ + {errorMsg} +
+ )} + + {/* Result summary */} + {result && !running && ( +
+
+ + {t("import.result.title")} +
+
+ + + + 0 ? "text-red-300" : "text-gray-500"} + /> +
+ {typeof result.files_scanned === "number" && ( +

+ {t("import.result.filesScanned", { count: result.files_scanned })} + {result.path ? ` · ${result.path}` : ""} +

+ )} +
+ )} + + {/* Restore-from-backup result summary */} + {backupResult && !running && ( +
+
+ + {t("import.backupResult.title")} +
+
+ + + + +
+

+ {t("import.backupResult.detail", { + agents: backupResult.agents, + workflows: backupResult.workflows, + runs: backupResult.dashboard_runs, + rules: backupResult.alert_rules, + })} +

+
+ )} +
+
+ ); +} + +function Step({ + title, + body, + children, +}: { + title: string; + body: string; + children?: React.ReactNode; +}) { + return ( +
+

{title}

+

{body}

+ {children} +
+ ); +} + +function ModeButton({ + active, + icon, + title, + desc, + onClick, +}: { + active: boolean; + icon: React.ReactNode; + title: string; + desc: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ResultStat({ label, value, color }: { label: string; value: number; color: string }) { + return ( +
+

{value.toLocaleString()}

+

{label}

+
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx new file mode 100644 index 0000000..d6182c9 --- /dev/null +++ b/client/src/components/Layout.tsx @@ -0,0 +1,128 @@ +/** + * @file Layout.tsx + * @description Application shell that frames every authenticated route: persistent + * sidebar, main content column, update notifier, and the Tabby assistant overlay. + * The layout is the single parent route in {@link App} — child pages render inside + * React Router's `` so navigation never remounts chrome. + * + * ## Sidebar persistence + * Collapsed state is read once from `localStorage` via {@link loadCollapsed} and + * written back on every toggle. Failures to access storage are swallowed so a + * private-browsing quota error never breaks the UI. + * + * ## Sticky descendants + * The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal + * overflow is clipped without creating a scroll container. That keeps `position: + * sticky` elements — e.g. the Settings page table-of-contents — pinned to the + * viewport rather than a nested scroll box. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./Sidebar` + * - `./UpdateNotifier` + * - `./Tabby/Tabby` + * + * ## Public surface + * - `Layout` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **Layout** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState, useCallback } from "react"; +import { Outlet } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar"; +import { UpdateNotifier } from "./UpdateNotifier"; +import { Tabby } from "./Tabby/Tabby"; + +/** Props for {@link Layout}. */ +interface LayoutProps { + /** Live WebSocket status forwarded to the sidebar connection indicator. */ + wsConnected: boolean; +} + +/** + * Root layout wrapping all dashboard routes. + * @param props See {@link LayoutProps}. + */ +export function Layout({ wsConnected }: LayoutProps) { + const { t } = useTranslation("nav"); + const [collapsed, setCollapsed] = useState(loadCollapsed); + + const toggle = useCallback(() => { + setCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + } catch {} + return next; + }); + }, []); + + return ( +
+ + {t("skipToContent")} + + + + +
+ {/* overflow-x-clip (not -hidden) clips horizontal overflow without + creating a scroll container, so descendant `position: sticky` + elements (e.g. the Settings page TOC) still pin to the window. */} +
+ +
+
+
+ ); +} diff --git a/client/src/components/RemoteSources.tsx b/client/src/components/RemoteSources.tsx new file mode 100644 index 0000000..bb51662 --- /dev/null +++ b/client/src/components/RemoteSources.tsx @@ -0,0 +1,773 @@ +/** + * @file RemoteSources.tsx + * @description Settings UI for the Remote Data Sources feature: manage the SSH + * machines this dashboard pulls Claude Code history from, and choose the global + * "data scope" (which machines' data the whole app shows). + * + * Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the + * global scope store ({@link useDataScope}). No secrets are entered or stored + * here — authentication defers to the host's SSH stack (~/.ssh/config, agent, + * keys, known_hosts); a source is just a label + ssh destination (+ optional + * port / identity file / remote home). Live status/sync updates arrive over the + * `remote_source.status` WebSocket message. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Supports federated dashboards: register SSH-backed or file-synced remote machines, health-check tunnels, and scope the entire UI to local vs all vs selected sources. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../lib/api` + * - `../lib/eventBus` + * - `../lib/dataScope` + * + * ## Public surface + * - `RemoteSources` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **RemoteSources** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Cloud, + Plus, + Server, + RefreshCw, + Wifi, + Trash2, + Pencil, + Check, + X, + CheckCircle, + XCircle, + Loader2, + Globe, + Monitor, + ListChecks, +} from "lucide-react"; +import { api } from "../lib/api"; +import type { RemoteSource, RemoteSourceInput } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; +import { useDataScope } from "../lib/dataScope"; +import type { ScopeMode } from "../lib/dataScope"; + +const EMPTY_FORM: RemoteSourceInput = { + label: "", + host: "", + ssh_port: null, + identity_file: "", + remote_home: "", + enabled: true, +}; + +/** Compact status pill for a source's last-known sync state. */ +function StatusPill({ status }: { status: RemoteSource["status"] }) { + const map: Record = { + idle: { cls: "text-gray-400 bg-gray-500/10 border-gray-500/20", label: "Idle" }, + syncing: { + cls: "text-amber-300 bg-amber-500/10 border-amber-500/25", + label: "Syncing", + pulse: true, + }, + ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" }, + error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" }, + }; + const s = map[status] || map.idle; + return ( + + + {s.label} + + ); +} + +export function RemoteSources() { + const { t } = useTranslation("settings"); + const [sources, setSources] = useState([]); + const [facetSources, setFacetSources] = useState([]); + const [loading, setLoading] = useState(true); + const [scope, setScope] = useDataScope(); + + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + const [busyId, setBusyId] = useState(null); + const [syncingAll, setSyncingAll] = useState(false); + const [testResults, setTestResults] = useState>( + {} + ); + const [confirmDelete, setConfirmDelete] = useState<{ id: string; purge: boolean } | null>(null); + + const load = useCallback(() => { + Promise.all([api.remoteSources.list(), api.sessions.facets()]) + .then(([srcRes, facetRes]) => { + setSources(srcRes.sources); + setFacetSources(facetRes.sources || []); + }) + .catch(() => undefined) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + load(); + }, [load]); + + // Refresh when a sync finishes or remote-imported rows change (session counts). + useEffect(() => { + return eventBus.subscribe((msg) => { + if (msg.type === "remote_source.status" || isRemoteDataRefreshMessage(msg)) load(); + }); + }, [load]); + + // ── Scope selector ────────────────────────────────────────────────────────── + + // Union of origins that have data (facets) + all configured source ids, so a + // freshly-added source is selectable before its first sync lands any rows. + const configuredIds = sources.map((s) => s.id); + const scopeOptionIds = ["local", ...new Set([...configuredIds, ...facetSources])].filter( + (id, i, arr) => id === "local" || (arr.indexOf(id) === i && id !== "local") + ); + const labelFor = (id: string) => + id === "local" + ? t("remoteSources.thisMachine", "This machine") + : sources.find((s) => s.id === id)?.label || id; + + function setMode(mode: ScopeMode) { + if (mode === "selected") { + const selected = scope.selected.length > 0 ? scope.selected : scopeOptionIds; + setScope({ mode, selected }); + } else { + setScope({ mode, selected: scope.selected }); + } + } + function toggleSelected(id: string) { + const set = new Set(scope.selected); + if (set.has(id)) set.delete(id); + else set.add(id); + setScope({ mode: "selected", selected: [...set] }); + } + + // ── Form ──────────────────────────────────────────────────────────────────── + + function openAdd() { + setForm(EMPTY_FORM); + setEditingId(null); + setFormError(null); + setShowForm(true); + } + function openEdit(s: RemoteSource) { + setForm({ + label: s.label, + host: s.host, + ssh_port: s.ssh_port, + identity_file: s.identity_file || "", + remote_home: s.remote_home || "", + enabled: s.enabled, + }); + setEditingId(s.id); + setFormError(null); + setShowForm(true); + } + function closeForm() { + setShowForm(false); + setEditingId(null); + setFormError(null); + } + + async function submitForm() { + setSaving(true); + setFormError(null); + // Normalize optional empties to null so the server stores nothing rather + // than empty strings (its validators treat absent as "use default"). + const payload: RemoteSourceInput = { + label: form.label.trim(), + host: form.host.trim(), + ssh_port: form.ssh_port ? Number(form.ssh_port) : null, + identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null, + remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null, + enabled: form.enabled, + }; + try { + if (editingId) await api.remoteSources.update(editingId, payload); + else await api.remoteSources.create(payload); + closeForm(); + load(); + } catch (err) { + setFormError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + } + + // ── Per-source actions ──────────────────────────────────────────────────────── + + async function toggleEnabled(s: RemoteSource) { + setBusyId(s.id); + try { + await api.remoteSources.update(s.id, { enabled: !s.enabled }); + load(); + } catch { + /* surfaced via reload */ + } finally { + setBusyId(null); + } + } + + async function testSource(s: RemoteSource) { + setBusyId(s.id); + setTestResults((r) => ({ ...r, [s.id]: { ok: false, message: "" } })); + try { + const res = await api.remoteSources.test(s.id); + setTestResults((r) => ({ ...r, [s.id]: { ok: res.ok, message: res.message } })); + } catch (err) { + setTestResults((r) => ({ + ...r, + [s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) }, + })); + } finally { + setBusyId(null); + } + } + + async function syncNow(s: RemoteSource) { + setBusyId(s.id); + try { + await api.remoteSources.sync(s.id); + load(); + } catch (err) { + setTestResults((r) => ({ + ...r, + [s.id]: { ok: false, message: err instanceof Error ? err.message : String(err) }, + })); + } finally { + setBusyId(null); + } + } + + async function syncAll() { + setSyncingAll(true); + try { + await api.remoteSources.syncAll(); + load(); + } catch { + /* per-source errors surface via each source's status on reload */ + } finally { + setSyncingAll(false); + } + } + + async function doDelete() { + if (!confirmDelete) return; + const { id, purge } = confirmDelete; + setBusyId(id); + try { + await api.remoteSources.remove(id, purge); + setConfirmDelete(null); + load(); + } catch { + /* surfaced via reload */ + } finally { + setBusyId(null); + } + } + + return ( +
+

+ + {t("remoteSources.title", "Remote Data Sources")} +

+

+ {t( + "remoteSources.description", + "Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here." + )} +

+

+ {t( + "cursorPathsNote", + "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)." + )} +

+ + {/* Data scope selector */} +
+
+ + + {t("remoteSources.scopeTitle", "Data scope")} + +
+

+ {t( + "remoteSources.scopeDesc", + "Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost." + )} +

+ {/* Card selector — one card per scope mode, each with a short explanation + so the choice is self-describing rather than a bare radio label. */} +
+ {( + [ + { + mode: "all", + Icon: Globe, + title: t("remoteSources.scopeAll", "All sources"), + desc: t( + "remoteSources.scopeAllDesc", + "This machine plus every configured remote source, combined." + ), + }, + { + mode: "local", + Icon: Monitor, + title: t("remoteSources.scopeLocal", "This machine only"), + desc: t( + "remoteSources.scopeLocalDesc", + "Only sessions collected locally — hides all remote-source data." + ), + }, + { + mode: "selected", + Icon: ListChecks, + title: t("remoteSources.scopeSelected", "Selected sources"), + desc: t( + "remoteSources.scopeSelectedDesc", + "Pick exactly which machines to include, below." + ), + }, + ] as { mode: ScopeMode; Icon: typeof Globe; title: string; desc: string }[] + ).map(({ mode, Icon, title, desc }) => { + const active = scope.mode === mode; + return ( + + ); + })} +
+ {scope.mode === "selected" && ( +
+
+ {t("remoteSources.scopePickMachines", "Machines to include")} +
+
+ {scopeOptionIds.map((id) => { + const on = scope.selected.includes(id); + return ( + + ); + })} +
+
+ )} +
+ + {/* Sources list header + add button */} +
+ + {t("remoteSources.listTitle", "Configured sources")} + +
+ {sources.some((s) => s.enabled) && ( + + )} + +
+
+ + {/* Add/Edit form */} + {showForm && ( +
+
+ {editingId + ? t("remoteSources.editTitle", "Edit source") + : t("remoteSources.addTitle", "Add a remote source")} +
+
+
+ + setForm((f) => ({ ...f, label: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, host: e.target.value }))} + /> +
+
+ + + setForm((f) => ({ + ...f, + ssh_port: e.target.value ? Number(e.target.value) : null, + })) + } + /> +
+
+ + setForm((f) => ({ ...f, identity_file: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, remote_home: e.target.value }))} + /> +

+ {t( + "remoteSources.fieldRemoteHomeHint", + "Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude." + )} +

+
+
+ + {formError && ( +
+ {formError} +
+ )} +
+ + +
+
+ )} + + {/* Sources list */} + {loading ? ( +
{t("common:loading", "Loading…")}
+ ) : sources.length === 0 ? ( +
+ +

+ {t("remoteSources.empty", "No remote sources yet.")} +

+

+ {t( + "remoteSources.emptyHint", + "Add a machine you reach over SSH to pull its Claude Code usage in." + )} +

+
+ ) : ( +
+ {sources.map((s) => { + const test = testResults[s.id]; + const busy = busyId === s.id; + return ( +
+
+
+
+ + {s.label} + + {!s.enabled && ( + + {t("remoteSources.paused", "Auto-sync off")} + + )} + {s.session_count != null && s.session_count > 0 && ( + + {t("remoteSources.sessionCountLinked", "{{n}} linked", { + n: s.session_count, + })} + + )} +
+
+ {s.host} + {s.ssh_port ? `:${s.ssh_port}` : ""} + {s.remote_home ? ` · ${s.remote_home}` : ""} +
+
+ {s.last_sync_at + ? t("remoteSources.lastSync", "Last sync: {{when}}", { + when: new Date(s.last_sync_at).toLocaleString(), + }) + : t("remoteSources.neverSynced", "Never synced")} + {s.last_sync_counts?.imported != null && + ` · ${t("remoteSources.syncNew", "{{n}} new", { + n: s.last_sync_counts.imported, + })}`} + {s.last_sync_counts?.sessions_tagged != null && + ` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", { + n: s.last_sync_counts.sessions_tagged, + })}`} +
+ {s.status === "error" && s.last_error && ( +
+ {s.last_error} +
+ )} + {test && test.message && ( +
+ {test.ok ? ( + + ) : ( + + )} + {test.message} +
+ )} +
+
+ + + + + +
+
+ + {/* Inline delete confirmation */} + {confirmDelete?.id === s.id && ( +
+

+ {t("remoteSources.confirmDelete", "Remove this source?")} +

+ +
+ + +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/client/src/components/Select.tsx b/client/src/components/Select.tsx new file mode 100644 index 0000000..a9e2961 --- /dev/null +++ b/client/src/components/Select.tsx @@ -0,0 +1,227 @@ +/** + * @file Select.tsx + * @description Generic styled dropdown that replaces native ` setQuery(e.target.value)} + aria-label="Ask Tabby" + /> + + + {answer && ( +

+ {answer} +

+ )} + + + ); +} + +interface Tone { + wrap: string; + value: string; + icon: string; +} + +const TONE_MUTED: Tone = { + wrap: "border-border bg-surface-1", + value: "text-gray-300", + icon: "text-gray-500", +}; + +const TONES: Record = { + accent: { wrap: "border-accent/30 bg-accent/10", value: "text-gray-100", icon: "text-accent" }, + amber: { + wrap: "border-amber-500/30 bg-amber-500/10", + value: "text-amber-200", + icon: "text-amber-400", + }, + red: { wrap: "border-red-500/30 bg-red-500/10", value: "text-red-200", icon: "text-red-400" }, + muted: TONE_MUTED, +}; + +function StatChip({ + icon: Icon, + label, + value, + tone, +}: { + icon: LucideIcon; + label: string; + value: number; + tone: string; +}): ReactNode { + const t = TONES[tone] ?? TONE_MUTED; + return ( +
+ + + {value} + + {label} +
+ ); +} + +function ActionButton({ + icon: Icon, + label, + onClick, + disabled, +}: { + icon: LucideIcon; + label: string; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} diff --git a/client/src/components/Tabby/__tests__/Tabby.test.tsx b/client/src/components/Tabby/__tests__/Tabby.test.tsx new file mode 100644 index 0000000..041cf8c --- /dev/null +++ b/client/src/components/Tabby/__tests__/Tabby.test.tsx @@ -0,0 +1,150 @@ +/** + * @file Tabby.test.tsx + * @description Render tests for the Tabby companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { Tabby } from "../Tabby"; +import { eventBus } from "../../../lib/eventBus"; +import type { WSMessage, Session } from "../../../lib/types"; + +function renderTabby() { + return render( + + + + ); +} + +const sessionMsg = (id: string, status: Session["status"]): WSMessage => ({ + type: "session_updated", + data: { id, status } as Session, + timestamp: "t", +}); + +beforeEach(() => { + localStorage.clear(); + eventBus.setConnected(true); + // Freeze timers so the brain's 1s heartbeat tick can't fire a state update + // outside act() mid-assertion. We never advance them in these tests. + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("Tabby widget", () => { + it("renders the avatar button by default", () => { + renderTabby(); + expect(screen.getByRole("button", { name: /open tabby companion/i })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /tabby/i })).toBeInTheDocument(); + }); + + it("opens the panel on click and answers a local status question", () => { + renderTabby(); + fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i })); + const panel = screen.getByRole("dialog", { name: /tabby companion/i }); + expect(panel).toBeInTheDocument(); + + const input = within(panel).getByLabelText(/ask tabby/i); + fireEvent.change(input, { target: { value: "status" } }); + fireEvent.submit(input.closest("form")!); + expect(within(panel).getByText(/live ·/i)).toBeInTheDocument(); + }); + + it("toggles open/closed with Cmd/Ctrl+B and closes with Esc", () => { + renderTabby(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + act(() => { + fireEvent.keyDown(window, { key: "b", metaKey: true }); + }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + act(() => { + fireEvent.keyDown(window, { key: "Escape" }); + }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("shows an error badge when a session errors", () => { + renderTabby(); + act(() => { + eventBus.publish(sessionMsg("a", "error")); + }); + const btn = screen.getByRole("button", { name: /open tabby companion/i }); + expect(within(btn).getByText("1")).toBeInTheDocument(); + }); + + it("reflects the live count in the panel status", () => { + renderTabby(); + act(() => { + eventBus.publish(sessionMsg("a", "active")); + eventBus.publish(sessionMsg("b", "active")); + }); + fireEvent.click(screen.getByRole("button", { name: /open tabby companion/i })); + const panel = screen.getByRole("dialog", { name: /tabby companion/i }); + // The "live" stat chip shows value 2 next to its label. + const liveChip = within(panel).getByText("live").closest("div")!; + expect(within(liveChip).getByText("2")).toBeInTheDocument(); + }); + + it("respects the enabled preference", () => { + localStorage.setItem("agent-dashboard-tabby-enabled", "false"); + renderTabby(); + expect(screen.queryByRole("button", { name: /open tabby companion/i })).not.toBeInTheDocument(); + }); + + it("a tap (no movement) still opens the panel", () => { + renderTabby(); + const btn = screen.getByRole("button", { name: /open tabby companion/i }); + act(() => { + fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); + fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 }); + }); + fireEvent.click(btn); + expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument(); + }); + + it("dragging snaps to an edge, persists position, and does not open the panel", () => { + renderTabby(); + const btn = screen.getByRole("button", { name: /open tabby companion/i }); + // Default dock is bottom-right. Drag far to the left past the threshold. + act(() => { + fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); + fireEvent.pointerMove(btn, { clientX: 80, clientY: 300 }); + fireEvent.pointerUp(btn, { clientX: 80, clientY: 300 }); + }); + // The synthetic click that follows a drag must be swallowed. + fireEvent.click(btn); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + const saved = JSON.parse(localStorage.getItem("agent-dashboard-tabby-pos") || "{}"); + expect(saved.side).toBe("left"); + expect(typeof saved.y).toBe("number"); + }); + + it("a sub-threshold pointer move is treated as a tap, not a drag", () => { + renderTabby(); + const btn = screen.getByRole("button", { name: /open tabby companion/i }); + act(() => { + fireEvent.pointerDown(btn, { clientX: 990, clientY: 700, button: 0 }); + fireEvent.pointerMove(btn, { clientX: 992, clientY: 701 }); // < 5px threshold + fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 }); + }); + fireEvent.click(btn); + expect(screen.getByRole("dialog", { name: /tabby companion/i })).toBeInTheDocument(); + // No position was persisted because no real drag happened. + expect(localStorage.getItem("agent-dashboard-tabby-pos")).toBeNull(); + }); + + it("restores a persisted left-edge position on mount", () => { + localStorage.setItem("agent-dashboard-tabby-pos", JSON.stringify({ side: "left", y: 0.2 })); + renderTabby(); + const btn = screen.getByRole("button", { name: /open tabby companion/i }) as HTMLElement; + // Left-docked → inline left equals the edge margin (16px). + expect(btn.style.left).toBe("16px"); + }); +}); diff --git a/client/src/components/Tabby/__tests__/brain.test.ts b/client/src/components/Tabby/__tests__/brain.test.ts new file mode 100644 index 0000000..a99a325 --- /dev/null +++ b/client/src/components/Tabby/__tests__/brain.test.ts @@ -0,0 +1,248 @@ +/** + * @file brain.test.ts + * @description Unit tests for the Tabby state brain — initial state, mood transitions driven by dashboard events, and status derivation. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { + initialTabbyState, + reduceTabby, + deriveMood, + statusOf, + clearErrors, + seedSessions, + HAPPY_MS, + WORRIED_MS, + STUCK_MS, + SLEEP_MS, + type TabbyState, +} from "../brain"; +import type { + WSMessage, + Session, + Agent, + DashboardEvent, + RunStatusPayload, +} from "../../../lib/types"; + +const T0 = 1_000_000; + +function sessionMsg(id: string, status: Session["status"], ts = T0): WSMessage { + return { type: "session_updated", data: { id, status } as Session, timestamp: String(ts) }; +} +function agentMsg(status: Agent["status"]): WSMessage { + return { type: "agent_updated", data: { status } as Agent, timestamp: String(T0) }; +} +function agentCreatedMsg(type: Agent["type"], status: Agent["status"] = "working"): WSMessage { + return { type: "agent_created", data: { type, status } as Agent, timestamp: String(T0) }; +} +function waitingMsg(id: string): WSMessage { + return { + type: "session_updated", + data: { id, status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" } as Session, + timestamp: String(T0), + }; +} +function eventMsg(event_type: string): WSMessage { + return { type: "new_event", data: { event_type } as DashboardEvent, timestamp: String(T0) }; +} +function runStatusMsg(d: Partial): WSMessage { + return { type: "run_status", data: d as RunStatusPayload, timestamp: String(T0) }; +} + +describe("deriveMood priority", () => { + it("disconnected outranks everything", () => { + const s: TabbyState = { ...initialTabbyState(T0), connected: false, worriedUntil: T0 + 9999 }; + expect(deriveMood(s, T0)).toBe("disconnected"); + }); + + it("worried outranks stuck", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // live + s = { ...s, lastActivityAt: T0 - STUCK_MS - 1, worriedUntil: T0 + 100 }; + expect(deriveMood(s, T0)).toBe("worried"); + }); + + it("stuck when a live session goes silent past STUCK_MS", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + expect(deriveMood(s, T0 + STUCK_MS + 1)).toBe("stuck"); + }); + + it("happy is transient then falls back to idle when nothing live", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + ({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0)); // no longer live + expect(deriveMood(s, T0 + 10)).toBe("happy"); + expect(deriveMood(s, T0 + HAPPY_MS + 1)).toBe("idle"); + }); + + it("thinking shows when set and nothing higher applies", () => { + const s = { ...initialTabbyState(T0), thinking: true }; + expect(deriveMood(s, T0)).toBe("thinking"); + }); + + it("watching when a session is live and recent", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + expect(deriveMood(s, T0 + 1000)).toBe("watching"); + }); + + it("sleeping after SLEEP_MS of no activity and nothing live", () => { + const s = initialTabbyState(T0); + expect(deriveMood(s, T0 + SLEEP_MS + 1)).toBe("sleeping"); + }); + + it("idle by default", () => { + expect(deriveMood(initialTabbyState(T0), T0)).toBe("idle"); + }); +}); + +describe("reduceTabby counts and pulses", () => { + it("tracks live count accurately across transitions", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + ({ state: s } = reduceTabby(s, sessionMsg("b", "active"), T0)); + expect(statusOf(s).liveCount).toBe(2); + ({ state: s } = reduceTabby(s, sessionMsg("a", "completed"), T0)); + expect(statusOf(s).liveCount).toBe(1); + }); + + it("counts errored sessions and emits error pulse", () => { + let s = initialTabbyState(T0); + const r = reduceTabby(s, sessionMsg("a", "error"), T0); + s = r.state; + expect(r.pulse).toBe("error"); + expect(statusOf(s).errorCount).toBe(1); + expect(deriveMood(s, T0)).toBe("worried"); + }); + + it("session_start pulse only on first active transition", () => { + let s = initialTabbyState(T0); + const r1 = reduceTabby(s, sessionMsg("a", "active"), T0); + expect(r1.pulse).toBe("session_start"); + const r2 = reduceTabby(r1.state, sessionMsg("a", "active"), T0); + expect(r2.pulse).toBe(null); + }); + + it("session_done pulse only when the session was tracked", () => { + let s = initialTabbyState(T0); + const untracked = reduceTabby(s, sessionMsg("ghost", "completed"), T0); + expect(untracked.pulse).toBe(null); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + const done = reduceTabby(s, sessionMsg("a", "completed"), T0); + expect(done.pulse).toBe("session_done"); + }); + + it("agent error triggers worried via pulse", () => { + const r = reduceTabby(initialTabbyState(T0), agentMsg("error"), T0); + expect(r.pulse).toBe("error"); + expect(deriveMood(r.state, T0)).toBe("worried"); + }); + + it("a newly created subagent emits subagent_spawn, a main agent does not", () => { + expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("subagent"), T0).pulse).toBe( + "subagent_spawn" + ); + expect(reduceTabby(initialTabbyState(T0), agentCreatedMsg("main"), T0).pulse).toBe(null); + }); + + it("waiting transition emits a waiting pulse once and still counts as live", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); // active + const first = reduceTabby(s, waitingMsg("a"), T0); + expect(first.pulse).toBe("waiting"); + expect(statusOf(first.state).liveCount).toBe(1); + // A repeat waiting update does not re-announce. + const second = reduceTabby(first.state, waitingMsg("a"), T0); + expect(second.pulse).toBe(null); + }); + + it("failure event types set worried, normal events do not", () => { + const fail = reduceTabby(initialTabbyState(T0), eventMsg("toolError"), T0); + expect(fail.pulse).toBe("error"); + const ok = reduceTabby(initialTabbyState(T0), eventMsg("postToolUse"), T0); + expect(ok.pulse).toBe(null); + expect(ok.state.worriedUntil).toBe(0); + }); + + it("run_status completed exit 0 is happy, nonzero/error/killed is worried", () => { + const good = reduceTabby( + initialTabbyState(T0), + runStatusMsg({ status: "completed", exitCode: 0 }), + T0 + ); + expect(good.pulse).toBe("run_done"); + expect(deriveMood(good.state, T0)).toBe("happy"); + const bad = reduceTabby( + initialTabbyState(T0), + runStatusMsg({ status: "completed", exitCode: 1 }), + T0 + ); + expect(bad.pulse).toBe("error"); + const err = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "error" }), T0); + expect(err.pulse).toBe("error"); + const killed = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "killed" }), T0); + expect(killed.pulse).toBe("error"); + const running = reduceTabby(initialTabbyState(T0), runStatusMsg({ status: "running" }), T0); + expect(running.pulse).toBe(null); + }); + + it("any handled message refreshes lastActivityAt", () => { + const s = { ...initialTabbyState(T0), lastActivityAt: T0 - 99999 }; + const { state } = reduceTabby(s, eventMsg("postToolUse"), T0 + 5); + expect(state.lastActivityAt).toBe(T0 + 5); + }); + + it("ignores unrelated message types without mutating", () => { + const s = initialTabbyState(T0); + const r = reduceTabby(s, { type: "import.progress", data: {} as never, timestamp: "x" }, T0); + expect(r.state).toBe(s); + expect(r.pulse).toBe(null); + }); +}); + +describe("seedSessions", () => { + it("hydrates live/waiting/errored counts from a REST snapshot", () => { + const s = seedSessions( + initialTabbyState(T0), + [ + { id: "a", status: "active" }, + { id: "b", status: "active", awaiting_input_since: "2026-05-29T00:00:00Z" }, + { id: "c", status: "error" }, + { id: "d", status: "completed" }, // ignored + ], + T0 + ); + const st = statusOf(s); + expect(st.liveCount).toBe(2); // a + b + expect(st.waitingCount).toBe(1); // b + expect(st.errorCount).toBe(1); // c + }); +}); + +describe("statusOf waiting", () => { + it("counts a waiting session as both live and waiting", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, waitingMsg("a"), T0)); + expect(statusOf(s)).toMatchObject({ liveCount: 1, waitingCount: 1, errorCount: 0 }); + }); +}); + +describe("clearErrors", () => { + it("drops errored sessions but keeps active ones", () => { + let s = initialTabbyState(T0); + ({ state: s } = reduceTabby(s, sessionMsg("a", "active"), T0)); + ({ state: s } = reduceTabby(s, sessionMsg("b", "error"), T0)); + s = clearErrors(s); + expect(statusOf(s).errorCount).toBe(0); + expect(statusOf(s).liveCount).toBe(1); + expect(s.worriedUntil).toBe(0); + }); +}); + +// Reference the imported constant so it is exercised and tsc-clean. +it("WORRIED_MS is a positive window", () => { + expect(WORRIED_MS).toBeGreaterThan(0); +}); diff --git a/client/src/components/Tabby/__tests__/intents.test.ts b/client/src/components/Tabby/__tests__/intents.test.ts new file mode 100644 index 0000000..9923456 --- /dev/null +++ b/client/src/components/Tabby/__tests__/intents.test.ts @@ -0,0 +1,78 @@ +/** + * @file intents.test.ts + * @description Unit tests for Tabby's intent matcher — natural-language queries mapped to dashboard navigation and status intents. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { matchIntent } from "../intents"; +import type { TabbyStatus } from "../brain"; + +const status = (over: Partial = {}): TabbyStatus => ({ + liveCount: 0, + waitingCount: 0, + errorCount: 0, + connected: true, + ...over, +}); + +describe("matchIntent", () => { + it("reports live sessions", () => { + const r = matchIntent("what's running?", status({ liveCount: 3 })); + expect(r).toEqual({ kind: "answer", text: expect.stringContaining("3 sessions live") }); + }); + + it("singularizes correctly", () => { + const r = matchIntent("anything active", status({ liveCount: 1 })); + expect(r.kind).toBe("answer"); + if (r.kind === "answer") expect(r.text).toContain("1 session live"); + }); + + it("says all quiet when nothing live", () => { + const r = matchIntent("what is running", status()); + if (r.kind === "answer") expect(r.text).toContain("nothing's running"); + }); + + it("reports errors and prioritizes error intent over live", () => { + const r = matchIntent("any failed runs?", status({ liveCount: 2, errorCount: 1 })); + if (r.kind === "answer") expect(r.text).toContain("1 session errored"); + }); + + it("clean when no errors", () => { + const r = matchIntent("are there errors", status({ liveCount: 2 })); + if (r.kind === "answer") expect(r.text).toContain("all clean"); + }); + + it("gives a combined status summary", () => { + const r = matchIntent( + "status", + status({ liveCount: 2, waitingCount: 1, errorCount: 1, connected: true }) + ); + if (r.kind === "answer") expect(r.text).toBe("2 live · 1 waiting · 1 errored · connected."); + }); + + it("reports sessions waiting on the user", () => { + const r = matchIntent("anything waiting on me?", status({ liveCount: 2, waitingCount: 1 })); + if (r.kind === "answer") expect(r.text).toContain("1 session waiting on you"); + }); + + it("reflects offline in summary", () => { + const r = matchIntent("overview", status({ connected: false })); + if (r.kind === "answer") expect(r.text).toContain("offline"); + }); + + it("explains itself on help", () => { + const r = matchIntent("help", status()); + if (r.kind === "answer") expect(r.text.toLowerCase()).toContain("watch your sessions"); + }); + + it("empty query nudges the user", () => { + const r = matchIntent(" ", status()); + expect(r.kind).toBe("answer"); + }); + + it("hands unknown questions to Claude, preserving original casing", () => { + const r = matchIntent("Refactor my auth module", status()); + expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" }); + }); +}); diff --git a/client/src/components/Tabby/__tests__/quips.test.ts b/client/src/components/Tabby/__tests__/quips.test.ts new file mode 100644 index 0000000..c460ec0 --- /dev/null +++ b/client/src/components/Tabby/__tests__/quips.test.ts @@ -0,0 +1,32 @@ +/** + * @file quips.test.ts + * @description Unit tests for Tabby's quip picker — full key coverage and stable selection behavior for the speech-bubble lines. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { pickQuip, ALL_QUIP_KEYS } from "../quips"; + +describe("pickQuip", () => { + it("returns a non-empty string for every known key", () => { + for (const key of ALL_QUIP_KEYS) { + expect(pickQuip(key, () => 0).length).toBeGreaterThan(0); + } + }); + + it("is deterministic given an injected rand", () => { + expect(pickQuip("session_done", () => 0)).toBe(pickQuip("session_done", () => 0)); + }); + + it("rand=0.999 stays within bounds (no out-of-range index)", () => { + for (const key of ALL_QUIP_KEYS) { + expect(typeof pickQuip(key, () => 0.999)).toBe("string"); + expect(pickQuip(key, () => 0.999).length).toBeGreaterThan(0); + } + }); + + it("returns empty string for an unknown key without throwing", () => { + // @ts-expect-error intentionally passing an invalid key + expect(pickQuip("nope", () => 0)).toBe(""); + }); +}); diff --git a/client/src/components/Tabby/brain.ts b/client/src/components/Tabby/brain.ts new file mode 100644 index 0000000..2872a47 --- /dev/null +++ b/client/src/components/Tabby/brain.ts @@ -0,0 +1,408 @@ +/** + * @file brain.ts + * @description Pure, framework-free core of the Tabby companion. Reduces the + * dashboard's live WebSocket stream into a small mood model and derives the + * current cat mood from that model plus the wall clock. Kept side-effect free + * so it can be unit-tested without React, timers, or the DOM. The React hook + * (`useTabbyBrain`) wires this to the event bus and to real timers. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/types` + * + * ## Public surface + * - `Mood` — exported API; see TSDoc on the symbol for behavior. + * - `TabbyPulse` — exported API; see TSDoc on the symbol for behavior. + * - `TabbyStatus` — exported API; see TSDoc on the symbol for behavior. + * - `TabbyState` — exported API; see TSDoc on the symbol for behavior. + * - `HAPPY_MS` — exported API; see TSDoc on the symbol for behavior. + * - `WORRIED_MS` — exported API; see TSDoc on the symbol for behavior. + * - `STUCK_MS` — exported API; see TSDoc on the symbol for behavior. + * - `SLEEP_MS` — exported API; see TSDoc on the symbol for behavior. + * - `FAILURE_EVENT_TYPES` — exported API; see TSDoc on the symbol for behavior. + * - `initialTabbyState` — exported API; see TSDoc on the symbol for behavior. + * - `statusOf` — exported API; see TSDoc on the symbol for behavior. + * - `deriveMood` — exported API; see TSDoc on the symbol for behavior. + * - `reduceTabby` — exported API; see TSDoc on the symbol for behavior. + * - `seedSessions` — exported API; see TSDoc on the symbol for behavior. + * - `clearErrors` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **Mood** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TabbyPulse** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TabbyStatus** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TabbyState** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **HAPPY_MS** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **WORRIED_MS** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **STUCK_MS** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **SLEEP_MS** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **FAILURE_EVENT_TYPES** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **initialTabbyState** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **statusOf** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **deriveMood** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **reduceTabby** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **seedSessions** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **clearErrors** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from "../../lib/types"; + +/** All moods Tabby can express, highest priority first (see `deriveMood`). */ +export type Mood = + | "disconnected" + | "worried" + | "stuck" + | "happy" + | "thinking" + | "watching" + | "sleeping" + | "idle"; + +/** + * A one-shot signal describing what just happened, emitted by `reduceTabby`. + * The hook turns pulses into transient speech bubbles. `null` means the message + * was irrelevant or non-notable. + */ +export type TabbyPulse = + | "session_done" + | "session_start" + | "subagent_spawn" + | "waiting" + | "error" + | "run_done" + | null; + +export interface TabbyStatus { + /** Active + waiting sessions (everything not finished/errored). */ + liveCount: number; + /** Subset of liveCount currently blocked on user input. */ + waitingCount: number; + errorCount: number; + connected: boolean; +} + +export interface TabbyState { + connected: boolean; + /** Latest status per session id we still care about. "waiting" = active but + * blocked on user input; counts as live for the status line. */ + sessions: Record; + /** Epoch ms of the last meaningful activity; drives stuck/sleeping. */ + lastActivityAt: number; + /** While `now < happyUntil`, mood can be `happy`. */ + happyUntil: number; + /** While `now < worriedUntil`, mood can be `worried`. */ + worriedUntil: number; + /** True while an Ask request is in flight (panel). */ + thinking: boolean; +} + +// Tunable timing constants (ms). +export const HAPPY_MS = 4000; +export const WORRIED_MS = 4500; +export const STUCK_MS = 10 * 60_000; +export const SLEEP_MS = 3 * 60_000; + +/** Event types from the hook ingestion that represent a genuine failure. */ +export const FAILURE_EVENT_TYPES: ReadonlySet = new Set([ + "error", + "toolError", + "agentError", + "subagentError", + "errorEvent", + "errorReport", + "errorBoundary", + "crashReport", + "diagnosticError", +]); + +export function initialTabbyState(now: number): TabbyState { + return { + connected: true, + sessions: {}, + lastActivityAt: now, + happyUntil: 0, + worriedUntil: 0, + thinking: false, + }; +} + +export function statusOf(state: TabbyState): TabbyStatus { + let liveCount = 0; + let waitingCount = 0; + let errorCount = 0; + for (const s of Object.values(state.sessions)) { + if (s === "active" || s === "waiting") { + liveCount++; + if (s === "waiting") waitingCount++; + } else if (s === "error") errorCount++; + } + return { liveCount, waitingCount, errorCount, connected: state.connected }; +} + +/** + * Pure mood resolver. Highest-priority matching state wins. `now` is injected + * so callers (and tests) control the clock; transient windows (happy/worried) + * and inactivity windows (stuck/sleeping) are evaluated against it. + */ +export function deriveMood(state: TabbyState, now: number): Mood { + if (!state.connected) return "disconnected"; + if (now < state.worriedUntil) return "worried"; + + const { liveCount } = statusOf(state); + const silent = now - state.lastActivityAt; + + if (liveCount > 0 && silent > STUCK_MS) return "stuck"; + if (now < state.happyUntil) return "happy"; + if (state.thinking) return "thinking"; + if (liveCount > 0) return "watching"; + if (silent > SLEEP_MS) return "sleeping"; + return "idle"; +} + +/** + * Fold a single WebSocket message into the Tabby state. Returns the next state + * (new object) and a one-shot pulse describing what happened. Unknown or + * irrelevant message types pass through unchanged with a `null` pulse. + */ +export function reduceTabby( + state: TabbyState, + msg: WSMessage, + now: number +): { state: TabbyState; pulse: TabbyPulse } { + switch (msg.type) { + case "session_created": + case "session_updated": { + const s = msg.data as Session; + if (!s || !s.id) return { state, pulse: null }; + const sessions = { ...state.sessions }; + let pulse: TabbyPulse = null; + let happyUntil = state.happyUntil; + let worriedUntil = state.worriedUntil; + + if (s.status === "active") { + // "waiting" = active session blocked on user input (permission prompt + // or sitting at a fresh prompt). Announce the transition once each way. + const isWaiting = !!s.awaiting_input_since; + const prev = sessions[s.id]; + if (isWaiting) { + sessions[s.id] = "waiting"; + if (prev !== "waiting") pulse = "waiting"; + } else { + sessions[s.id] = "active"; + if (prev === undefined) pulse = "session_start"; + } + } else if (s.status === "error") { + sessions[s.id] = "error"; + worriedUntil = now + WORRIED_MS; + pulse = "error"; + } else if (s.status === "completed" || s.status === "abandoned") { + const wasTracked = s.id in sessions; + delete sessions[s.id]; + if (s.status === "completed") { + happyUntil = now + HAPPY_MS; + if (wasTracked) pulse = "session_done"; + } + } + + return { + state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now }, + pulse, + }; + } + + case "agent_created": { + const a = msg.data as Agent; + if (a && a.status === "error") { + return { + state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now }, + pulse: "error", + }; + } + // A freshly spawned subagent is worth announcing; the main agent landing + // is already covered by session_start. + const pulse: TabbyPulse = a && a.type === "subagent" ? "subagent_spawn" : null; + return { state: { ...state, lastActivityAt: now }, pulse }; + } + + case "agent_updated": { + const a = msg.data as Agent; + if (a && a.status === "error") { + return { + state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now }, + pulse: "error", + }; + } + return { state: { ...state, lastActivityAt: now }, pulse: null }; + } + + case "new_event": { + const e = msg.data as DashboardEvent; + const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type); + return { + state: { + ...state, + lastActivityAt: now, + worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil, + }, + pulse: isFailure ? "error" : null, + }; + } + + case "run_status": { + const r = msg.data as RunStatusPayload; + if (!r) return { state, pulse: null }; + // A run that finished cleanly (exit 0, or no exit code reported) → happy. + if (r.status === "completed" && (r.exitCode == null || r.exitCode === 0)) { + return { + state: { ...state, happyUntil: now + HAPPY_MS, lastActivityAt: now }, + pulse: "run_done", + }; + } + // Errored, killed, or completed with a nonzero exit code → worried. + if ( + r.status === "error" || + r.status === "killed" || + (r.status === "completed" && r.exitCode != null && r.exitCode !== 0) + ) { + return { + state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now }, + pulse: "error", + }; + } + // spawning / running → activity only. + return { state: { ...state, lastActivityAt: now }, pulse: null }; + } + + case "run_stream": + // Streaming output counts as activity but is not itself notable. + return { state: { ...state, lastActivityAt: now }, pulse: null }; + + default: + return { state, pulse: null }; + } +} + +/** + * Hydrate session tracking from a REST snapshot (the same data the dashboard + * fetches on load). Without this, the brain only learns about sessions from + * live WS deltas that arrive *after* it mounts, so a freshly-loaded page shows + * "0 live" even when sessions already exist. Merges in non-finished sessions; + * never clears the error window. Live WS deltas continue to refine this. + */ +export function seedSessions( + state: TabbyState, + rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>, + now: number +): TabbyState { + const sessions = { ...state.sessions }; + for (const r of rows) { + if (!r || !r.id) continue; + if (r.status === "error") sessions[r.id] = "error"; + else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active"; + // completed / abandoned: leave untracked. + } + return { ...state, sessions, lastActivityAt: now }; +} + +/** Drop all errored sessions from tracking (used by "clear alerts"). */ +export function clearErrors(state: TabbyState): TabbyState { + const sessions: TabbyState["sessions"] = {}; + for (const [id, s] of Object.entries(state.sessions)) { + if (s !== "error") sessions[id] = s; + } + return { ...state, sessions, worriedUntil: 0 }; +} diff --git a/client/src/components/Tabby/intents.ts b/client/src/components/Tabby/intents.ts new file mode 100644 index 0000000..7296e2b --- /dev/null +++ b/client/src/components/Tabby/intents.ts @@ -0,0 +1,129 @@ +/** + * @file intents.ts + * @description Tabby's local "Ask" brain. Matches a free-text question against a + * small set of intents answerable from cached dashboard status. Anything it + * can't answer becomes a handoff to the Run page (spawn a real `claude`). + * Pure function - no network, no DOM - so it's fully unit-testable. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./brain` + * + * ## Public surface + * - `AskResult` — exported API; see TSDoc on the symbol for behavior. + * - `matchIntent` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **AskResult** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **matchIntent** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import type { TabbyStatus } from "./brain"; + +export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string }; + +const plural = (n: number) => (n === 1 ? "" : "s"); + +export function matchIntent(query: string, status: TabbyStatus): AskResult { + const q = query.trim().toLowerCase(); + if (!q) { + return { + kind: "answer", + text: "ask me about your sessions - what's running, any errors, or a quick status.", + }; + } + + const has = (...words: string[]) => words.some((w) => q.includes(w)); + + if (has("help", "what can you", "what do you do")) { + return { + kind: "answer", + text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.', + }; + } + + // Errors first: "any failed runs" should report errors, not live count. + if (has("error", "broke", "broken", "fail", "wrong", "crash")) { + return { + kind: "answer", + text: + status.errorCount > 0 + ? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.` + : "no errors - all clean 🐾", + }; + } + + if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) { + return { + kind: "answer", + text: + status.waitingCount > 0 + ? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀` + : "nothing's waiting on you right now 🐾", + }; + } + + if (has("running", "active", "live", "going on", "happening", "in progress")) { + const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : ""; + return { + kind: "answer", + text: + status.liveCount > 0 + ? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}` + : "nothing's running right now - all quiet.", + }; + } + + if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) { + return { + kind: "answer", + text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${ + status.connected ? "connected" : "offline" + }.`, + }; + } + + return { kind: "handoff", prompt: query.trim() }; +} diff --git a/client/src/components/Tabby/prefs.ts b/client/src/components/Tabby/prefs.ts new file mode 100644 index 0000000..b2ea5be --- /dev/null +++ b/client/src/components/Tabby/prefs.ts @@ -0,0 +1,138 @@ +/** + * @file prefs.ts + * @description Tiny localStorage-backed preference store for Tabby (enabled + + * muted). Broadcasts changes via a window CustomEvent so the Settings toggle + * and the live widget stay in sync within the same tab without a reload. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `TabbyPos` — exported API; see TSDoc on the symbol for behavior. + * - `tabbyPrefs` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **TabbyPos** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **tabbyPrefs** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +const ENABLED_KEY = "agent-dashboard-tabby-enabled"; +const MUTED_KEY = "agent-dashboard-tabby-muted"; +const POS_KEY = "agent-dashboard-tabby-pos"; +const EVENT = "tabby:prefs"; + +/** + * Persisted resting position, AssistiveTouch-style: the widget always docks to + * the left or right edge, remembering its vertical offset. `y` is stored as a + * fraction of the viewport height (0–1) so it survives window resizes. + */ +export interface TabbyPos { + side: "left" | "right"; + y: number; +} + +function readBool(key: string, fallback: boolean): boolean { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v === "true"; + } catch { + return fallback; + } +} + +function writeBool(key: string, value: boolean): void { + try { + localStorage.setItem(key, String(value)); + } catch { + // Ignore storage failures (private mode, quota) - prefs are best-effort. + } + try { + window.dispatchEvent(new CustomEvent(EVENT)); + } catch { + // SSR / non-DOM contexts: nothing to notify. + } +} + +function readPos(): TabbyPos | null { + try { + const raw = localStorage.getItem(POS_KEY); + if (!raw) return null; + const p = JSON.parse(raw) as Partial; + if ((p.side === "left" || p.side === "right") && typeof p.y === "number") { + return { side: p.side, y: Math.min(1, Math.max(0, p.y)) }; + } + return null; + } catch { + return null; + } +} + +function writePos(pos: TabbyPos): void { + try { + localStorage.setItem(POS_KEY, JSON.stringify(pos)); + } catch { + // Ignore storage failures - position is best-effort. + } + // Note: intentionally does NOT dispatch the prefs event - position changes + // are local to the widget and shouldn't churn the Settings toggle listeners. +} + +export const tabbyPrefs = { + getEnabled: () => readBool(ENABLED_KEY, true), + setEnabled: (v: boolean) => writeBool(ENABLED_KEY, v), + getMuted: () => readBool(MUTED_KEY, false), + setMuted: (v: boolean) => writeBool(MUTED_KEY, v), + getPos: readPos, + setPos: writePos, + /** Subscribe to any pref change; returns an unsubscribe fn. */ + subscribe(handler: () => void): () => void { + const listener = () => handler(); + window.addEventListener(EVENT, listener); + // Also react to changes from other tabs. + window.addEventListener("storage", listener); + return () => { + window.removeEventListener(EVENT, listener); + window.removeEventListener("storage", listener); + }; + }, +}; diff --git a/client/src/components/Tabby/quips.ts b/client/src/components/Tabby/quips.ts new file mode 100644 index 0000000..8e31220 --- /dev/null +++ b/client/src/components/Tabby/quips.ts @@ -0,0 +1,140 @@ +/** + * @file quips.ts + * @description Tabby's personality: pools of short phrases keyed by pulse/mood, + * plus a deterministic-by-injection picker. Pure data + a pure function so it + * can be unit-tested without randomness leaking in. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./brain` + * + * ## Public surface + * - `QuipKey` — exported API; see TSDoc on the symbol for behavior. + * - `pickQuip` — exported API; see TSDoc on the symbol for behavior. + * - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **QuipKey** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **pickQuip** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **ALL_QUIP_KEYS** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import type { Mood, TabbyPulse } from "./brain"; + +export type QuipKey = NonNullable | Mood; + +const QUIPS: Record = { + // Pulses (event-driven, transient bubbles) + session_done: [ + "a session just wrapped up! 🐾", + "a session finished - nice work! ✨", + "that session's all done 😺", + "clean finish on that one 💜", + ], + session_start: [ + "a new session started! 👀", + "a fresh session just landed 🐾", + "ooh, a new session to watch 😻", + "something new is cooking 🍲", + ], + subagent_spawn: [ + "a subagent just spawned! 🐾", + "a little helper joined in 🤝", + "a subagent's on the job 🚀", + "reinforcements - new subagent! 😺", + ], + waiting: [ + "a session needs your input 👀", + "a session is waiting on you ⏳", + "a session paused for your reply 💬", + "your turn - a session's waiting 🐾", + ], + error: [ + "uh oh, a session hit an error 😿", + "something broke - wanna peek? 🙀", + "a hook tripped on something ⚠️", + "hiss… an error popped up 💢", + ], + run_done: [ + "your run just finished! 🐾", + "the run's all wrapped up ✨", + "run complete - that's a wrap 😸", + "all done with that run 💜", + ], + // Moods (steady-state flavor, used by the panel / idle bubbles) + disconnected: [ + "lost the connection… 😴", + "can't reach the server 📡", + "no signal - taking a nap 💤", + ], + worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"], + stuck: [ + "a session's been quiet a while… 🤔", + "is something stuck? ⏳", + "still chewing on it… 😾", + ], + happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"], + thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"], + watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"], + sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"], + idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"], +}; + +/** + * Pick a quip for a key. `rand` is injectable for deterministic tests; defaults + * to Math.random. Returns "" only for an unknown key (never throws). + */ +export function pickQuip(key: QuipKey, rand: () => number = Math.random): string { + const pool = QUIPS[key]; + if (!pool || pool.length === 0) return ""; + const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length))); + return pool[i] ?? ""; +} + +export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[]; diff --git a/client/src/components/Tabby/tabby.css b/client/src/components/Tabby/tabby.css new file mode 100644 index 0000000..092917d --- /dev/null +++ b/client/src/components/Tabby/tabby.css @@ -0,0 +1,412 @@ +/** + * tabby.css - animations + mood expressions for the Tabby companion. + * Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale) + * with warm pink accents (ears, cheeks, nose) for cuteness. All continuous + * motion is disabled under [data-reduced="1"] and prefers-reduced-motion. + * @author Nguyễn Ngọc Trí Vĩ + */ + +.tabby-cat { + overflow: visible; + cursor: pointer; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5)); +} + +/* ── palette ── */ +.tabby-head, +.tabby-body { + fill: url(#tabbyFur); + stroke: #8b93f9; + stroke-width: 2; +} +.tabby-ear { + fill: url(#tabbyFur); + stroke: #8b93f9; + stroke-width: 2; + stroke-linejoin: round; +} +.tabby-ear-inner { + fill: #f7a8c8; +} +.tabby-paw { + fill: #4a4a72; + stroke: #8b93f9; + stroke-width: 1.5; +} +.tabby-toe { + fill: none; + stroke: #2c2c46; + stroke-width: 1.2; + stroke-linecap: round; + opacity: 0.7; +} +.tabby-stripes path { + fill: none; + stroke: #8b93f9; + stroke-width: 2; + stroke-linecap: round; + opacity: 0.55; +} +.tabby-cheeks ellipse { + fill: #f7849f; + opacity: 0.5; +} +.tabby-eye { + fill: #f3f4ff; +} +.tabby-pupil { + fill: #15131f; +} +.tabby-glint { + fill: #ffffff; + opacity: 0.95; +} +.tabby-glint-sm { + opacity: 0.75; +} +.tabby-nose { + fill: #f7849f; +} +.tabby-tail { + fill: url(#tabbyFur); + stroke: #8b93f9; + stroke-width: 2; + stroke-linejoin: round; + transform-origin: 80px 68px; +} +.tabby-whiskers path, +.tabby-brows path, +.tabby-eyes-happy path, +.tabby-eyes-closed path, +.tabby-mouth-idle, +.tabby-mouth-happy, +.tabby-mouth-worried { + fill: none; + stroke: #dfe1ff; + stroke-width: 2.2; + stroke-linecap: round; +} +.tabby-whiskers path { + stroke: #9aa0c8; + stroke-width: 1.4; + opacity: 0.8; +} +.tabby-brows path { + stroke: #8b93f9; +} +.tabby-halo { + fill: url(#tabbyHalo); + opacity: 0.18; +} +.tabby-sparkle path { + fill: #fde68a; +} +.tabby-zzz text, +.tabby-bang text { + fill: #a5b4fc; + font-family: "JetBrains Mono", monospace; + font-size: 11px; + font-weight: 700; +} + +/* ── default visibility: show open eyes + idle mouth, hide the rest ── */ +.tabby-eyes-happy, +.tabby-eyes-closed, +.tabby-brows, +.tabby-mouth-happy, +.tabby-mouth-worried, +.tabby-zzz, +.tabby-bang, +.tabby-sparkle { + display: none; +} + +/* ── happy ── */ +.tabby-cat[data-mood="happy"] .tabby-eyes-open { + display: none; +} +.tabby-cat[data-mood="happy"] .tabby-eyes-happy, +.tabby-cat[data-mood="happy"] .tabby-mouth-happy, +.tabby-cat[data-mood="happy"] .tabby-sparkle { + display: block; +} +.tabby-cat[data-mood="happy"] .tabby-mouth-idle { + display: none; +} + +/* ── watching ── (alert, smiling, tail flick) */ +.tabby-cat[data-mood="watching"] .tabby-mouth-idle { + display: none; +} +.tabby-cat[data-mood="watching"] .tabby-mouth-happy { + display: block; +} + +/* ── worried ── (brows down, frown) */ +.tabby-cat[data-mood="worried"] .tabby-brows, +.tabby-cat[data-mood="worried"] .tabby-mouth-worried { + display: block; +} +.tabby-cat[data-mood="worried"] .tabby-mouth-idle { + display: none; +} +.tabby-cat[data-mood="worried"] .tabby-cheeks ellipse { + opacity: 0.7; +} + +/* ── stuck ── (alert bang, ears up) */ +.tabby-cat[data-mood="stuck"] .tabby-bang { + display: block; +} + +/* ── thinking ── keeps open eyes + idle mouth; head tilt handled below */ + +/* ── sleeping / disconnected ── (closed eyes) */ +.tabby-cat[data-mood="sleeping"] .tabby-eyes-open, +.tabby-cat[data-mood="disconnected"] .tabby-eyes-open { + display: none; +} +.tabby-cat[data-mood="sleeping"] .tabby-eyes-closed, +.tabby-cat[data-mood="disconnected"] .tabby-eyes-closed { + display: block; +} +.tabby-cat[data-mood="sleeping"] .tabby-zzz { + display: block; +} +.tabby-cat[data-mood="disconnected"] { + opacity: 0.5; + filter: grayscale(0.65) drop-shadow(0 4px 12px rgba(0, 0, 0, 0.5)); +} + +/* ════════ animations ════════ */ +@keyframes tabby-breathe { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.035); + } +} +@keyframes tabby-blink { + 0%, + 92%, + 100% { + transform: scaleY(1); + } + 96% { + transform: scaleY(0.1); + } +} +@keyframes tabby-shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px); + } + 75% { + transform: translateX(2px); + } +} +@keyframes tabby-bob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-3px); + } +} +@keyframes tabby-tail-flick { + 0%, + 100% { + transform: rotate(0deg); + } + 50% { + transform: rotate(-12deg); + } +} +@keyframes tabby-ears-perk { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-1.5px); + } +} +@keyframes tabby-sparkle-twinkle { + 0%, + 100% { + transform: scale(0.6); + opacity: 0.4; + } + 50% { + transform: scale(1); + opacity: 1; + } +} + +/* idle: gentle breathing + occasional blink */ +.tabby-cat[data-mood="idle"] { + animation: tabby-breathe 4s ease-in-out infinite; + transform-origin: 50px 60px; +} +/* Blink lives on the inner group so it never overrides the outer group's + eye-tracking translate (which would freeze the eyes - see CatAvatar). */ +.tabby-cat[data-mood="idle"] .tabby-pupils-blink { + animation: tabby-blink 5s ease-in-out infinite; + transform-origin: 50px 50px; +} +/* watching: tail flicks, ears perk */ +.tabby-cat[data-mood="watching"] .tabby-tail { + animation: tabby-tail-flick 1.8s ease-in-out infinite; +} +.tabby-cat[data-mood="watching"] .tabby-ears { + animation: tabby-ears-perk 1.8s ease-in-out infinite; + transform-origin: 50px 24px; +} +/* happy: head bob + twinkling sparkle */ +.tabby-cat[data-mood="happy"] { + animation: tabby-bob 0.5s ease-in-out 0s 4; + transform-origin: 50px 60px; +} +.tabby-cat[data-mood="happy"] .tabby-sparkle { + animation: tabby-sparkle-twinkle 0.9s ease-in-out infinite; + transform-origin: 84px 45px; +} +/* worried: shake + puff (halo grows) */ +.tabby-cat[data-mood="worried"] { + animation: tabby-shake 0.35s ease-in-out 0s 3; + transform-origin: 50px 60px; +} +.tabby-cat[data-mood="worried"] .tabby-halo { + opacity: 0.32; +} +/* stuck: ears stay perked, slow breathe */ +.tabby-cat[data-mood="stuck"] { + animation: tabby-breathe 2.4s ease-in-out infinite; + transform-origin: 50px 60px; +} +.tabby-cat[data-mood="stuck"] .tabby-ears { + transform: translateY(-2px); + transform-origin: 50px 24px; +} +/* thinking: subtle head tilt */ +.tabby-cat[data-mood="thinking"] { + transform: rotate(-6deg); + transform-origin: 50px 60px; +} +/* sleeping: slow breathe, droop */ +.tabby-cat[data-mood="sleeping"] { + animation: tabby-breathe 5s ease-in-out infinite; + transform-origin: 50px 60px; +} + +/* ── reduced motion: kill all continuous animation ── */ +.tabby-cat[data-reduced="1"], +.tabby-cat[data-reduced="1"] * { + animation: none !important; +} +@media (prefers-reduced-motion: reduce) { + .tabby-cat, + .tabby-cat * { + animation: none !important; + } +} + +/* ════════ shell (avatar button, bubble, panel) ════════ */ + +/* The draggable avatar. Fixed-positioned; left/top are set inline by the drag + hook. It docks to an edge (AssistiveTouch-style) and remembers where. */ +.tabby-avatar-btn { + position: fixed; + z-index: 41; /* above the flyout */ + background: transparent; + border: none; + padding: 0; + line-height: 0; + border-radius: 9999px; + cursor: grab; + touch-action: none; /* pointer drives the drag instead of scrolling on touch */ + /* Glide to the snapped edge after a drag; killed mid-drag for 1:1 tracking. */ + transition: + left 0.28s cubic-bezier(0.22, 1, 0.36, 1), + top 0.28s cubic-bezier(0.22, 1, 0.36, 1), + transform 0.15s ease; +} +.tabby-avatar-btn:hover { + transform: scale(1.06); +} +.tabby-avatar-btn:focus-visible { + outline: 2px solid #818cf8; + outline-offset: 3px; +} +.tabby-avatar-btn[data-dragging="1"] { + cursor: grabbing; + transition: transform 0.15s ease; /* no left/top easing while dragging */ +} +.tabby-avatar-btn[data-dragging="1"]:hover { + transform: scale(1.1); +} + +/* Self-clamping flyout that holds the bubble / panel next to the avatar. Its + left/top are computed and set inline by TabbyFlyout so it never crops. */ +.tabby-flyout { + position: fixed; + z-index: 40; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.tabby-avatar-btn:focus-visible { + outline: 2px solid #818cf8; + outline-offset: 3px; +} +.tabby-error-dot { + position: absolute; + top: 2px; + right: 2px; + width: 14px; + height: 14px; + border-radius: 9999px; + background: #ef4444; + color: #fff; + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #0c0c14; +} +.tabby-bubble { + max-width: 16rem; + background: #1a1a28; + border: 1px solid #363650; + color: #e8e8f0; + font-size: 0.8125rem; + line-height: 1.2rem; + padding: 0.5rem 0.75rem; + border-radius: 0.75rem; + border-bottom-right-radius: 0.25rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); +} +.tabby-bubble-enter { + animation: tabby-bubble-in 0.22s ease-out; +} +@keyframes tabby-bubble-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} +.tabby-cat[data-reduced="1"] ~ * .tabby-bubble-enter, +.tabby-bubble.tabby-no-anim { + animation: none; +} diff --git a/client/src/components/Tabby/useTabbyBrain.ts b/client/src/components/Tabby/useTabbyBrain.ts new file mode 100644 index 0000000..566471c --- /dev/null +++ b/client/src/components/Tabby/useTabbyBrain.ts @@ -0,0 +1,202 @@ +/** + * @file useTabbyBrain.ts + * @description React hook that wires the pure Tabby brain to the live event bus + * and to real timers. It is the only unit that subscribes to `eventBus`. It + * exposes the derived mood, a status summary, the current speech bubble, and + * imperative controls (mute, clear alerts, set thinking) for the UI shell. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/eventBus` + * - `../../lib/api` + * - `../../lib/types` + * - `./brain` + * - `./quips` + * - `./prefs` + * + * ## Public surface + * - `TabbyBrain` — exported API; see TSDoc on the symbol for behavior. + * - `useTabbyBrain` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **TabbyBrain** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **useTabbyBrain** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { eventBus } from "../../lib/eventBus"; +import { api } from "../../lib/api"; +import type { WSMessage } from "../../lib/types"; +import { + initialTabbyState, + reduceTabby, + deriveMood, + statusOf, + clearErrors, + seedSessions, + type Mood, + type TabbyState, + type TabbyStatus, +} from "./brain"; +import { pickQuip } from "./quips"; +import { tabbyPrefs } from "./prefs"; + +const BUBBLE_MS = 4500; +// Minimum gap between non-error bubbles, so a burst of activity doesn't spam. +const BUBBLE_THROTTLE_MS = 3000; + +export interface TabbyBrain { + mood: Mood; + status: TabbyStatus; + bubble: string | null; + dismissBubble: () => void; + muted: boolean; + toggleMute: () => void; + clearAlerts: () => void; + setThinking: (v: boolean) => void; +} + +export function useTabbyBrain(): TabbyBrain { + const now0 = Date.now(); + // Start optimistically connected (idle, open eyes) so the cursor-tracking + // eyes are live from the first frame instead of after the WebSocket finishes + // its initial handshake. onConnection below corrects this if it's truly down. + const [state, setState] = useState(() => ({ + ...initialTabbyState(now0), + connected: true, + })); + const [tick, setTick] = useState(now0); + const [bubble, setBubble] = useState(null); + const [muted, setMuted] = useState(() => tabbyPrefs.getMuted()); + + const bubbleTimer = useRef>(); + const lastBubbleAt = useRef(0); + const mutedRef = useRef(muted); + mutedRef.current = muted; + + // Keep mute in sync with the Settings page / other tabs. + useEffect(() => tabbyPrefs.subscribe(() => setMuted(tabbyPrefs.getMuted())), []); + + const showBubble = useCallback((text: string, force: boolean) => { + if (!text) return; + if (mutedRef.current) return; + const t = Date.now(); + if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return; + lastBubbleAt.current = t; + clearTimeout(bubbleTimer.current); + setBubble(text); + bubbleTimer.current = setTimeout(() => setBubble(null), BUBBLE_MS); + }, []); + + // Seed from the REST snapshot on mount so counts are accurate immediately - + // the brain otherwise only learns about sessions from WS deltas that arrive + // after it mounts, showing "0 live" on a fresh load even when sessions exist. + // Pull a generous page of non-finished sessions; live WS deltas refine it. + useEffect(() => { + let cancelled = false; + api.sessions + .list({ status: "active", limit: 100 }) + .then((res) => { + if (cancelled) return; + setState((prev) => seedSessions(prev, res.sessions, Date.now())); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + // Subscribe to the live stream and connection status. + useEffect(() => { + const unsubMsg = eventBus.subscribe((msg: WSMessage) => { + const t = Date.now(); + setState((prev) => { + const { state: next, pulse } = reduceTabby(prev, msg, t); + if (pulse) showBubble(pickQuip(pulse), pulse === "error"); + return next; + }); + }); + const unsubConn = eventBus.onConnection((connected) => { + setState((prev) => ({ ...prev, connected })); + }); + return () => { + unsubMsg(); + unsubConn(); + }; + }, [showBubble]); + + // Advance the clock so timed moods (stuck/sleeping, and exit from + // happy/worried) re-evaluate without needing a new event. + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), 1000); + return () => clearInterval(id); + }, []); + + useEffect(() => () => clearTimeout(bubbleTimer.current), []); + + const mood = useMemo(() => deriveMood(state, tick), [state, tick]); + const status = useMemo(() => statusOf(state), [state]); + + const dismissBubble = useCallback(() => { + clearTimeout(bubbleTimer.current); + setBubble(null); + }, []); + + const toggleMute = useCallback(() => { + const next = !mutedRef.current; + tabbyPrefs.setMuted(next); + setMuted(next); + if (next) dismissBubble(); + }, [dismissBubble]); + + const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []); + + const setThinking = useCallback( + (v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })), + [] + ); + + return { mood, status, bubble, dismissBubble, muted, toggleMute, clearAlerts, setThinking }; +} diff --git a/client/src/components/Tabby/useTabbyPosition.ts b/client/src/components/Tabby/useTabbyPosition.ts new file mode 100644 index 0000000..2f07f2f --- /dev/null +++ b/client/src/components/Tabby/useTabbyPosition.ts @@ -0,0 +1,211 @@ +/** + * @file useTabbyPosition.ts + * @description AssistiveTouch-style draggable docking for the Tabby avatar. The + * avatar follows the pointer 1:1 while dragging (via Pointer Capture, so it + * keeps tracking even if the cursor outruns it), and on release snaps to the + * nearest left/right edge, remembering its vertical offset (persisted as a + * viewport fraction so it survives resizes). A small movement threshold tells + * a drag apart from a tap so dragging never opens the panel. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Tabby is the optional on-screen cat assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome. React hook: isolates side effects and subscription wiring so presentational components stay declarative. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./prefs` + * + * ## Public surface + * - `TABBY_SIZE` — exported API; see TSDoc on the symbol for behavior. + * - `TABBY_MARGIN` — exported API; see TSDoc on the symbol for behavior. + * - `TabbyPlacement` — exported API; see TSDoc on the symbol for behavior. + * - `useTabbyPosition` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **TABBY_SIZE** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TABBY_MARGIN** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TabbyPlacement** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **useTabbyPosition** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { tabbyPrefs, type TabbyPos } from "./prefs"; +import type { PointerEvent as ReactPointerEvent } from "react"; + +// Avatar footprint + edge gap, in px. SIZE matches CatAvatar's default size. +export const TABBY_SIZE = 60; +export const TABBY_MARGIN = 16; +const DRAG_THRESHOLD = 5; + +const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024); +const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768); + +function defaultPos(): TabbyPos { + return { side: "right", y: 0.5 }; // right edge, vertically centered +} + +/** Resting top-left screen coords for a docked position. */ +function restingScreen(pos: TabbyPos) { + const avail = Math.max(0, vh() - TABBY_SIZE - 2 * TABBY_MARGIN); + const left = pos.side === "left" ? TABBY_MARGIN : vw() - TABBY_SIZE - TABBY_MARGIN; + const top = TABBY_MARGIN + pos.y * avail; + return { left, top }; +} + +export interface TabbyPlacement { + /** Avatar top-left, in screen px. */ + left: number; + top: number; + size: number; + side: "left" | "right"; + /** True when the avatar sits in the lower half - flyouts open upward. */ + openUp: boolean; + dragging: boolean; + onPointerDown: (e: ReactPointerEvent) => void; + onPointerMove: (e: ReactPointerEvent) => void; + onPointerUp: (e: ReactPointerEvent) => void; + /** Returns true (once) if a drag just ended, so the click handler can skip. */ + consumeDrag: () => boolean; +} + +export function useTabbyPosition(): TabbyPlacement { + const [pos, setPos] = useState(() => tabbyPrefs.getPos() ?? defaultPos()); + const [drag, setDrag] = useState<{ left: number; top: number } | null>(null); + const [, force] = useState(0); // re-derive resting coords on resize + + const draggedRef = useRef(false); + const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null); + const movedRef = useRef(false); + // Latest dragged coords, mirrored in a ref so pointerup can read them + // synchronously - the setDrag state may not have committed yet under React's + // event batching, so we never rely on its functional-updater `cur`. + const liveRef = useRef<{ left: number; top: number } | null>(null); + + useEffect(() => { + const onResize = () => force((n) => n + 1); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + const resting = restingScreen(pos); + const screen = drag ?? resting; + + const onPointerDown = useCallback( + (e: ReactPointerEvent) => { + if (e.button !== undefined && e.button !== 0) return; + // Capture so the avatar keeps receiving move/up events even when the + // pointer leaves it - essential for a fast, 1:1 drag. + try { + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + } catch { + /* capture unsupported - window-free fallback still works via props */ + } + startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top }; + movedRef.current = false; + }, + [screen.left, screen.top] + ); + + const onPointerMove = useCallback((e: ReactPointerEvent) => { + const start = startRef.current; + if (!start) return; + const dx = e.clientX - start.px; + const dy = e.clientY - start.py; + if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return; + movedRef.current = true; + const left = Math.min( + vw() - TABBY_SIZE - TABBY_MARGIN, + Math.max(TABBY_MARGIN, start.left + dx) + ); + const top = Math.min(vh() - TABBY_SIZE - TABBY_MARGIN, Math.max(TABBY_MARGIN, start.top + dy)); + liveRef.current = { left, top }; + setDrag({ left, top }); + }, []); + + const onPointerUp = useCallback((e: ReactPointerEvent) => { + try { + (e.currentTarget as Element).releasePointerCapture?.(e.pointerId); + } catch { + /* ignore */ + } + const live = liveRef.current; + if (live) { + draggedRef.current = true; + const side: "left" | "right" = live.left + TABBY_SIZE / 2 < vw() / 2 ? "left" : "right"; + const avail = Math.max(1, vh() - TABBY_SIZE - 2 * TABBY_MARGIN); + const y = Math.min(1, Math.max(0, (live.top - TABBY_MARGIN) / avail)); + const next: TabbyPos = { side, y }; + tabbyPrefs.setPos(next); + setPos(next); + setDrag(null); // leave drag mode; resting coords (with transition) take over + } + liveRef.current = null; + startRef.current = null; + movedRef.current = false; + }, []); + + const consumeDrag = useCallback(() => { + const was = draggedRef.current; + draggedRef.current = false; + return was; + }, []); + + return { + left: screen.left, + top: screen.top, + size: TABBY_SIZE, + side: pos.side, + openUp: screen.top + TABBY_SIZE / 2 > vh() / 2, + dragging: drag !== null, + onPointerDown, + onPointerMove, + onPointerUp, + consumeDrag, + }; +} diff --git a/client/src/components/Tip.tsx b/client/src/components/Tip.tsx new file mode 100644 index 0000000..a6f454c --- /dev/null +++ b/client/src/components/Tip.tsx @@ -0,0 +1,155 @@ +/** + * @file Tip.tsx + * @description Cursor-following tooltip for revealing extra detail on hover — used + * by {@link StatCard} for raw metric values and anywhere a compact display needs + * a full-precision expansion without cluttering the layout. + * + * ## Portal rendering + * Tooltip content is portaled to `document.body` with `position: fixed` so + * parent `overflow: hidden` cannot clip it. Placement flips left/up when the + * cursor is near viewport edges. + * + * ## No-op mode + * When `raw` is omitted the component returns `children` unchanged — callers + * do not need conditional wrappers. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `Tip` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **Tip** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState, useRef, useCallback } from "react"; +import { createPortal } from "react-dom"; + +/** Props for {@link Tip}. */ +interface TipProps { + /** Tooltip body; when absent, only `children` are rendered. */ + raw?: string; + /** Element that triggers the tooltip on hover. */ + children: React.ReactNode; + /** Max tooltip width in pixels. Default `320`. */ + maxWidth?: number; + /** Use a block-level wrapper instead of inline `span` for full-width targets. */ + block?: boolean; +} + +/** + * Hover tooltip anchored to the mouse cursor. + * @param props See {@link TipProps}. + */ +export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps) { + const [show, setShow] = useState(false); + const [pos, setPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); + const tipRef = useRef(null); + + const updatePos = useCallback((e: React.MouseEvent) => { + setPos({ x: e.clientX, y: e.clientY }); + }, []); + + if (!raw) return <>{children}; + + const Wrapper = block ? "div" : "span"; + const wrapperClass = block ? "cursor-default" : "relative inline-block cursor-default"; + + // Compute tooltip placement avoiding screen edges + let tipStyle: React.CSSProperties = { + position: "fixed", + zIndex: 99999, + maxWidth, + visibility: "hidden", + }; + if (show) { + const tipW = tipRef.current?.offsetWidth ?? 200; + const tipH = tipRef.current?.offsetHeight ?? 32; + const vw = document.documentElement.clientWidth; + const vh = window.innerHeight; + const pad = 12; + + // Default: below-right of cursor + let left = pos.x + pad; + let top = pos.y + pad; + + // If goes off right edge, flip to left of cursor + if (left + tipW > vw - pad) { + left = pos.x - tipW - pad; + } + // If goes off left edge, clamp + if (left < pad) left = pad; + + // If goes off bottom, show above cursor + if (top + tipH > vh - pad) { + top = pos.y - tipH - pad; + } + // If goes off top, clamp + if (top < pad) top = pad; + + tipStyle = { position: "fixed", left, top, zIndex: 99999, maxWidth, visibility: "visible" }; + } + + return ( + { + setShow(true); + updatePos(e); + }} + onMouseMove={updatePos} + onMouseLeave={() => setShow(false)} + > + {children} + {show && + createPortal( +
+ {raw} +
, + document.body + )} +
+ ); +} diff --git a/client/src/components/UpdateNotifier.tsx b/client/src/components/UpdateNotifier.tsx new file mode 100644 index 0000000..96f0bb1 --- /dev/null +++ b/client/src/components/UpdateNotifier.tsx @@ -0,0 +1,310 @@ +/** + * @file UpdateNotifier.tsx + * @description Modal surfaced when the dashboard's git checkout is behind its + * remote tracking branch. Shows how many commits behind, the exact terminal + * command to update, and copy-to-clipboard — the dashboard never pulls or + * restarts itself. + * + * ## State sources + * - Initial fetch via `api.updates.status()` on mount. + * - Live refresh from WebSocket `update_status` events on {@link eventBus}. + * + * ## Dismissal persistence + * Dismissals are keyed by `remote_sha` in `localStorage` so a new upstream + * commit re-opens the prompt. Settings can reset dismissal via the + * `dashboard:reset-update-dismissal` window event. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../lib/api` + * - `../lib/eventBus` + * - `../lib/types` + * + * ## Public surface + * - `UpdateNotifier` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **UpdateNotifier** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Download, X, Copy, Check, RefreshCw } from "lucide-react"; +import { api } from "../lib/api"; +import { eventBus } from "../lib/eventBus"; +import type { UpdateStatusPayload, WSMessage } from "../lib/types"; + +/** `localStorage` key storing the dismissed upstream SHA. */ +const DISMISS_KEY = "agent-monitor-update-dismissed-sha"; + +/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */ +function isUpdatePayload(x: unknown): x is UpdateStatusPayload { + return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x; +} + +/** Read the last dismissed upstream SHA from `localStorage`, or null. */ +function loadDismissedSha(): string | null { + try { + return localStorage.getItem(DISMISS_KEY); + } catch { + return null; + } +} + +/** + * Git update availability modal — mounted once in {@link Layout}. + * @returns `null` when no update is available or the current SHA was dismissed. + */ +export function UpdateNotifier() { + const { t } = useTranslation("updates"); + const [status, setStatus] = useState(null); + const [dismissedSha, setDismissedSha] = useState(loadDismissedSha); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [checking, setChecking] = useState(false); + + const syncFromPayload = useCallback((s: UpdateStatusPayload) => { + setStatus(s); + if (!s.fetch_error) setError(null); + }, []); + + useEffect(() => { + let cancelled = false; + api.updates + .status() + .then((s) => { + if (cancelled) return; + syncFromPayload(s); + eventBus.publish({ + type: "update_status", + data: s, + timestamp: new Date().toISOString(), + }); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [syncFromPayload]); + + useEffect(() => { + return eventBus.subscribe((msg: WSMessage) => { + if (msg.type !== "update_status") return; + if (isUpdatePayload(msg.data)) syncFromPayload(msg.data); + }); + }, [syncFromPayload]); + + useEffect(() => { + const handler = () => setDismissedSha(null); + window.addEventListener("dashboard:reset-update-dismissal", handler); + return () => window.removeEventListener("dashboard:reset-update-dismissal", handler); + }, []); + + const show = Boolean( + status?.update_available && status.remote_sha && dismissedSha !== status.remote_sha + ); + + const dismiss = useCallback(() => { + if (!status?.remote_sha) return; + try { + localStorage.setItem(DISMISS_KEY, status.remote_sha); + } catch { + /* ignore */ + } + setDismissedSha(status.remote_sha); + }, [status?.remote_sha]); + + // Escape to dismiss - standard modal affordance. + useEffect(() => { + if (!show) return; + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") dismiss(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [show, dismiss]); + + const copyCmd = async () => { + if (!status?.manual_command) return; + try { + await navigator.clipboard.writeText(status.manual_command); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + /* ignore */ + } + }; + + const checkNow = async () => { + if (checking) return; + setError(null); + setChecking(true); + try { + const fresh = await api.updates.check(); + syncFromPayload(fresh); + } catch (e) { + setError(e instanceof Error ? e.message : t("checkError")); + } finally { + setChecking(false); + } + }; + + if (!show || !status) return null; + + const refLabel = status.remote_ref || "origin"; + const behind = status.commits_behind ?? 0; + + return ( +
{ + if (e.target === e.currentTarget) dismiss(); + }} + > +
+ {/* Header */} +
+
+
+ +
+
+

+ {t("title")} +

+

+ {t("commitsBehind", { count: behind, ref: refLabel })} +

+
+
+ +
+ + {/* Body */} +
+

{t("lead")}

+ + {status.fetch_error ? ( +
+ {t("fetchError")} +
+ ) : null} + + {!status.git_repo ? ( +
+ {t("notGit")} +
+ ) : null} + + {status.situation_note ? ( +
+ {status.situation_note} +
+ ) : null} + + {status.manual_command ? ( +
+              {status.manual_command}
+            
+ ) : null} + + {/* The restart hint only applies when the printed command actually + * rewrites the working tree. Feature-branch / detached-HEAD commands + * are fetch-only - restarting the dashboard would change nothing. */} + {status.situation === "tracking_canonical" || + status.situation === "fork_or_diverged_tracking" ? ( +

{t("restartNote")}

+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} +
+ + {/* Footer */} +
+ + + {status.manual_command ? ( + + ) : null} +
+
+
+ ); +} diff --git a/client/src/components/WebhookSettings.tsx b/client/src/components/WebhookSettings.tsx new file mode 100644 index 0000000..24b7521 --- /dev/null +++ b/client/src/components/WebhookSettings.tsx @@ -0,0 +1,826 @@ +/** + * @file WebhookSettings.tsx + * @description Settings-page panel for universal webhook notifications across 14 + * first-class providers (Slack, Discord, Teams, Google Chat, Mattermost, + * Rocket.Chat, Telegram, PagerDuty, Opsgenie, Splunk On-Call, Zapier, Make, n8n, + * Pipedream) plus a generic endpoint. The form is driven by provider metadata + * fetched from the server (`/api/webhooks/providers`): each provider declares + * whether it needs a URL and which credential fields to render, so adding a + * provider server-side surfaces here with no UI change. Secrets are never + * returned by the API - URLs are masked and re-entered to change. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** React hook: isolates side effects and subscription wiring so presentational components stay declarative. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../lib/api` + * - `./Select` + * - `./ConfirmModal` + * - `./Checkbox` + * - `./webhookGuides` + * - `../lib/format` + * - `../lib/types` + * + * ## Public surface + * - `WebhookSettings` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **WebhookSettings** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Webhook, + Plus, + Trash2, + X, + Pencil, + Zap, + Check, + AlertTriangle, + CheckCircle, + XCircle, + Loader2, + BookOpen, + ChevronDown, + ExternalLink, + Info, +} from "lucide-react"; +import { api } from "../lib/api"; +import { Select } from "./Select"; +import { ConfirmModal } from "./ConfirmModal"; +import { Checkbox } from "./Checkbox"; +import { WEBHOOK_DOCS } from "./webhookGuides"; +import { timeAgo } from "../lib/format"; +import type { + AlertRule, + WebhookProvider, + WebhookTarget, + WebhookType, + WebhookTestResult, +} from "../lib/types"; + +// Brand-ish accent per provider type; anything unmapped falls back to neutral. +const TYPE_STYLES: Partial> = { + slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20", + discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20", + teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20", + google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20", + mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20", + rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20", + telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20", + pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20", + opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20", + splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20", +}; +const NEUTRAL_STYLE = "text-gray-300 bg-surface-2 border-border"; + +interface HeaderRow { + key: string; + value: string; +} + +interface FormState { + id: string | null; + name: string; + type: WebhookType; + url: string; + secret: string; + headerRows: HeaderRow[]; + replaceHeaders: boolean; + config: Record; + scopeAll: boolean; + ruleIds: string[]; + enabled: boolean; +} + +function defaultsFor(provider: WebhookProvider | undefined): Record { + const out: Record = {}; + if (!provider) return out; + for (const f of provider.fields) if (f.default != null) out[f.key] = f.default; + return out; +} + +function Toggle({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label?: string; +}) { + return ( + + ); +} + +export function WebhookSettings() { + const { t } = useTranslation("settings"); + const [targets, setTargets] = useState([]); + const [providers, setProviders] = useState([]); + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [formOpen, setFormOpen] = useState(false); + const [form, setForm] = useState(null); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + const [testResult, setTestResult] = useState>({}); + const [confirmDelete, setConfirmDelete] = useState(null); + const [guideOpen, setGuideOpen] = useState(false); + + const providerOf = useCallback( + (type: WebhookType) => providers.find((p) => p.type === type), + [providers] + ); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await api.webhooks.list(); + setTargets(res.targets); + } catch (err) { + console.error("Failed to load webhook targets:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + useEffect(() => { + api.webhooks + .providers() + .then((res) => setProviders(res.providers)) + .catch(() => setProviders([])); + api.alerts.rules + .list() + .then((res) => setRules(res.rules)) + .catch(() => setRules([])); + }, []); + + const set = (patch: Partial) => + setForm((prev) => (prev ? { ...prev, ...patch } : prev)); + + const openCreate = () => { + const first = providers[0]; + setForm({ + id: null, + name: "", + type: (first?.type as WebhookType) || "slack", + url: "", + secret: "", + headerRows: [], + replaceHeaders: true, + config: defaultsFor(first), + scopeAll: true, + ruleIds: [], + enabled: true, + }); + setFormError(null); + setFormOpen(true); + }; + + const openEdit = (target: WebhookTarget) => { + const provider = providerOf(target.type); + // Prefill non-secret config (region, chat_id, severity, …); leave secret + // fields blank - they're redacted and re-entered only to change. + const config: Record = {}; + for (const f of provider?.fields || []) { + if (f.secret) continue; + const v = target.config?.[f.key]; + config[f.key] = v != null ? String(v) : (f.default ?? ""); + } + setForm({ + id: target.id, + name: target.name, + type: target.type, + url: "", + secret: "", + headerRows: [], + replaceHeaders: false, + config, + scopeAll: !target.rule_ids || target.rule_ids.length === 0, + ruleIds: target.rule_ids || [], + enabled: target.enabled, + }); + setFormError(null); + setFormOpen(true); + }; + + const closeForm = () => { + setFormOpen(false); + setForm(null); + setFormError(null); + }; + + const provider = form ? providerOf(form.type) : undefined; + const isEdit = !!form?.id; + const showUrl = !!provider && (provider.url_required || provider.has_default_url); + const urlOptional = !!provider && !provider.url_required; + + const canSubmit = useMemo(() => { + if (!form || !provider) return false; + if (!form.name.trim()) return false; + if (isEdit) return true; // server merge - existing values fill the gaps + if (provider.url_required && !form.url.trim()) return false; + for (const f of provider.fields) { + if (f.required && f.type !== "enum" && !(form.config[f.key] || "").trim()) return false; + } + return true; + }, [form, provider, isEdit]); + + const buildConfigObj = (): Record | undefined => { + if (!form || !provider || provider.fields.length === 0) return undefined; + const out: Record = {}; + for (const f of provider.fields) { + const v = (form.config[f.key] ?? "").toString(); + if (f.type === "enum") { + if (v) out[f.key] = v; + } else if (v.trim()) { + out[f.key] = v.trim(); + } + } + return out; + }; + + const buildHeaders = (): Record => { + if (!form) return {}; + const out: Record = {}; + for (const r of form.headerRows) if (r.key.trim()) out[r.key.trim()] = r.value; + return out; + }; + + const onSubmit = async () => { + if (!form || !provider || saving || !canSubmit) return; + setSaving(true); + setFormError(null); + try { + const ruleIds = form.scopeAll ? [] : form.ruleIds; + const config = buildConfigObj(); + const genericFamily = provider.supports_secret || provider.supports_headers; + if (isEdit && form.id) { + const patch: Parameters[1] = { + name: form.name.trim(), + enabled: form.enabled, + rule_ids: ruleIds, + }; + if (form.url.trim()) patch.url = form.url.trim(); + if (config) patch.config = config; + if (genericFamily && form.secret.trim()) patch.secret = form.secret.trim(); + if (provider.supports_headers && form.replaceHeaders) patch.headers = buildHeaders(); + await api.webhooks.update(form.id, patch); + } else { + await api.webhooks.create({ + name: form.name.trim(), + type: form.type, + url: form.url.trim() || undefined, + enabled: form.enabled, + secret: genericFamily && form.secret.trim() ? form.secret.trim() : undefined, + headers: provider.supports_headers ? buildHeaders() : undefined, + config, + rule_ids: ruleIds.length ? ruleIds : undefined, + }); + } + closeForm(); + load(); + } catch (err) { + setFormError(err instanceof Error ? err.message : String(err)); + } finally { + setSaving(false); + } + }; + + const onToggle = async (target: WebhookTarget) => { + try { + await api.webhooks.update(target.id, { enabled: !target.enabled }); + load(); + } catch (err) { + console.error("Failed to toggle webhook:", err); + } + }; + + const onDelete = async (id: string) => { + try { + await api.webhooks.remove(id); + setConfirmDelete(null); + load(); + } catch (err) { + console.error("Failed to delete webhook:", err); + } + }; + + const onTest = async (id: string) => { + setTesting(id); + setTestResult((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + try { + const result = await api.webhooks.test(id); + setTestResult((prev) => ({ ...prev, [id]: result })); + } catch (err) { + setTestResult((prev) => ({ + ...prev, + [id]: { ok: false, status: null, attempts: 0, error: String(err) }, + })); + } finally { + setTesting(null); + load(); + } + }; + + const labelOf = (type: WebhookType) => providerOf(type)?.label || type; + + return ( +
+
+
+ + {t("webhooks.count", { count: targets.length })} +
+ {!formOpen && ( + + )} +
+ + {/* Target list */} + {loading ? ( +

{t("webhooks.loading")}

+ ) : targets.length === 0 && !formOpen ? ( +
+ + {t("webhooks.empty")} +
+ ) : ( +
+ {targets.map((target) => { + const result = testResult[target.id]; + return ( +
+
+ + {labelOf(target.type)} + + {target.name} + + {target.url_preview} + + {target.rule_ids && target.rule_ids.length > 0 && ( + + {t("webhooks.scopedTo", { count: target.rule_ids.length })} + + )} +
+ {target.last_delivery && ( + + {target.last_delivery.status === "success" ? ( + + ) : ( + + )} + {timeAgo(target.last_delivery.created_at)} + + )} + onToggle(target)} + label={t("webhooks.enabled")} + /> +
+
+ +
+ + + + {result && ( + + {result.ok ? ( + + ) : ( + + )} + {result.ok + ? t("webhooks.testOk", { status: result.status ?? 200 }) + : t("webhooks.testFail", { + error: result.error || `HTTP ${result.status ?? "?"}`, + })} + + )} +
+
+ ); + })} +
+ )} + + {/* Create / edit form */} + {formOpen && form && provider && ( +
+
+

+ {isEdit ? t("webhooks.editTitle") : t("webhooks.addTitle")} +

+ +
+ +
+ + +
+ + {/* URL (hidden for providers that derive their own URL) */} + {showUrl && ( + + )} + {!showUrl && ( +

+ + {t("webhooks.urlAuto")} +

+ )} + + {/* Collapsible per-provider setup guide */} +
+ + {guideOpen && ( +
+
    + {(t(`webhookGuides.${form.type}.steps`, { returnObjects: true }) as string[]).map( + (s, i) => ( +
  1. {s}
  2. + ) + )} +
+ {WEBHOOK_DOCS[form.type] && ( + + + {t("webhooks.guideDocs", { provider: provider.label })} + + )} +

+ + {t("webhooks.guideStaleNote")} +

+
+ )} +
+ + {/* Provider-specific config fields */} + {provider.fields.length > 0 && ( +
+ {provider.fields.map((f) => ( +
+ )} + + x.id === confirmDelete)?.name ?? "", + })} + confirmLabel={t("webhooks.delete")} + cancelLabel={t("webhooks.cancel")} + onCancel={() => setConfirmDelete(null)} + onConfirm={() => confirmDelete && onDelete(confirmDelete)} + /> +
+ ); +} diff --git a/client/src/components/__tests__/AgentCard.test.tsx b/client/src/components/__tests__/AgentCard.test.tsx new file mode 100644 index 0000000..1f9e6ae --- /dev/null +++ b/client/src/components/__tests__/AgentCard.test.tsx @@ -0,0 +1,328 @@ +/** + * @file AgentCard.test.tsx + * @description Unit tests for the AgentCard component, which displays information about an agent in the application. The tests cover rendering of agent details such as name, status, subagent type, task, and current tool, as well as interaction handling like click events. The tests use React Testing Library and Vitest for assertions and mocking. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +// render is used inside renderCard helper +import { MemoryRouter } from "react-router-dom"; +import { AgentCard } from "../AgentCard"; +import type { Agent } from "../../lib/types"; +import { formatModelName, fmtCost } from "../../lib/format"; + +function renderCard(element: JSX.Element) { + return render({element}); +} + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: "agent-1", + session_id: "sess-1", + name: "Main Agent", + type: "main", + subagent_type: null, + status: "working", + task: null, + current_tool: null, + started_at: "2026-03-05T10:00:00.000Z", + ended_at: null, + updated_at: "2026-03-05T10:00:00.000Z", + parent_agent_id: null, + metadata: null, + ...overrides, + }; +} + +describe("AgentCard", () => { + it("should render agent name", () => { + renderCard(); + expect(screen.getByText("Test Agent")).toBeInTheDocument(); + }); + + it("should render status badge", () => { + renderCard(); + expect(screen.getByText("Working")).toBeInTheDocument(); + }); + + it("should render subagent_type when present", () => { + renderCard( + + ); + expect(screen.getByText("Explore")).toBeInTheDocument(); + }); + + it("should show the subagent's own model from metadata, not the session model (issue #185)", () => { + renderCard( + + ); + // Subtitle is the subagent type + project (cwd); the model badge shows the + // subagent's OWN model. + expect(screen.getByText("qa · x")).toBeInTheDocument(); + expect(screen.getByText(formatModelName("claude-haiku-4-5-20251001")!)).toBeInTheDocument(); + // The Opus session model must NOT appear on a subagent card. + expect(screen.queryByText(formatModelName("claude-opus-4-8")!)).not.toBeInTheDocument(); + }); + + it("main agent subtitle shows project + subagent count, with the model only once (#185)", () => { + renderCard( + + ); + // Subtitle: project basename + SUBAGENT count + turn count (model excluded). + // agent_count includes the main agent itself, so 4 agents => 3 subagents. + // Showing subagents (not agents) reconciles the card with the "Active + // Subagents" dashboard stat, which excludes main agents. + expect(screen.getByText("proj · 3 subagents · 12 turns")).toBeInTheDocument(); + // The model appears exactly once — in the footer badge, not duplicated in + // the subtitle the way main cards used to. + expect(screen.getAllByText(formatModelName("claude-opus-4-8")!)).toHaveLength(1); + }); + + it("shows a subagent's OWN cost, not the session total (avoids misleading spend)", () => { + renderCard( + + ); + expect(screen.getByText(fmtCost(3.5))).toBeInTheDocument(); + // The session total must NOT appear on a subagent card. + expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument(); + }); + + it("shows the session total on a main-agent card", () => { + renderCard( + + ); + expect(screen.getByText(fmtCost(646.5))).toBeInTheDocument(); + }); + + it("shows no cost on a subagent card with no recorded usage", () => { + renderCard( + + ); + expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument(); + }); + + it("swaps the real session title into the hook-style placeholder (Session )", () => { + renderCard( + + ); + expect(screen.getByText("Main Agent - Resumable runs UI")).toBeInTheDocument(); + }); + + it("swaps the real session title into the import-style placeholder ( - )", () => { + // Regression: imported / background-synced main agents are named + // "Main Agent - - ", which the old Session-only regex + // could not rewrite, so they kept showing "work - e3f8e613" forever even + // though the session title was known. + renderCard( + + ); + expect( + screen.getByText("Main Agent - Implement in-process libdocs MCP server") + ).toBeInTheDocument(); + expect(screen.queryByText("Main Agent - work - e3f8e613")).not.toBeInTheDocument(); + }); + + it("keeps the placeholder when the session name is still auto-generated", () => { + renderCard( + + ); + // "Session " is suppressed as a non-name, so nothing to swap in. + expect(screen.getByText("Main Agent - work - e3f8e613")).toBeInTheDocument(); + }); + + it("should not render subagent_type when null", () => { + const { container } = renderCard(); + // Only the name should be in the name container, no subagent type + expect(container.querySelectorAll(".text-\\[11px\\].text-gray-500.truncate")).toHaveLength(0); + }); + + it("should render task when present", () => { + renderCard(); + expect(screen.getByText("Searching for patterns")).toBeInTheDocument(); + }); + + it("should not render task when null", () => { + renderCard(); + expect(screen.queryByText("Searching for patterns")).not.toBeInTheDocument(); + }); + + it("should render current_tool when present", () => { + renderCard(); + expect(screen.getByText("Bash")).toBeInTheDocument(); + }); + + it("should not render current_tool when null", () => { + renderCard(); + expect(screen.queryByText("Bash")).not.toBeInTheDocument(); + }); + + it("should apply active border for working agents", () => { + const { container } = renderCard(); + const card = container.querySelector(".card-hover"); + expect(card?.className).toContain("border-l-2"); + }); + + it("should apply yellow border for waiting agents even without awaiting_input_since", () => { + const { container } = renderCard(); + const card = container.querySelector(".card-hover"); + expect(card?.className).toContain("border-l-2"); + expect(card?.className).toContain("border-l-yellow-500/60"); + }); + + it("should not apply active border for completed agents", () => { + const { container } = renderCard(); + const card = container.querySelector(".card-hover"); + expect(card?.className).not.toContain("border-l-2"); + }); + + it("should call onClick when clicked", () => { + const onClick = vi.fn(); + renderCard(); + fireEvent.click(screen.getByText("Main Agent")); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders waiting badge and yellow accent when awaiting_input_since is set", () => { + const { container } = renderCard( + + ); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + const card = container.querySelector(".card-hover"); + expect(card?.className).toContain("border-l-yellow-500/60"); + }); + + it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => { + renderCard( + + ); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + // Cards are narrow — the inline chip would squeeze the title, so the + // reason must NOT render inline here (hover tooltip only). + expect(screen.queryByText("Needs input")).not.toBeInTheDocument(); + }); + + it("degrades to a plain Waiting badge on an unknown awaiting_reason", () => { + renderCard( + + ); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.queryByText("Needs input")).not.toBeInTheDocument(); + }); + + it("ignores awaiting_input_since once the agent has completed", () => { + renderCard( + + ); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect(screen.queryByText("Waiting")).not.toBeInTheDocument(); + }); + + it("should show duration for completed agents with ended_at", () => { + renderCard( + + ); + expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/EmptyState.test.tsx b/client/src/components/__tests__/EmptyState.test.tsx new file mode 100644 index 0000000..2c81e09 --- /dev/null +++ b/client/src/components/__tests__/EmptyState.test.tsx @@ -0,0 +1,44 @@ +/** + * @file EmptyState.test.tsx + * @description Unit tests for the EmptyState component, which is a reusable React component that displays an empty state with an icon, title, description, and an optional action. The tests cover rendering of the title, description, icon, and action button when provided. The tests use React Testing Library and Vitest for assertions and mocking. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { EmptyState } from "../EmptyState"; +import { Bot } from "lucide-react"; + +describe("EmptyState", () => { + it("should render title", () => { + render( + + ); + expect(screen.getByText("No agents")).toBeInTheDocument(); + }); + + it("should render description", () => { + render( + + ); + expect(screen.getByText("Start a session to see agents.")).toBeInTheDocument(); + }); + + it("should render the icon", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("should render action when provided", () => { + render( + Retry} /> + ); + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("should not render action when not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/EventDetail.test.tsx b/client/src/components/__tests__/EventDetail.test.tsx new file mode 100644 index 0000000..7cea9c8 --- /dev/null +++ b/client/src/components/__tests__/EventDetail.test.tsx @@ -0,0 +1,127 @@ +/** + * @file EventDetail.test.tsx + * @description Unit tests for the EventDetail component. Verifies the uniform + * label/value row rendering: event-level fields appear first, payload keys + * follow, scalars render inline, objects/arrays/multiline strings render in a + * terminal-styled code view, and JSON parse failures fall back to showing the + * raw data as a single row. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { EventDetail } from "../EventDetail"; +import type { DashboardEvent } from "../../lib/types"; + +const baseEvent: DashboardEvent = { + id: 42, + session_id: "sess-123", + agent_id: "agent-abc", + event_type: "PreToolUse", + tool_name: "Bash", + summary: "Using tool: Bash", + data: JSON.stringify({ + cwd: "/tmp", + permission_mode: "bypassPermissions", + hook_event_name: "PreToolUse", + tool_name: "Bash", + tool_input: { command: "ls -la", description: "list files" }, + stop_hook_active: false, + }), + created_at: "2026-04-22T10:00:00.000Z", +}; + +describe("EventDetail", () => { + it("renders event-level fields first: event_id, session_id, agent_id", () => { + render(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("sess-123")).toBeInTheDocument(); + expect(screen.getByText("agent-abc")).toBeInTheDocument(); + }); + + it("renders scalar payload fields with humanized i18n labels", () => { + render(); + // Translated labels from common:eventDetail.* - never raw snake_case keys. + expect(screen.getByText("Working directory")).toBeInTheDocument(); + expect(screen.getByText("/tmp")).toBeInTheDocument(); + expect(screen.getByText("Permission mode")).toBeInTheDocument(); + expect(screen.getByText("bypassPermissions")).toBeInTheDocument(); + expect(screen.getByText("Hook Event Name")).toBeInTheDocument(); + }); + + it("renders boolean values as pills", () => { + render(); + expect(screen.getByText("false")).toBeInTheDocument(); + }); + + it("renders Bash tool_input as a terminal block (command + description)", () => { + render(); + expect(screen.getByText("Tool Input")).toBeInTheDocument(); + // Terminal renderer shows the raw command and the `# description` line, + // not the pretty-printed JSON. Description appears both in the Summary + // block and in the terminal - `getAllByText` allows both. + expect(screen.getByText("ls -la")).toBeInTheDocument(); + expect(screen.getAllByText(/list files/).length).toBeGreaterThan(0); + }); + + it("renders multiline strings in a text code view", () => { + const event = { + ...baseEvent, + data: JSON.stringify({ last_assistant_message: "line 1\nline 2\nline 3" }), + }; + render(); + expect(screen.getByText("Last Assistant Message")).toBeInTheDocument(); + expect(screen.getByText(/line 1/)).toBeInTheDocument(); + expect(screen.getByText(/line 3/)).toBeInTheDocument(); + }); + + it("does not duplicate session_id or agent_id from payload", () => { + const event = { + ...baseEvent, + data: JSON.stringify({ session_id: "sess-123", agent_id: "agent-abc", cwd: "/tmp" }), + }; + render(); + // "sess-123" should appear exactly once (from event-level row). + expect(screen.getAllByText("sess-123")).toHaveLength(1); + expect(screen.getAllByText("agent-abc")).toHaveLength(1); + }); + + it("humanizes unknown payload keys instead of showing raw snake_case", () => { + const event = { + ...baseEvent, + // A key that's NOT in PAYLOAD_LABEL_KEYS should still get a tidy + // Title-Cased label rather than appearing as `some_unknown_field`. + data: JSON.stringify({ some_unknown_field: "hello" }), + }; + render(); + expect(screen.getByText("Some Unknown Field")).toBeInTheDocument(); + expect(screen.queryByText("some_unknown_field")).not.toBeInTheDocument(); + }); + + it("translates known payload keys (tool_use_id → Tool Use ID)", () => { + const event = { + ...baseEvent, + data: JSON.stringify({ tool_use_id: "toolu_01ABC", tool_name: "Bash" }), + }; + render(); + expect(screen.getByText("Tool Use ID")).toBeInTheDocument(); + expect(screen.getByText("Tool")).toBeInTheDocument(); + expect(screen.queryByText("tool_use_id")).not.toBeInTheDocument(); + expect(screen.queryByText("tool_name")).not.toBeInTheDocument(); + }); + + it("falls back to a raw-payload row when JSON parsing fails", () => { + const event = { ...baseEvent, data: "not-json-at-all" }; + render(); + expect(screen.getByText(/raw payload/i)).toBeInTheDocument(); + expect(screen.getByText(/not-json-at-all/)).toBeInTheDocument(); + }); + + it("handles null `data` gracefully without crashing", () => { + const event = { ...baseEvent, data: null }; + render(); + // Still renders event-level rows. + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("sess-123")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/EventFilters.test.tsx b/client/src/components/__tests__/EventFilters.test.tsx new file mode 100644 index 0000000..2029b59 --- /dev/null +++ b/client/src/components/__tests__/EventFilters.test.tsx @@ -0,0 +1,85 @@ +/** + * @file EventFilters.test.tsx + * @description Smoke tests for the EventFilters toolbar. Verifies that the + * toolbar renders its inputs, emits debounced text search changes, toggles + * selected chips, fires the clear-all handler, and fetches facet options on + * mount via the events API (mocked). + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters"; +import type { EventFiltersValue } from "../EventFilters"; +import { api } from "../../lib/api"; + +describe("EventFilters", () => { + beforeEach(() => { + vi.spyOn(api.events, "facets").mockResolvedValue({ + event_types: ["PreToolUse", "PostToolUse", "Stop"], + tool_names: ["Bash", "Edit"], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("isEmptyFilters treats EMPTY_FILTERS as empty", () => { + expect(isEmptyFilters(EMPTY_FILTERS)).toBe(true); + expect(isEmptyFilters({ ...EMPTY_FILTERS, q: "curl" })).toBe(false); + }); + + it("renders the search input with a translated placeholder", () => { + render( {}} />); + expect(screen.getByPlaceholderText(/search summary/i)).toBeInTheDocument(); + }); + + it("fetches facets on mount and opens the event-type dropdown", async () => { + render( {}} />); + await waitFor(() => expect(api.events.facets).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole("button", { name: /event type/i })); + expect(await screen.findByText("PreToolUse")).toBeInTheDocument(); + expect(screen.getByText("Stop")).toBeInTheDocument(); + }); + + it("debounces text search by 300ms", async () => { + vi.useFakeTimers(); + try { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByPlaceholderText(/search summary/i), { + target: { value: "curl" }, + }); + expect(onChange).not.toHaveBeenCalled(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ q: "curl" })); + } finally { + vi.useRealTimers(); + } + }); + + it("toggles an event_type chip and emits the updated array", async () => { + const onChange = vi.fn(); + render(); + await waitFor(() => expect(api.events.facets).toHaveBeenCalled()); + fireEvent.click(screen.getByRole("button", { name: /event type/i })); + const option = await screen.findByText("PreToolUse"); + fireEvent.click(option); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ event_type: ["PreToolUse"] })); + }); + + it("shows the clear-all button only when filters are non-empty", () => { + const withFilter: EventFiltersValue = { ...EMPTY_FILTERS, q: "curl" }; + const onChange = vi.fn(); + const { rerender } = render(); + expect(screen.queryByRole("button", { name: /clear filters/i })).not.toBeInTheDocument(); + + rerender(); + const clear = screen.getByRole("button", { name: /clear filters/i }); + fireEvent.click(clear); + expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS); + }); +}); diff --git a/client/src/components/__tests__/Sidebar.test.tsx b/client/src/components/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..9ac0a6a --- /dev/null +++ b/client/src/components/__tests__/Sidebar.test.tsx @@ -0,0 +1,100 @@ +/** + * @file Sidebar.test.tsx + * @description Unit tests for the Sidebar component, which is responsible for rendering the application's sidebar navigation. The tests cover rendering of the brand name, subtitle, navigation links, WebSocket connection status, and version number. The tests use React Testing Library and Vitest for assertions and mocking. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { Sidebar } from "../Sidebar"; + +function renderSidebar(wsConnected: boolean, collapsed = false) { + return render( + + {}} /> + + ); +} + +describe("Sidebar", () => { + it("should render the brand name", () => { + renderSidebar(true); + expect(screen.getByText("Agent Dashboard")).toBeInTheDocument(); + }); + + it("should render the subtitle", () => { + renderSidebar(true); + expect(screen.getByText("Claude Code Monitor")).toBeInTheDocument(); + }); + + it("should render all navigation links", () => { + renderSidebar(true); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Kanban Board")).toBeInTheDocument(); + expect(screen.getByText("Sessions")).toBeInTheDocument(); + expect(screen.getByText("Activity Feed")).toBeInTheDocument(); + }); + + it('should show "Live" when WebSocket is connected', () => { + renderSidebar(true); + expect(screen.getByText("Live")).toBeInTheDocument(); + }); + + it('should show "Disconnected" when WebSocket is not connected', () => { + renderSidebar(false); + expect(screen.getByText("Disconnected")).toBeInTheDocument(); + }); + + it("should show version number", () => { + // `__APP_VERSION__` is injected by Vite from the repo-root package.json + // (see vite.config.ts) and replaced at transform time in tests too, so this + // stays correct as the project version changes. + renderSidebar(true); + expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument(); + }); + + it("should have correct navigation hrefs", () => { + renderSidebar(true); + const links = screen.getAllByRole("link"); + const hrefs = links.map((link) => link.getAttribute("href")); + expect(hrefs).toContain("/"); + expect(hrefs).toContain("/kanban"); + expect(hrefs).toContain("/sessions"); + expect(hrefs).toContain("/activity"); + }); + + it("should render both language options in expanded mode", () => { + renderSidebar(true); + expect(screen.getByRole("button", { name: "English" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Vietnamese" })).toBeInTheDocument(); + // Chinese and Korean were dropped; offering them would render raw keys. + expect(screen.queryByRole("button", { name: "Chinese" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Korean" })).toBeNull(); + }); + + it("should switch to Vietnamese when Vietnamese option is clicked", async () => { + const user = userEvent.setup(); + renderSidebar(true); + + await user.click(screen.getByRole("button", { name: "Vietnamese" })); + + await waitFor(() => { + expect(screen.getByText("Tổng quan")).toBeInTheDocument(); + expect(screen.getByText("Bảng Kanban")).toBeInTheDocument(); + }); + }); + + it("should cycle language in collapsed mode", async () => { + const user = userEvent.setup(); + renderSidebar(true, true); + + expect(screen.getByText("EN")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Switch to Vietnamese" })); + + await waitFor(() => { + expect(screen.getByText("VI")).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/__tests__/StatCard.test.tsx b/client/src/components/__tests__/StatCard.test.tsx new file mode 100644 index 0000000..726dab4 --- /dev/null +++ b/client/src/components/__tests__/StatCard.test.tsx @@ -0,0 +1,74 @@ +/** + * @file StatCard.test.tsx + * @description Unit tests for the StatCard component, which is a reusable React component that displays a statistic with a label, value, icon, and optional trend information. The tests cover rendering of the label, value (both numeric and string), trend information, and the icon. The tests also verify that custom accent colors are applied correctly. The tests use React Testing Library and Vitest for assertions and mocking. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { StatCard } from "../StatCard"; +import { Activity } from "lucide-react"; + +describe("StatCard", () => { + it("should render label", () => { + render(); + expect(screen.getByText("Total Sessions")).toBeInTheDocument(); + }); + + it("should render numeric value", () => { + render(); + expect(screen.getByText("156")).toBeInTheDocument(); + }); + + it("should render string value", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("should render trend when provided", () => { + render(); + expect(screen.getByText("3 active")).toBeInTheDocument(); + }); + + it("should not render trend when not provided", () => { + render(); + expect(screen.queryByText("active")).not.toBeInTheDocument(); + }); + + it("should render the icon", () => { + const { container } = render(); + // Lucide renders as SVG + const svg = container.querySelector("svg"); + expect(svg).toBeInTheDocument(); + }); + + it("should apply custom accent color", () => { + const { container } = render( + + ); + const svg = container.querySelector("svg"); + expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-emerald-400"); + }); + + it("should apply default accent color when not specified", () => { + const { container } = render(); + const svg = container.querySelector("svg"); + expect(svg?.className?.baseVal ?? svg?.getAttribute("class")).toContain("text-accent"); + }); + + it("should render a skeleton placeholder when loading and hide the real value", () => { + const { container } = render(); + // value text should NOT appear so users never see a flash of "-" or 0 + expect(screen.queryByText("-")).not.toBeInTheDocument(); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + // skeleton primitive renders an aria-busy node + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + }); + + it("should swap from skeleton to value when loading flips false", () => { + const { rerender } = render(); + expect(screen.queryByText("42")).not.toBeInTheDocument(); + rerender(); + expect(screen.getByText("42")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/StatusBadge.test.tsx b/client/src/components/__tests__/StatusBadge.test.tsx new file mode 100644 index 0000000..0e666e7 --- /dev/null +++ b/client/src/components/__tests__/StatusBadge.test.tsx @@ -0,0 +1,159 @@ +/** + * @file StatusBadge.test.tsx + * @description Unit tests for the StatusBadge component, which includes AgentStatusBadge and SessionStatusBadge. These components are responsible for displaying the status of agents and sessions in the dashboard. The tests cover rendering of different statuses, application of pulse animation based on status, respect for explicit pulse overrides, and the awaiting-reason suffix (icon + short label + hover tooltip) that explains WHY a row is in the Waiting state. The tests use React Testing Library and Vitest for assertions and mocking. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AgentStatusBadge, SessionStatusBadge } from "../StatusBadge"; + +describe("AgentStatusBadge", () => { + it("should render waiting status", () => { + render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + }); + + it("should render working status", () => { + render(); + expect(screen.getByText("Working")).toBeInTheDocument(); + }); + + it("should render completed status", () => { + render(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + }); + + it("should render error status", () => { + render(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("should apply pulse animation for working status by default", () => { + const { container } = render(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + }); + + it("should not apply pulse for connected status (now working - has pulse)", () => { + const { container } = render(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + }); + + it("should apply pulse animation for waiting status by default", () => { + const { container } = render(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + }); + + it("should respect explicit pulse=false override", () => { + const { container } = render(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).not.toBeInTheDocument(); + }); + + it("should respect explicit pulse=true override", () => { + const { container } = render(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + }); + + it("should render waiting status with yellow dot and pulse by default", () => { + const { container } = render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument(); + }); +}); + +describe("SessionStatusBadge", () => { + it("should render active status", () => { + render(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); + + it("should render completed status", () => { + render(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + }); + + it("should render error status", () => { + render(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("should render abandoned status", () => { + render(); + expect(screen.getByText("Abandoned")).toBeInTheDocument(); + }); + + it("should render waiting status with pulsing yellow dot", () => { + const { container } = render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + const dot = container.querySelector(".animate-pulse-dot"); + expect(dot).toBeInTheDocument(); + expect(container.querySelector(".bg-yellow-400")).toBeInTheDocument(); + }); +}); + +describe("awaiting-reason suffix", () => { + it("renders the reason label next to Waiting on AgentStatusBadge", () => { + render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.getByText("Needs input")).toBeInTheDocument(); + }); + + it("renders the reason label next to Waiting on SessionStatusBadge", () => { + render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.getByText("Turn done")).toBeInTheDocument(); + }); + + it("ignores the reason on non-waiting statuses", () => { + render(); + expect(screen.queryByText("Needs input")).not.toBeInTheDocument(); + render(); + expect(screen.queryByText("Turn done")).not.toBeInTheDocument(); + }); + + it("renders no suffix when reason is null/omitted", () => { + render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.queryByText("Needs input")).not.toBeInTheDocument(); + expect(screen.queryByText("Turn done")).not.toBeInTheDocument(); + }); + + it("shows the full reason description in a tooltip on hover", () => { + const { container } = render(); + expect(screen.getByText("Interrupted")).toBeInTheDocument(); + // Tip attaches its handlers to the wrapper element and portals the tooltip + // body into document.body. + fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 }); + expect(screen.getByText(/The last turn was interrupted/)).toBeInTheDocument(); + }); + + it("marks urgent reasons with the hotter amber tint", () => { + const { container } = render(); + expect(container.querySelector(".text-amber-300")).toBeInTheDocument(); + const { container: calm } = render(); + expect(calm.querySelector(".text-amber-300")).not.toBeInTheDocument(); + }); + + it("compact mode suppresses the inline chip but keeps the hover tooltip", () => { + const { container } = render( + + ); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.queryByText("Needs input")).not.toBeInTheDocument(); + fireEvent.mouseEnter(container.firstElementChild!, { clientX: 10, clientY: 10 }); + expect(screen.getByText(/Blocked on a permission prompt/)).toBeInTheDocument(); + }); + + it("compact mode works on SessionStatusBadge too", () => { + render(); + expect(screen.getByText("Waiting")).toBeInTheDocument(); + expect(screen.queryByText("Turn done")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/conversation/CodeBlock.tsx b/client/src/components/conversation/CodeBlock.tsx new file mode 100644 index 0000000..baf68c7 --- /dev/null +++ b/client/src/components/conversation/CodeBlock.tsx @@ -0,0 +1,258 @@ +/** + * @file CodeBlock.tsx + * @description Reusable, syntax-highlighted code block with a chrome bar (language pill, + * optional filename, copy-to-clipboard, line count) and optional gutter line numbers. + * Used by MarkdownContent for fenced code blocks and by ToolCallBlock for tool I/O. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/highlight` + * + * ## Public surface + * - `CodeBlock` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **CodeBlock** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useMemo, useState } from "react"; +import { Check, Copy, FileCode } from "lucide-react"; +import { canonicalLang, highlight, tokenClass, type Token } from "../../lib/highlight"; + +interface CodeBlockProps { + code: string; + lang?: string; + /** Optional filename to display in the chrome bar. */ + filename?: string; + /** Render compact (no chrome bar). */ + compact?: boolean; + /** Override for the right-side label (e.g. "Output", "Error"). */ + label?: string; + /** Tone - "default" matches the surface, "danger" tints red, "success" tints emerald. */ + tone?: "default" | "danger" | "success"; + /** Cap the rendered height; pass null to disable. Default 24rem. */ + maxHeight?: string | null; + /** Show a left gutter with line numbers. Default true for >= 4 lines. */ + showLineNumbers?: boolean; +} + +const LANG_DISPLAY: Record = { + js: "JavaScript", + ts: "TypeScript", + python: "Python", + json: "JSON", + bash: "Shell", + html: "HTML", + css: "CSS", + sql: "SQL", + yaml: "YAML", + diff: "Diff", + plain: "Text", +}; + +function langDisplay(lang: string): string { + const canon = canonicalLang(lang); + return LANG_DISPLAY[canon] ?? (lang || "Text"); +} + +/** + * Split tokens that span multiple lines so we can render one line at a time + * (necessary for the gutter line-number column to align). + */ +function splitTokensByLine(tokens: Token[]): Token[][] { + const lines: Token[][] = [[]]; + for (const t of tokens) { + const parts = t.text.split("\n"); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + const piece = parts[i]!; + if (piece.length > 0) { + lines[lines.length - 1]!.push({ type: t.type, text: piece }); + } + } + } + return lines; +} + +export function CodeBlock({ + code, + lang = "", + filename, + compact = false, + label, + tone = "default", + maxHeight = "24rem", + showLineNumbers, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false); + + const tokens = useMemo(() => highlight(code, lang), [code, lang]); + const lineTokens = useMemo(() => splitTokensByLine(tokens), [tokens]); + const totalLines = lineTokens.length; + const gutter = showLineNumbers ?? totalLines >= 4; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard may be unavailable in some contexts - fail silently. + } + }; + + const palette = + tone === "danger" + ? { + wrapper: "border-red-500/30 bg-red-500/5", + chrome: "bg-red-500/10 border-b border-red-500/20", + label: "text-red-300", + } + : tone === "success" + ? { + wrapper: "border-emerald-500/30 bg-emerald-500/5", + chrome: "bg-emerald-500/10 border-b border-emerald-500/20", + label: "text-emerald-300", + } + : { + wrapper: "border-surface-3 bg-surface-4/50", + chrome: "bg-surface-3/70 border-b border-surface-3", + label: "text-gray-400", + }; + + const preStyle: React.CSSProperties = {}; + if (maxHeight) preStyle.maxHeight = maxHeight; + + return ( +
+ {!compact && ( +
+ {/* Language pill */} + + {filename ? : null} + {filename ?? label ?? langDisplay(lang)} + + + {/* Filename + lang together when both are set */} + {filename && !label && ( + {langDisplay(lang)} + )} + {filename && label && ( + · {label} + )} + + {/* Right side: line count + copy */} +
+ {totalLines > 1 && ( + + {totalLines} {totalLines === 1 ? "line" : "lines"} + + )} + +
+
+ )} + +
+
+          
+            {gutter ? (
+              
+                
+                  {lineTokens.map((line, i) => (
+                    
+                      
+                      
+                    
+                  ))}
+                
+              
+ {i + 1} + + {line.length === 0 ? ( +   + ) : ( + line.map((t, j) => ( + + {t.text} + + )) + )} +
+ ) : ( +
+ {tokens.map((t, i) => ( + + {t.text} + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/client/src/components/conversation/ConversationView.tsx b/client/src/components/conversation/ConversationView.tsx new file mode 100644 index 0000000..ac08678 --- /dev/null +++ b/client/src/components/conversation/ConversationView.tsx @@ -0,0 +1,495 @@ +/** + * @file ConversationView.tsx + * @description Conversation tab on the Session detail page. Loads a session + * (or sub-agent) JSONL transcript, paginates it incrementally, and renders + * the message stream via MessageList. Combines a WebSocket subscription, a + * visibility-gated polling fallback, and a manual refresh button so the view + * stays caught up even when hooks miss frames or the user is mid-text-only + * turn (no PreToolUse fires until Stop). + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/api` + * - `../../lib/eventBus` + * - `./MessageList` + * - `../../lib/types` + * + * ## Public surface + * - `ConversationView` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ConversationView** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useEffect, useState, useCallback, useRef } from "react"; +import { ChevronDown, Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react"; +import { api } from "../../lib/api"; +import { eventBus } from "../../lib/eventBus"; +import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents"; +import { MessageList } from "./MessageList"; +import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types"; + +// Catch-up poll interval. Claude Code only fires hooks on PreToolUse / +// PostToolUse / Stop, which means a user-typed message (no hook) and any +// assistant text written between two hook fires is invisible until the next +// hook event. A short visibility-gated poll closes that gap and also rescues +// the conversation from missed/late WebSocket frames. +const POLL_INTERVAL_MS = 3000; +// Rescan the transcripts list periodically so new subagents that spawn +// mid-session appear in the dropdown without a page reload. +const TRANSCRIPTS_REFRESH_MS = 15000; + +interface ConversationViewProps { + sessionId: string; + initialTranscriptId?: string | null; +} + +export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) { + const [messages, setMessages] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [loadingHistory, setLoadingHistory] = useState(false); + const [selectedTranscript, setSelectedTranscript] = useState( + initialTranscriptId ?? null + ); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(null); + const [transcripts, setTranscripts] = useState([]); + const [showNewMsg, setShowNewMsg] = useState(false); + + // Track JSONL line numbers for incremental requests and history loading + const lastLineRef = useRef(0); + const firstLineRef = useRef(0); + const scrollContainerRef = useRef(null); + const isAtBottomRef = useRef(true); + const fetchingRef = useRef(false); + // When a fetch is in flight and a new trigger arrives (WS event, poll, + // manual refresh), we queue exactly one re-fetch so events that landed + // during the in-flight request aren't silently dropped. + const pendingFetchRef = useRef(false); + // Refresh-button spinner state - separate from initial `loading` so the + // existing skeleton doesn't blink during a manual refresh. + const [refreshing, setRefreshing] = useState(false); + + // Load available transcript list (also rescanned on a short interval so + // newly-spawned subagents appear in the dropdown without a page reload). + useEffect(() => { + let cancelled = false; + async function loadTranscripts() { + try { + const result = await api.sessions.transcripts(sessionId); + if (cancelled) return; + setTranscripts(result.transcripts); + } catch { + // Non-fatal + } + } + loadTranscripts(); + const interval = window.setInterval(loadTranscripts, TRANSCRIPTS_REFRESH_MS); + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [sessionId]); + + // Sync external initialTranscriptId to internal state + useEffect(() => { + if (initialTranscriptId != null) { + setSelectedTranscript(initialTranscriptId); + } + }, [initialTranscriptId]); + + // Initial load: fetch the latest N messages + useEffect(() => { + let cancelled = false; + + async function load() { + try { + setError(null); + setLoading(true); + setShowNewMsg(false); + const result = await api.sessions.transcript(sessionId, { + agent_id: selectedTranscript || undefined, + limit: 50, + }); + if (cancelled) return; + setMessages(result.messages); + setTotal(result.total); + setHasMore(result.has_more); + lastLineRef.current = result.last_line; + firstLineRef.current = result.first_line; + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load transcript"); + setMessages([]); + setTotal(0); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { + cancelled = true; + }; + }, [sessionId, selectedTranscript]); + + // Incrementally load new messages. Two modes: + // - bootstrap (lastLineRef === 0): the initial load saw an empty + // transcript, so we pull the latest 50 to seed the view. This unblocks + // fresh sessions where the JSONL hadn't been written yet at mount. + // - incremental (lastLineRef > 0): tail-fetch lines after the highest + // parsed message we've seen. The server already de-overlaps via + // afterLine, so we can safely append. + const fetchNewMessages = useCallback(async () => { + if (fetchingRef.current) { + // Coalesce: remember a trigger arrived during this fetch and re-run + // exactly once when the in-flight request settles. + pendingFetchRef.current = true; + return; + } + fetchingRef.current = true; + pendingFetchRef.current = false; + + const wasBootstrap = lastLineRef.current === 0; + try { + const result = await api.sessions.transcript(sessionId, { + agent_id: selectedTranscript || undefined, + ...(wasBootstrap ? {} : { after: lastLineRef.current }), + limit: 50, + }); + if (result.messages.length === 0) return; + + lastLineRef.current = result.last_line; + + if (wasBootstrap) { + // Seed the view in a single render so the user sees the whole + // catch-up batch instead of a blank panel followed by a partial one. + setMessages(result.messages); + firstLineRef.current = result.first_line; + setHasMore(result.has_more); + } else { + setMessages((prev) => [...prev, ...result.messages]); + } + setTotal(result.total); + + // Auto-scroll if user is at bottom; otherwise show "new messages" indicator + if (isAtBottomRef.current) { + scrollToBottom(); + } else { + setShowNewMsg(true); + } + } catch { + // Non-fatal + } finally { + fetchingRef.current = false; + // Drain a queued trigger if one arrived during the fetch. + if (pendingFetchRef.current) { + pendingFetchRef.current = false; + // Defer one tick so React state updates from this call commit first. + setTimeout(() => fetchNewMessages(), 0); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId, selectedTranscript]); + + // WebSocket subscription: refetch on every new_event for this session. + // Hook coverage isn't complete (a user-typed message fires no hook), so we + // also poll below to catch what WS misses. + useEffect(() => { + const unsubscribe = eventBus.subscribe((msg: WSMessage) => { + if (isRemoteDataRefreshMessage(msg)) { + fetchNewMessages(); + return; + } + if (msg.type !== "new_event") return; + const data = msg.data as { session_id?: string }; + if (data.session_id !== sessionId) return; + fetchNewMessages(); + }); + return unsubscribe; + }, [sessionId, fetchNewMessages]); + + // Resync on WebSocket reconnect: events that landed during a transient + // disconnect are gone from the bus, but the JSONL still has them, so a + // single tail-fetch on reconnect catches the conversation up. + useEffect(() => { + return eventBus.onConnection((connected) => { + if (connected) fetchNewMessages(); + }); + }, [fetchNewMessages]); + + // Visibility-gated polling fallback. Covers: + // 1. User-typed messages (no Claude Code hook fires for those). + // 2. Long assistant turns where text streams between hook fires. + // 3. Late JSONL flushes that arrive after the triggering hook's fetch. + // 4. Dropped/missed WebSocket frames. + useEffect(() => { + let interval: number | null = null; + function start() { + if (interval !== null) return; + interval = window.setInterval(() => { + if (document.visibilityState === "visible") fetchNewMessages(); + }, POLL_INTERVAL_MS); + } + function stop() { + if (interval !== null) { + window.clearInterval(interval); + interval = null; + } + } + function onVisibility() { + if (document.visibilityState === "visible") { + // Tab just became visible - fire a one-shot catch-up immediately + // and resume polling. Backgrounded tabs throttle setInterval, so + // restarting on focus avoids a stale conversation. + fetchNewMessages(); + start(); + } else { + stop(); + } + } + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibility); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [fetchNewMessages]); + + // Manual refresh - surfaces a control in the toolbar so users can force + // a sync without reloading the page. + const refresh = useCallback(async () => { + setRefreshing(true); + try { + await fetchNewMessages(); + } finally { + setRefreshing(false); + } + }, [fetchNewMessages]); + + // Scroll-up to load history + const loadHistory = useCallback(async () => { + if (loadingHistory || !hasMore) return; + // Need the first message's line number + // Since message objects don't have a _line field, we track it via firstLineRef + // firstLineRef is updated on initial load and each history load + try { + setLoadingHistory(true); + const container = scrollContainerRef.current; + const prevScrollHeight = container?.scrollHeight ?? 0; + + const result = await api.sessions.transcript(sessionId, { + agent_id: selectedTranscript || undefined, + before: firstLineRef.current || undefined, + limit: 50, + }); + + if (result.messages.length === 0) { + // Nothing older exists - clear hasMore so the hint stops showing + // even if the server still claims more is available. + setHasMore(false); + setLoadingHistory(false); + return; + } + + // Update firstLineRef to the oldest message's line number in the history batch + firstLineRef.current = result.first_line; + + setMessages((prev) => [...result.messages, ...prev]); + setHasMore(result.has_more); + + // Preserve scroll position (don't jump to top) + requestAnimationFrame(() => { + if (container) { + const newScrollHeight = container.scrollHeight; + container.scrollTop = newScrollHeight - prevScrollHeight; + } + }); + } catch { + // Non-fatal + } finally { + setLoadingHistory(false); + } + }, [sessionId, selectedTranscript, loadingHistory, hasMore]); + + // Scroll to bottom + const scrollToBottom = useCallback(() => { + requestAnimationFrame(() => { + const container = scrollContainerRef.current; + if (container) { + container.scrollTop = container.scrollHeight; + } + }); + }, []); + + // Listen for scroll events: detect bottom position + trigger history load + const handleScroll = useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return; + + // Detect if at bottom + const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100; + isAtBottomRef.current = atBottom; + + // Hide "new messages" indicator when scrolled to bottom + if (atBottom) { + setShowNewMsg(false); + } + + // Load history when scrolled to top + if (container.scrollTop < 50 && hasMore && !loadingHistory) { + loadHistory(); + } + }, [hasMore, loadingHistory, loadHistory]); + + // Auto-scroll to bottom after initial load + useEffect(() => { + if (!loading && messages.length > 0) { + scrollToBottom(); + } + }, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {/* Toolbar - always rendered after the initial load so users can + refresh even when no messages have streamed yet. */} + {!loading && ( +
+ {transcripts.length > 1 && ( +
+ + +
+ )} + + + {total} message{total !== 1 ? "s" : ""} + + +
+ )} + + {/* Error alert */} + {error && ( +
+ {error} +
+ )} + + {/* Message list container */} +
+ {/* History loading indicator */} + {loadingHistory && ( +
+ + Loading history... +
+ )} + + {/* Scroll-up for history hint */} + {hasMore && !loadingHistory && !loading && ( +
+ ↑ Scroll up for older messages +
+ )} + + {loading ? ( +
+ Loading conversation... +
+ ) : messages.length === 0 ? ( +
+

No conversation records found.

+

+ This session's metadata was imported, but its transcript file is no longer on disk. + Claude Code automatically deletes inactive session transcripts after a retention + period (cleanupPeriodDays, default 30 days), so + older conversations may already be gone. Sessions imported from now on are snapshotted + and kept even after Claude Code prunes the originals. +

+
+ ) : ( + + )} +
+ + {/* New messages indicator */} + {showNewMsg && ( + + )} +
+ ); +} diff --git a/client/src/components/conversation/MarkdownContent.tsx b/client/src/components/conversation/MarkdownContent.tsx new file mode 100644 index 0000000..32e1d7b --- /dev/null +++ b/client/src/components/conversation/MarkdownContent.tsx @@ -0,0 +1,513 @@ +/** + * @file MarkdownContent.tsx + * @description Lightweight markdown renderer for conversation messages. Supports the + * subset of CommonMark + GFM that actually appears in Claude Code transcripts: + * fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes, + * horizontal rules, simple tables, inline code, bold, italic, strikethrough, links, + * and auto-linked URLs. + * + * Output is built as a React element tree (no dangerouslySetInnerHTML) so user content + * is escaped by React. Code blocks delegate to for syntax highlighting and + * copy-to-clipboard. + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./CodeBlock` + * + * ## Public surface + * - `MarkdownContent` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **MarkdownContent** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import React from "react"; +import { CodeBlock } from "./CodeBlock"; + +type Block = + | { kind: "code"; lang: string; code: string } + | { kind: "heading"; level: number; text: string } + | { kind: "list"; ordered: boolean; items: string[] } + | { kind: "quote"; text: string } + | { kind: "hr" } + | { + kind: "table"; + header: string[]; + aligns: ("left" | "center" | "right" | null)[]; + rows: string[][]; + } + | { kind: "para"; text: string }; + +const FENCE_RE = /^([ \t]*)(```|~~~)(\s*[\w+-]*)\s*$/; +const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/; +const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/; +const UL_RE = /^(\s*)([-*+])\s+(.*)$/; +const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/; +const QUOTE_RE = /^\s*>\s?(.*)$/; +const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/; + +function splitTableRow(line: string): string[] { + // Trim leading/trailing pipes, then split, respecting escaped pipes. + let s = line.trim(); + if (s.startsWith("|")) s = s.slice(1); + if (s.endsWith("|")) s = s.slice(0, -1); + // Split on unescaped pipes + const parts: string[] = []; + let cur = ""; + for (let i = 0; i < s.length; i++) { + if (s[i] === "\\" && s[i + 1] === "|") { + cur += "|"; + i++; + continue; + } + if (s[i] === "|") { + parts.push(cur.trim()); + cur = ""; + } else { + cur += s[i]; + } + } + parts.push(cur.trim()); + return parts; +} + +function parseAlignments(divider: string): ("left" | "center" | "right" | null)[] { + return splitTableRow(divider).map((cell) => { + const left = cell.startsWith(":"); + const right = cell.endsWith(":"); + if (left && right) return "center"; + if (right) return "right"; + if (left) return "left"; + return null; + }); +} + +function parseBlocks(src: string): Block[] { + const lines = src.split("\n"); + const blocks: Block[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i]!; + + // Fenced code block + const fence = line.match(FENCE_RE); + if (fence) { + const fenceMarker = fence[2]!; + const lang = (fence[3] ?? "").trim(); + const codeLines: string[] = []; + i++; + while (i < lines.length) { + const closing = lines[i]!.match(/^([ \t]*)(```|~~~)\s*$/); + if (closing && closing[2] === fenceMarker) { + i++; + break; + } + codeLines.push(lines[i]!); + i++; + } + blocks.push({ kind: "code", lang, code: codeLines.join("\n") }); + continue; + } + + // Blank line + if (line.trim() === "") { + i++; + continue; + } + + // ATX heading + const heading = line.match(HEADING_RE); + if (heading) { + blocks.push({ kind: "heading", level: heading[1]!.length, text: heading[2]! }); + i++; + continue; + } + + // Horizontal rule + if (HR_RE.test(line)) { + blocks.push({ kind: "hr" }); + i++; + continue; + } + + // Table: header line followed by an alignment divider + if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) { + const header = splitTableRow(line); + const aligns = parseAlignments(lines[i + 1]!); + i += 2; + const rows: string[][] = []; + while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim() !== "") { + rows.push(splitTableRow(lines[i]!)); + i++; + } + blocks.push({ kind: "table", header, aligns, rows }); + continue; + } + + // Lists + const ulMatch = line.match(UL_RE); + const olMatch = line.match(OL_RE); + if (ulMatch || olMatch) { + const ordered = !!olMatch; + const itemRe = ordered ? OL_RE : UL_RE; + const items: string[] = []; + while (i < lines.length) { + const m = lines[i]!.match(itemRe); + if (m) { + items.push(m[3]!); + i++; + while ( + i < lines.length && + lines[i]!.trim() !== "" && + !lines[i]!.match(UL_RE) && + !lines[i]!.match(OL_RE) && + /^\s+\S/.test(lines[i]!) + ) { + items[items.length - 1] += "\n" + lines[i]!.trim(); + i++; + } + } else { + break; + } + } + blocks.push({ kind: "list", ordered, items }); + continue; + } + + // Blockquote + if (QUOTE_RE.test(line)) { + const qLines: string[] = []; + while (i < lines.length) { + const m = lines[i]!.match(QUOTE_RE); + if (!m) break; + qLines.push(m[1]!); + i++; + } + blocks.push({ kind: "quote", text: qLines.join("\n") }); + continue; + } + + // Paragraph: collect until a blank line or the start of another block + const paraLines: string[] = [line]; + i++; + while (i < lines.length) { + const nl = lines[i]!; + if ( + nl.trim() === "" || + FENCE_RE.test(nl) || + HEADING_RE.test(nl) || + HR_RE.test(nl) || + UL_RE.test(nl) || + OL_RE.test(nl) || + QUOTE_RE.test(nl) + ) { + break; + } + paraLines.push(nl); + i++; + } + blocks.push({ kind: "para", text: paraLines.join("\n") }); + } + return blocks; +} + +/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */ +function renderInline(text: string, baseKey = ""): React.ReactNode[] { + const out: React.ReactNode[] = []; + let i = 0; + let buf = ""; + let n = 0; + const flush = () => { + if (buf) { + out.push(buf); + buf = ""; + } + }; + const push = (node: React.ReactNode) => { + flush(); + out.push({node}); + }; + + while (i < text.length) { + const rest = text.slice(i); + + // Inline code: `...` + const codeM = rest.match(/^`([^`\n]+)`/); + if (codeM) { + push( + + {codeM[1]} + + ); + i += codeM[0].length; + continue; + } + + // Bold: **...** or __...__ + const boldM = rest.match(/^(\*\*|__)(.+?)\1/); + if (boldM) { + push( + + {renderInline(boldM[2]!, `${baseKey}-b${n}`)} + + ); + i += boldM[0].length; + continue; + } + + // Italic: *...* or _..._ + const italicM = rest.match(/^(\*|_)([^*_\n]+?)\1/); + if (italicM) { + push( + {renderInline(italicM[2]!, `${baseKey}-i${n}`)} + ); + i += italicM[0].length; + continue; + } + + // Strikethrough + const strikeM = rest.match(/^~~(.+?)~~/); + if (strikeM) { + push( + + {renderInline(strikeM[1]!, `${baseKey}-s${n}`)} + + ); + i += strikeM[0].length; + continue; + } + + // Markdown link + const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/); + if (linkM) { + push( + + {renderInline(linkM[1]!, `${baseKey}-l${n}`)} + + ); + i += linkM[0].length; + continue; + } + + // Auto-link + const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/); + if (urlM) { + push( + + {urlM[0]} + + ); + i += urlM[0].length; + continue; + } + + buf += text[i]!; + i++; + } + flush(); + return out; +} + +/** Render a single list item, handling [ ] / [x] task list prefixes. */ +function renderListItem(item: string, key: string): React.ReactNode { + const taskMatch = item.match(/^\[([ xX])\]\s+(.*)$/s); + if (taskMatch) { + const checked = taskMatch[1]!.toLowerCase() === "x"; + return ( + + + ); + } + return renderInline(item, key); +} + +interface MarkdownContentProps { + text: string; + /** Tighter spacing for nested contexts (list items, quotes). */ + dense?: boolean; +} + +const HEADING_STYLES = [ + "text-[18px] font-semibold text-gray-50 mt-2 pb-1 border-b border-surface-3", + "text-[16px] font-semibold text-gray-50 mt-2", + "text-[15px] font-semibold text-gray-100", + "text-sm font-semibold text-gray-100", + "text-sm font-medium text-gray-200", + "text-xs font-medium text-gray-300 uppercase tracking-wider", +]; + +export function MarkdownContent({ text, dense = false }: MarkdownContentProps) { + const blocks = parseBlocks(text); + const gap = dense ? "space-y-1.5" : "space-y-2.5"; + + return ( +
+ {blocks.map((b, idx) => { + switch (b.kind) { + case "code": + return ; + + case "heading": { + const cls = HEADING_STYLES[b.level - 1] ?? HEADING_STYLES[5]; + return ( +
+ {renderInline(b.text, `h${idx}`)} +
+ ); + } + + case "list": + if (b.ordered) { + return ( +
    + {b.items.map((item, i) => ( +
  1. + {renderListItem(item, `li${idx}-${i}`)} +
  2. + ))} +
+ ); + } + return ( +
    + {b.items.map((item, i) => ( +
  • + {renderListItem(item, `li${idx}-${i}`)} +
  • + ))} +
+ ); + + case "quote": + return ( +
+ {renderInline(b.text, `q${idx}`)} +
+ ); + + case "hr": + return ( +
+ ); + + case "table": { + const alignClass = (a: "left" | "center" | "right" | null) => + a === "center" ? "text-center" : a === "right" ? "text-right" : "text-left"; + return ( +
+ + + + {b.header.map((cell, i) => ( + + ))} + + + + {b.rows.map((row, ri) => ( + + {row.map((cell, ci) => ( + + ))} + + ))} + +
+ {renderInline(cell, `th${idx}-${i}`)} +
+ {renderInline(cell, `td${idx}-${ri}-${ci}`)} +
+
+ ); + } + + case "para": + return ( +

+ {renderInline(b.text, `p${idx}`)} +

+ ); + } + })} +
+ ); +} diff --git a/client/src/components/conversation/MessageList.tsx b/client/src/components/conversation/MessageList.tsx new file mode 100644 index 0000000..0391e4f --- /dev/null +++ b/client/src/components/conversation/MessageList.tsx @@ -0,0 +1,522 @@ +/** + * @file MessageList.tsx + * @description Renders the chronological message stream of a Claude Code + * transcript: alternating user / assistant rows with collapsible thinking + * blocks, inline ToolCallBlocks for tool_use / tool_result pairs, and + * MarkdownContent for prose. Used by ConversationView as the main body of + * the Conversation tab on the Session detail page. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/types` + * - `./ToolCallBlock` + * - `./MarkdownContent` + * - `../../lib/format` + * - `./tuiSegments` + * + * ## Public surface + * - `MessageList` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **MessageList** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState, useMemo } from "react"; +import { + ChevronDown, + ChevronRight, + Bot, + User, + Brain, + ScrollText, + Terminal, + Info, + AlertTriangle, + Pencil, + Workflow, + Cog, +} from "lucide-react"; +import type { TranscriptMessage, TranscriptContent, TranscriptSender } from "../../lib/types"; + +/** Per-sender visual treatment for a transcript row. A JSONL `type:"user"` line + * is not always the human (tool results, harness task-notifications, the + * orchestrator's task to a subagent) — each sender gets its own label, icon, + * and accent so attribution is unambiguous. */ +const SENDER_STYLES: Record< + TranscriptSender, + { label: string; icon: typeof User; avatarRing: string; accentBar: string; headerText: string } +> = { + user: { + label: "User", + icon: User, + avatarRing: + "bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30", + accentBar: "before:bg-blue-500/40", + headerText: "text-blue-200", + }, + assistant: { + label: "Assistant", + icon: Bot, + avatarRing: + "bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30", + accentBar: "before:bg-violet-500/40", + headerText: "text-violet-200", + }, + orchestrator: { + label: "Main agent", + icon: Workflow, + avatarRing: + "bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30", + accentBar: "before:bg-teal-500/40", + headerText: "text-teal-200", + }, + system: { + label: "System", + icon: Cog, + avatarRing: + "bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30", + accentBar: "before:bg-slate-500/40", + headerText: "text-gray-300", + }, + tool: { + label: "Tool", + icon: Terminal, + avatarRing: + "bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30", + accentBar: "before:bg-amber-500/40", + headerText: "text-amber-200", + }, +}; +import { ToolCallBlock } from "./ToolCallBlock"; +import { MarkdownContent } from "./MarkdownContent"; +import { fmt, formatModelName } from "../../lib/format"; +import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments"; + +interface MessageListProps { + messages: TranscriptMessage[]; + loading: boolean; +} + +/** Build a map from tool_use id → tool_result for matching */ +function buildToolResultMap(messages: TranscriptMessage[]): Map { + const map = new Map(); + for (const msg of messages) { + if (msg.type !== "user") continue; + for (const c of msg.content) { + if (c.type === "tool_result" && c.id) { + map.set(c.id, c); + } + } + } + return map; +} + +/** Detect if text is skill loading content (starts with "Base directory for this skill:") */ +function isSkillContent(text: string): boolean { + return text.startsWith("Base directory for this skill:"); +} + +/** Detect if text is a task notification (contains tag) */ +function isTaskNotification(text: string): boolean { + return text.includes("") || text.includes(""); +} + +/** Format a timestamp as compact local time (e.g. "14:23:01"). */ +function formatLocalTime(iso: string): string { + try { + return new Date(iso).toLocaleTimeString(); + } catch { + return ""; + } +} + +/** Centered marker for a session rename (/rename, `claude -n`, picker Ctrl+R). + * These TUI-only commands write no conversation turn, so without this they're + * invisible in the transcript. */ +function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) { + return ( +
+
+ + Renamed session → + {title || "(untitled)"} + {timestamp && ( + + {formatLocalTime(timestamp)} + + )} +
+
+ ); +} + +/** Compact pill for /command invocations parsed out of TUI markup. */ +function CommandPill({ display }: { display: string }) { + return ( +
+ + {display} +
+ ); +} + +/** Terminal-style fenced block for stdout/stderr captured from local commands. */ +function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "stderr" }) { + const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, ""); + const isErr = stream === "stderr"; + const accent = isErr + ? "border-red-500/30 bg-red-950/30 text-red-200/90" + : "border-surface-3 bg-surface-4/60 text-gray-200"; + const labelColor = isErr ? "text-red-300/80" : "text-gray-400"; + return ( +
+
+ + {stream} +
+
+        {cleaned}
+      
+
+ ); +} + +/** Subtle inline note for the local-command-caveat banner. */ +function CaveatBlock({ text }: { text: string }) { + return ( +
+ + {stripAnsi(text).trim()} +
+ ); +} + +/** Render a single segment produced by parseTuiSegments. */ +function renderSegment(seg: TuiSegment, key: number): React.ReactNode { + switch (seg.kind) { + case "command": + return ; + case "stdout": + return ; + case "stderr": + return ; + case "caveat": + return ; + case "system-reminder": + return ( + } + title="System reminder" + borderClass="border-amber-500/20" + bgClass="bg-amber-500/5" + textClass="text-amber-300/80" + /> + ); + case "persisted-output": + return ( + } + title="Persisted output" + borderClass="border-violet-500/20" + bgClass="bg-violet-500/5" + textClass="text-violet-300/80" + /> + ); + case "text": { + const cleaned = stripAnsi(seg.text); + if (!cleaned.trim()) return null; + return ( +
+ +
+ ); + } + } +} + +/** Generic collapsible content block */ +function CollapsibleBlock({ + text, + icon, + title, + borderClass, + bgClass, + textClass, +}: { + text: string; + icon: React.ReactNode; + title: string; + borderClass: string; + bgClass: string; + textClass: string; +}) { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+
+            {text}
+          
+
+ )} +
+ ); +} + +export function MessageList({ messages, loading }: MessageListProps) { + const [expandedThinking, setExpandedThinking] = useState>(() => new Set()); + + if (loading) { + return ( +
+ Loading conversation... +
+ ); + } + + if (messages.length === 0) { + return ( +
No conversation records found.
+ ); + } + + const toolResultMap = buildToolResultMap(messages); + + // Track which user messages are pure tool_result (no text) - we merge those into the preceding assistant message + const userMsgHasText = useMemo(() => { + const map = new Map(); + messages.forEach((msg, idx) => { + if (msg.type !== "user") return; + const hasText = msg.content.some((c) => c.type === "text"); + map.set(idx, hasText); + }); + return map; + }, [messages]); + + return ( +
+ {messages.map((msg, idx) => { + // Session lifecycle markers (e.g. /rename) render as a centered chip, + // not as a user/assistant row. + if (msg.type === "session_event") { + return ; + } + + // Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock + if (msg.type === "user" && !userMsgHasText.get(idx)) { + return null; + } + + const isAssistant = msg.type === "assistant"; + // The true sender (classified server-side) drives the label + styling. + // Falls back to the coarse type for older payloads without `sender`. + const sender: TranscriptSender = msg.sender ?? (isAssistant ? "assistant" : "user"); + const style = SENDER_STYLES[sender] ?? SENDER_STYLES.user; + const SenderIcon = style.icon; + + return ( +
+ {/* Avatar */} +
+ +
+ + {/* Message body */} +
+ {/* Header line */} +
+ + {style.label} + + {msg.model && ( + + {formatModelName(msg.model)} + + )} + {msg.usage && ( + + ↓ {fmt(msg.usage.input_tokens)} + · + ↑ {fmt(msg.usage.output_tokens)} + + )} + {msg.timestamp && ( + + {formatLocalTime(msg.timestamp)} + + )} +
+ + {/* Content blocks */} + {msg.content.map((block, bIdx) => { + if (block.type === "text" && block.text) { + // Detect task notifications, collapsed by default + if (isTaskNotification(block.text)) { + return ( + } + title="Task Notification" + borderClass="border-cyan-500/20" + bgClass="bg-cyan-500/5" + textClass="text-cyan-400/80" + /> + ); + } + + // Detect skill content, collapsed by default + if (isSkillContent(block.text)) { + const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/); + const skillPath = pathMatch ? pathMatch[1]! : "Skill"; + return ( + } + title={skillPath} + borderClass="border-blue-500/20" + bgClass="bg-blue-500/5" + textClass="text-blue-400/80" + /> + ); + } + + // Mixed TUI markup: caveat / command / stdout / stderr / system-reminder + // can appear inline (sometimes interleaved with prose). Parse the text + // into segments and render each with the appropriate visual treatment. + if (hasTuiTags(block.text)) { + const segments = parseTuiSegments(block.text); + return ( +
+ {segments.map((s, sIdx) => renderSegment(s, sIdx))} +
+ ); + } + + return ( +
+ +
+ ); + } + + if (block.type === "thinking" && block.text) { + const thinkKey = idx * 100 + bIdx; + const isExpanded = expandedThinking.has(thinkKey); + return ( +
+ + {isExpanded && ( +
+ +
+ )} +
+ ); + } + + if (block.type === "tool_use") { + const matchedResult = block.id ? (toolResultMap.get(block.id) ?? null) : null; + return ; + } + + // tool_result blocks rendered inside ToolCallBlock, skip standalone + return null; + })} +
+
+ ); + })} +
+ ); +} diff --git a/client/src/components/conversation/ToolCallBlock.tsx b/client/src/components/conversation/ToolCallBlock.tsx new file mode 100644 index 0000000..5a81e86 --- /dev/null +++ b/client/src/components/conversation/ToolCallBlock.tsx @@ -0,0 +1,330 @@ +/** + * @file ToolCallBlock.tsx + * @description Collapsible block rendered inside an assistant message for each + * tool_use / tool_result pair. Shows tool icon + name in the header, with the + * paired result inline when present. Per-tool styling comes from toolStyle.ts; + * the tool's input/output payload is delegated to for syntax + * highlighting. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `../../lib/types` + * - `./CodeBlock` + * - `./toolStyle` + * + * ## Public surface + * - `ToolCallBlock` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ToolCallBlock** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState } from "react"; +import { ChevronRight, AlertCircle, FileText, CheckCircle2 } from "lucide-react"; +import type { TranscriptContent } from "../../lib/types"; +import { CodeBlock } from "./CodeBlock"; +import { styleForTool } from "./toolStyle"; + +interface ToolCallBlockProps { + toolUse: TranscriptContent; + toolResult?: TranscriptContent | null; +} + +/** Detect a likely language from a file path's extension. */ +function langFromPath(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() ?? ""; + const map: Record = { + ts: "ts", + tsx: "ts", + js: "js", + jsx: "js", + mjs: "js", + cjs: "js", + py: "python", + json: "json", + yml: "yaml", + yaml: "yaml", + sh: "bash", + bash: "bash", + zsh: "bash", + html: "html", + htm: "html", + css: "css", + scss: "css", + sql: "sql", + md: "plain", + txt: "plain", + diff: "diff", + patch: "diff", + }; + return map[ext] ?? "plain"; +} + +/** Build a one-line summary of the tool call to show in the collapsed header. */ +function buildSummary(toolUse: TranscriptContent): string | null { + const input = toolUse.input; + if (!input || typeof input !== "object" || "_truncated" in input) return null; + const obj = input as Record; + if (typeof obj.file_path === "string") return obj.file_path; + if (typeof obj.path === "string") return obj.path; + if (typeof obj.command === "string") return obj.command.slice(0, 200); + if (typeof obj.pattern === "string") return obj.pattern; + if (typeof obj.query === "string") return obj.query; + if (typeof obj.url === "string") return obj.url; + if (typeof obj.description === "string") return obj.description; + return null; +} + +/** Render the input pane with tool-aware formatting. */ +function renderInput(toolUse: TranscriptContent) { + const input = toolUse.input; + if (!input) return null; + + // Truncated payload from the backend + if (typeof input === "object" && "_truncated" in input) { + return ( + + ); + } + + const obj = input as Record; + const tool = (toolUse.name ?? "").toLowerCase(); + + // Bash: show the command with shell highlighting + if (tool === "bash" && typeof obj.command === "string") { + return ( +
+ + {typeof obj.description === "string" && ( +

{obj.description}

+ )} +
+ ); + } + + // Write: render new content as code with the file path as the chrome label + if (tool === "write" && typeof obj.file_path === "string" && typeof obj.content === "string") { + return ( + + ); + } + + // Edit: side-by-side old/new + if (tool === "edit" && typeof obj.file_path === "string") { + const lang = langFromPath(obj.file_path); + return ( +
+
+ + {obj.file_path} + {obj.replace_all === true && ( + + replace all + + )} +
+ {typeof obj.old_string === "string" && ( + + )} + {typeof obj.new_string === "string" && ( + + )} +
+ ); + } + + // Read: just show the path with offset/limit + if (tool === "read" && typeof obj.file_path === "string") { + return ( +
+ + {obj.file_path} + {(typeof obj.offset === "number" || typeof obj.limit === "number") && ( + + {typeof obj.offset === "number" ? `:${obj.offset}` : ""} + {typeof obj.limit === "number" ? `+${obj.limit}` : ""} + + )} +
+ ); + } + + // Grep: pattern + path + if (tool === "grep" && typeof obj.pattern === "string") { + return ( +
+
+ + Pattern + + + {obj.pattern} + +
+ {typeof obj.path === "string" && ( +
+ + Path + + {obj.path} +
+ )} + {typeof obj.glob === "string" && ( +
+ + Glob + + {obj.glob} +
+ )} +
+ ); + } + + // Default: pretty JSON + return ; +} + +/** Render the result pane: detect diff/json/text. */ +function renderResult(toolResult: TranscriptContent, toolName: string) { + const text = toolResult.output ?? ""; + if (text.length === 0) return
(empty)
; + + const isError = !!toolResult.is_error; + const tool = toolName.toLowerCase(); + const label = isError ? "Error" : "Output"; + + // Heuristics for language + let lang = "plain"; + const trimmed = text.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + JSON.parse(trimmed); + lang = "json"; + } catch { + // fall through + } + } else if (/^(\+\+\+|---|@@) /m.test(text) || /^diff --git /m.test(text)) { + lang = "diff"; + } else if (tool === "bash") { + lang = "bash"; + } + + return ; +} + +export function ToolCallBlock({ toolUse, toolResult }: ToolCallBlockProps) { + const [expanded, setExpanded] = useState(false); + + const isError = toolResult?.is_error; + const hasResult = toolResult != null; + const summary = buildSummary(toolUse); + const style = styleForTool(toolUse.name); + const Icon = style.Icon; + + const wrapperBorder = isError ? "border-red-500/30" : style.border; + const wrapperBg = isError ? "bg-red-500/5" : "bg-surface-2/60"; + + return ( +
+ {/* Collapsed/expanded toggle */} + + + {/* Expanded body */} + {expanded && ( +
+ {renderInput(toolUse)} + {hasResult && renderResult(toolResult, toolUse.name ?? "")} +
+ )} +
+ ); +} diff --git a/client/src/components/conversation/__tests__/MarkdownContent.test.tsx b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx new file mode 100644 index 0000000..1a23eb0 --- /dev/null +++ b/client/src/components/conversation/__tests__/MarkdownContent.test.tsx @@ -0,0 +1,75 @@ +/** + * @file MarkdownContent.test.tsx + * @description Tests for the lightweight markdown renderer used by the conversation viewer. + * Focuses on the block parser since the inline parser is well-exercised by snapshot-style + * DOM assertions. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MarkdownContent } from "../MarkdownContent"; + +describe("", () => { + it("renders fenced code blocks with the language label", () => { + render(); + // The CodeBlock header shows the language + expect(screen.getByText(/javascript/i)).toBeInTheDocument(); + // The code text is present (split across syntax-highlighted spans, so use a substring) + expect(screen.getByText(/const/)).toBeInTheDocument(); + }); + + it("renders headings as semantic-looking elements", () => { + render(); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("body")).toBeInTheDocument(); + }); + + it("renders unordered and ordered lists", () => { + const { container } = render(); + expect(container.querySelectorAll("ul li")).toHaveLength(2); + expect(container.querySelectorAll("ol li")).toHaveLength(2); + }); + + it("renders blockquotes", () => { + const { container } = render( a quote"} />); + expect(container.querySelector("blockquote")).not.toBeNull(); + expect(screen.getByText("a quote")).toBeInTheDocument(); + }); + + it("renders inline code, bold, and italic", () => { + const { container } = render( + + ); + expect(container.querySelector("code")).not.toBeNull(); + expect(container.querySelector("strong")).not.toBeNull(); + expect(container.querySelector("em")).not.toBeNull(); + }); + + it("auto-links bare URLs and renders explicit markdown links", () => { + const { container } = render( + + ); + const links = container.querySelectorAll("a"); + expect(links.length).toBe(2); + expect(links[0]!.getAttribute("href")).toBe("https://example.com"); + expect(links[1]!.getAttribute("href")).toBe("https://example.com/docs"); + // Both should open in a new tab safely + for (const a of links) { + expect(a.getAttribute("target")).toBe("_blank"); + expect(a.getAttribute("rel")).toContain("noopener"); + } + }); + + it("renders plain text without any markdown features", () => { + render(); + expect(screen.getByText("just a normal sentence.")).toBeInTheDocument(); + }); + + it("handles empty input safely", () => { + const { container } = render(); + // Wrapper exists but no block elements + expect(container.firstChild).not.toBeNull(); + expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0); + }); +}); diff --git a/client/src/components/conversation/__tests__/MessageList.sender.test.tsx b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx new file mode 100644 index 0000000..881f82f --- /dev/null +++ b/client/src/components/conversation/__tests__/MessageList.sender.test.tsx @@ -0,0 +1,54 @@ +/** + * @file MessageList.sender.test.tsx + * @description Verifies the transcript renders each message under its TRUE + * sender label — User / Assistant / Main agent / System — instead of labeling + * every `type:"user"` line "User" (reported transcript mis-attribution). + * @author Nguyễn Ngọc Trí Vĩ + */ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MessageList } from "../MessageList"; +import type { TranscriptMessage } from "../../../lib/types"; + +function msg(partial: Partial): TranscriptMessage { + return { + type: "user", + timestamp: "2026-06-26T08:14:00.000Z", + content: [{ type: "text", text: "hello" }], + ...partial, + } as TranscriptMessage; +} + +describe("MessageList — sender attribution", () => { + it("labels each row by its sender, not blanket 'User'", () => { + const messages: TranscriptMessage[] = [ + msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }), + msg({ + type: "assistant", + sender: "assistant", + content: [{ type: "text", text: "on it" }], + }), + msg({ + sender: "system", + content: [{ type: "text", text: "\nx\n" }], + }), + msg({ sender: "orchestrator", content: [{ type: "text", text: "Light research task…" }] }), + ]; + render(); + + expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByText("Assistant")).toBeInTheDocument(); + expect(screen.getByText("System")).toBeInTheDocument(); + expect(screen.getByText("Main agent")).toBeInTheDocument(); + }); + + it("falls back to type-based labels when sender is absent (legacy payloads)", () => { + const messages: TranscriptMessage[] = [ + msg({ content: [{ type: "text", text: "hi there" }] }), // no sender → "User" + msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant" + ]; + render(); + expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByText("Assistant")).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/conversation/toolStyle.ts b/client/src/components/conversation/toolStyle.ts new file mode 100644 index 0000000..92780bd --- /dev/null +++ b/client/src/components/conversation/toolStyle.ts @@ -0,0 +1,203 @@ +/** + * @file toolStyle.ts + * @description Per-tool visual styling - icon component, accent colour, and tinted + * surface classes. Keeps the conversation viewer's tool blocks visually distinct so + * users can scan a long transcript quickly. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `ToolStyle` — exported API; see TSDoc on the symbol for behavior. + * - `styleForTool` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ToolStyle** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **styleForTool** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { + Wrench, + Terminal, + FileText, + FilePlus2, + FilePen, + Search, + Globe, + Bot, + ListTodo, + Clock, + Sparkles, + FolderTree, + type LucideIcon, +} from "lucide-react"; + +export interface ToolStyle { + Icon: LucideIcon; + /** Tailwind text colour for the icon and tool name. */ + text: string; + /** Tailwind tinted background for the icon chip (15% opacity - sits behind + * the icon glyph; staying low-saturation keeps the icon legible). */ + chip: string; + /** Tailwind background for solid fills like progress bars (60% opacity - + * high enough to read at a glance against the dark surface, distinct + * from the chip used for the icon backdrop). */ + bar: string; + /** Tailwind border colour for the tool block when not in error state. */ + border: string; +} + +const VIOLET: ToolStyle = { + Icon: Wrench, + text: "text-violet-300", + chip: "bg-violet-500/15 text-violet-300", + bar: "bg-violet-500/60", + border: "border-violet-500/20", +}; + +const STYLES: Record = { + bash: { + Icon: Terminal, + text: "text-emerald-300", + chip: "bg-emerald-500/15 text-emerald-300", + bar: "bg-emerald-500/60", + border: "border-emerald-500/20", + }, + read: { + Icon: FileText, + text: "text-sky-300", + chip: "bg-sky-500/15 text-sky-300", + bar: "bg-sky-500/60", + border: "border-sky-500/20", + }, + write: { + Icon: FilePlus2, + text: "text-violet-300", + chip: "bg-violet-500/15 text-violet-300", + bar: "bg-violet-500/60", + border: "border-violet-500/20", + }, + edit: { + Icon: FilePen, + text: "text-amber-300", + chip: "bg-amber-500/15 text-amber-300", + bar: "bg-amber-500/60", + border: "border-amber-500/20", + }, + multiedit: { + Icon: FilePen, + text: "text-amber-300", + chip: "bg-amber-500/15 text-amber-300", + bar: "bg-amber-500/60", + border: "border-amber-500/20", + }, + grep: { + Icon: Search, + text: "text-cyan-300", + chip: "bg-cyan-500/15 text-cyan-300", + bar: "bg-cyan-500/60", + border: "border-cyan-500/20", + }, + glob: { + Icon: FolderTree, + text: "text-cyan-300", + chip: "bg-cyan-500/15 text-cyan-300", + bar: "bg-cyan-500/60", + border: "border-cyan-500/20", + }, + webfetch: { + Icon: Globe, + text: "text-blue-300", + chip: "bg-blue-500/15 text-blue-300", + bar: "bg-blue-500/60", + border: "border-blue-500/20", + }, + websearch: { + Icon: Globe, + text: "text-blue-300", + chip: "bg-blue-500/15 text-blue-300", + bar: "bg-blue-500/60", + border: "border-blue-500/20", + }, + task: { + Icon: Bot, + text: "text-pink-300", + chip: "bg-pink-500/15 text-pink-300", + bar: "bg-pink-500/60", + border: "border-pink-500/20", + }, + agent: { + Icon: Bot, + text: "text-pink-300", + chip: "bg-pink-500/15 text-pink-300", + bar: "bg-pink-500/60", + border: "border-pink-500/20", + }, + todowrite: { + Icon: ListTodo, + text: "text-rose-300", + chip: "bg-rose-500/15 text-rose-300", + bar: "bg-rose-500/60", + border: "border-rose-500/20", + }, + schedulewakeup: { + Icon: Clock, + text: "text-orange-300", + chip: "bg-orange-500/15 text-orange-300", + bar: "bg-orange-500/60", + border: "border-orange-500/20", + }, + skill: { + Icon: Sparkles, + text: "text-fuchsia-300", + chip: "bg-fuchsia-500/15 text-fuchsia-300", + bar: "bg-fuchsia-500/60", + border: "border-fuchsia-500/20", + }, +}; + +export function styleForTool(toolName: string | undefined | null): ToolStyle { + if (!toolName) return VIOLET; + const key = toolName.toLowerCase().replace(/[^a-z0-9]/g, ""); + return STYLES[key] ?? VIOLET; +} diff --git a/client/src/components/conversation/tuiSegments.ts b/client/src/components/conversation/tuiSegments.ts new file mode 100644 index 0000000..0ff9d5c --- /dev/null +++ b/client/src/components/conversation/tuiSegments.ts @@ -0,0 +1,190 @@ +/** + * @file tuiSegments.ts + * @description Parses Claude TUI tag markup that appears in user messages - + * caveats, command invocations, captured stdout/stderr, system reminders - + * into a flat segment list the renderer can lay out inline. Also strips bare + * ANSI/SGR escape sequences (e.g. "[1m...[22m") that survive the JSONL pipe + * so messages render as plain text instead of leaking codes. + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Renders Claude transcript rows (user, assistant, tool calls) inside Session Detail with markdown, syntax highlighting, and TUI-style segments. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `TuiSegment` — exported API; see TSDoc on the symbol for behavior. + * - `stripAnsi` — exported API; see TSDoc on the symbol for behavior. + * - `parseTuiSegments` — exported API; see TSDoc on the symbol for behavior. + * - `hasTuiTags` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **TuiSegment** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **stripAnsi** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **parseTuiSegments** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **hasTuiTags** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +export type TuiSegment = + | { kind: "caveat"; text: string } + | { kind: "stdout"; text: string } + | { kind: "stderr"; text: string } + | { kind: "system-reminder"; text: string } + | { kind: "persisted-output"; text: string } + | { kind: "command"; display: string } + | { kind: "text"; text: string }; + +const SIMPLE_TAGS: Record = { + "local-command-caveat": "caveat", + "local-command-stdout": "stdout", + "local-command-stderr": "stderr", + "system-reminder": "system-reminder", + "persisted-output": "persisted-output", +}; + +const COMMAND_TAGS = ["command-name", "command-message", "command-args"] as const; + +const KNOWN_TAG_RE = new RegExp( + `<(?:${[...Object.keys(SIMPLE_TAGS), ...COMMAND_TAGS].join("|")})\\b` +); + +// Strip both real ESC-prefixed SGR codes and the bare "[Nm" forms that show up +// when the ESC byte is dropped during JSON encoding. Only matches when followed +// by `m` (the SGR terminator), so it does not eat ordinary bracketed text. +const ANSI_RE = /\[[\d;]*m|\[\d+(?:;\d+)*m/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_RE, ""); +} + +interface MatchSpan { + start: number; + end: number; + segment: TuiSegment; +} + +function findSimpleTagMatches(input: string): MatchSpan[] { + const matches: MatchSpan[] = []; + for (const [tag, kind] of Object.entries(SIMPLE_TAGS)) { + const re = new RegExp(`<${tag}>([\\s\\S]*?)`, "g"); + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + matches.push({ + start: m.index, + end: m.index + m[0].length, + segment: { kind, text: m[1] ?? "" } as TuiSegment, + }); + } + } + return matches; +} + +function findCommandBlocks(input: string): MatchSpan[] { + // A command block is one or more tags possibly + // separated by whitespace. Group them so a single pill renders even when + // the tags arrive in name -> message -> args order. + const re = /(?:[^<]*<\/command-(?:name|message|args)>\s*){1,3}/g; + const out: MatchSpan[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(input)) !== null) { + const block = m[0]; + const name = /([^<]*)<\/command-name>/.exec(block)?.[1] ?? ""; + const args = /([^<]*)<\/command-args>/.exec(block)?.[1] ?? ""; + if (!name) continue; + const trimmedArgs = args.trim(); + out.push({ + start: m.index, + end: m.index + block.length, + segment: { + kind: "command", + display: trimmedArgs ? `${name} ${trimmedArgs}` : name, + }, + }); + } + return out; +} + +/** + * Walks a message text and splits out recognized TUI/command segments while + * preserving the surrounding prose as `text` segments. Returns a single + * `text` segment for inputs that contain no recognized markup. + */ +export function parseTuiSegments(input: string): TuiSegment[] { + if (!KNOWN_TAG_RE.test(input)) { + return [{ kind: "text", text: input }]; + } + + const matches = [...findSimpleTagMatches(input), ...findCommandBlocks(input)].sort( + (a, b) => a.start - b.start + ); + + const segments: TuiSegment[] = []; + let cursor = 0; + for (const m of matches) { + if (m.start < cursor) continue; + if (m.start > cursor) { + const between = input.slice(cursor, m.start); + if (between.trim()) { + segments.push({ kind: "text", text: between }); + } + } + segments.push(m.segment); + cursor = m.end; + } + if (cursor < input.length) { + const tail = input.slice(cursor); + if (tail.trim()) segments.push({ kind: "text", text: tail }); + } + + return segments.length > 0 ? segments : [{ kind: "text", text: input }]; +} + +/** True if any recognized TUI tag would alter the rendering of this text. */ +export function hasTuiTags(input: string): boolean { + return KNOWN_TAG_RE.test(input); +} diff --git a/client/src/components/event-views/primitives.tsx b/client/src/components/event-views/primitives.tsx new file mode 100644 index 0000000..ed99f8e --- /dev/null +++ b/client/src/components/event-views/primitives.tsx @@ -0,0 +1,457 @@ +/** + * @file primitives.tsx + * @description Presentational building blocks used by the per-tool input and + * response renderers. Each primitive is a pure component with a narrow, + * typed contract so they can be composed freely (Terminal + TerminalOutput for + * Bash; Terminal + UnifiedDiff for Edit; LineNumberedCode for Read/Write; + * FileList/MatchList for Grep/Glob; KeyValueCard for MCP tools). + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Public surface + * - `CopyButton` — exported API; see TSDoc on the symbol for behavior. + * - `Terminal` — exported API; see TSDoc on the symbol for behavior. + * - `TerminalOutput` — exported API; see TSDoc on the symbol for behavior. + * - `LineNumberedCode` — exported API; see TSDoc on the symbol for behavior. + * - `DiffHunk` — exported API; see TSDoc on the symbol for behavior. + * - `UnifiedDiff` — exported API; see TSDoc on the symbol for behavior. + * - `KeyValueCard` — exported API; see TSDoc on the symbol for behavior. + * - `FileList` — exported API; see TSDoc on the symbol for behavior. + * - `GrepMatch` — exported API; see TSDoc on the symbol for behavior. + * - `MatchList` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **CopyButton** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **Terminal** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **TerminalOutput** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **LineNumberedCode** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **DiffHunk** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **UnifiedDiff** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **KeyValueCard** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **FileList** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **GrepMatch** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **MatchList** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, Check } from "lucide-react"; + +// ───────────────────────── Copy button ───────────────────────── + +export function CopyButton({ text }: { text: string }) { + const { t } = useTranslation("common"); + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API can fail in insecure contexts - silently ignore. + } + } + + return ( + + ); +} + +// ───────────────────────── Terminal (command) ───────────────────────── + +export function Terminal({ command, description }: { command: string; description?: string }) { + return ( +
+
+ terminal + +
+
+        {description && 
# {description}
} +
+ $ + {command} +
+
+
+ ); +} + +// ───────────────────────── Terminal output (stdout/stderr) ───────────────────────── + +export function TerminalOutput({ + stdout, + stderr, + interrupted, + exitCode, +}: { + stdout?: string; + stderr?: string; + interrupted?: boolean; + exitCode?: number; +}) { + const hasStdout = typeof stdout === "string" && stdout.length > 0; + const hasStderr = typeof stderr === "string" && stderr.length > 0; + const flag = + interrupted === true + ? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" } + : typeof exitCode === "number" && exitCode !== 0 + ? { + label: `exit ${exitCode}`, + color: "text-red-400 border-red-500/40 bg-red-500/10", + } + : null; + + return ( +
+ {hasStdout && } + {hasStderr && } + {flag && ( + + {flag.label} + + )} +
+ ); +} + +function OutputBlock({ + label, + text, + variant, +}: { + label: string; + text: string; + variant: "out" | "err"; +}) { + const color = variant === "err" ? "text-red-300" : "text-gray-200"; + return ( +
+
+ {label} + +
+
+        {text}
+      
+
+ ); +} + +// ───────────────────────── Line-numbered code ───────────────────────── + +export function LineNumberedCode({ + text, + maxHeight = "24rem", + startLine = 1, + label, +}: { + text: string; + maxHeight?: string; + startLine?: number; + label?: string; +}) { + const lines = text.split(/\r?\n/); + return ( +
+ {label && ( +
+ {label} + +
+ )} +
+ + + {lines.map((line, i) => ( + + + + + ))} + +
+ {i + startLine} + {line}
+
+
+ ); +} + +// ───────────────────────── Unified diff ───────────────────────── + +export type DiffHunk = { + oldStart: number; + newStart: number; + oldLines: number; + newLines: number; + lines: string[]; +}; + +export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) { + if (hunks.length === 0) { + return

no diff

; + } + return ( +
+
+ {hunks.map((hunk, i) => ( + + ))} +
+
+ ); +} + +function HunkView({ hunk }: { hunk: DiffHunk }) { + let oldLine = hunk.oldStart; + let newLine = hunk.newStart; + return ( +
+
+ @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@ +
+ + + {hunk.lines.map((line, i) => { + const kind = line.startsWith("+") ? "add" : line.startsWith("-") ? "remove" : "ctx"; + const body = line.slice(kind === "ctx" ? 0 : 1); + const showOld = kind !== "add"; + const showNew = kind !== "remove"; + const rowBg = + kind === "add" + ? "bg-green-500/10 text-green-200" + : kind === "remove" + ? "bg-red-500/10 text-red-200" + : "text-gray-300"; + const oldCell = showOld ? oldLine++ : ""; + const newCell = showNew ? newLine++ : ""; + const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " "; + return ( + + + + + + + ); + })} + +
+ {oldCell} + + {newCell} + {sign}{body}
+
+ ); +} + +// ───────────────────────── Key-value card ───────────────────────── + +export function KeyValueCard({ + data, + priority = [], +}: { + data: Record; + priority?: string[]; +}) { + const entries = Object.entries(data); + const priorityEntries = priority + .map((k) => [k, data[k]] as [string, unknown]) + .filter(([, v]) => v !== undefined); + const restEntries = entries.filter(([k]) => !priority.includes(k)); + const ordered = [...priorityEntries, ...restEntries]; + + if (ordered.length === 0) { + return

empty

; + } + + return ( + + + {ordered.map(([k, v], i) => ( + 0 ? "border-t border-border" : ""}> + + + + ))} + +
+ {k} + + +
+ ); +} + +function ValueCell({ value }: { value: unknown }) { + if (value == null) return null; + if (typeof value === "boolean") + return ( + + {String(value)} + + ); + if (typeof value === "number") return {value}; + if (typeof value === "string") { + if (value.length > 120 || value.includes("\n")) { + return ( +
+          {value}
+        
+ ); + } + return {value}; + } + if (Array.isArray(value)) { + if (value.length === 0) return []; + return ( +
    + {value.map((item, i) => ( +
  1. + +
  2. + ))} +
+ ); + } + return ( +
+      {safeStringify(value)}
+    
+ ); +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +// ───────────────────────── File list / match list ───────────────────────── + +export function FileList({ paths }: { paths: string[] }) { + if (paths.length === 0) return

no files

; + return ( +
    + {paths.map((p, i) => ( +
  • + {p} +
  • + ))} +
+ ); +} + +export type GrepMatch = { + file?: string; + line?: number; + text?: string; +}; + +export function MatchList({ matches }: { matches: GrepMatch[] }) { + if (matches.length === 0) return

no matches

; + return ( +
    + {matches.map((m, i) => ( +
  • + {m.file && {m.file}} + {m.line != null && :{m.line}} + {m.text && : {m.text}} +
  • + ))} +
+ ); +} diff --git a/client/src/components/event-views/tool-views.tsx b/client/src/components/event-views/tool-views.tsx new file mode 100644 index 0000000..c53edcd --- /dev/null +++ b/client/src/components/event-views/tool-views.tsx @@ -0,0 +1,468 @@ +/** + * @file tool-views.tsx + * @description Per-tool renderers for `tool_input` and `tool_response` fields. + * Dispatched from EventDetail when the event's `tool_name` is recognised. + * Unknown tools fall back to the caller's generic JSON code view. + * + * Handled tools: + * - Bash / PowerShell → Terminal + TerminalOutput + * - Edit / NotebookEdit → Terminal + UnifiedDiff (from old/new + structuredPatch) + * - Read → Terminal + LineNumberedCode + * - Write → Terminal + LineNumberedCode + * - Grep → Terminal + MatchList + * - Glob → Terminal + FileList + * - WebFetch → Terminal + LineNumberedCode + * - Task / Agent → metadata + prompt + * - mcp__* → KeyValueCard with known semantic fields promoted + * - AskUserQuestion → formatted Q with options + * - Any other → returns null (caller falls back to generic JSON) + * + * @author Nguyễn Ngọc Trí Vĩ + */ +/* ============================================================================= + * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) + * ============================================================================= + * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. + * + * ## Design constraints + * - Local-first: no telemetry leaves the machine unless the user configures webhooks. + * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that + * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). + * - Destructive flows stay behind explicit confirmation modals and server-side gates. + * - Internationalization: user-visible strings belong in i18n JSON, not literals here. + * + * ## Remote data & SSH + * Remote Data Sources let operators aggregate multiple machines. SSH entries describe + * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every + * scoped GET via `?sources=`. Health checks and import history surface in Settings. + * + * ## Observability + * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four + * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and + * Docker Compose profiles are documented in `monitoring/README.md`. + * + * ## Internal dependencies + * - `./primitives` + * + * ## Public surface + * - `ToolInputView` — exported API; see TSDoc on the symbol for behavior. + * - `ToolResponseView` — exported API; see TSDoc on the symbol for behavior. + * + * ## Testing pointers + * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. + * - Server contract changes require `npm run test:server` and OpenAPI sync. + * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. + * + * ## Related docs + * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. + * - `docs/API.md` — REST reference. + * - `.claude/skills/file-headers/` — mandatory `@author` header policy. + * ============================================================================= */ +/* ----------------------------------------------------------------------------- + * EXPORT CATALOG — quick index of symbols defined below (documentation only). + * ----------------------------------------------------------------------------- + * **ToolInputView** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * **ToolResponseView** + * Part of this module's public contract. Downstream imports should treat + * the signature and return type as stable unless release notes say otherwise. + * When behavior changes, update the `@file` overview and relevant tests. + * + * ----------------------------------------------------------------------------- */ + +import { + FileList, + KeyValueCard, + LineNumberedCode, + MatchList, + Terminal, + TerminalOutput, + UnifiedDiff, +} from "./primitives"; +import type { DiffHunk, GrepMatch } from "./primitives"; + +// ───────────────────────── Helpers ───────────────────────── + +function str(v: unknown): string { + return typeof v === "string" ? v : ""; +} + +function obj(v: unknown): Record | null { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Record) : null; +} + +function isMcp(toolName: string): boolean { + return toolName.startsWith("mcp__"); +} + +/** Builds a unified-diff hunk from a bare old_string / new_string pair. One + * hunk, minimal context - good enough for the input preview before the + * actual structuredPatch comes back in the response. */ +function diffFromStrings(oldStr: string, newStr: string): DiffHunk[] { + if (!oldStr && !newStr) return []; + const oldLines = oldStr ? oldStr.split(/\r?\n/) : []; + const newLines = newStr ? newStr.split(/\r?\n/) : []; + const lines: string[] = []; + for (const l of oldLines) lines.push(`-${l}`); + for (const l of newLines) lines.push(`+${l}`); + return [ + { + oldStart: 1, + newStart: 1, + oldLines: oldLines.length, + newLines: newLines.length, + lines, + }, + ]; +} + +/** Normalises the `structuredPatch` array that shows up in Edit/NotebookEdit + * tool_response into DiffHunk shape. Tolerates missing fields. */ +function parseStructuredPatch(value: unknown): DiffHunk[] { + if (!Array.isArray(value)) return []; + const hunks: DiffHunk[] = []; + for (const raw of value) { + const r = obj(raw); + if (!r) continue; + const lines = Array.isArray(r.lines) + ? (r.lines.filter((l) => typeof l === "string") as string[]) + : []; + hunks.push({ + oldStart: typeof r.oldStart === "number" ? r.oldStart : 1, + newStart: typeof r.newStart === "number" ? r.newStart : 1, + oldLines: typeof r.oldLines === "number" ? r.oldLines : lines.length, + newLines: typeof r.newLines === "number" ? r.newLines : lines.length, + lines, + }); + } + return hunks; +} + +/** Best-effort match list from a Grep tool_response. Supports the common + * shapes: array of strings, array of {file,line,text}, or an object with + * `matches` / `files` keys. */ +function parseGrepMatches(value: unknown): GrepMatch[] { + if (Array.isArray(value)) return value.map(toMatch).filter(Boolean) as GrepMatch[]; + const o = obj(value); + if (!o) return []; + if (Array.isArray(o.matches)) return o.matches.map(toMatch).filter(Boolean) as GrepMatch[]; + if (Array.isArray(o.files)) + return (o.files as unknown[]) + .map((f) => (typeof f === "string" ? ({ file: f } as GrepMatch) : null)) + .filter(Boolean) as GrepMatch[]; + return []; +} + +function toMatch(raw: unknown): GrepMatch | null { + if (typeof raw === "string") { + const m = raw.match(/^(.+?):(\d+):(.*)$/); + if (m) return { file: m[1], line: Number(m[2]), text: m[3] }; + return { text: raw }; + } + const o = obj(raw); + if (!o) return null; + const match: GrepMatch = {}; + if (typeof o.file === "string") match.file = o.file; + if (typeof o.path === "string" && !match.file) match.file = o.path; + if (typeof o.line === "number") match.line = o.line; + if (typeof o.line_number === "number" && match.line == null) match.line = o.line_number; + if (typeof o.text === "string") match.text = o.text; + if (typeof o.match === "string" && !match.text) match.text = o.match; + if (typeof o.content === "string" && !match.text) match.text = o.content; + return match; +} + +function parseFileList(value: unknown): string[] { + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + const o = obj(value); + if (!o) return []; + if (Array.isArray(o.files)) + return (o.files as unknown[]).filter((v): v is string => typeof v === "string"); + if (Array.isArray(o.paths)) + return (o.paths as unknown[]).filter((v): v is string => typeof v === "string"); + return []; +} + +// ───────────────────────── Top-level dispatchers ───────────────────────── + +/** Returns a rendered view for the tool's input, or null when the tool isn't + * specifically handled (caller renders JSON fallback). */ +export function ToolInputView({ + toolName, + input, +}: { + toolName: string | null; + input: unknown; +}): React.ReactNode | null { + if (!toolName) return null; + const i = obj(input); + if (!i) return null; + + // MCP tools - show the input as a key-value card with URL/query/id promoted. + if (isMcp(toolName)) { + return ( + + ); + } + + switch (toolName) { + case "Bash": + case "PowerShell": { + const cmd = str(i.command); + const desc = str(i.description); + if (!cmd) return null; + return ; + } + case "Read": { + const path = str(i.file_path); + if (!path) return null; + const flags: string[] = []; + if (i.offset != null) flags.push(`--offset=${i.offset}`); + if (i.limit != null) flags.push(`--limit=${i.limit}`); + return ; + } + case "Write": { + const path = str(i.file_path); + const content = str(i.content); + return ( +
+ {path && } + {content && } +
+ ); + } + case "Edit": + case "NotebookEdit": { + const path = str(i.file_path); + const oldStr = str(i.old_string); + const newStr = str(i.new_string); + const hunks = diffFromStrings(oldStr, newStr); + const replaceAll = i.replace_all === true ? " --replace-all" : ""; + return ( +
+ {path && } + {hunks.length > 0 && } +
+ ); + } + case "Grep": { + const pattern = str(i.pattern); + const path = str(i.path); + const flags = [ + i.glob ? `--glob=${str(i.glob)}` : null, + i.type ? `--type=${str(i.type)}` : null, + i.output_mode ? `--mode=${str(i.output_mode)}` : null, + i["-i"] ? "-i" : null, + i["-n"] ? "-n" : null, + ].filter(Boolean) as string[]; + const cmd = `grep "${pattern}"${path ? " " + path : ""}${flags.length ? " " + flags.join(" ") : ""}`; + return ; + } + case "Glob": { + const pattern = str(i.pattern); + const path = str(i.path); + return ; + } + case "WebFetch": { + const url = str(i.url); + const prompt = str(i.prompt); + return ( +
+ {url && } +
+ ); + } + case "Task": + case "Agent": { + const desc = str(i.description); + const subtype = str(i.subagent_type); + const prompt = str(i.prompt); + return ( +
+ + {prompt && } +
+ ); + } + case "TaskCreate": + case "TaskUpdate": + case "TaskGet": + case "TaskStop": + case "TaskOutput": + case "TaskList": + return ; + case "AskUserQuestion": { + const questions = Array.isArray(i.questions) ? i.questions : null; + if (!questions) return null; + return ( +
+ {questions.map((q, idx) => { + const qo = obj(q); + if (!qo) return null; + return ( + + ); + })} +
+ ); + } + default: + return null; + } +} + +/** Returns a rendered view for the tool's response, or null when the tool + * isn't specifically handled (caller renders JSON fallback). */ +export function ToolResponseView({ + toolName, + response, +}: { + toolName: string | null; + response: unknown; +}): React.ReactNode | null { + if (!toolName) return null; + + if (isMcp(toolName)) { + const r = obj(response); + if (r) + return ( + + ); + // Non-object responses (string, array) fall through to generic. + return null; + } + + switch (toolName) { + case "Bash": + case "PowerShell": { + const r = obj(response); + if (!r) return null; + return ( + + ); + } + case "Edit": + case "NotebookEdit": { + const r = obj(response); + if (!r) return null; + const hunks = parseStructuredPatch(r.structuredPatch); + const originalFile = typeof r.originalFile === "string" ? r.originalFile : ""; + return ( +
+ {hunks.length > 0 && } + {originalFile && ( +
+ + original file + + ({originalFile.split(/\r?\n/).length} lines) + + +
+ +
+
+ )} +
+ ); + } + case "Read": { + if (typeof response === "string") return ; + const r = obj(response); + if (r && typeof r.content === "string") return ; + return null; + } + case "Write": { + const r = obj(response); + if (!r) return null; + return ; + } + case "Grep": { + const matches = parseGrepMatches(response); + if (matches.length === 0) return null; + return ; + } + case "Glob": { + const files = parseFileList(response); + if (files.length === 0) return null; + return ; + } + case "WebFetch": { + if (typeof response === "string") return ; + const r = obj(response); + if (r && typeof r.content === "string") return ; + if (r) return ; + return null; + } + case "Task": + case "Agent": { + if (typeof response === "string") return ; + const r = obj(response); + if (r) return ; + return null; + } + case "TaskCreate": + case "TaskUpdate": + case "TaskGet": + case "TaskStop": + case "TaskOutput": + case "TaskList": { + const r = obj(response); + if (r) + return ; + return null; + } + case "AskUserQuestion": { + const r = obj(response); + if (r) return ; + return null; + } + default: + return null; + } +} diff --git a/client/src/components/lanes/AddLaneModal.tsx b/client/src/components/lanes/AddLaneModal.tsx new file mode 100644 index 0000000..b670ed9 --- /dev/null +++ b/client/src/components/lanes/AddLaneModal.tsx @@ -0,0 +1,193 @@ +/** + * @file AddLaneModal.tsx + * @description The "+ Add lane" flow: pick a SOURCE repo (not a folder to + * adopt), pick which of its branches to fork from, name the feature, and the + * dashboard provisions a managed git worktree via `POST /api/lanes/worktree` + * — the dashboard invents the lane's own directory and branch name, the same + * way Shipyard's "+ Add lane" never asks a human to name a folder. The lane + * returned is `status: "provisioning"`; the existing `lane_update` WebSocket + * subscription in the Workspace page flips it to idle when the worktree is + * actually ready, so this component does not poll. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ConfirmModal } from "../ConfirmModal"; +import { CwdAutocomplete } from "../run/RunSetup"; +import { api } from "../../lib/api"; +import type { CwdSuggestion } from "../../lib/api"; +import type { Lane } from "../../lib/types"; + +export function AddLaneModal({ + open, + onClose, + onAdded, + cwdSuggestions, +}: { + open: boolean; + onClose: () => void; + /** Called with the newly provisioned (still-provisioning) lane. */ + onAdded: (lane: Lane) => void; + /** The same suggestion list the Run form already fetched (dashboard cwd, + * home, recently-used paths) — reused rather than fetched a second time. */ + cwdSuggestions: CwdSuggestion[]; +}) { + const { t } = useTranslation(["lanes"]); + const [sourceRepo, setSourceRepo] = useState(""); + const [title, setTitle] = useState(""); + const [branches, setBranches] = useState(null); + const [base, setBase] = useState(""); + const [branchesError, setBranchesError] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const reset = () => { + setSourceRepo(""); + setTitle(""); + setBranches(null); + setBase(""); + setBranchesError(null); + setError(null); + setBusy(false); + }; + + // Look up the repo's branches once the path settles - debounced so every + // keystroke while typing a path doesn't fire a request against a path that + // isn't finished yet. + const lookedUpFor = useRef(""); + useEffect(() => { + const path = sourceRepo.trim(); + if (!path) { + setBranches(null); + setBase(""); + setBranchesError(null); + return; + } + const timer = window.setTimeout(async () => { + lookedUpFor.current = path; + try { + const r = await api.lanes.branches(path); + if (lookedUpFor.current !== path) return; // a newer path superseded this one + setBranches(r.branches); + setBase(r.current || r.branches[0] || ""); + setBranchesError(null); + } catch { + if (lookedUpFor.current !== path) return; + // Not yet a valid repo path (still being typed, or genuinely wrong) - + // quiet by design, the same way CwdAutocomplete never errors either. + setBranches(null); + setBase(""); + setBranchesError(t("addLaneNotARepo")); + } + }, 300); + return () => window.clearTimeout(timer); + }, [sourceRepo, t]); + + const submit = async () => { + const repo = sourceRepo.trim(); + const name = title.trim(); + if (!repo || !branches || !name) return; + setBusy(true); + setError(null); + try { + const result = await api.lanes.worktree({ + sourceRepo: repo, + title: name, + base: base || undefined, + }); + reset(); + onAdded(result.lane); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setBusy(false); + } + }; + + // ConfirmModal re-focuses its Cancel button in an effect keyed on `onCancel`'s + // identity. Every keystroke in the fields below re-renders this component; an + // inline `() => {...}` handed to `onCancel` would get a new identity each + // time, re-running that effect and yanking focus off the field being typed + // into after the very first character. useCallback keeps the identity stable + // across renders so only mount/unmount (and a real onClose change) refocuses. + const handleCancel = useCallback(() => { + reset(); + onClose(); + }, [onClose]); + + return ( + +
+
+ + +

{t("addLaneRepoHint")}

+
+ +
+ + setTitle(e.target.value)} + placeholder={t("addLaneTitlePlaceholder")} + className="w-full rounded-md border border-neutral-700 bg-neutral-900 px-3 py-1.5 text-xs text-neutral-100 placeholder:text-neutral-600 focus:border-blue-400 focus:outline-none" + /> +
+ + {branches && ( +
+ + {branches.length === 0 ? ( +

{t("addLaneNoBranches")}

+ ) : ( + + )} +
+ )} + {branchesError && !branches && ( +

{branchesError}

+ )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/client/src/components/lanes/DestructiveLaneModal.tsx b/client/src/components/lanes/DestructiveLaneModal.tsx new file mode 100644 index 0000000..8277310 --- /dev/null +++ b/client/src/components/lanes/DestructiveLaneModal.tsx @@ -0,0 +1,229 @@ +/** + * @file DestructiveLaneModal.tsx + * @description Fetches and displays the exact lane lifecycle preflight facts + * before delegating confirmation controls and accessibility to ConfirmModal. + * + * The server is the authority on what is permitted; this modal must never be + * stricter than it. A blocker that does not actually stop the chosen action is + * rendered as context (see HARD_BLOCKERS and noticesFor), not as a refusal — + * treating every blocker as fatal is what left "Forget" dead for adopted lanes. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ConfirmModal } from "../ConfirmModal"; +import { api } from "../../lib/api"; +import type { Lane, LanePreflight } from "../../lib/types"; + +type DestructiveAction = "reset" | "remove" | "purge"; + +export interface DestructiveLaneModalProps { + lane: Lane; + action: DestructiveAction; + open: boolean; + onClose: () => void; + onConfirm: (body: { expect: Record; force?: true }) => void; +} + +function expectFor(preflight: LanePreflight): Record { + if (preflight.action === "purge") { + return { + sessions: preflight.sessions, + events: preflight.events, + tokenRows: preflight.tokenRows, + }; + } + return { + head: preflight.head, + dirty: preflight.dirty, + untracked: preflight.untracked, + unpushed: preflight.unpushed, + }; +} + +/** + * Which preflight blockers genuinely prevent each action in the UI. + * + * The server is the authority, and it permits EVERY shape of `remove`: an + * adopted lane's record is forgotten with its directory untouched, a + * hand-deleted worktree takes the prune path, an unreadable one is force + * -removed. So `remove` is blocked here by nothing — treating `adopted` or + * `missing` as blockers left the Forget button permanently dead with no + * fallback. `reset` really is impossible in all three shapes. + * + * `unpushed-commits` is force-overridable and is gated by the Force checkbox + * instead of by this list. + */ +const HARD_BLOCKERS: Record<"reset" | "remove", readonly string[]> = { + reset: ["adopted", "missing", "unreadable"], + remove: [], +}; + +function blockingReason(preflight: LanePreflight | null): string | null { + if (!preflight || preflight.action === "purge") return null; + const hard = HARD_BLOCKERS[preflight.action]; + return preflight.blocked.find((blocker) => hard.includes(blocker)) || null; +} + +/** + * Blockers that do not prevent THIS action but still change what it does, shown + * as context rather than as an obstacle — e.g. forgetting an adopted lane leaves + * its directory alone. `unpushed-commits` is excluded: the Force copy covers it. + */ +function noticesFor(preflight: LanePreflight | null): string[] { + if (!preflight || preflight.action === "purge") return []; + const hard = HARD_BLOCKERS[preflight.action]; + return preflight.blocked.filter( + (blocker) => blocker !== "unpushed-commits" && !hard.includes(blocker) + ); +} + +/** Rough byte estimate as a short human string; the server's own number is a guess. */ +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function warningsFor(preflight: LanePreflight | null): string[] { + if (!preflight || preflight.action === "purge") return []; + return preflight.warnings; +} + +export function DestructiveLaneModal({ + lane, + action, + open, + onClose, + onConfirm, +}: DestructiveLaneModalProps) { + const { t } = useTranslation(["lanes"]); + const [preflight, setPreflight] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [force, setForce] = useState(false); + + useEffect(() => { + if (!open) return; + let current = true; + setLoading(true); + setError(null); + setPreflight(null); + setForce(false); + void api.lanes + .preflight(lane.id, action) + .then((result) => current && setPreflight(result)) + .catch((err) => current && setError(err instanceof Error ? err.message : t("preflightError"))) + .finally(() => current && setLoading(false)); + return () => { + current = false; + }; + }, [action, lane.id, open, t]); + + const blocked = blockingReason(preflight); + // Mirrors the server's gate exactly (server/routes/lanes.js): force is required + // for `reset`, and for `remove` only when a real worktree is at risk. Forgetting + // an adopted lane risks nothing on disk, so it needs no force even with unpushed + // commits. Never offered alongside a hard blocker, where confirming is + // impossible anyway. + const requiresForce = + !blocked && + preflight !== null && + preflight.action !== "purge" && + preflight.blocked.includes("unpushed-commits") && + (preflight.action === "reset" || preflight.kind === "managed"); + const disabled = + loading || !preflight || Boolean(error) || Boolean(blocked) || (requiresForce && !force); + const facts = preflight ? Object.entries(expectFor(preflight)) : []; + const purge = preflight?.action === "purge" ? preflight : null; + + return ( + { + if (!preflight || disabled) return; + onConfirm({ expect: expectFor(preflight), ...(force ? { force: true as const } : {}) }); + }} + > + {loading &&

{t("destructive.loading")}

} + {error && ( +

+ {t("preflightErrorWithMessage", { message: error })} +

+ )} + {preflight && ( + + + {facts.map(([name, value]) => ( + + + + + ))} + {/* Not part of `expect`: an estimate the server never verifies. */} + {purge && ( + + + + + )} + +
+ {t(`destructive.count.${name}`)} + {value ?? "—"}
+ {t("destructive.count.bytesEstimate")} + + {formatBytes(purge.bytesEstimate)} +
+ )} + {blocked && ( +

+ {t(`destructive.blocked.${blocked}`)} +

+ )} + {purge?.activeSessionSkipped && ( +

+ {t("destructive.notice.activeSessionSkipped")} +

+ )} + {noticesFor(preflight).map((notice) => ( +

+ {t(`destructive.notice.${notice}`)} +

+ ))} + {warningsFor(preflight).map((warning) => ( +

+ {t(`destructive.warning.${warning}`)} +

+ ))} + {requiresForce && ( + + )} + {action === "reset" && ( +

{t("destructive.reset.survives")}

+ )} +
+ ); +} diff --git a/client/src/components/lanes/LaneCard.tsx b/client/src/components/lanes/LaneCard.tsx new file mode 100644 index 0000000..46e8af1 --- /dev/null +++ b/client/src/components/lanes/LaneCard.tsx @@ -0,0 +1,308 @@ +/** + * @file One lane's card: title, stage badge with time-on-phase, progress bar, + * branch/CI/PR facts, the "needs you" banner sourced from Claude Code's + * Notification hook, and the control row. A dead lane (its driving session went + * silent while it should have been working) is called out loudly — that is the + * failure this view exists to catch. An "auto: " chip appears only when + * the server's detected stage is ahead of the agent's own declaration — when the + * declaration leads or matches, it stays the sole headline. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DestructiveLaneModal } from "./DestructiveLaneModal"; +import { api } from "../../lib/api"; +import type { Lane, LaneGitFacts } from "../../lib/types"; + +/** How often a mounted card re-reads its working-copy facts. Slow on purpose: + * each call is three git subprocesses server-side, and a branch name does not + * change on the timescale the lane list is polled at. */ +const GIT_REFRESH_MS = 30_000; + +/** + * The lane's own working copy, fetched per card rather than folded into the + * polled lane list. Absent facts are not an error state: a lane may point at a + * plain directory, and it renders as a card without a git row. + */ +function useLaneGitFacts(laneId: number): LaneGitFacts | null { + const [facts, setFacts] = useState(null); + + useEffect(() => { + let alive = true; + const read = () => { + api.lanes + .git(laneId) + .then((f) => { + if (alive) setFacts(f); + }) + .catch(() => { + // Silent: a card that cannot read git shows the rest of itself. An + // error banner here would fire on every lane on every server blip. + if (alive) setFacts({ available: false }); + }); + }; + read(); + const timer = setInterval(read, GIT_REFRESH_MS); + return () => { + alive = false; + clearInterval(timer); + }; + }, [laneId]); + + return facts; +} + +const LIVENESS_DOT: Record = { + active: "bg-emerald-400", + idle: "bg-neutral-500", + dead: "bg-red-500", +}; + +function since(sec: number | null): string { + if (sec === null) return "—"; + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; + return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; +} + +/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the + * pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched + * detected stage never outranks a matched declaration. */ +function detectionLeadsDeclaration(lane: Lane): boolean { + if (!lane.detected_stage) return false; + const ids = lane.pipeline_nodes.map((n) => n.id); + return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage); +} + +export default function LaneCard({ + lane, + onAction, +}: { + lane: Lane; + onAction: (action: string, body?: Record) => void; +}) { + const { t } = useTranslation(["lanes"]); + const [destructiveAction, setDestructiveAction] = useState<"reset" | "remove" | "purge" | null>( + null + ); + const [menuOpen, setMenuOpen] = useState(false); + const git = useLaneGitFacts(lane.id); + + return ( + <> +
+ {/* Identity strip: which lane, and is it alive. Kept on one line and in + uppercase so a wall of cards can be scanned vertically. */} +
+ + {t("cardId", { id: lane.id })} + + + + {/* i18next returns the KEY on a miss, so `|| raw` never fires and a + non-standard status rendered as the literal "status.foo". + defaultValue makes it degrade to the raw status instead. */} + {lane.liveness === "dead" + ? t("statusDead") + : t(`status.${lane.status}`, { defaultValue: lane.status })} + +
+ +

+ {lane.title || lane.cwd} +

+ + {/* Stage line: the declared stage, how far through, and how long it has + been sitting there — the three facts that say whether a lane is + moving. The inferred chip sits beside them, never instead of them. */} +
+ + {lane.stage} + +
0 ? "bg-neutral-800" : "bg-transparent" + }`} + > +
+
+ {lane.progress > 0 && ( + {lane.progress}% + )} + {since(lane.stage_seconds)} +
+ +
+ + {t(`kind.${lane.kind}`)} + + {detectionLeadsDeclaration(lane) && ( + + {t("autoStage", { stage: lane.detected_stage })} + + )} + {lane.ci_status && ( + + CI {lane.ci_status} + + )} +
+ + {lane.needs_action && ( +
+ ⚠ {lane.needs_action} +
+ )} + +
+ {git?.available && ( +
+
+ ⑂ {git.branch} + {git.head} +
+
+ {git.subject} +
+ {(git.dirty > 0 || git.untracked > 0) && ( +
+ {t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })} +
+ )} +
+ )} + {/* The lane's own recorded branch, shown only when git could not be + read — otherwise it duplicates the live branch above. */} + {!git?.available && lane.branch &&
⑂ {lane.branch}
} +
+ {lane.cwd} +
+
+ + {/* mt-auto pins the controls to the bottom so cards of differing height + in one grid row still line their buttons up. */} +
+ {(["start", "stop", "clear"] as const).map((a) => ( + + ))} + {/* Destructive verbs sit behind a menu so the card is not a wall of + red. Red is kept for the items inside, where it means something. */} +
+ + {menuOpen && ( +
+ {lane.kind === "managed" && ( + + )} + + +
+ )} +
+
+
+ + {destructiveAction && ( + setDestructiveAction(null)} + onConfirm={(body) => { + setDestructiveAction(null); + onAction(destructiveAction, body); + }} + /> + )} + + ); +} diff --git a/client/src/components/lanes/LaneStripCard.tsx b/client/src/components/lanes/LaneStripCard.tsx new file mode 100644 index 0000000..c34259b --- /dev/null +++ b/client/src/components/lanes/LaneStripCard.tsx @@ -0,0 +1,92 @@ +/** + * @file The compact lane tile used in the Workspace carousel. It carries only + * what you need to pick a lane — which lane, is it alive, what stage, how far — + * because the full card, its controls and its working-copy facts live in the + * detail panel below. Keeping the tile small is what lets a dozen lanes stay + * scannable in one horizontal row. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useTranslation } from "react-i18next"; +import type { Lane } from "../../lib/types"; + +const LIVENESS_DOT: Record = { + active: "bg-emerald-400", + idle: "bg-neutral-500", + dead: "bg-red-500", +}; + +/** Whether the inferred stage sits ahead of the declared one in node order. */ +function detectionLeads(lane: Lane): boolean { + if (!lane.detected_stage) return false; + const ids = lane.pipeline_nodes.map((n) => n.id); + return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage); +} + +export default function LaneStripCard({ + lane, + selected, + onSelect, +}: { + lane: Lane; + selected: boolean; + onSelect: () => void; +}) { + const { t } = useTranslation(["lanes"]); + + return ( + + ); +} diff --git a/client/src/components/lanes/PipelineMap.tsx b/client/src/components/lanes/PipelineMap.tsx new file mode 100644 index 0000000..e09c303 --- /dev/null +++ b/client/src/components/lanes/PipelineMap.tsx @@ -0,0 +1,62 @@ +/** + * @file The lane pipeline map: a horizontal chain of stage nodes coloured by + * state. Layout is computed from the node list (flex + connectors), never from + * hardcoded coordinates, so a lane can use a longer or shorter template without + * touching this component. "passed without evidence" is deliberately its own + * colour: a stage the agent claimed but left no artifact for is not the same as + * a stage that is genuinely done. A `detected` node (the server's heuristic saw + * tool-event evidence but the agent never declared it) gets a FOURTH treatment — + * dashed amber, overriding whatever `state` it carries — because it must never + * be mistaken for the solid green of a real "done". + * @author Nguyễn Ngọc Trí Vĩ + */ + +import type { LaneNode } from "../../lib/types"; + +const STATE_CLASS: Record = { + done: "border-emerald-500 text-emerald-400 bg-emerald-500/10", + current: "border-blue-400 text-blue-300 bg-blue-500/20 ring-2 ring-blue-400/40", + "passed-no-evidence": "border-amber-500 text-amber-400 bg-amber-500/10", + failed: "border-red-500 text-red-400 bg-red-500/10", + pending: "border-neutral-700 text-neutral-500 bg-transparent", +}; + +// Dashed border distinguishes an inferred stage from every other class above, +// including the solid amber of "passed-no-evidence" — never let it read as done. +const DETECTED_CLASS = "border-dashed border-amber-400 text-amber-300 bg-amber-500/5"; + +export default function PipelineMap({ + nodes, + detectedSignal, +}: { + nodes: LaneNode[]; + detectedSignal?: string | null; +}) { + if (!nodes.length) return
no pipeline
; + return ( + // The map spans the panel: every node takes an equal share and the + // connectors absorb the slack, so the pipeline reads as one track across + // the width rather than a short cluster hugging the left edge. +
+ {nodes.map((n, i) => ( +
+
+ {n.icon} + {n.label} +
+ {i < nodes.length - 1 &&
} +
+ ))} +
+ ); +} diff --git a/client/src/components/lanes/__tests__/AddLaneModal.test.tsx b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx new file mode 100644 index 0000000..5037770 --- /dev/null +++ b/client/src/components/lanes/__tests__/AddLaneModal.test.tsx @@ -0,0 +1,172 @@ +/** + * @file AddLaneModal.test.tsx + * @description Pins the "+ Add lane" flow after it was rebuilt around a source + * repo instead of an existing folder: picking or typing a repo path triggers a + * branch lookup, the base-branch picker only appears once that lookup resolves, + * confirm submits through the provisioning endpoint (not the adopt/ensure one), + * an unresolvable path degrades to a quiet hint instead of blocking the form, + * and a server error surfaces instead of closing the modal. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AddLaneModal } from "../AddLaneModal"; +import { api } from "../../../lib/api"; +import type { CwdSuggestion } from "../../../lib/api"; +import type { Lane } from "../../../lib/types"; + +vi.mock("../../../lib/api", () => ({ + api: { lanes: { branches: vi.fn(), worktree: vi.fn() } }, +})); + +function laneFixture(over: Partial = {}): Lane { + return { + id: 9, + title: "", + cwd: "/lanes/repo__feature", + branch: "feat/feature", + kind: "managed", + pipeline: "default", + session_id: null, + run_id: null, + stage: "idle", + stage_since: null, + status: "provisioning", + gate_decision: null, + ci_status: null, + needs_action: null, + links: {}, + stages: {}, + notes: null, + pipeline_name: "Default", + pipeline_nodes: [], + progress: 0, + stage_seconds: null, + last_event_seconds: null, + liveness: "idle", + detected_stage: null, + detected_signal: null, + ...over, + }; +} + +const SUGGESTIONS: CwdSuggestion[] = [ + { kind: "home", path: "/Users/tester", label: "Home" }, + { kind: "recent", path: "/Users/tester/projects/repo", label: "repo" }, +]; + +function renderModal(over: Partial> = {}) { + return render( + + ); +} + +/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which + * races userEvent.type() and can eat the first keystroke. Let that timer fire, + * then click the field to reclaim focus before typing. */ +async function focusField(user: ReturnType, el: HTMLElement) { + await new Promise((r) => setTimeout(r, 0)); + await user.click(el); +} + +beforeEach(() => { + vi.mocked(api.lanes.branches).mockReset(); + vi.mocked(api.lanes.worktree).mockReset(); +}); + +describe("AddLaneModal", () => { + it("disables confirm until a repo, a title, and a resolved branch list are all present", () => { + renderModal(); + expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); + }); + + it("looks up branches once the repo path settles, and shows them as a picker", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue({ + branches: ["main", "feat/other"], + current: "main", + }); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + + await waitFor(() => expect(api.lanes.branches).toHaveBeenCalledWith("/Users/tester/projects/repo")); + const base = await screen.findByLabelText("Branch to fork from"); + expect(base).toHaveValue("main"); // the repo's current branch is preselected + expect(screen.getByRole("option", { name: "feat/other" })).toBeInTheDocument(); + }); + + it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => { + vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO")); + renderModal(); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/not/a/repo"); + + await waitFor(() => expect(api.lanes.branches).toHaveBeenCalled()); + expect(await screen.findByText(/Not a git repository/)).toBeInTheDocument(); + expect(screen.queryByLabelText("Branch to fork from")).toBeNull(); + expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled(); + }); + + it("submits through the worktree provisioning endpoint, not ensure", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) }); + const onAdded = vi.fn(); + const onClose = vi.fn(); + renderModal({ onClose, onAdded }); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "New feature"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + await waitFor(() => { + expect(api.lanes.worktree).toHaveBeenCalledWith({ + sourceRepo: "/Users/tester/projects/repo", + title: "New feature", + base: "main", + }); + }); + expect(onAdded).toHaveBeenCalledWith(expect.objectContaining({ id: 9, status: "provisioning" })); + expect(onClose).toHaveBeenCalled(); + }); + + it("shows a server error and leaves the modal open instead of closing silently", async () => { + vi.mocked(api.lanes.branches).mockResolvedValue({ branches: ["main"], current: "main" }); + vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION")); + const onClose = vi.fn(); + renderModal({ onClose }); + const user = userEvent.setup(); + + const repoField = screen.getByLabelText("Source repository"); + await focusField(user, repoField); + await user.type(repoField, "/Users/tester/projects/repo"); + await screen.findByLabelText("Branch to fork from"); + await user.type(screen.getByLabelText("Title"), "New feature"); + await user.click(screen.getByRole("button", { name: "Add lane" })); + + expect(await screen.findByText("EWORKTREEDIRCOLLISION")).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("renders nothing when closed", () => { + renderModal({ open: false }); + expect(screen.queryByRole("dialog")).toBeNull(); + }); +}); diff --git a/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx b/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx new file mode 100644 index 0000000..e1e7191 --- /dev/null +++ b/client/src/components/lanes/__tests__/DestructiveLaneModal.test.tsx @@ -0,0 +1,457 @@ +/** + * @file Tests for DestructiveLaneModal: the preflight-gated confirmation for + * reset/remove/purge. Covers that the displayed counts are exactly the + * preflight facts, that `reset` is refused for adopted/missing/unreadable + * lanes while `remove` stays available for all of them (the server permits it, + * so the UI must not be stricter), that the Force checkbox appears exactly when + * the server would demand force, and that confirming echoes back exactly the + * `expect` block the modal displayed. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; + +const { preflightMock } = vi.hoisted(() => ({ preflightMock: vi.fn() })); +vi.mock("../../../lib/api", () => ({ + api: { lanes: { preflight: preflightMock } }, +})); + +import { DestructiveLaneModal } from "../DestructiveLaneModal"; +import type { Lane, LanePurgePreflight, LaneWorktreePreflight } from "../../../lib/types"; + +function makeLane(overrides: Partial = {}): Lane { + return { + id: 1, + title: "demo", + cwd: "/work/demo", + branch: "lane/demo", + kind: "managed", + pipeline: "default", + session_id: null, + run_id: null, + stage: "plan", + stage_since: null, + status: "idle", + gate_decision: null, + ci_status: null, + needs_action: null, + links: {}, + stages: {}, + notes: null, + pipeline_name: "Default", + pipeline_nodes: [], + progress: 0, + stage_seconds: null, + last_event_seconds: null, + liveness: "idle", + detected_stage: null, + detected_signal: null, + ...overrides, + }; +} + +function worktreePreflight(overrides: Partial = {}): LaneWorktreePreflight { + return { + action: "reset", + lane: 1, + kind: "managed", + branch: "lane/demo", + head: "abc1234", + dirty: 2, + untracked: 3, + unpushed: 0, + blocked: [], + warnings: [], + ...overrides, + }; +} + +beforeEach(() => { + preflightMock.mockReset(); +}); + +describe("DestructiveLaneModal", () => { + it("renders exactly the counts the preflight returned", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ head: "deadbee", dirty: 4, untracked: 5, unpushed: 0 }) + ); + render( + + ); + expect(await screen.findByText("deadbee")).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); + + it("disables RESET for an adopted lane (a worktree action can never touch it)", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ action: "reset", kind: "adopted", blocked: ["adopted"] }) + ); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + }); + + it("ENABLES remove for an adopted lane and sends the payload — the server forgets the row and leaves the directory alone", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "remove", + kind: "adopted", + head: "adopt01", + dirty: 0, + untracked: 0, + unpushed: 0, + blocked: ["adopted"], + }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + // "adopted" is shown as context, not as a refusal. + expect(screen.getByText(/Only the dashboard's record of it is dropped/)).toBeInTheDocument(); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: "adopt01", dirty: 0, untracked: 0, unpushed: 0 }, + }); + }); + + it("forgets an adopted lane with unpushed commits without offering Force — the server does not require it", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "remove", + kind: "adopted", + head: "adopt02", + dirty: 0, + untracked: 0, + unpushed: 7, + blocked: ["adopted", "unpushed-commits"], + }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: "adopt02", dirty: 0, untracked: 0, unpushed: 7 }, + }); + }); + + it("disables RESET when the worktree directory is missing", async () => { + preflightMock.mockResolvedValue(worktreePreflight({ action: "reset", blocked: ["missing"] })); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + }); + + it("ENABLES remove when the worktree directory is missing — the server takes the prune path", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "remove", + head: null, + dirty: 0, + untracked: 0, + unpushed: 0, + blocked: ["missing"], + }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + expect(screen.getByText(/The lane directory is already gone/)).toBeInTheDocument(); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 }, + }); + }); + + it("disables RESET when the worktree directory is unreadable", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ action: "reset", blocked: ["unreadable"] }) + ); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + }); + + it("ENABLES remove when the worktree directory is unreadable — the server attempts removal and falls back to deregistering it", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "remove", + head: null, + dirty: 0, + untracked: 0, + unpushed: 0, + blocked: ["unreadable"], + }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + expect(screen.getByText(/cannot be read as a Git worktree/)).toBeInTheDocument(); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: null, dirty: 0, untracked: 0, unpushed: 0 }, + }); + }); + + it("shows the Force checkbox only when the sole blocker is unpushed commits, and ticking it enables confirm", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ action: "remove", unpushed: 3, blocked: ["unpushed-commits"] }) + ); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + fireEvent.click(screen.getByRole("checkbox")); + expect(confirmButton).not.toBeDisabled(); + }); + + it("a local-only managed lane (no-remote warning, unpushed-commits blocker) is resettable with Force", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "reset", + unpushed: 3, + blocked: ["unpushed-commits"], + warnings: ["no-remote"], + }) + ); + render( + + ); + // The warning is shown as context, not as an obstacle. + expect(await screen.findByText(/No Git remote is configured/)).toBeInTheDocument(); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + fireEvent.click(screen.getByRole("checkbox")); + expect(confirmButton).not.toBeDisabled(); + }); + + it("does not offer the Force checkbox when a hard blocker makes confirming impossible anyway", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ action: "reset", unpushed: 3, blocked: ["unpushed-commits", "missing"] }) + ); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).toBeDisabled()); + expect(screen.queryByRole("checkbox")).not.toBeInTheDocument(); + }); + + it("confirming a worktree action passes back exactly the expect block that was displayed", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0, blocked: [] }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Reset worktree" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: "cafefeed", dirty: 1, untracked: 2, unpushed: 0 }, + }); + }); + + it("confirming an unpushed-commits removal with Force ticked sends force:true plus the same expect block", async () => { + preflightMock.mockResolvedValue( + worktreePreflight({ + action: "remove", + head: "abc0000", + dirty: 0, + untracked: 0, + unpushed: 2, + blocked: ["unpushed-commits"], + }) + ); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Remove lane" }); + fireEvent.click(screen.getByRole("checkbox")); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { head: "abc0000", dirty: 0, untracked: 0, unpushed: 2 }, + force: true, + }); + }); + + it("confirming a purge passes back the purge-specific expect block", async () => { + const purgePreflight: LanePurgePreflight = { + action: "purge", + lane: 1, + sessions: 4, + events: 120, + tokenRows: 30, + bytesEstimate: 4096, + activeSessionSkipped: false, + }; + preflightMock.mockResolvedValue(purgePreflight); + const onConfirm = vi.fn(); + render( + + ); + const confirmButton = await screen.findByRole("button", { name: "Purge history" }); + await waitFor(() => expect(confirmButton).not.toBeDisabled()); + fireEvent.click(confirmButton); + expect(onConfirm).toHaveBeenCalledWith({ + expect: { sessions: 4, events: 120, tokenRows: 30 }, + }); + }); + + it("shows the purge size estimate and says when a live session was spared", async () => { + const purgePreflight: LanePurgePreflight = { + action: "purge", + lane: 1, + sessions: 2, + events: 8, + tokenRows: 2, + bytesEstimate: 5120, + activeSessionSkipped: true, + }; + preflightMock.mockResolvedValue(purgePreflight); + render( + + ); + expect(await screen.findByText("5.0 KB")).toBeInTheDocument(); + expect(screen.getByText(/still active and will be kept/)).toBeInTheDocument(); + }); + + it("does not claim a session was spared when none was", async () => { + const purgePreflight: LanePurgePreflight = { + action: "purge", + lane: 1, + sessions: 1, + events: 1, + tokenRows: 0, + bytesEstimate: 512, + activeSessionSkipped: false, + }; + preflightMock.mockResolvedValue(purgePreflight); + render( + + ); + expect(await screen.findByText("512 B")).toBeInTheDocument(); + expect(screen.queryByText(/still active and will be kept/)).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/lanes/__tests__/LaneCard.test.tsx b/client/src/components/lanes/__tests__/LaneCard.test.tsx new file mode 100644 index 0000000..15543f0 --- /dev/null +++ b/client/src/components/lanes/__tests__/LaneCard.test.tsx @@ -0,0 +1,262 @@ +/** + * @file Regression test for the lane card's status badge. Every lane status + * the server can set must have a real translated word behind + * `t("status." + lane.status)`; before this test existed, no locale defined + * any `status.*` key, and i18next's default missing-key behavior (return the + * key itself) hid that from the `||` fallback, so the badge showed literal + * text like `status.active`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import LaneCard from "../LaneCard"; +import type { Lane } from "../../../lib/types"; +import { api } from "../../../lib/api"; + +vi.mock("../../../lib/api", () => ({ + api: { + lanes: { git: vi.fn(), preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }) }, + }, +})); + +beforeEach(() => { + vi.mocked(api.lanes.git).mockReset(); + vi.mocked(api.lanes.git).mockResolvedValue({ available: false }); +}); + +function makeLane(overrides: Partial = {}): Lane { + return { + id: 1, + title: "demo", + cwd: "/work/demo", + branch: "lane/demo", + kind: "adopted", + pipeline: "default", + session_id: null, + run_id: null, + stage: "plan", + stage_since: null, + status: "idle", + gate_decision: null, + ci_status: null, + needs_action: null, + links: {}, + stages: {}, + notes: null, + pipeline_name: "Default", + pipeline_nodes: [], + progress: 0, + stage_seconds: null, + last_event_seconds: null, + liveness: "idle", + detected_stage: null, + detected_signal: null, + ...overrides, + }; +} + +describe("LaneCard status badge", () => { + for (const status of ["idle", "running", "provisioning", "failed"] as const) { + it(`renders a real word for status "${status}", not the raw key`, () => { + render(); + expect(screen.queryByText(`status.${status}`)).not.toBeInTheDocument(); + expect(screen.queryByText(status.toUpperCase())).not.toBeInTheDocument(); + }); + } +}); + +const pipelineNodes: Lane["pipeline_nodes"] = [ + { id: "intake", label: "intake", icon: "📥", gate: false, state: "done" }, + { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" }, + { id: "implement", label: "implement", icon: "🛠", gate: false, state: "current" }, + { id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending" }, +]; + +describe("LaneCard auto: chip", () => { + it("shows the auto chip when the detected stage is ahead of the declared stage", () => { + render( + + ); + expect(screen.getByText("auto: tests")).toBeInTheDocument(); + }); + + it("hides the auto chip when the detected stage matches the declared stage", () => { + render( + + ); + expect(screen.queryByText("auto: plan")).not.toBeInTheDocument(); + }); + + it("hides the auto chip when the detected stage trails the declared stage", () => { + render( + + ); + expect(screen.queryByText("auto: intake")).not.toBeInTheDocument(); + }); + + it("hides the auto chip when nothing is detected", () => { + render( + + ); + expect(screen.queryByText(/^auto:/)).not.toBeInTheDocument(); + }); +}); + +describe("LaneCard rebuilt layout", () => { + const full = (over: Partial = {}) => + makeLane({ + id: 7, + title: "Rename Metric to Rule", + kind: "managed", + status: "running", + liveness: "active", + stage: "plan", + stage_seconds: 152, + progress: 48, + ci_status: "green", + pipeline_nodes: pipelineNodes, + ...over, + }); + + it("labels the card with the lane id", () => { + render(); + expect(screen.getByTestId("lane-card-7")).toBeInTheDocument(); + }); + + it("shows the declared stage, the progress percentage and the time on stage", () => { + render(); + expect(screen.getByTestId("lane-stage").textContent).toBe("plan"); + expect(screen.getByText("48%")).toBeInTheDocument(); + expect(screen.getByText("2m 32s")).toBeInTheDocument(); + }); + + it("gives the progress bar a width matching the lane's progress", () => { + render(); + expect(screen.getByTestId("lane-progress-fill").getAttribute("style")).toContain("48%"); + }); + + it("surfaces a needs-you message", () => { + render(); + expect(screen.getByText(/waiting on approval/)).toBeInTheDocument(); + }); + + it("fires a plain action with its own name", async () => { + const onAction = vi.fn(); + render(); + await userEvent.setup().click(screen.getByTestId("lane-action-stop")); + expect(onAction).toHaveBeenCalledWith("stop"); + }); + + it("keeps the destructive verbs out of the card until the menu is opened", async () => { + render(); + // A wall of red buttons makes none of them read as the dangerous one, so + // reset/remove/purge live behind the ⋯ menu. + expect(screen.queryByTestId("lane-action-reset")).toBeNull(); + expect(screen.queryByTestId("lane-action-remove")).toBeNull(); + + await userEvent.setup().click(screen.getByTestId("lane-more")); + expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); + expect(screen.getByTestId("lane-action-remove")).toBeInTheDocument(); + }); + + it("routes reset through the confirmation modal rather than firing it", async () => { + const onAction = vi.fn(); + render(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("lane-more")); + await user.click(screen.getByTestId("lane-action-reset")); + expect(onAction).not.toHaveBeenCalled(); + }); + + it("offers reset only for a managed lane", async () => { + const user = userEvent.setup(); + const { unmount } = render(); + await user.click(screen.getByTestId("lane-more")); + expect(screen.getByTestId("lane-action-reset")).toBeInTheDocument(); + unmount(); + + render(); + await user.click(screen.getByTestId("lane-more")); + expect(screen.queryByTestId("lane-action-reset")).toBeNull(); + }); +}); + +describe("LaneCard git block", () => { + const facts = { + available: true as const, + branch: "feat/rename-metric", + head: "9b3e74a", + subject: "free-text rule mode in the form", + dirty: 2, + untracked: 1, + }; + + it("renders the live branch, head, subject and uncommitted counts", async () => { + vi.mocked(api.lanes.git).mockResolvedValueOnce(facts); + render(); + + const block = await screen.findByTestId("lane-git"); + expect(block.textContent).toContain("feat/rename-metric"); + expect(block.textContent).toContain("9b3e74a"); + expect(block.textContent).toContain("free-text rule mode in the form"); + expect(block.textContent).toContain("2"); + // The live branch replaces the lane's recorded one rather than doubling it. + expect(screen.queryByText(/stale\/recorded/)).toBeNull(); + }); + + it("omits the uncommitted line when the tree is clean", async () => { + vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 }); + render(); + const block = await screen.findByTestId("lane-git"); + expect(block.textContent).not.toContain("modified"); + }); + + it("renders the card with no git block and no error when git is unavailable", async () => { + vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false }); + render(); + + expect(await screen.findByText("plain dir lane")).toBeInTheDocument(); + expect(screen.queryByTestId("lane-git")).toBeNull(); + }); + + it("swallows a rejected request instead of surfacing an error", async () => { + vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down")); + render(); + + expect(await screen.findByText("offline lane")).toBeInTheDocument(); + expect(screen.queryByTestId("lane-git")).toBeNull(); + expect(screen.queryByText(/network down/)).toBeNull(); + }); + + it("stops polling once the card unmounts", async () => { + vi.useFakeTimers(); + try { + vi.mocked(api.lanes.git).mockResolvedValue({ available: false }); + const { unmount } = render(); + expect(api.lanes.git).toHaveBeenCalledTimes(1); + unmount(); + vi.advanceTimersByTime(120_000); + expect(api.lanes.git).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/client/src/components/lanes/__tests__/PipelineMap.test.tsx b/client/src/components/lanes/__tests__/PipelineMap.test.tsx new file mode 100644 index 0000000..1ac6c59 --- /dev/null +++ b/client/src/components/lanes/__tests__/PipelineMap.test.tsx @@ -0,0 +1,106 @@ +/** + * @file Rendering tests for the lane pipeline map: every node renders with a + * state-specific class so "done", "current" and "passed without evidence" stay + * visually distinguishable, and the amber state is never conflated with done. + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import PipelineMap from "../PipelineMap"; +import type { LaneNode } from "../../../lib/types"; + +const nodes: LaneNode[] = [ + { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" }, + { id: "implement", label: "implement", icon: "🛠", gate: false, state: "passed-no-evidence" }, + { id: "review", label: "review", icon: "👀", gate: true, state: "current" }, + { id: "gate", label: "gate", icon: "🚦", gate: true, state: "failed" }, + { id: "done", label: "done", icon: "✅", gate: false, state: "pending" }, +]; + +describe("PipelineMap", () => { + it("renders one element per node, labelled by state", () => { + render(); + expect(screen.getAllByTestId(/^pipeline-node-/)).toHaveLength(5); + expect(screen.getByTestId("pipeline-node-plan")).toHaveAttribute("data-state", "done"); + expect(screen.getByTestId("pipeline-node-implement")).toHaveAttribute( + "data-state", + "passed-no-evidence" + ); + expect(screen.getByTestId("pipeline-node-review")).toHaveAttribute("data-state", "current"); + expect(screen.getByTestId("pipeline-node-gate")).toHaveAttribute("data-state", "failed"); + expect(screen.getByTestId("pipeline-node-done")).toHaveAttribute("data-state", "pending"); + }); + + it("gives amber nodes a different class from done nodes", () => { + render(); + const done = screen.getByTestId("pipeline-node-plan").className; + const amber = screen.getByTestId("pipeline-node-implement").className; + // The done node must contain emerald colour token and the amber node must contain amber token. + expect(done).toContain("emerald"); + expect(amber).toContain("amber"); + expect(done).not.toEqual(amber); + }); + + it("renders nothing but an empty hint when there are no nodes", () => { + render(); + expect(screen.queryAllByTestId(/^pipeline-node-/)).toHaveLength(0); + }); + + describe("detected (inferred) nodes", () => { + const detectedNodes: LaneNode[] = [ + { id: "intake", label: "intake", icon: "📥", gate: false, state: "pending", detected: true }, + { id: "tests", label: "tests", icon: "🧪", gate: false, state: "pending", detected: true }, + { id: "plan", label: "plan", icon: "🧭", gate: false, state: "done" }, + { + id: "implement", + label: "implement", + icon: "🛠", + gate: false, + state: "passed-no-evidence", + }, + ]; + + it("marks a detected node with data-detected and a dashed-border class token", () => { + render(); + const node = screen.getByTestId("pipeline-node-tests"); + expect(node).toHaveAttribute("data-detected", "true"); + expect(node.className).toContain("border-dashed"); + }); + + it("gives a detected node a class different from both done and plain passed-no-evidence", () => { + render(); + const detected = screen.getByTestId("pipeline-node-tests").className; + const done = screen.getByTestId("pipeline-node-plan").className; + const amber = screen.getByTestId("pipeline-node-implement").className; + expect(detected).not.toEqual(done); + expect(detected).not.toEqual(amber); + }); + + it("names the signal in the detected node's tooltip", () => { + render(); + const node = screen.getByTestId("pipeline-node-tests"); + expect(node).toHaveAttribute("title", "tests ← npm run test:server"); + }); + + it("PREMISE GUARD: detected wins over state=done — dashed amber, never emerald", () => { + // The server never emits this pair, and this is the guard that says the + // component would not paint an inference green even if it did. Asserting + // against a fixture whose detected nodes are already `pending` would only + // re-assert the fixture. + const impossible: LaneNode[] = [ + { id: "tests", label: "tests", icon: "🧪", gate: false, state: "done", detected: true }, + ]; + render(); + const node = screen.getByTestId("pipeline-node-tests"); + expect(node.className).toContain("border-dashed"); + expect(node.className).toContain("amber"); + expect(node.className).not.toContain("emerald"); + }); + + it("non-detected nodes carry no data-detected attribute", () => { + render(); + expect(screen.getByTestId("pipeline-node-plan")).not.toHaveAttribute("data-detected"); + }); + }); +}); diff --git a/client/src/components/run/RunConsole.tsx b/client/src/components/run/RunConsole.tsx new file mode 100644 index 0000000..f3b57ab --- /dev/null +++ b/client/src/components/run/RunConsole.tsx @@ -0,0 +1,1081 @@ +/** + * @file RunConsole.tsx + * @description The run console: everything that renders one run's live + * conversation and drives its next turn. Moved verbatim out of `pages/Run.tsx` + * (where it was `RunSession`) so the Run page and the Workspace page can both + * mount the same console. + * + * Three pieces live here: + * - the envelope stream — user turns, assistant markdown, thinking, tool + * uses and tool results, plus the result footer; + * - the token / context-window meter rolled up from the envelope log; + * - the prompt editor with its `/` slash-command and `@` file autocomplete. + * + * Props only: no API call except the `@`-file lookup the editor already owned, + * and no stream subscription — `envelopes` arrives as a prop, so the page keeps + * `useRunStream` and both pages share one subscription per run. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { + Play, + Square, + Send, + RefreshCw, + Sparkles, + Terminal, + CheckCircle2, + XCircle, + Clock, + ExternalLink, + Plus, + AtSign, + Slash as SlashIcon, + FileCode, +} from "lucide-react"; +import { api } from "../../lib/api"; +import type { RunHandle, RunMode } from "../../lib/api"; +import { MarkdownContent } from "../conversation/MarkdownContent"; +import type { + AssistantMessage, + ContentBlock, + Envelope, + ResultEnvelope, + SystemInit, + UserMessage, +} from "../../hooks/useRunStream"; + +// ── Token / context-window meter ────────────────────────────────────── + +interface TokenStats { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + costUsd: number | null; + contextWindow: number | null; +} + +const DEFAULT_CONTEXT_WINDOW = 200_000; + +/** + * Roll up token usage from the in-memory envelope log. Pulls the latest + * `usage` block from `stream_event/message_delta` events (live numbers + * during streaming) and the canonical `result.usage` envelope when the run + * finishes. The 1M-context Opus variants emit `contextWindow` in + * `result.modelUsage`; we surface that to size the meter correctly. + */ +function computeTokens(envelopes: Envelope[]): TokenStats { + // Per-turn rolling counters (overwritten as each new turn's message_start + // arrives). The latest message_start's input + cache numbers reflect the + // current turn's prompt size, which is the right thing to show in the + // "Context" gauge. + let inputTokens = 0; + let cacheReadTokens = 0; + let cacheCreationTokens = 0; + // Output is summed across all completed turns plus the running current + // turn - claude reports output_tokens as a per-turn (per-message) number, + // not cumulative. Without summing, the meter resets every time a new + // `message_start` arrives. + let completedOutputTokens = 0; + let currentTurnOutput = 0; + let costUsd: number | null = null; + let contextWindow: number | null = null; + let sawMessageStart = false; + // While we don't have an authoritative output count from message_delta / + // result, estimate from the char count in the streaming assistant block + // so the meter ticks live as text appears (claude doesn't emit usage on + // every text_delta). + let outputAuthoritativeForCurrent = false; + let streamingChars = 0; + + const commitTurn = () => { + completedOutputTokens += currentTurnOutput; + currentTurnOutput = 0; + outputAuthoritativeForCurrent = false; + streamingChars = 0; + }; + + for (const env of envelopes) { + const e = env as { type?: string }; + if (e.type === "stream_event") { + const ev = ( + env as { + event?: { + type?: string; + usage?: Record; + message?: { usage?: Record }; + }; + } + ).event; + if (!ev) continue; + if (ev.type === "message_start") { + // Roll the previous turn's running output into the cumulative total + // before resetting for this new turn. + if (sawMessageStart) commitTurn(); + sawMessageStart = true; + const u = ev.message?.usage; + if (u) { + inputTokens = u.input_tokens ?? 0; + cacheReadTokens = u.cache_read_input_tokens ?? 0; + cacheCreationTokens = u.cache_creation_input_tokens ?? 0; + currentTurnOutput = u.output_tokens ?? 0; + } + } else if (ev.type === "message_delta") { + const u = ev.usage; + if (u && typeof u.output_tokens === "number") { + // Authoritative running output for the current turn. + currentTurnOutput = u.output_tokens; + outputAuthoritativeForCurrent = true; + } + } + } else if (e.type === "result") { + const r = env as ResultEnvelope & { + modelUsage?: Record< + string, + { + contextWindow?: number; + inputTokens?: number; + outputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + } + >; + }; + // Result is end-of-run: commit any in-flight current turn first. + if (currentTurnOutput > 0) { + completedOutputTokens += currentTurnOutput; + currentTurnOutput = 0; + outputAuthoritativeForCurrent = false; + } + if (typeof r.total_cost_usd === "number") costUsd = r.total_cost_usd; + if (r.modelUsage && typeof r.modelUsage === "object") { + for (const m of Object.values(r.modelUsage)) { + if (!m || typeof m !== "object") continue; + if (typeof m.contextWindow === "number") contextWindow = m.contextWindow; + // Prefer modelUsage's per-model totals when available - these are + // the canonical per-run numbers. + if (typeof m.inputTokens === "number") inputTokens = m.inputTokens; + if (typeof m.cacheReadInputTokens === "number") cacheReadTokens = m.cacheReadInputTokens; + if (typeof m.cacheCreationInputTokens === "number") + cacheCreationTokens = m.cacheCreationInputTokens; + if (typeof m.outputTokens === "number") { + // modelUsage.outputTokens is the run total for this model - use + // it as the canonical cumulative output, replacing our running + // sum. + completedOutputTokens = m.outputTokens; + } + } + } + } else if (e.type === "system" && (env as SystemInit).model) { + // Heuristic: 1M Opus has [1m] in the model id + const model = (env as SystemInit).model || ""; + if (/\[1m\]/i.test(model)) contextWindow = 1_000_000; + } else if (e.type === "assistant") { + const msg = ( + env as { + message?: { + _streaming?: boolean; + content?: ContentBlock[]; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + } + ).message; + if (msg?._streaming) { + streamingChars = 0; + const blocks = msg.content || []; + for (const b of blocks) { + if (b.type === "text") { + streamingChars += ((b as { text?: string }).text || "").length; + } else if (b.type === "thinking") { + streamingChars += ((b as { thinking?: string }).thinking || "").length; + } + } + } else if (msg?.usage) { + // Transcript-derived seed envelopes carry usage but have no + // `message.id` (transcriptToEnvelopes doesn't set one). Live-stream + // canonical envelopes always have an id assigned by message_start, + // and their tokens are already counted via stream_event / commitTurn + // - folding them here would double-count. Use id-presence as the + // discriminator: no id → transcript-seeded → fold; id → live → skip. + const hasId = !!(msg as { id?: string }).id; + if (!hasId) { + const u = msg.usage; + if (typeof u.input_tokens === "number") inputTokens = u.input_tokens; + if (typeof u.cache_read_input_tokens === "number") { + cacheReadTokens = u.cache_read_input_tokens; + } + if (typeof u.cache_creation_input_tokens === "number") { + cacheCreationTokens = u.cache_creation_input_tokens; + } + if (typeof u.output_tokens === "number") { + completedOutputTokens += u.output_tokens; + } + } + } + } + } + + // While we don't have an authoritative output count for the current turn, + // surface the char-based estimate so the meter ticks live during streaming. + if (!outputAuthoritativeForCurrent && streamingChars > 0) { + const estimate = Math.ceil(streamingChars / 4); + if (estimate > currentTurnOutput) currentTurnOutput = estimate; + } + + return { + inputTokens, + outputTokens: completedOutputTokens + currentTurnOutput, + cacheReadTokens, + cacheCreationTokens, + costUsd, + contextWindow, + }; +} + +function formatNum(n: number): string { + if (n < 1000) return String(n); + if (n < 100_000) return (n / 1000).toFixed(1) + "k"; + if (n < 1_000_000) return Math.round(n / 1000) + "k"; + return (n / 1_000_000).toFixed(2) + "M"; +} + +function TokenMeter({ stats }: { stats: TokenStats }) { + const { t } = useTranslation("run"); + const total = stats.inputTokens + stats.cacheReadTokens + stats.cacheCreationTokens; + const cap = stats.contextWindow ?? DEFAULT_CONTEXT_WINDOW; + const pct = Math.min(100, Math.round((total / cap) * 100)); + // Colour is the whole warning mechanism here - the meter is one status line, + // so there is no room for a bar plus five labelled figures. + const tone = pct >= 95 ? "text-red-300" : pct >= 80 ? "text-amber-300" : "text-gray-400"; + return ( +
+ + ── + + {`${formatNum(total)} / ${formatNum(cap)} (${pct}%)`} + ↑{formatNum(stats.outputTokens)} + {stats.cacheReadTokens > 0 && ( + + ⚡{formatNum(stats.cacheReadTokens)} + + )} + {stats.costUsd != null && ( + ${stats.costUsd.toFixed(4)} + )} +
+ ); +} + +// ── Slash commands (built-in list + user/project/plugin from API) ───── + +export interface SlashCommand { + name: string; + description?: string; + source: "builtin" | "user" | "project" | "plugin"; + filePath?: string; +} + +// Built-in commands the CLI handles itself. We surface them in autocomplete +// with a "CLI only" tag so users know they won't actually execute when +// sent over stream-json stdin. +export const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [ + { name: "help", description: "List available commands", source: "builtin" }, + { name: "clear", description: "Clear the conversation", source: "builtin" }, + { name: "config", description: "Open the interactive config menu", source: "builtin" }, + { name: "model", description: "Change model mid-session", source: "builtin" }, + { name: "compact", description: "Compact the conversation context", source: "builtin" }, + { name: "memory", description: "Edit CLAUDE.md", source: "builtin" }, + { name: "hooks", description: "Manage hooks", source: "builtin" }, + { name: "cost", description: "Show session cost", source: "builtin" }, + { name: "agents", description: "List subagents", source: "builtin" }, + { name: "review", description: "Review current changes", source: "builtin" }, + { name: "release-notes", description: "Show CC release notes", source: "builtin" }, + { name: "permissions", description: "Edit permission rules", source: "builtin" }, + { name: "status", description: "Show session status", source: "builtin" }, + { name: "init", description: "Initialise CLAUDE.md from codebase", source: "builtin" }, + { name: "login", description: "Sign in to Claude", source: "builtin" }, + { name: "logout", description: "Sign out", source: "builtin" }, + { name: "exit", description: "Exit the session", source: "builtin" }, + { name: "mcp", description: "Manage MCP servers", source: "builtin" }, + { name: "plugin", description: "Manage plugins", source: "builtin" }, + { name: "output-style", description: "Change output style", source: "builtin" }, +]; + +function commandSourceLabel(s: SlashCommand["source"]): string { + return s === "builtin" + ? "CLI only" + : s === "user" + ? "user" + : s === "project" + ? "project" + : "plugin"; +} + +function commandSourceTone(s: SlashCommand["source"]): string { + return s === "builtin" + ? "bg-gray-500/10 text-gray-400 border-gray-500/30" + : s === "user" + ? "bg-sky-500/10 text-sky-300 border-sky-500/30" + : s === "project" + ? "bg-emerald-500/10 text-emerald-300 border-emerald-500/30" + : "bg-violet-500/10 text-violet-300 border-violet-500/30"; +} + +// ── Autocomplete dropdown for slash + @-files ───────────────────────── + +interface AutocompleteState { + kind: "slash" | "file"; + query: string; + // The position in the textarea where the trigger character starts (so we + // can replace from there to the cursor on selection). + triggerStart: number; + cursor: number; +} + +/** + * Tiered slash-command match scoring. Higher = more relevant. Returns 0 for + * "doesn't match, hide it." Tiers in descending priority: + * 1. Exact name match + * 2. Name starts with query + * 3. Word boundary (after `-` / `_` / `.`) starts with query + * 4. Name contains query (earlier index ranks higher) + * 5. Subsequence match across the name + * 6. Description contains query - only when query is at least 3 chars, + * so a single keystroke can't drag in tangential descriptions. + */ +function scoreSlashMatch(name: string, description: string | undefined, q: string): number { + if (!q) return 1; + const n = name.toLowerCase(); + if (n === q) return 1000; + if (n.startsWith(q)) return 800 - Math.min(n.length, 100); + const parts = n.split(/[-_.\s]/); + if (parts.some((p) => p.startsWith(q))) { + return 600 - Math.min(n.length, 100); + } + const idx = n.indexOf(q); + if (idx >= 0) return 400 - Math.min(idx, 100); + if (subsequenceMatch(n, q)) return 200; + if (q.length >= 3) { + const d = (description || "").toLowerCase(); + if (d.includes(q)) return 100; + } + return 0; +} + +function subsequenceMatch(s: string, q: string): boolean { + let i = 0; + for (let k = 0; k < s.length && i < q.length; k++) { + if (s[k] === q[i]) i++; + } + return i === q.length; +} + +function detectAutocomplete(value: string, cursor: number): AutocompleteState | null { + // Look back from the cursor to find the active "token". A token starts at + // the beginning of the line / after whitespace and continues until cursor. + let start = cursor; + while (start > 0) { + const ch = value[start - 1]; + if (!ch || /\s/.test(ch)) break; + start--; + } + const tok = value.slice(start, cursor); + if (tok.startsWith("/") && tok.length >= 1) { + // Only trigger for slash if it's at line start OR right after whitespace. + // The detection above already enforces that. + return { kind: "slash", query: tok.slice(1), triggerStart: start, cursor }; + } + if (tok.startsWith("@") && tok.length >= 1) { + return { kind: "file", query: tok.slice(1), triggerStart: start, cursor }; + } + return null; +} + +interface PromptEditorProps { + value: string; + onChange: (s: string) => void; + onSubmit?: () => void; + placeholder?: string; + rows?: number; + slashCommands: SlashCommand[]; + fileCwd: string; + autoFocus?: boolean; +} + +export function PromptEditor({ + value, + onChange, + onSubmit, + placeholder, + rows = 4, + slashCommands, + fileCwd, + autoFocus, +}: PromptEditorProps) { + const { t } = useTranslation("run"); + const taRef = useRef(null); + const [state, setState] = useState(null); + const [active, setActive] = useState(0); + const [fileSuggestions, setFileSuggestions] = useState([]); + const fileFetchRef = useRef<{ q: string; t: number } | null>(null); + + // Slash filter - tiered scoring so prefix matches outrank arbitrary + // substring hits, name matches outrank description matches, and shorter + // names break ties when scores are equal. + const slashItems = useMemo(() => { + if (!state || state.kind !== "slash") return [] as SlashCommand[]; + const q = state.query.toLowerCase(); + const sourceOrder = { project: 0, user: 1, plugin: 2, builtin: 3 } as const; + if (!q) { + return [...slashCommands].sort( + (a, b) => sourceOrder[a.source] - sourceOrder[b.source] || a.name.localeCompare(b.name) + ); + } + type Scored = { cmd: SlashCommand; score: number }; + const scored: Scored[] = []; + for (const cmd of slashCommands) { + const score = scoreSlashMatch(cmd.name, cmd.description, q); + if (score > 0) scored.push({ cmd, score }); + } + return scored + .sort( + (a, b) => + b.score - a.score || + sourceOrder[a.cmd.source] - sourceOrder[b.cmd.source] || + a.cmd.name.length - b.cmd.name.length || + a.cmd.name.localeCompare(b.cmd.name) + ) + .map((s) => s.cmd); + }, [state, slashCommands]); + + // File fetch (debounced) + useEffect(() => { + if (!state || state.kind !== "file") return; + const ts = Date.now(); + fileFetchRef.current = { q: state.query, t: ts }; + const tid = setTimeout(() => { + if (fileFetchRef.current?.t !== ts) return; + api.run + .files(fileCwd, state.query) + .then((r) => setFileSuggestions(r.items)) + .catch(() => setFileSuggestions([])); + }, 120); + return () => clearTimeout(tid); + }, [state, fileCwd]); + + const items = state?.kind === "file" ? fileSuggestions : slashItems; + + useEffect(() => { + if (active >= items.length) setActive(Math.max(0, items.length - 1)); + }, [items.length, active]); + + const insertChoice = (choice: SlashCommand | string) => { + if (!state || !taRef.current) return; + const ta = taRef.current; + const before = value.slice(0, state.triggerStart); + const after = value.slice(state.cursor); + let inserted: string; + if (state.kind === "slash") { + const c = choice as SlashCommand; + inserted = `/${c.name}`; + } else { + inserted = `@${choice as string}`; + } + const next = before + inserted + (after.startsWith(" ") || after === "" ? "" : " ") + after; + onChange(next); + setState(null); + setActive(0); + // Re-position cursor after the inserted token + a trailing space + requestAnimationFrame(() => { + const pos = before.length + inserted.length + 1; + ta.focus(); + ta.setSelectionRange(pos, pos); + }); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (state && items.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((a) => Math.min(items.length - 1, a + 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(0, a - 1)); + return; + } + if (e.key === "Enter" && !e.metaKey && !e.ctrlKey) { + e.preventDefault(); + const choice = items[active]; + if (choice) insertChoice(choice); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + const choice = items[active]; + if (choice) insertChoice(choice); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setState(null); + return; + } + } + if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + e.preventDefault(); + onSubmit?.(); + } + }; + + const onTextareaInput = (e: React.ChangeEvent) => { + onChange(e.target.value); + const ta = e.target; + const next = detectAutocomplete(ta.value, ta.selectionStart || 0); + setState(next); + if (!next) setActive(0); + }; + + const onSelect = (e: React.SyntheticEvent) => { + const ta = e.currentTarget; + const next = detectAutocomplete(ta.value, ta.selectionStart || 0); + setState(next); + }; + + return ( +
+