Compare commits
157 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0500dc592 | |||
| 6c605849d8 | |||
| c067abc68f | |||
| b0cf2132d1 | |||
| 094d9c4418 | |||
| 198fbb7980 | |||
| 442f133930 | |||
| d4961d94ef | |||
| efa417488a | |||
| 6715fbd971 | |||
| 03f1673fce | |||
| 8fb703c6db | |||
| 17a5c7470d | |||
| 215cae7393 | |||
| 138e706dd1 | |||
| 151dc8a480 | |||
| ad6e80192d | |||
| 65fe680d38 | |||
| ed1bf20e6b | |||
| 8098f446b6 | |||
| 326dfa9d19 | |||
| 1597c110d4 | |||
| 8f6360f993 | |||
| e5762a59d6 | |||
| b7efba8747 | |||
| 4df944c6aa | |||
| d9511ea446 | |||
| fadecaddd0 | |||
| da1b17778c | |||
| f35bf2043f | |||
| 4802684d2f | |||
| c507d37baf | |||
| 5084334d81 | |||
| a6bd33bcff | |||
| 5b269698f6 | |||
| d0535af626 | |||
| 2e051e23b6 | |||
| 744e9c6cda | |||
| b71024f244 | |||
| eb90244182 | |||
| ba2c4e1914 | |||
| 5b3884b309 | |||
| e01cb26bde | |||
| f887c16309 | |||
| c5aba094bd | |||
| 058c574138 | |||
| 969b06253c | |||
| 7ec5a7cd85 | |||
| fcfebe44f1 | |||
| 77917203fd | |||
| 0aa7fc232e | |||
| 887dc4640e | |||
| 2b8f83d2dd | |||
| 3c238bc5c4 | |||
| 9d096b61f7 | |||
| 952e8584f0 | |||
| 3f6e903c22 | |||
| 1da46f10f4 | |||
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 | |||
| 804b193ac7 | |||
| d397d3a1f6 | |||
| 67210bcd61 | |||
| 4e0b869dd3 | |||
| cfe832383c | |||
| 0737786faf | |||
| 428519335d | |||
| faa56f5c75 | |||
| 7bc4594b94 | |||
| 6153d50fa8 | |||
| 562f43fd88 | |||
| 8ea40260ad | |||
| 6353e88c9e | |||
| 6cf8a82889 | |||
| 2ea2184372 | |||
| aff863c69f | |||
| 764b967856 | |||
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 | |||
| 952ee7c724 | |||
| 92cee94431 | |||
| 1b561c22d9 | |||
| 32a8887419 | |||
| f77049c238 | |||
| 64017c6ca5 | |||
| 9156781d75 | |||
| 8d48a038c7 | |||
| 59b3ba9e39 | |||
| eab7c437df | |||
| e217314248 | |||
| 26b0fe53c7 | |||
| 4227a97ab1 | |||
| 0004cf7036 | |||
| 75a2de60b2 | |||
| 1aee080fca | |||
| 470f9909b4 | |||
| 5ba9e07d78 | |||
| b0e8368c1d | |||
| 686b80bd99 | |||
| 02ee46c862 | |||
| 5bd1c0323f | |||
| b5ca531800 | |||
| 0e38d42cf9 | |||
| af2ad63a18 | |||
| 2fe1dcd46f | |||
| 7fae2f48c0 | |||
| 229002cc2b | |||
| 27923db4e0 | |||
| 5ab674f134 | |||
| 00a2aedb09 | |||
| 9128352d1b | |||
| fb3796f064 | |||
| c3f8dec104 | |||
| 6e685ce1c3 | |||
| 29a5da3e4e | |||
| 713e9fe58b | |||
| 13bc7154d6 | |||
| e895c0e8eb | |||
| 0a86dfb8c5 | |||
| fc7b58f15b | |||
| c4025cd2f7 | |||
| ee1e07bb5e | |||
| 91af3dca86 | |||
| baa355bee4 | |||
| b7e81af0b6 | |||
| c90dd7b147 | |||
| 49a041b1dc | |||
| de8d1079e0 | |||
| 0335e0e6bc | |||
| 18614f378d | |||
| 63ed90ba05 | |||
| 3ed88062d6 | |||
| 27313d4413 | |||
| d4b949a6f4 | |||
| 8930865376 | |||
| 29858ddccb | |||
| 7000a9c5ce | |||
| 99e718ad91 | |||
| 67c948bbe2 | |||
| 8b8582f231 | |||
| b868a2fa85 | |||
| 30bc6debde | |||
| d4052ed235 | |||
| aca35dd4cd | |||
| cc85e35439 | |||
| eb63569324 | |||
| a146284087 | |||
| 61cc37ba32 | |||
| e972267a42 |
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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/`, 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`
|
||||
|
||||
## References
|
||||
- `references/module-map.md`
|
||||
- `references/verification-map.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Verification Map
|
||||
|
||||
- Backend changes:
|
||||
- `npm run test:server`
|
||||
- Frontend changes:
|
||||
- `npm run test:client`
|
||||
- Docs-only changes:
|
||||
- validate command consistency against root `package.json`
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-code-agent-monitor-plugins",
|
||||
"name": "agenthub",
|
||||
"description": "Official plugin marketplace for AgentHub — the `agenthub` plugin installs the dashboard itself (hooks, server, CLI), plus 10 focused 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",
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "agenthub",
|
||||
@@ -16,61 +16,61 @@
|
||||
},
|
||||
{
|
||||
"name": "agenthub-analytics",
|
||||
"path": "plugins/agenthub-analytics",
|
||||
"source": "./plugins/agenthub-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": "agenthub-cost-guard",
|
||||
"path": "plugins/agenthub-cost-guard",
|
||||
"source": "./plugins/agenthub-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": "agenthub-productivity",
|
||||
"path": "plugins/agenthub-productivity",
|
||||
"source": "./plugins/agenthub-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": "agenthub-devtools",
|
||||
"path": "plugins/agenthub-devtools",
|
||||
"source": "./plugins/agenthub-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": "agenthub-insights",
|
||||
"path": "plugins/agenthub-insights",
|
||||
"source": "./plugins/agenthub-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": "agenthub-sessions",
|
||||
"path": "plugins/agenthub-sessions",
|
||||
"source": "./plugins/agenthub-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": "agenthub-workflows",
|
||||
"path": "plugins/agenthub-workflows",
|
||||
"source": "./plugins/agenthub-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": "agenthub-quality",
|
||||
"path": "plugins/agenthub-quality",
|
||||
"source": "./plugins/agenthub-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": "agenthub-config",
|
||||
"path": "plugins/agenthub-config",
|
||||
"source": "./plugins/agenthub-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": "agenthub-dashboard",
|
||||
"path": "plugins/agenthub-dashboard",
|
||||
"source": "./plugins/agenthub-dashboard",
|
||||
"description": "Dashboard connector — quick status checks, one-line metrics, live activity watch, and endpoint probing against the Agent Monitor API.",
|
||||
"tags": ["dashboard", "status", "connector"]
|
||||
}
|
||||
|
||||
@@ -5,32 +5,14 @@
|
||||
"name": "Nguyễn Ngọc Trí Vĩ",
|
||||
"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.git",
|
||||
"homepage": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"repository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git",
|
||||
"license": "MIT",
|
||||
"keywords": ["dashboard", "monitoring", "sessions", "cost", "lanes", "claude-code"],
|
||||
"commands": [
|
||||
"./plugins/agenthub/commands/agenthub-doctor.md",
|
||||
"./plugins/agenthub/commands/agenthub-update.md",
|
||||
"./plugins/agenthub/commands/agenthub-open.md",
|
||||
"./plugins/agenthub/commands/intake.md",
|
||||
"./plugins/agenthub/commands/plan.md",
|
||||
"./plugins/agenthub/commands/implement.md",
|
||||
"./plugins/agenthub/commands/tests.md",
|
||||
"./plugins/agenthub/commands/e2e.md",
|
||||
"./plugins/agenthub/commands/e2e-passed.md",
|
||||
"./plugins/agenthub/commands/review.md",
|
||||
"./plugins/agenthub/commands/qc-plan.md",
|
||||
"./plugins/agenthub/commands/qc.md",
|
||||
"./plugins/agenthub/commands/gate.md",
|
||||
"./plugins/agenthub/commands/publishing.md",
|
||||
"./plugins/agenthub/commands/pr-open.md",
|
||||
"./plugins/agenthub/commands/reported.md",
|
||||
"./plugins/agenthub/commands/watching-pr.md",
|
||||
"./plugins/agenthub/commands/merged.md",
|
||||
"./plugins/agenthub/commands/done.md",
|
||||
"./plugins/agenthub/commands/ship-full.md",
|
||||
"./plugins/agenthub/commands/ship-quick.md"
|
||||
"./plugins/agenthub/commands/agenthub-open.md"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions:
|
||||
|
||||
## Included skills
|
||||
|
||||
- `repo-onboarding` — architecture discovery and verification selection
|
||||
- `release-guard` — release readiness checks
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
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/`, 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`
|
||||
|
||||
## References
|
||||
- `references/module-map.md`
|
||||
- `references/verification-map.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
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."
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Verification Map
|
||||
|
||||
- Backend changes:
|
||||
- `npm run test:server`
|
||||
- Frontend changes:
|
||||
- `npm run test:client`
|
||||
- Docs-only changes:
|
||||
- validate command consistency against root `package.json`
|
||||
@@ -1,7 +1,7 @@
|
||||
# Optional dev-container image for AgentHub.
|
||||
# Node 22 to match the production Dockerfile and the project's engines.
|
||||
# Node 22 to match 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.
|
||||
# Python 3 (scripts/expand-ts-module-docs.py) and the sqlite3 CLI.
|
||||
#
|
||||
# This image is ONLY used by Dev Containers / Codespaces. Host-based development
|
||||
# (npm run dev / npm start) is unaffected.
|
||||
|
||||
@@ -22,13 +22,13 @@ dependencies (`npm run setup`).
|
||||
|
||||
| Component | Detail |
|
||||
| ---------------- | ------------------------------------------------------------------- |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` (matches prod) |
|
||||
| Base image | `mcr.microsoft.com/devcontainers/javascript-node:22` |
|
||||
| Native toolchain | `build-essential` + `python3` so `better-sqlite3` compiles |
|
||||
| Python | `python3` / `python` for `statusline.py` and helper scripts |
|
||||
| Python | `python3` / `python` for `scripts/expand-ts-module-docs.py` |
|
||||
| sqlite3 CLI | inspect the dashboard DB during development |
|
||||
| Features | GitHub CLI, Docker-in-Docker (build/run the project's own Dockerfile) |
|
||||
| Features | GitHub CLI |
|
||||
| Forwarded ports | `4820` (server API + WebSocket), `5173` (Vite client) |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, Docker, YAML, Tailwind |
|
||||
| Editor | ESLint + Prettier (format on save), Vitest, YAML, Tailwind |
|
||||
|
||||
## Everyday commands
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// 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) and the VS Code extension. The native `better-sqlite3`
|
||||
// addon builds here (build-essential + python3 are installed in the Dockerfile).
|
||||
// Covers the full project: the Express server (4820) and the React/Vite
|
||||
// client (5173). 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
|
||||
@@ -20,10 +20,7 @@
|
||||
},
|
||||
|
||||
"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": {}
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
|
||||
// Server (API + WebSocket) and the Vite client dev server. VS Code forwards
|
||||
@@ -40,7 +37,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
// Install root + client + vscode-extension deps.
|
||||
// Install root + client deps.
|
||||
// Never installs Claude Code hooks (host-only — see note above).
|
||||
"postCreateCommand": "bash .devcontainer/post-create.sh",
|
||||
"waitFor": "postCreateCommand",
|
||||
@@ -59,7 +56,6 @@
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"vitest.explorer",
|
||||
"ms-azuretools.vscode-docker",
|
||||
"redhat.vscode-yaml",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"ms-python.python",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
set -euo pipefail
|
||||
|
||||
echo "▶ Installing server + client + vscode-extension dependencies (npm run setup)…"
|
||||
echo "▶ Installing server + client dependencies (npm run setup)…"
|
||||
npm run setup
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
client/dist/
|
||||
data/
|
||||
.git/
|
||||
.github/
|
||||
*.md
|
||||
!README.md
|
||||
images/
|
||||
wiki/
|
||||
index.html
|
||||
og-image.svg
|
||||
favicon.svg
|
||||
.prettierrc
|
||||
.prettierignore
|
||||
@@ -2,6 +2,11 @@
|
||||
# Override this if your Claude Code data is in a different location, e.g.:
|
||||
# CLAUDE_HOME=~/.codefuse/engine/cc
|
||||
# CLAUDE_HOME=~/.claude
|
||||
# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR)
|
||||
# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins
|
||||
# over it, and the real environment wins over both; a key set here or in the real
|
||||
# environment is reported as env-owned in Settings (a value saved there would be
|
||||
# overridden again at the next start).
|
||||
|
||||
# ── Server / network ────────────────────────────────────────────────────────
|
||||
# Port to listen on (default: 4820)
|
||||
@@ -66,6 +71,38 @@
|
||||
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||
|
||||
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
||||
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
|
||||
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
||||
|
||||
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||
# Where agy keeps its data, when not ~/.gemini (agy itself has no override).
|
||||
# AGENTHUB_GEMINI_DIR=~/.gemini
|
||||
|
||||
# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**):
|
||||
# drives both the codex lane running/idle mirror and Codex sessions + tokens
|
||||
# (0 disables both; default: 15000).
|
||||
# DASHBOARD_CODEX_POLL_MS=15000
|
||||
|
||||
# Automatic pricing: a model seen in token usage with no rate row of its own
|
||||
# gets one from LiteLLM's public price list. 0 / false / off disables it
|
||||
# (default: enabled).
|
||||
# AGENTHUB_PRICING_SYNC=0
|
||||
|
||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||
# their own connection so they never stall other requests. 0 runs them inline
|
||||
# on the event loop instead (default: worker). A job past the timeout answers
|
||||
# 503 EREADTIMEOUT and the next one gets a fresh worker (default: 60000).
|
||||
# AGENTHUB_READ_WORKER=0
|
||||
# AGENTHUB_READ_TIMEOUT_MS=60000
|
||||
|
||||
# Where settings saved from the dashboard persist (default:
|
||||
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
|
||||
# Here or in the real environment; the file is located after this one loads.
|
||||
# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env
|
||||
|
||||
# ── 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
|
||||
|
||||
@@ -13,13 +13,3 @@
|
||||
*.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
|
||||
|
||||
@@ -9,17 +9,5 @@ package-lock.json
|
||||
client/package-lock.json
|
||||
.vs/
|
||||
*.md
|
||||
index.html
|
||||
wiki/index.html
|
||||
*.yml
|
||||
*.yaml
|
||||
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
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# 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 →
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
{"reason":"idle timeout","timestamp":1774536800234}
|
||||
@@ -1 +0,0 @@
|
||||
1542
|
||||
@@ -11,42 +11,11 @@
|
||||
- Update docs whenever workflow or architecture changes — follow `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ĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||
|
||||
## Declare your lane stage as you work
|
||||
## Workspace stage
|
||||
|
||||
This repository is watched by AgentHub, its own dashboard. A **lane** is keyed by
|
||||
working directory, and the pipeline map only moves forward when the agent doing
|
||||
the work declares its stage:
|
||||
|
||||
```bash
|
||||
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
|
||||
```
|
||||
|
||||
- Call it at every real transition — starting to plan, starting to implement,
|
||||
running tests, opening the PR — not once at the end.
|
||||
- Stage names, `default` template: `intake → plan → implement → tests → review
|
||||
→ gate → ship → done` (the 16-node `ship-feature` template accepts these as
|
||||
aliases too; `agenthub stage` warns on stderr and names the valid nodes if a stage
|
||||
matches none).
|
||||
- **`--tool codex` matters.** One directory can back both a `claude` lane and a
|
||||
`codex` lane, and without a tool the command fails rather than guess (it names
|
||||
both candidates). Inside a pane the dashboard started you need nothing — that
|
||||
pane carries `AGENTHUB_LANE_ID`, which outranks everything but `--lane`.
|
||||
- If `agenthub stage` reports that no lane owns the directory, this checkout was
|
||||
never adopted: skip the stage calls and carry on. Stage reporting is
|
||||
reporting, never control flow — a failed `agenthub stage` never justifies skipping
|
||||
a step of the actual work.
|
||||
|
||||
The dashboard also *infers* your stage from your tool calls (it reads Codex's
|
||||
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
|
||||
that inference does move the pipeline map forward on its own. It still never
|
||||
counts as **evidence**: a node it moved through stays amber and never renders
|
||||
green. Only your own declaration does that — which is why the call above is
|
||||
worth making even when the map already looks right.
|
||||
|
||||
If you hold a stage for a while with no evidence recorded, the dashboard may
|
||||
type a one-line `[agenthub]` reminder straight into this pane asking you to run
|
||||
`agenthub stage ... --evidence "..."`. That is the dashboard, not the user — treat
|
||||
it as the same instruction as this section and just make the real call.
|
||||
AgentHub's `codex` workspaces — lanes in the API and CLI — (like every non-Claude AI tool workspace) carry no pipeline: the dashboard shows no stage for
|
||||
them and never infers or advances one, so there is no `agenthub stage` call to
|
||||
make while you work here as Codex.
|
||||
|
||||
## Where to work
|
||||
- `server/` for API/routes/data processing.
|
||||
|
||||
+343
-339
File diff suppressed because one or more lines are too long
-67
@@ -1,67 +0,0 @@
|
||||
# Multi-stage Dockerfile for AgentHub - a Node.js server with a React client.
|
||||
# This setup optimizes the final image size by separating the build and runtime stages.
|
||||
# The first stage installs only the production dependencies for the server, while the second stage builds the React client.
|
||||
# The final stage combines the necessary files and dependencies to run the application in production.
|
||||
# Compatibility: This setup is compatible with both Podman and Docker. Runnable on any platform that supports Node.js and Alpine Linux.
|
||||
#
|
||||
# Author: Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
|
||||
# ── Stage 1: Install server production deps ───────────────────────────
|
||||
FROM node:22-alpine AS server-deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
# The root `postinstall` hook (scripts/postinstall.js) fires during `npm ci`, so
|
||||
# the file must exist here or npm aborts with MODULE_NOT_FOUND before installing
|
||||
# anything. It self-skips when client/ is absent (as it is in this stage), so
|
||||
# copying just the one script keeps this deps-cache layer from busting on
|
||||
# unrelated scripts/ edits. Do NOT use --ignore-scripts: that would also skip
|
||||
# better-sqlite3's prebuild fetch and silently drop the native SQLite driver.
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
# ── Stage 2: Build React client ───────────────────────────────────────
|
||||
FROM node:22-alpine AS client-build
|
||||
WORKDIR /app/client
|
||||
COPY client/package.json client/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY client/ ./
|
||||
# vite.config.ts stamps the UI version from the repo-root package.json (one level
|
||||
# up from the client dir). Provide it here so the built client shows the real
|
||||
# release version; the config falls back gracefully if it is ever absent.
|
||||
COPY package.json /app/package.json
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 3: Production runtime ───────────────────────────────────────
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Runs Claude Code sessions inside a named tmux session per lane so both the
|
||||
# dashboard (via node-pty attach) and a real terminal can share one live pane.
|
||||
RUN apk add --no-cache tmux
|
||||
|
||||
COPY --from=server-deps /app/node_modules ./node_modules/
|
||||
COPY package.json ./
|
||||
COPY server/ ./server/
|
||||
COPY scripts/ ./scripts/
|
||||
COPY statusline/ ./statusline/
|
||||
COPY --from=client-build /app/client/dist ./client/dist/
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
EXPOSE 4820
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# In a container the app MUST bind all interfaces: the server binds loopback by
|
||||
# default (GHSA-gr74-4xfh-6jw9), but a container's loopback is a separate
|
||||
# namespace the published port cannot reach, so a loopback bind makes the port
|
||||
# unreachable. Inside a container the trust boundary is the *host* port publish
|
||||
# (keep it on 127.0.0.1 — see docker-compose.yml / INSTALL.md), not this bind.
|
||||
ENV DASHBOARD_HOST=0.0.0.0
|
||||
|
||||
# The recommended ~/.claude bind mount is read-only, so the server cannot write
|
||||
# its default data dir (~/.claude/agenthub). Persist to the mounted
|
||||
# volume at /app/data instead.
|
||||
ENV DASHBOARD_DATA_DIR=/app/data
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
+160
-100
@@ -7,8 +7,8 @@ A step-by-step guide to get AgentHub — the local control surface for Claude Co
|
||||
If all you want is a working dashboard, skip every step below. From Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The next session start installs the hooks, boots the server, puts the `agenthub` CLI
|
||||
@@ -30,17 +30,140 @@ dashboard, run it from source, or stay on Node 20.
|
||||
| Node.js | 20+ (22+ recommended) | Required for server and client |
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Python | 3.6+ | Optional — statusline utility only |
|
||||
| Git | Any | For cloning the repository |
|
||||
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x (3.7+ for pane scrollbars) | **Required for the run console** — the browser terminal, `agenthub workspace shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
|
||||
| Codex CLI | Any | Optional — only for lanes whose tool is `codex` |
|
||||
| GitHub CLI (`gh`) | Any | Optional — only for `agenthub workspace ci status` |
|
||||
| Python | 3.6+ | Optional — only for `scripts/expand-ts-module-docs.py` (doc generation) |
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — System packages
|
||||
|
||||
Node and npm are not enough: `git` backs every lane operation, and **`tmux`
|
||||
backs the run console**. The dashboard shells out to the `tmux` binary and has
|
||||
no bundled fallback, so without it **Start Run** and `agenthub workspace shell`
|
||||
fail while everything else keeps working.
|
||||
|
||||
```bash
|
||||
sudo apt install git tmux # Debian / Ubuntu
|
||||
sudo dnf install git tmux # Fedora / RHEL
|
||||
brew install git tmux # macOS
|
||||
sudo pacman -S git tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux. Run AgentHub under **WSL2** and install it inside
|
||||
the WSL distro — a tmux from Git Bash or MSYS is not what the server executes.
|
||||
On a native Windows install, workspace terminals are unavailable, but Settings →
|
||||
Agents still detects each AI CLI through `PATHEXT` (`claude.exe`, `codex.cmd`)
|
||||
and runs a `.cmd` updater or npm through `cmd.exe`.
|
||||
|
||||
A terminal pane gets a draggable scrollbar only on **tmux 3.7+** — older
|
||||
versions work, just without it, and `/agenthub-doctor` warns. Homebrew ships the
|
||||
latest (`brew upgrade tmux`); Ubuntu 24.04's apt has 3.4, so build the release:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential pkg-config libevent-dev libncurses-dev bison
|
||||
curl -fsSL https://github.com/tmux/tmux/releases/download/3.7c/tmux-3.7c.tar.gz | tar xz
|
||||
cd tmux-3.7c && ./configure && make && sudo make install # into /usr/local/bin
|
||||
```
|
||||
|
||||
A tmux server already running keeps its old version until every tmux session
|
||||
has closed (or the machine reboots) — only then do the scrollbars appear.
|
||||
|
||||
Check it:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
git --version
|
||||
node -v # v20.x / v22.x / v24.x
|
||||
```
|
||||
|
||||
Once the server is up you can ask it directly — this is the same probe the
|
||||
dashboard uses:
|
||||
|
||||
```bash
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
**No configuration is needed.** AgentHub creates its own session per lane,
|
||||
named `agenthub-lane-<id>`, and never reads your `~/.tmux.conf` for anything it
|
||||
depends on (mouse mode, the hidden status bar and the scrollbar are set on the
|
||||
session explicitly). You can attach to the
|
||||
exact same pane from a normal terminal:
|
||||
|
||||
```bash
|
||||
agenthub workspace shell # from inside the lane's directory
|
||||
tmux attach -t agenthub-lane-7 # or by name
|
||||
tmux ls # every dashboard session is prefixed agenthub-lane-
|
||||
```
|
||||
|
||||
Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
||||
service install`), so the dashboard comes back after every login with no
|
||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||
`launchctl` fails, the server is a detached process started by the next
|
||||
Claude Code session (within about 10 s) or by `agenthub start`, as before. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the workspace home.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot and log in WITHOUT opening `claude`: the dashboard answers within about 10 s (`agenthub service status` shows `state = running`).
|
||||
9. `kill <server pid>` (from `agenthub status`): it is back within a few seconds.
|
||||
10. With a lane run open, `/agenthub-update` completes, the server restarts on the new version, the plist names the new cache directory, and the run's terminal is still alive.
|
||||
11. `agenthub service uninstall`: the plist is gone and nothing restarts the server after a `kill`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor.git
|
||||
cd Claude-Code-Monitor
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
```
|
||||
|
||||
---
|
||||
@@ -85,7 +208,7 @@ This starts two processes concurrently:
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard in Docker or Podman, install hooks from the host with `npm run install-hooks` after the container is up.
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json` — unless the `agenthub` plugin is enabled there, whose own hooks already run; then it removes any copies a checkout wrote instead, since two sets post every event twice. If you run the dashboard inside a container, install hooks from the host with `npm run install-hooks` instead — a hook path written from inside a container points at the container filesystem.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,7 +231,7 @@ After starting a Claude Code session, you should see:
|
||||
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
|
||||
- **Activity Feed** — events streaming in as Claude Code uses tools
|
||||
- **Dashboard** — stats updating in real-time
|
||||
- **Settings page** — model pricing rules, hook configuration status, data export and cleanup tools
|
||||
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
|
||||
|
||||
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
||||
|
||||
@@ -120,8 +243,6 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
|
||||
2. Once installed, the dashboard launches in its own window with no browser chrome
|
||||
3. Offline support: previously visited pages and assets are served from the Service Worker cache when the network is unavailable
|
||||
|
||||
The landing page and wiki are also installable PWAs with their own manifests and service workers — visit each in a browser to install independently.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — (Optional) Import existing Claude Code history
|
||||
@@ -189,13 +310,15 @@ npm start # Start Express serving client/dist on port 4820
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
`npm start` (and `agenthub start`, its backgrounded form) only run while that
|
||||
process is alive — a reboot loses it. On Linux, run the server as a systemd
|
||||
`--user` service instead so it comes back on its own after every reboot, with
|
||||
no manual step and no Claude Code (or any other tool) needed to trigger it:
|
||||
process is alive — a reboot loses it. Run the server as a systemd `--user`
|
||||
service (Linux) or a LaunchAgent (macOS) instead so it comes back on its own
|
||||
after every reboot, with no manual step and no Claude Code (or any other tool)
|
||||
needed to trigger it:
|
||||
|
||||
```bash
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
@@ -212,7 +335,6 @@ This repository includes extension packs for both Claude Code and Codex.
|
||||
- `CLAUDE.md`
|
||||
- `.claude/rules/`
|
||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
||||
- `agents/` (shipped as the `agenthub` plugin's agents)
|
||||
- Codex project packs live under `.codex/`:
|
||||
- `AGENTS.md`
|
||||
- `.codex/rules/`
|
||||
@@ -223,89 +345,6 @@ See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
---
|
||||
|
||||
## Optional: VS Code extension
|
||||
|
||||
**AgentHub** is also available as a dedicated VS Code extension for seamless, integrated monitoring.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
### Features
|
||||
- **Real-time Sidebar**: Monitor agent status, health, and usage stats in the Activity Bar.
|
||||
- **Pulse Status Bar**: High-level session and agent counts in the bottom status bar.
|
||||
- **Direct Navigation**: Jump to specific dashboard pages or recent sessions.
|
||||
- **Embedded Dashboard**: Full dashboard interface within a native VS Code tab.
|
||||
|
||||
### Installation
|
||||
1. Open the [vscode-extension](./vscode-extension) folder in VS Code.
|
||||
2. Install via the Marketplace or package it manually:
|
||||
```bash
|
||||
cd vscode-extension
|
||||
npm install
|
||||
# Generate .vsix for local install
|
||||
npm run package
|
||||
```
|
||||
3. After installation, ensure the main dashboard server is running (`npm run dev`).
|
||||
4. Look for the **Radar icon** in your VS Code Activity Bar.
|
||||
|
||||
For advanced configuration, refer to the [.vscode](./.vscode) and [vscode-extension](./vscode-extension) directories.
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
---
|
||||
|
||||
## Container mode (Docker / Podman)
|
||||
|
||||
The repository includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. Docker and Podman are both supported.
|
||||
|
||||
### Compose
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
```
|
||||
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
### Plain Docker / Podman
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
### Container notes
|
||||
|
||||
| Mount | Purpose |
|
||||
|---|---|
|
||||
| `~/.claude:/root/.claude:ro` | Lets the server import legacy Claude session history |
|
||||
| `~/.claude/agenthub:/app/data` | **Canonical SQLite database** (shared with native installs) |
|
||||
|
||||
> [!NOTE]
|
||||
> The image sets `DASHBOARD_HOST=0.0.0.0` and `DASHBOARD_DATA_DIR=/app/data` internally (baked into the `Dockerfile`). The `0.0.0.0` bind is required because a container's loopback is a separate namespace the published port cannot reach; `/app/data` is bind-mounted to `~/.claude/agenthub` on the host so Docker uses the same database as `npm start`. The trust boundary is the **host** port publish — the examples use `-p 127.0.0.1:4820:4820`, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Claude Code hooks run on the host, not inside the container. After the container is healthy on `http://localhost:4820`, run `npm run install-hooks` on the host so Claude Code posts hook events back to the containerized server. The installer refuses to run inside a container (issue #193) to avoid writing a container-internal handler path into a bind-mounted `~/.claude`; use `AGENTHUB_ALLOW_CONTAINER_HOOKS=1` only if you run Claude Code inside the same container.
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `npm run setup` shows `better-sqlite3` errors
|
||||
@@ -346,6 +385,27 @@ npm run setup
|
||||
|
||||
The Vite dev server and Express server run on different ports. Make sure both are running (`npm run dev` starts both). Open **http://localhost:5173**, not `http://localhost:4820`, during development.
|
||||
|
||||
### "Start Run" fails, or the terminal never attaches
|
||||
|
||||
The run console is tmux-backed. Check, in this order:
|
||||
|
||||
```bash
|
||||
tmux -V # not installed → see Step 0
|
||||
curl -s localhost:4820/api/run/tmux # {"available":false} → the SERVER cannot see tmux
|
||||
curl -s "localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the server runs with
|
||||
a different `PATH` — most often a systemd `--user` service, which does not read
|
||||
your shell profile. Fix it by installing tmux system-wide (`/usr/bin/tmux`)
|
||||
rather than into a shell-managed prefix, then `agenthub service uninstall &&
|
||||
agenthub service install`.
|
||||
|
||||
The same `PATH` rule applies to `claude` and `codex`: a run starts the binary by
|
||||
name, so a `claude` installed only through an interactive shell's profile is
|
||||
invisible to a service-managed server. `{"found":false}` for the lane's tool is
|
||||
that case.
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
||||
|
||||
@@ -94,30 +94,6 @@ import-history: ## Import sessions from ~/.claude/ history
|
||||
clear-data: ## Delete all data rows (preserves schema)
|
||||
node scripts/clear-data.js
|
||||
|
||||
# ─── Docker / Podman ───────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: docker-build
|
||||
docker-build: ## Build dashboard Docker image
|
||||
docker build -t claude-agent-monitor:local .
|
||||
|
||||
.PHONY: docker-up
|
||||
docker-up: ## Start via docker-compose
|
||||
docker compose up -d
|
||||
|
||||
.PHONY: docker-down
|
||||
docker-down: ## Stop docker-compose stack
|
||||
docker compose down
|
||||
|
||||
.PHONY: docker-logs
|
||||
docker-logs: ## Tail docker-compose logs
|
||||
docker compose logs -f
|
||||
|
||||
|
||||
.PHONY: podman-build
|
||||
podman-build: ## Build dashboard Podman image
|
||||
podman build -t localhost/claude-agent-monitor:local .
|
||||
|
||||
|
||||
# ─── Help ───────────────────────────────────────────────────────────────────
|
||||
|
||||
.PHONY: help
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
|
||||
parallel agent work through a pipeline; and the `agenthub` CLI drives real terminal
|
||||
sessions, lane runtimes, and Claude Code's own configuration.
|
||||
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
|
||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, workspace runtimes, and Claude Code's own configuration.
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
every decision.
|
||||
@@ -13,54 +13,130 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
|
||||
## What it does
|
||||
## Main features
|
||||
|
||||
**Observe**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. A lane
|
||||
moves through pipeline stages and the dashboard shows where it is.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
that never calls `agenthub stage` still shows progress — rendered dashed amber and
|
||||
never as done, because an inference is not evidence.
|
||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||
targets that fire on them.
|
||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||
startup; archives from another machine come in through Settings → Import
|
||||
History (idempotent, never double-counts tokens).
|
||||
- **Remote sources.** Pull session history from other machines over SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Drive**
|
||||
|
||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a workspace moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
|
||||
setting.
|
||||
- **Analytics (in-dashboard tab), alerts, and a workflow view**, plus a CLI.
|
||||
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
||||
changed files, a full review of every diff, or one file at a time, comparing
|
||||
either uncommitted work or the whole branch against its base. There is no
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
||||
|
||||
## Requirements
|
||||
**Configure**
|
||||
|
||||
Node **>= 20** (`engines` in `package.json`). Node **24** is what the test suites
|
||||
are verified on — node 25 currently breaks 6 server tests through a
|
||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
||||
other its conversation with a prompt box — sending a message resumes it,
|
||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||
prompt box that starts a new session, Recents, and the workspace's own
|
||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
||||
sessions start with.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||
untracked worktrees a workspace's Add folder suggests.
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
Per-browser only.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
Pop, Alert — off by default) played with every notification shown, and each
|
||||
notification type can pick its own.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Need | Version | Required for |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||
change.
|
||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||
v26.8.1).
|
||||
|
||||
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
|
||||
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
|
||||
`superpowers:brainstorming`, `writing-plans`, `test-driven-development`,
|
||||
`requesting-code-review` and `verification-before-completion` phase by phase,
|
||||
and both built-in pipeline templates name those same slash commands as their
|
||||
per-node drivers. Without it the dashboard, hooks and CLI all work
|
||||
normally — the two pipeline skills are what stop partway. `/agenthub-doctor` reports
|
||||
it as a `WARN` row.
|
||||
### Installing tmux
|
||||
|
||||
**You do not normally have to install it.** The plugin's `SessionStart`
|
||||
bootstrap installs it for you, once, in its detached worker — so a session never
|
||||
waits on the clone, and the current session needs `/reload-plugins` before the
|
||||
skills resolve (any later session picks them up on its own). The outcome is
|
||||
logged to `~/.claude/agenthub/runtime/bootstrap.log`, and `/agenthub-update`
|
||||
retries a failed one. To install it by hand instead, or after opting out:
|
||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
||||
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Set `AGENTHUB_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the
|
||||
plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed
|
||||
this way — `superpowers` today. A custom pipeline template naming some other
|
||||
plugin's command is *reported* by `/agenthub-doctor` and never installed, because a
|
||||
template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`.
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
## Install as a Claude Code plugin
|
||||
|
||||
@@ -68,8 +144,8 @@ Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
`npm run setup`:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
||||
@@ -83,7 +159,7 @@ Details, including what to delete on uninstall:
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root, client and vscode-extension dependencies
|
||||
npm run setup # root and client dependencies
|
||||
npm run build # builds the client into client/dist
|
||||
npm start # serves the built client and the API on :4820
|
||||
```
|
||||
@@ -105,19 +181,20 @@ every event is counted twice.
|
||||
### Run as a service (auto-start on boot)
|
||||
|
||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||
process) is alive — a reboot loses it. On Linux, `agenthub service install` sets
|
||||
up a systemd `--user` service instead, so `:4820` is up again on its own after
|
||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||
to trigger it:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
`agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. Plugin installs get the same persistence automatically — see
|
||||
@@ -132,80 +209,102 @@ agenthub status # is the dashboard up
|
||||
agenthub start # start it in the background and wait for healthy
|
||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
||||
agenthub sessions # recent sessions
|
||||
agenthub lanes # lanes with stage and progress
|
||||
agenthub lanes pipeline # this lane's pipeline template, or switch it
|
||||
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
# (in a session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub workspaces # workspaces with stage and progress
|
||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
|
||||
`agenthub --help` lists the rest.
|
||||
|
||||
## Lanes
|
||||
## Workspaces
|
||||
|
||||
A lane is a working directory the dashboard watches. Two kinds:
|
||||
A workspace is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It owns
|
||||
the full lifecycle and may reset or remove it, behind a three-check destroy
|
||||
guard and a counted preflight the caller has to echo back.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
```
|
||||
|
||||
Every lane also carries the CLI that drives it — `claude` (the default) or
|
||||
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
|
||||
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex emits none of this
|
||||
dashboard's hooks, so a Codex lane records its runs but stays absent from
|
||||
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
|
||||
also **advances the declared stage** — the one place an inference moves the blue
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Three things help it get there: the run console
|
||||
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
|
||||
from its own pipeline nodes, `agenthub lanes agents install` writes the same
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
minutes.
|
||||
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
||||
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
||||
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
||||
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
||||
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
||||
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
||||
it asks you to trust them (**Trust all and continue**), and from then on each
|
||||
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
||||
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
||||
card and as a notification while Codex waits for an approval. A Codex workspace also
|
||||
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
||||
advanced; without hooks the dashboard mirrors its running/idle status from the
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
||||
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||
|
||||
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
||||
usage are read from agy's own conversation files
|
||||
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
||||
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
||||
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
||||
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
||||
the same tab, add a live feed: each tool call in the session's timeline and a
|
||||
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
||||
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
||||
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
||||
on an API error or a permission refused in headless mode, so a session gone
|
||||
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
||||
for a session the hooks report on (a long tool call writes nothing, and after a
|
||||
Stop agy may still be waiting for you), after two minutes for one without
|
||||
hooks. Antigravity workspaces carry no pipeline either.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||
Further development is planned.
|
||||
|
||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
||||
move backwards between work sessions. Detection never writes the declared stage,
|
||||
and an inferred node never renders as done.
|
||||
|
||||
A lane's pipeline template can be switched after creation — `agenthub lanes
|
||||
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||
the lane's title in the Workspace detail panel. Both re-resolve the lane's
|
||||
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
||||
current declared stage against the new template's nodes and warn if it no
|
||||
longer matches one.
|
||||
|
||||
A lane can also run **its own application stack**, isolated per lane, when its
|
||||
A workspace can also run **its own application stack**, isolated per workspace, when its
|
||||
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
||||
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
|
||||
ports and per-lane directories derive from it:
|
||||
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
||||
ports and per-workspace directories derive from it:
|
||||
|
||||
```bash
|
||||
agenthub lanes up # boot the stack of the lane owning this directory
|
||||
agenthub lanes runtime # slot, ports, service health
|
||||
agenthub lanes logs api # tail a service log
|
||||
agenthub lanes down
|
||||
agenthub workspace up # boot the stack of the lane owning this directory
|
||||
agenthub workspace runtime # slot, ports, service health
|
||||
agenthub workspace logs api # tail a service log
|
||||
agenthub workspace down
|
||||
```
|
||||
|
||||
Services are fully detached, so restarting the dashboard never stops a running
|
||||
lane. This is resource namespacing on the host, not a container: lanes run as the
|
||||
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
||||
same user and share the network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||
`POST /:id/git/:action`), and the full
|
||||
runtime/profile contract.
|
||||
|
||||
## Tests
|
||||
@@ -221,16 +320,16 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `server/` | Express API, SQLite schema, hook ingest, lane and worktree libraries |
|
||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, lanes, database |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||
|
||||
## Docs
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
||||
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
||||
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
|
||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
|
||||
+140
-50
@@ -2,9 +2,9 @@
|
||||
|
||||
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
|
||||
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
|
||||
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
|
||||
công việc song song của agent qua một pipeline; còn CLI `agenthub` điều khiển phiên
|
||||
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
|
||||
Express + SQLite và React UI cập nhật qua WebSocket; **workspace** theo dõi
|
||||
công việc song song của agent, mỗi working directory một workspace; còn CLI `agenthub` điều khiển phiên
|
||||
terminal thật, runtime của workspace, và cả cấu hình của chính Claude Code.
|
||||
|
||||
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
|
||||
mọi quyết định.
|
||||
@@ -13,27 +13,111 @@ Internal build — all rights reserved.
|
||||
|
||||
*(Bản dịch tiếng Việt của [README.md](README.md), tham khảo — README.md gốc là bản chính thức.)*
|
||||
|
||||
## Làm được gì
|
||||
## Chức năng chính
|
||||
|
||||
**Quan sát**
|
||||
|
||||
- **Session, agent, event.** Mọi thứ Claude Code emit ra, ghi lại và tìm kiếm
|
||||
được: tool call, token usage, cost, cây subagent, transcript.
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
|
||||
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
|
||||
session không bao giờ gọi `agenthub stage` vẫn hiển thị progress — render dạng
|
||||
- **Analytics và chi phí.** Token và cost ước tính theo từng model, với bảng
|
||||
pricing sửa được; top tool, agent type và workflow pattern.
|
||||
- **Activity feed và alert.** Luồng event trực tiếp, alert rule, và webhook
|
||||
target bắn khi rule khớp.
|
||||
- **Import lịch sử.** Mọi session dưới `~/.claude/projects/` được import lúc
|
||||
khởi động; lịch sử từ máy khác đưa vào qua Settings → Import History
|
||||
(idempotent, không bao giờ đếm trùng token).
|
||||
- **Remote source.** Kéo lịch sử session từ máy khác qua SSH
|
||||
(`agenthub remote-sources`).
|
||||
|
||||
**Điều khiển**
|
||||
|
||||
- **Workspace.** Mỗi working directory một workspace, sống sót qua session restart. Khi bạn
|
||||
cài một pipeline template (không có template nào đi kèm), workspace đi qua các
|
||||
stage của nó và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
|
||||
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
|
||||
progress — render dạng
|
||||
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
|
||||
không phải bằng chứng.
|
||||
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
|
||||
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
|
||||
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
|
||||
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane.
|
||||
- **Analytics (tab trong dashboard), alert và workflow view**, cộng thêm CLI.
|
||||
`agenthub workspace shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
|
||||
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
|
||||
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
workspace được cấp slot, port và service riêng (`agenthub workspace up|down|logs`).
|
||||
|
||||
## Yêu cầu
|
||||
**Cấu hình**
|
||||
|
||||
Node **>= 20** (`engines` trong `package.json`). Node **24** là bản test suite
|
||||
được verify — node 25 hiện đang làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`.
|
||||
- **Workspace như project của Claude Desktop.** Sidebar liệt kê mọi workspace
|
||||
kèm các session của nó (session còn sống mở đúng tmux pane, session khác mở
|
||||
transcript — không bao giờ resume). Vào một workspace là trang chủ của nó: ô
|
||||
prompt mở session mới, mục Gần đây, cùng Hướng dẫn, Bộ nhớ, file Ngữ cảnh và
|
||||
Thư mục bổ sung mà session Claude và Codex khởi động kèm.
|
||||
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
|
||||
trái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
|
||||
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
|
||||
những worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace.
|
||||
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
|
||||
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
|
||||
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
||||
Code biết — đọc được tất cả, còn sửa/tạo/xóa thì chỉ cho các bề mặt dạng file
|
||||
text (skill, agent, command, output style, `CLAUDE.md`). Plugin, MCP, hook và
|
||||
`settings.json` để read-only: chúng tranh ghi với CLI đang chạy.
|
||||
- **Cập nhật.** Phát hiện bản mới dựa trên release, kèm nút **Update all** ngay
|
||||
trong UI (marketplace → mọi plugin → AgentHub sau cùng → reload session).
|
||||
- **CLI cho toàn bộ** (`agenthub`, và `agenthub repl`), cài được như PWA, UI có
|
||||
tiếng Anh, Việt, Trung và Hàn.
|
||||
|
||||
## Yêu cầu hệ thống
|
||||
|
||||
| Cần | Phiên bản | Dùng cho |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
|
||||
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub workspace ci status` |
|
||||
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
|
||||
|
||||
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
|
||||
`better-sqlite3` và 20 test client do thay đổi global `localStorage`. Tới 26 thì
|
||||
cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
|
||||
|
||||
### Cài tmux
|
||||
|
||||
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
|
||||
thì **Start Run** và `agenthub workspace shell` sẽ lỗi. Phần còn lại (session,
|
||||
analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
tmux -V # ví dụ: tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
|
||||
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub workspace
|
||||
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
|
||||
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
|
||||
|
||||
## Cài như Claude Code plugin
|
||||
|
||||
@@ -41,8 +125,8 @@ Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không c
|
||||
`npm run setup`:
|
||||
|
||||
```
|
||||
/plugin marketplace add Smartgift-AI/Claude-Code-Monitor
|
||||
/plugin install agenthub@claude-code-agent-monitor-plugins
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
Lần session-start đầu tiên sẽ cài hooks, boot server, đưa `agenthub` vào PATH và
|
||||
@@ -55,7 +139,7 @@ cần xóa lúc uninstall: [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
## Cài từ checkout
|
||||
|
||||
```bash
|
||||
npm run setup # cài dependency cho root, client và vscode-extension
|
||||
npm run setup # cài dependency cho root và client
|
||||
npm run build # build client vào client/dist
|
||||
npm start # serve client đã build + API trên :4820
|
||||
```
|
||||
@@ -105,19 +189,18 @@ agenthub status # dashboard có đang chạy không
|
||||
agenthub start # start ngầm (background) và chờ tới khi healthy
|
||||
agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux)
|
||||
agenthub sessions # session gần đây
|
||||
agenthub lanes # danh sách lane kèm stage và progress
|
||||
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
|
||||
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
|
||||
# (trong session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub workspaces # danh sách workspace kèm stage và progress
|
||||
agenthub workspace pipeline # pipeline template của lane này, hoặc đổi nó
|
||||
agenthub workspace node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
|
||||
agenthub stage <name> # khai báo stage hiện tại của lane
|
||||
agenthub tail # xem live event feed
|
||||
```
|
||||
|
||||
`agenthub --help` liệt kê phần còn lại.
|
||||
|
||||
## Lane
|
||||
## Workspace
|
||||
|
||||
Lane là một working directory mà dashboard theo dõi. Hai loại:
|
||||
Workspace là một working directory mà dashboard theo dõi. Hai loại:
|
||||
|
||||
- **adopted** (nhận nuôi) — một thư mục bạn đã có sẵn. Dashboard chỉ đọc nó;
|
||||
không bao giờ reset hay xóa.
|
||||
@@ -127,46 +210,53 @@ Lane là một working directory mà dashboard theo dõi. Hai loại:
|
||||
gọi phải echo lại.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
```
|
||||
|
||||
Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
|
||||
chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel
|
||||
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
|
||||
chọn trong modal Add workspace và cố định suốt vòng đời của workspace. Workspace Codex có panel
|
||||
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
|
||||
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
|
||||
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
|
||||
**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa
|
||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms,
|
||||
mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính
|
||||
session đó mới gọi được `agenthub stage`.
|
||||
workspace Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
|
||||
Workspace Codex cũng **không có pipeline** — không stage, không pipeline map, không
|
||||
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
|
||||
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
|
||||
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
|
||||
|
||||
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
|
||||
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
|
||||
`DASHBOARD_PIPELINES_DIR` (xem
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
|
||||
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
|
||||
này sẽ được phát triển tiếp.
|
||||
|
||||
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
|
||||
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
|
||||
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên workspace có
|
||||
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
|
||||
báo, và node suy luận không bao giờ render thành `done`.
|
||||
|
||||
Pipeline template của lane có thể đổi sau khi tạo — `agenthub lanes
|
||||
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub workspace
|
||||
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
|
||||
cạnh tiêu đề lane trong panel chi tiết của trang Workspace. Cả hai đều
|
||||
re-resolve lại stage đã khai báo hiện tại của lane theo node-list của template
|
||||
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
|
||||
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
|
||||
mới, và cảnh báo nếu nó không còn khớp node nào.
|
||||
|
||||
Lane cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
|
||||
lane, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
|
||||
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi lane
|
||||
được cấp một slot, port và thư mục riêng theo lane được suy ra từ đó:
|
||||
Workspace cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
|
||||
workspace, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
|
||||
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi workspace
|
||||
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
|
||||
|
||||
```bash
|
||||
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại
|
||||
agenthub lanes runtime # slot, port, tình trạng service
|
||||
agenthub lanes logs api # tail log của một service
|
||||
agenthub lanes down
|
||||
agenthub workspace up # boot stack của lane sở hữu thư mục hiện tại
|
||||
agenthub workspace runtime # slot, port, tình trạng service
|
||||
agenthub workspace logs api # tail log của một service
|
||||
agenthub workspace down
|
||||
```
|
||||
|
||||
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
|
||||
lane đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
||||
container: các lane chạy chung user và share network.
|
||||
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
||||
container: các workspace chạy chung user và share network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
|
||||
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
|
||||
@@ -186,16 +276,16 @@ thêm Prettier.
|
||||
|
||||
| Đường dẫn | Là gì |
|
||||
|---|---|
|
||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
|
||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
||||
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, lanes, database |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
||||
|
||||
## Tài liệu
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
|
||||
- [`docs/LANES.md`](docs/LANES.md) — lane, pipeline, stage detection
|
||||
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
|
||||
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
|
||||
@@ -12,7 +12,7 @@ Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
||||
Browser ← WebSocket broadcast ← Express server ← SQLite
|
||||
```
|
||||
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup. Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
|
||||
---
|
||||
|
||||
@@ -48,48 +48,11 @@ npm run install-hooks
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Container note: do not rely on hook auto-install from inside Docker or Podman. The hook path written by a container would point at the container filesystem, not the host. Start the container first, then run `npm run install-hooks` on the host. As a safeguard (issue #193), the installer now **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`; the containerized server logs the same guidance instead of silently writing a bad path. If you genuinely run Claude Code inside the same container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||
|
||||
> [!NOTE]
|
||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||
|
||||
### Container runtime (Docker / Podman)
|
||||
|
||||
The repo includes both a multi-stage `Dockerfile` and a `docker-compose.yml` file. The container image serves the built client and API on port `4820`, stores SQLite data under `/app/data`, and can import legacy Claude history from a read-only `~/.claude` mount.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose up -d --build
|
||||
|
||||
# Podman Compose
|
||||
CLAUDE_HOME="$HOME/.claude" podman compose up -d --build
|
||||
|
||||
# Plain Docker
|
||||
docker build -t agent-monitor .
|
||||
docker run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v agent-monitor-data:/app/data \
|
||||
agent-monitor
|
||||
|
||||
# Plain Podman
|
||||
podman build -t agent-monitor .
|
||||
podman run -d --name agent-monitor \
|
||||
-p 127.0.0.1:4820:4820 \
|
||||
-v "$HOME/.claude:/root/.claude:ro" \
|
||||
-v "$HOME/.claude/agenthub:/app/data" \
|
||||
agent-monitor
|
||||
```
|
||||
|
||||
Container-specific behavior:
|
||||
|
||||
- The dashboard is available at `http://localhost:4820`
|
||||
- The image sets `DASHBOARD_HOST=0.0.0.0` (bind inside the container — its loopback is a separate namespace the published port cannot reach) and `DASHBOARD_DATA_DIR=/app/data` internally; both are baked into the `Dockerfile`
|
||||
- The examples publish on `127.0.0.1` only, so the dashboard is local-only. To expose it on a LAN, publish on `0.0.0.0` (`-p 4820:4820`) **and** set `DASHBOARD_TOKEN`
|
||||
- `~/.claude:/root/.claude:ro` is used for history import only
|
||||
- `~/.claude/agenthub:/app/data` is the **canonical** SQLite database (shared with native installs)
|
||||
- Claude Code hooks still execute on the host, so install them from the host with `npm run install-hooks`
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -136,39 +99,18 @@ This repository ships extension files for both agent ecosystems:
|
||||
|
||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||
|
||||
### VS Code extension setup
|
||||
|
||||
**AgentHub** is available as an integrated VS Code extension for seamless monitoring within your editor.
|
||||
|
||||
- **Activity Bar View**: Adds a custom "Radar" icon to the activity bar providing real-time agent health, token counts, and session stats.
|
||||
- **Status Bar Integration**: Displays live session and agent pulse counts in the bottom bar.
|
||||
- **Embedded Dashboard**: Renders the full web dashboard directly in a VS Code editor tab.
|
||||
- **Automated Detection**: Automatically finds your dashboard server on ports `5173` or `4820`.
|
||||
|
||||
<p align="center">
|
||||
<img src="vscode-extension/vscode.png" alt="VS Code Extension Screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
To install or develop the extension:
|
||||
1. Open the [vscode-extension](./vscode-extension) directory in VS Code.
|
||||
2. Run `npm install` and `npm run package` to generate a local `.vsix` installer.
|
||||
3. For developer details, see [vscode-extension/README.md](./vscode-extension/README.md).
|
||||
|
||||
> [!TIP]
|
||||
> Extension on VS Code Marketplace: [AgentHub](https://marketplace.visualstudio.com/items?itemName=hoangsonw.claude-code-agent-monitor)
|
||||
|
||||
### PWA configuration (optional)
|
||||
|
||||
The dashboard, landing page, and wiki each ship as independent Progressive Web Apps. No configuration is required — manifests and service workers are included out of the box.
|
||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||
|
||||
**Customising the manifest:** Edit the `manifest.json` in the relevant directory (`client/public/` for dashboard, root for landing, `wiki/` for wiki). Common fields to change:
|
||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||
|
||||
- `name` / `short_name` — displayed on the home screen / dock
|
||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||
- `background_color` — splash screen background
|
||||
- `start_url` — entry point when launched from home screen
|
||||
|
||||
**Updating the service worker cache:** Each SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||
|
||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||
|
||||
@@ -190,7 +132,11 @@ npm run clear-data
|
||||
|
||||
### Data management via Settings page
|
||||
|
||||
The Settings page (`/settings`) provides a UI for:
|
||||
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
||||
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
||||
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
||||
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
||||
hooks in their own tabs.
|
||||
|
||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
||||
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||
@@ -349,16 +295,6 @@ Commonly used targets:
|
||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||
| `make seed` | `npm run seed` | Load demo data |
|
||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||
| `make docker-up` | `docker compose up -d` | Start via docker-compose |
|
||||
| `make docker-down` | `docker compose down` | Stop docker-compose stack |
|
||||
|
||||
---
|
||||
|
||||
## Statusline (optional)
|
||||
|
||||
The `statusline/` directory contains a standalone terminal statusline for Claude Code showing model, working directory, git branch, context window usage, and token counts. It is independent of the web dashboard.
|
||||
|
||||
See [statusline/README.md](./statusline/README.md) for installation instructions.
|
||||
|
||||
---
|
||||
|
||||
@@ -392,6 +328,26 @@ The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock
|
||||
|
||||
---
|
||||
|
||||
### The run console won't start a run / the terminal never attaches
|
||||
|
||||
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
|
||||
|
||||
```bash
|
||||
tmux -V # missing → sudo apt install tmux
|
||||
curl -s http://localhost:4820/api/run/tmux # {"available":true}
|
||||
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
||||
```
|
||||
|
||||
`{"available":false}` while your own shell finds tmux means the **server's**
|
||||
`PATH` differs from yours — typically a systemd `--user` service, which never
|
||||
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
|
||||
re-run `agenthub service uninstall && agenthub service install`. The same
|
||||
applies to `claude` / `codex`: a run launches them by name.
|
||||
|
||||
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
|
||||
|
||||
---
|
||||
|
||||
### No sessions appearing after starting Claude Code
|
||||
|
||||
**Check 1 — Is the server running?**
|
||||
@@ -464,36 +420,3 @@ And make sure Claude Code posts hooks to the new port:
|
||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||
# or edit scripts/hook-handler.js and change the default port
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Docker / Podman container starts but no sessions appear
|
||||
|
||||
**Check 1 — Is the container healthy?**
|
||||
|
||||
```bash
|
||||
curl http://localhost:4820/api/health
|
||||
# Expected: {"status":"ok","timestamp":"..."}
|
||||
```
|
||||
|
||||
**Check 2 — Did you install hooks on the host?**
|
||||
|
||||
Hooks run on the host machine, not inside the container. After the container is up:
|
||||
|
||||
```bash
|
||||
npm run install-hooks
|
||||
```
|
||||
|
||||
**Check 3 — Are hooks pointing to the right port?**
|
||||
|
||||
Open `~/.claude/settings.json` and verify the hook commands reference `localhost:4820` (or whatever port the container is mapped to). If you changed the port mapping, update hooks accordingly.
|
||||
|
||||
---
|
||||
|
||||
### Docker build fails during `npm ci`
|
||||
|
||||
If the build fails in Stage 1 with `better-sqlite3` errors, this is expected and should not block the build — `better-sqlite3` is an optional dependency. If the build still fails:
|
||||
|
||||
- Ensure you are using the latest Dockerfile (it should use `node:22-alpine` and **not** install `python3`, `make`, or `g++`)
|
||||
- Run `docker build --no-cache -t agent-monitor .` to force a clean rebuild
|
||||
- Check that `package.json` has `better-sqlite3` under `optionalDependencies`, not `dependencies`
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
---
|
||||
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
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a backend reviewer for this repository. Review ONLY files under `server/` in the diff you are given — ignore `client/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
Focus on:
|
||||
- Hook event lifecycle correctness.
|
||||
- Session/agent state-machine regressions.
|
||||
- API contract compatibility.
|
||||
- Transaction and persistence correctness.
|
||||
- Missing or weak verification coverage.
|
||||
|
||||
## Output (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `BACKEND-REVIEW: CLEAN`
|
||||
- `BACKEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring backend defect patterns and false-positive traps you find here (e.g. hook shapes that look wrong but aren't, common state-machine edge cases). Future backend-reviewer runs read this.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: frontend-reviewer
|
||||
description: Review React UI changes for behavior regressions, state consistency, and UX breakage.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a frontend reviewer for this repository. Review ONLY files under `client/` in the diff you are given — ignore `server/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
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 (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `FRONTEND-REVIEW: CLEAN`
|
||||
- `FRONTEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring frontend defect patterns and false-positive traps you find here (e.g. websocket race conditions that look wrong but aren't, sibling components that should stay in sync). Future frontend-reviewer runs read this.
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: "qc-local"
|
||||
description: "Local browser-QC agent for the ship-feature-lane pipeline in AgentHub. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `agenthub lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the AgentHub proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
|
||||
model: opus
|
||||
color: green
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically; there is no marker file to check).
|
||||
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).
|
||||
|
||||
Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).
|
||||
|
||||
## Target environment
|
||||
|
||||
- **App**: the lane's own booted frontend — check `agenthub lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: agenthub lanes up --no-build)`.
|
||||
- **QC boot env**: the QC stack boots via `agenthub lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
|
||||
- **Account / login.** Two paths:
|
||||
- **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
|
||||
- **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
|
||||
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
|
||||
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
|
||||
- **Cover every in-scope scenario** in the plan — skip none.
|
||||
- **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
|
||||
- **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
|
||||
- **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
|
||||
- **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
|
||||
- **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
|
||||
- **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
|
||||
- **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
|
||||
- No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
|
||||
2. **Execute with the local-QC MCP's tools**:
|
||||
- Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
|
||||
- After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
|
||||
- Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`agenthub lanes logs <svc>`).
|
||||
- **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
|
||||
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`agenthub lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.
|
||||
|
||||
## Checkpoint & resume *(rate-limits and crashes must not zero your work)*
|
||||
|
||||
- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
|
||||
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
|
||||
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.
|
||||
|
||||
## Quality bar (verify before returning)
|
||||
|
||||
- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
|
||||
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
|
||||
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
|
||||
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
|
||||
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
|
||||
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
|
||||
- [ ] No commits made; password never echoed anywhere.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:
|
||||
|
||||
- `LOCAL-QC: PASS`
|
||||
- `LOCAL-QC: FAIL — <comma-separated reasons>`
|
||||
|
||||
A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: "senior-gate-reviewer"
|
||||
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in AgentHub. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
|
||||
model: opus
|
||||
color: red
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically).
|
||||
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
|
||||
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
|
||||
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
|
||||
- The **`agenthub lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
|
||||
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.
|
||||
|
||||
If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).
|
||||
|
||||
## Your checks
|
||||
|
||||
**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.
|
||||
|
||||
1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.
|
||||
|
||||
2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.
|
||||
|
||||
3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && agenthub lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
|
||||
- **Stack-specific checks (from the active profile):** read `<lane>/.agenthub/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
|
||||
- **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.
|
||||
|
||||
4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
|
||||
- **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
|
||||
- Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
|
||||
- Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
|
||||
If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.
|
||||
|
||||
5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:
|
||||
|
||||
- `VERDICT: GO`
|
||||
- `VERDICT: NO-GO — <comma-separated required fixes>`
|
||||
|
||||
Rules:
|
||||
- Only return `GO` when all three checks pass with high confidence.
|
||||
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
|
||||
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
|
||||
+236
-277
File diff suppressed because it is too large
Load Diff
+81
-22
@@ -19,8 +19,6 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -54,7 +52,18 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||||
- **React Router 6.28** - Client-side routing with nested layouts
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
@@ -67,11 +76,10 @@ graph TB
|
||||
Layout[Layout Component]
|
||||
|
||||
subgraph "Pages"
|
||||
Home[Dashboard<br/>Monitor / Analytics / Health tabs]
|
||||
Home[Dashboard<br/>Monitor / Analytics (incl. Workflow) / Health tabs]
|
||||
Sessions[Sessions]
|
||||
Detail[SessionDetail]
|
||||
Feed[ActivityFeed]
|
||||
Workflows[Workflows]
|
||||
Settings[Settings]
|
||||
end
|
||||
|
||||
@@ -98,7 +106,7 @@ graph TB
|
||||
end
|
||||
|
||||
Router --> Layout
|
||||
Layout --> Home & Sessions & Detail & Feed & Workflows & Settings
|
||||
Layout --> Home & Sessions & Detail & Feed & Settings
|
||||
Home & Detail --> AgentCard & StatCard & StatusBadge
|
||||
Feed --> EventDetail
|
||||
API --> REST
|
||||
@@ -176,17 +184,32 @@ client/
|
||||
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
|
||||
│ │ ├── EmptyState.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), tool filter, search over workspace + session names, row menu
|
||||
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: repo facts, prompt box (new session in a free pane slot), Recents with cost, Instructions / Memory / Context / Folder
|
||||
│ │ ├── lanes/SessionResumeButton.tsx # The transcript page's explicit Resume (own-folder Claude sessions; warns, then a free pane) / Open pane
|
||||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||
│ │ ├── settings/ # Settings panels, among them:
|
||||
│ │ │ ├── SettingsRow.tsx # Row primitives: SettingsSection, SettingsRow, SettingsSwitchRow, SettingsSegmented
|
||||
│ │ │ ├── AiToolsSettings.tsx # Settings → Agents → AI tools: installed/available CLIs, enable/default, install/update
|
||||
│ │ │ ├── WorktreeVisibilitySettings.tsx # Settings → Git → Worktrees: which untracked worktrees the sidebar lists
|
||||
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
|
||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||
│ │ ├── Workflows.tsx
|
||||
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
||||
│ │ ├── Settings.tsx
|
||||
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
|
||||
│ │ └── NotFound.tsx
|
||||
│ │
|
||||
│ ├── lib/ # Core utilities & business logic
|
||||
@@ -199,10 +222,11 @@ client/
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers; a shown one plays its type's sound (`notifPrefs.sound`/`soundByType`, `playSound` in lib/notify.ts, WAVs in public/sounds/ generated by scripts/gen-notification-sounds.js)
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── i18n/ # Internationalization (en / vi)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
||||
@@ -230,11 +254,12 @@ graph TB
|
||||
Sessions[Sessions<br/>filterable table]
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||||
Settings[Settings<br/>pricing + notifications + hooks]
|
||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
Dashboard --> Workflows
|
||||
|
||||
Dashboard --> StatCard[StatCard × 6]
|
||||
Dashboard --> AgentCard[AgentCard × N]
|
||||
@@ -277,7 +302,7 @@ sequenceDiagram
|
||||
|
||||
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
|
||||
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → Overview), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
|
||||
### State Strategy
|
||||
|
||||
@@ -394,8 +419,8 @@ Server broadcasts these event types over WebSocket:
|
||||
| `notification.received` | Notification object | Notification hook |
|
||||
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
|
||||
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
|
||||
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
|
||||
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
|
||||
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a workspace's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
|
||||
| `lane_runtime` | `{ laneId, runtime? , error? }` | A workspace's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
|
||||
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
|
||||
|
||||
### EventBus Pattern
|
||||
@@ -447,11 +472,11 @@ graph TB
|
||||
Sessions["/sessions (tabs: sessions | events)"]
|
||||
Detail["/sessions/:id"]
|
||||
Feed["/activity (redirect)"]
|
||||
Workflows["/workflows"]
|
||||
Workflows["/workflows (redirect → /?tab=analytics&sub=workflow)"]
|
||||
CcConfig["/cc-config (redirect)"]
|
||||
Lanes["/lanes (redirect)"]
|
||||
Run["/run (redirect)"]
|
||||
Workspace["/workspace"]
|
||||
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
|
||||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||||
NF["/* (NotFound)"]
|
||||
|
||||
@@ -478,14 +503,14 @@ function App() {
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Workflows />} />
|
||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||
{/* legacy path - Claude config now lives in a Settings tab */}
|
||||
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
|
||||
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
|
||||
{/* legacy paths - lanes and runs are one page now */}
|
||||
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="workspace" element={<Workspace />} />
|
||||
<Route path="settings" element={<SettingsHub />} />
|
||||
<Route path="settings/*" element={<SettingsHub />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -494,6 +519,8 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
```mermaid
|
||||
@@ -603,6 +630,8 @@ graph TB
|
||||
style Success fill:#10B981
|
||||
```
|
||||
|
||||
A thrown error carries the server's `error.code` as `code` and the HTTP `status` (plus `runId` from a `409 ESESSIONLIVE` start, `auth` from `409 EGITAUTH`). A `200` that is not JSON — the SPA's `index.html` from a proxy or a dev server that missed the route — throws `code: "EBADRESPONSE"` with the translated `errors:badResponse` message instead of `Unexpected token '<'`. Pages surface a failure through `toast` (`lib/toast.ts`, rendered by `components/Toaster.tsx`; an error toast stays until dismissed) rather than `alert()`, and ask for confirmation through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`, rendered by `components/ConfirmHost.tsx`) rather than `window.confirm`.
|
||||
|
||||
---
|
||||
|
||||
## UI Components
|
||||
@@ -728,6 +757,36 @@ flowchart LR
|
||||
|
||||
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
|
||||
|
||||
#### Tooltips (`components/Tooltip.tsx`)
|
||||
|
||||
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
|
||||
app the same designed tooltip — there is no wrapper to add at the call site.
|
||||
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
|
||||
focus (immediately) it takes the element's native `title`, shows it as an
|
||||
anchored bubble with an arrow, and puts the attribute back when the bubble
|
||||
closes. Consequences worth knowing:
|
||||
|
||||
- **Write `title="…"` exactly as before.** That is the whole API. A control
|
||||
keeps its native tooltip, and its accessible name, when JS has not run.
|
||||
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
|
||||
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
|
||||
in light mode; it flips above/below, clamps to the viewport, and closes on
|
||||
scroll, resize, pointer-down or `Escape`.
|
||||
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
|
||||
expanding a truncated **number** inside a line of text, but draws the same
|
||||
surface so the two read as one thing.
|
||||
|
||||
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
|
||||
|
||||
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
|
||||
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
|
||||
behind a **Preview** / **Raw** toggle in two places: the config explorer's
|
||||
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
|
||||
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
|
||||
in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+30
-12
@@ -1,15 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
var d =
|
||||
m === "light"
|
||||
? false
|
||||
: m === "system"
|
||||
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
: true;
|
||||
var r = document.documentElement;
|
||||
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - Claude Code Monitor</title>
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -23,8 +41,8 @@
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta property="og:site_name" content="AgentHub" />
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -33,17 +51,17 @@
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - Claude Code Monitor" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -51,7 +69,7 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Agent Dashboard",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
@@ -61,12 +79,12 @@
|
||||
"name": "SmartGift",
|
||||
"url": "https://smartgift.vn"
|
||||
},
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/Claude-Code-Monitor",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates, CLI statusline"
|
||||
"codeRepository": "https://git.smartgift.io.vn/Smartgift-AI/AgentHub",
|
||||
"featureList": "Real-time dashboard, Kanban board, Session tracking, Activity feed, Token usage analytics, Cost tracking, Browser notifications, WebSocket live updates"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-surface-0 text-gray-200 antialiased">
|
||||
<body class="bg-surface-0 text-fg-primary antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
Generated
+1523
-8
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -14,7 +14,10 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
@@ -23,7 +26,9 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-router-dom": "^6.28.2"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(0,249.335,-249.128,0,125,.332)"><stop stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs><style>.a{fill:url(#g1)}</style><path class="a" d="m84.3 5.1q3.7-1.5 7.7-2.6 3.9-1 7.9-1.6 4-0.5 8.1-0.6 4 0 8 0.5 20.7 2.4 37.1 17.7 0.1 0.1 0.4 0.3 0.1 0 0.2 0 0 0 0.2 0 0 0 0.1 0 0 0 0.1 0 5.2-1.4 10.7-1.9 5.4-0.4 10.7 0.1 5.5 0.4 10.7 1.9 5.2 1.3 10.1 3.6l0.6 0.4 1.6 0.8q5.2 2.5 9.7 6.1 4.7 3.4 8.6 7.7 3.8 4.3 6.9 9.2 3 4.8 5.2 10.2 4.3 10.5 4.3 22.1 0.2 2.1 0 4.2-0.1 2.2-0.2 4.3-0.3 2.1-0.7 4.3-0.4 2.1-0.9 4.1 0 0.2 0 0.4 0 0.2 0 0.5 0 0.1 0.1 0.4 0.1 0.1 0.3 0.3 12.3 12.6 16.3 30 6 29.7-12.2 53.5l-1.9 2.2q-3 3.5-6.5 6.4-3.4 3.1-7.3 5.5-3.8 2.4-8.1 4.2-4.1 1.9-8.5 3.2-0.3 0-0.4 0.2-0.3 0-0.4 0.1-0.1 0.1-0.3 0.4 0 0.1-0.1 0.3c-2.7 7.7-5.3 14.2-10.2 20.7-12.5 16.5-30.8 25.5-51.5 25.5q-24.6-0.1-43.6-18.1-0.2-0.1-0.4-0.2-0.2-0.1-0.4-0.1-0.2 0-0.3 0-0.3 0-0.4 0c-5.4 1.7-10.9 1.9-16.7 1.9q-3.5 0-7-0.5-3.4-0.4-6.9-1.2-3.3-0.8-6.6-2-3.3-1.2-6.4-2.8-3.3-1.6-6.4-3.6-3-2-5.8-4.3-3-2.3-5.5-5-2.5-2.6-4.6-5.6c-2.2-2.7-4.3-5.4-5.8-8.5q-0.8-1.6-1.6-3.2-0.6-1.7-1.3-3.3-0.7-1.7-1.2-3.4-0.5-1.6-1-3.4-1.1-4-1.6-7.9-0.6-4-0.6-8 0-4 0.6-8 0.4-4 1.4-8 0 0 0-0.1 0-0.1 0-0.1 0.2-0.2 0.2-0.3 0-0.1-0.2-0.1 0-0.2 0-0.3 0-0.1-0.1-0.1 0-0.2 0-0.2-0.1-0.1-0.1-0.1-2.4-2.5-4.6-5.2-2.1-2.7-4-5.4-1.7-3-3.2-6-1.5-3.1-2.6-6.3-0.8-2-1.3-4.1-0.7-2-1.1-4-0.4-2.1-0.7-4.2-0.2-2.2-0.4-4.3-0.2-2.8-0.1-5.6 0-2.8 0.3-5.4 0.1-2.8 0.6-5.6 0.4-2.8 1.1-5.5 7-23.1 26.9-36.3 4.3-2.9 8.2-4.5 4.5-1.9 9-3.2 0.2 0 0.3-0.1 0.1-0.2 0.3-0.3 0.1 0 0.1-0.3 0.1-0.1 0.1-0.2 1-3.1 2.2-6 1-2.9 2.5-5.7 1.5-3 3.2-5.6 1.7-2.7 3.7-5.1 2.5-3.2 5.3-5.9 3-2.8 6.1-5.4 3.2-2.4 6.8-4.4 3.5-2 7.2-3.5zm48.3 146.4c-2.3 0.1-4.4 1-6 2.8-1.5 1.6-2.4 3.7-2.4 5.9 0 2.3 0.9 4.4 2.4 6.2 1.6 1.6 3.7 2.5 6 2.6h50.4c2.4 0.1 4.8-0.6 6.5-2.4 1.7-1.6 2.8-4 2.8-6.4 0-2.4-1.1-4.7-2.8-6.3-1.7-1.8-4.1-2.6-6.5-2.4zm-56.7-64.9c-1.2-1.9-3-3.4-5.3-3.9-2.2-0.5-4.5-0.3-6.5 0.9-2 1.1-3.5 3-4.1 5.2-0.7 2.2-0.4 4.6 0.6 6.5l17.7 30.9-17.5 29.5c-1.2 2-1.6 4.5-1.1 6.8 0.7 2.3 2.1 4.1 4.1 5.3 2 1.2 4.4 1.6 6.7 0.9 2.2-0.5 4.2-1.9 5.4-3.9l20.1-34.1q0.7-0.9 0.9-2.1 0.3-1.1 0.3-2.3 0-1.2-0.3-2.2-0.2-1.2-0.8-2.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 250 250" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
|
||||
<path d="M0,4.391C-0,1.982 1.982,0 4.391,0C4.391,0 19.609,-0 19.609,-0C22.018,-0 24,1.982 24,4.391C24,4.391 24,19.609 24,19.609C24,22.018 22.018,24 19.609,24C19.609,24 4.391,24 4.391,24C1.982,24 -0,22.018 -0,19.609C-0,19.609 0,4.391 0,4.391Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
|
||||
<path d="M19.74,1.444C21.285,1.444 22.556,2.715 22.556,4.26L22.556,19.74C22.556,21.285 21.285,22.556 19.74,22.556L4.26,22.556C2.715,22.556 1.444,21.285 1.444,19.74L1.444,4.26C1.444,2.715 2.715,1.444 4.26,1.444L19.74,1.444ZM7.236,8.564L14.988,12.292L7.236,16.019L7.236,18.821L16.793,14.225L16.793,10.359L7.236,5.763L7.236,8.564Z" style="fill:rgb(30,30,46);"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-24,9.907,-9.907,-24,24,6.587)"><stop offset="0" style="stop-color:rgb(238,77,93);stop-opacity:1"/><stop offset="0.33" style="stop-color:rgb(179,129,221);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(32,124,254);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,124,254);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(288.354,233.302,-248.835,307.552,-6.865,4.771)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g2" x2="1" href="#P" gradientTransform="matrix(44.906,54.969,-62.556,51.105,0,100.75)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g3" x2="1" href="#P" gradientTransform="matrix(52.229,46.969,-41.209,45.824,201.927,103.677)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient></defs><style>.a{fill:url(#g1)}.b{fill:url(#g2)}.c{fill:url(#g3)}.d{fill:#ff4d4d}.e{fill:#050810}.f{fill:#00e5cc}</style><path class="a" d="m125 26.8c-65.9-0.1-98.9 54.9-98.9 98.9 0 43.9 33 87.9 65.9 98.9v21.9h22v-21.9c0 0 11 4.3 22 0v21.9h22v-21.9c32.9-11 65.9-55 65.9-98.9 0-44-33-99-98.9-99z"/><path class="b" d="m37.1 103.7c-33-11-44 11-33 32.9 11 22 33 11 44-10.9 6.6-15.4 0-22-11-22z"/><path class="c" d="m212.9 103.7c33-11 44 11 33 32.9-11 22-33 11-44-10.9-6.6-15.4 0-22 11-22z"/><path class="d" d="m57.4 19.5c4.9-2.9 10.8-2.4 16.8 0.4 6 2.8 12.7 8.1 20.2 15.5 0.6 0.6 0.9 1.5 0.9 2.3 0 0.9-0.3 1.8-0.9 2.4-0.7 0.6-1.5 0.9-2.4 0.9-0.8 0-1.7-0.3-2.3-0.9-7.2-7.2-13.3-11.9-18.3-14.2-4.9-2.3-8.3-2.1-10.6-0.7-0.8 0.4-1.6 0.4-2.4 0.2-0.9-0.2-1.5-0.7-2-1.5-0.4-0.7-0.5-1.5-0.4-2.3 0.2-0.9 0.7-1.6 1.4-2.1zm118.4 0.4c6-2.8 11.9-3.3 16.8-0.4 0.7 0.5 1.2 1.2 1.4 2.1 0.1 0.8 0 1.6-0.4 2.3-0.5 0.8-1.1 1.3-2 1.5-0.8 0.2-1.6 0.2-2.4-0.2-2.3-1.4-5.7-1.6-10.6 0.7-5 2.3-11.1 7-18.3 14.2-0.6 0.6-1.5 0.9-2.3 0.9-0.9 0-1.7-0.3-2.4-0.9-0.6-0.6-0.9-1.5-0.9-2.4 0-0.8 0.3-1.7 0.9-2.3 7.5-7.4 14.2-12.7 20.2-15.5z"/><path class="e" d="m92 94.9c3.5 0 6.9-1.4 9.4-3.9 2.4-2.5 3.8-5.8 3.8-9.3 0-3.5-1.4-6.9-3.8-9.3-2.5-2.5-5.9-3.9-9.4-3.9-3.5 0-6.8 1.4-9.3 3.9-2.5 2.4-3.9 5.8-3.9 9.3 0 3.5 1.4 6.8 3.9 9.3 2.5 2.5 5.8 3.9 9.3 3.9zm66 0c3.5 0 6.8-1.4 9.3-3.9 2.5-2.5 3.9-5.8 3.9-9.3 0-3.5-1.4-6.9-3.9-9.3-2.5-2.5-5.8-3.9-9.3-3.9-3.5 0-6.9 1.4-9.4 3.9-2.4 2.4-3.8 5.8-3.8 9.3 0 3.5 1.4 6.8 3.8 9.3 2.5 2.5 5.9 3.9 9.4 3.9z"/><path class="f" d="m94.2 85c1.5 0 2.9-0.6 3.9-1.6 1-1 1.6-2.4 1.6-3.9 0-1.5-0.6-2.9-1.6-3.9-1-1-2.4-1.6-3.9-1.6-1.4 0-2.8 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.8 1.6 3.9 1.1 1 2.5 1.6 3.9 1.6zm66 0c1.4 0 2.8-0.6 3.8-1.6 1.1-1 1.7-2.4 1.7-3.9 0-1.5-0.6-2.8-1.7-3.9-1-1-2.4-1.6-3.8-1.6-1.5 0-2.9 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.9 1.6 3.9 1 1 2.4 1.6 3.9 1.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -13,15 +13,15 @@
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-1
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window" }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.focus) {
|
||||
if ("focus" in client) {
|
||||
// Reuse an already-open tab instead of stacking a new one — that's
|
||||
// also why `navigate` (same-origin only) beats postMessage: it works
|
||||
// even if the page hasn't wired up a listener for this.
|
||||
if (url && "navigate" in client) client.navigate(url).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (url && clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+89
-15
@@ -40,10 +40,9 @@
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/History`
|
||||
* - `./pages/SessionDetail`
|
||||
* - `./pages/Workflows`
|
||||
* - `./pages/SettingsHub`
|
||||
* - `./pages/Workspace`
|
||||
* - `./pages/SessionDetail` (lazy)
|
||||
* - `./pages/SettingsHub` (lazy)
|
||||
* - `./pages/Workspace` (lazy)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -55,23 +54,58 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useCallback } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams, useLocation } from "react-router-dom";
|
||||
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { DocumentTitle } from "./components/DocumentTitle";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
import { Workflows } from "./pages/Workflows";
|
||||
import { SettingsHub } from "./pages/SettingsHub";
|
||||
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";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
// visit instead of in the initial bundle.
|
||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||
const SettingsHub = lazy(() =>
|
||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||
);
|
||||
const SessionDetail = lazy(() =>
|
||||
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
|
||||
);
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div role="status" className="flex items-center justify-center py-20 text-sm text-fg-muted">
|
||||
{t("common:loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<RouteErrorBoundary resetKey={pathname}>
|
||||
<Suspense fallback={<PageFallback />}>{children}</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
return <SessionDetail key={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application root component mounted by {@link main.tsx}.
|
||||
* @returns Routed dashboard UI inside `BrowserRouter`.
|
||||
@@ -84,26 +118,66 @@ export default function App() {
|
||||
const { connected } = useWebSocket(onMessage);
|
||||
useNotifications();
|
||||
|
||||
// A self-update replaced the hashed assets this tab still references: Vite
|
||||
// reports the failed preload here; reload once (guarded against loops).
|
||||
useEffect(() => {
|
||||
const onPreloadError = (e: Event) => {
|
||||
if (reloadOnce()) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("vite:preloadError", onPreloadError);
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
{/* One renderer gives every `title` in the app the designed bubble —
|
||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||
<GlobalTooltip />
|
||||
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
|
||||
<Toaster />
|
||||
<ConfirmHost />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
<Page>
|
||||
<SessionDetailRoute />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Workflows />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
element={<Navigate to="/?tab=analytics&sub=workflow" replace />}
|
||||
/>
|
||||
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
|
||||
<Route
|
||||
path="cc-config"
|
||||
element={<Navigate to="/settings?tool=claude-code" replace />}
|
||||
element={<Navigate to="/settings/agents/claude-code" replace />}
|
||||
/>
|
||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="workspace" element={<Workspace />} />
|
||||
<Route path="settings" element={<SettingsHub />} />
|
||||
<Route
|
||||
path="workspace"
|
||||
element={
|
||||
<Page>
|
||||
<Workspace />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
<Page>
|
||||
<SettingsHub />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @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.
|
||||
* @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; a right-click opens `SessionRowMenu`. It also visually distinguishes active agents with a border highlight.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,8 +49,11 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { useContextMenu } from "./ContextMenu";
|
||||
import { SessionRowMenu } from "./SessionRowMenu";
|
||||
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
||||
import type { Agent, Session } from "../lib/types";
|
||||
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
||||
@@ -80,9 +83,17 @@ interface AgentCardProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
/** Memoized: the Dashboard re-renders on every live update, and a card only
|
||||
* changes with its own agent/session. */
|
||||
export const AgentCard = memo(function AgentCard({
|
||||
agent,
|
||||
session,
|
||||
label,
|
||||
onClick,
|
||||
}: AgentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("kanban");
|
||||
const ctx = useContextMenu<true>();
|
||||
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
||||
const status = effectiveAgentStatus(agent);
|
||||
const isActive = agent.status === "working";
|
||||
@@ -116,15 +127,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
// 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) {
|
||||
const subagentModel = useMemo(() => {
|
||||
if (isMain || !agent.metadata) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(agent.metadata) as { model?: string };
|
||||
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
|
||||
return parsed?.model ? formatModelName(parsed.model) : null;
|
||||
} catch {
|
||||
subagentModel = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [isMain, agent.metadata]);
|
||||
// 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;
|
||||
@@ -140,15 +151,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
// "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) {
|
||||
const sessionTurns = useMemo(() => {
|
||||
if (!isMain || !session?.metadata) return 0;
|
||||
try {
|
||||
const m = JSON.parse(session.metadata) as { turn_count?: number };
|
||||
if (typeof m?.turn_count === "number") sessionTurns = m.turn_count;
|
||||
return typeof m?.turn_count === "number" ? m.turn_count : 0;
|
||||
} catch {
|
||||
sessionTurns = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}, [isMain, session?.metadata]);
|
||||
const subtitle = isMain
|
||||
? [
|
||||
cwdBase,
|
||||
@@ -159,22 +170,27 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
.join(" · ") || null
|
||||
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
navigate(`/sessions/${agent.session_id}`);
|
||||
}
|
||||
const sessionHref = `/sessions/${agent.session_id}`;
|
||||
// The whole card is clickable with the mouse; the title is the focusable
|
||||
// control (a real link, or a button when the caller handles the click).
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
if (onClick) onClick();
|
||||
else navigate(sessionHref);
|
||||
}
|
||||
const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name;
|
||||
const titleClass =
|
||||
"block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => ctx.open(e, true)}
|
||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||
isWaiting
|
||||
? "border-l-2 border-l-yellow-500/60"
|
||||
? "border-l-2 border-l-status-warning/60"
|
||||
: isActive
|
||||
? "border-l-2 border-l-emerald-500/50"
|
||||
? "border-l-2 border-l-status-success/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
@@ -182,19 +198,25 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-sm font-medium text-fg-secondary truncate">
|
||||
{/* 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}
|
||||
</p>
|
||||
{/* 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. */}
|
||||
{onClick ? (
|
||||
<button type="button" onClick={onClick} className={titleClass}>
|
||||
{titleText}
|
||||
</button>
|
||||
) : (
|
||||
<Link to={sessionHref} className={titleClass}>
|
||||
{titleText}
|
||||
</Link>
|
||||
)}
|
||||
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,11 +267,19 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
{timeAgo(agent.updated_at || agent.started_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0">
|
||||
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
|
||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{ctx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: agent.session_id, lane_id: session?.lane_id, cwd: session?.cwd }}
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file AiToolIcon.tsx
|
||||
* @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
|
||||
* when there is one, else
|
||||
* the server's cached favicon (GET /api/ai-tools/:id/icon), else the label's
|
||||
* first letter. Every tool icon in the app renders through here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, type AiTool } from "../lib/api";
|
||||
|
||||
/** Shipped marks by tool id. */
|
||||
const BUNDLED: Record<string, string> = {
|
||||
claude: "/ai-tools/claude.svg",
|
||||
codex: "/ai-tools/codex.svg",
|
||||
gemini: "/ai-tools/gemini.svg",
|
||||
openclaw: "/ai-tools/openclaw.svg",
|
||||
hermes: "/ai-tools/hermes.svg",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param props.tool - The catalog entry.
|
||||
* @param props.className - Size classes (default `h-8 w-8`).
|
||||
*/
|
||||
export function AiToolIcon({
|
||||
tool,
|
||||
className = "h-8 w-8",
|
||||
}: {
|
||||
tool: Pick<AiTool, "id" | "label">;
|
||||
className?: string;
|
||||
}) {
|
||||
// Keyed by id: one instance showing another tool must retry its icon.
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
|
||||
const bundled = BUNDLED[tool.id];
|
||||
if (bundled) return <img src={bundled} alt="" className={box} />;
|
||||
if (failedId !== tool.id) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailedId(tool.id)}
|
||||
className={box}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
|
||||
>
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* • 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.
|
||||
* A right-click on a rule opens its Enable/Disable and Delete as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -69,8 +70,8 @@ import {
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -85,7 +86,9 @@ import { WebhookSettings } from "./WebhookSettings";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -153,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
|
||||
cooldown_seconds: "300",
|
||||
};
|
||||
|
||||
/** A rule's stored values as form fields, for editing it. */
|
||||
function formFromRule(rule: AlertRule): RuleFormState {
|
||||
const c = rule.config;
|
||||
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
|
||||
return {
|
||||
name: rule.name,
|
||||
rule_type: rule.rule_type,
|
||||
event_type: str(c.event_type, ""),
|
||||
tool_name: str(c.tool_name, ""),
|
||||
summary_contains: str(c.summary_contains, ""),
|
||||
count: str(c.count, EMPTY_FORM.count),
|
||||
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
|
||||
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
||||
status: c.status === "waiting" ? "waiting" : "working",
|
||||
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
||||
cooldown_seconds: String(rule.cooldown_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
@@ -215,9 +239,18 @@ export function AlertsNotifications() {
|
||||
const [loadingRules, setLoadingRules] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
|
||||
// The rule being edited, or null while the form creates a new one.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
const ruleCtx = useContextMenu<AlertRule>();
|
||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||
const rule = ruleCtx.menu?.item;
|
||||
ruleCtx.close();
|
||||
if (rule) fn(rule);
|
||||
};
|
||||
|
||||
// Feed
|
||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||
@@ -232,11 +265,11 @@ export function AlertsNotifications() {
|
||||
const res = await api.alerts.rules.list();
|
||||
setRules(res.rules);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alert rules:", err);
|
||||
toast.error(t("errors.loadRules", { error: errText(err) }));
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoadingAlerts(true);
|
||||
@@ -250,11 +283,11 @@ export function AlertsNotifications() {
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alerts:", err);
|
||||
toast.error(t("errors.loadAlerts", { error: errText(err) }));
|
||||
} finally {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly]);
|
||||
}, [unackedOnly, t]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -267,9 +300,9 @@ export function AlertsNotifications() {
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load more alerts:", err);
|
||||
toast.error(t("errors.loadAlerts", { error: errText(err) }));
|
||||
}
|
||||
}, [unackedOnly, alerts.length]);
|
||||
}, [unackedOnly, alerts.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
@@ -291,20 +324,33 @@ export function AlertsNotifications() {
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const onCreateRule = async () => {
|
||||
if (saving) return;
|
||||
const closeForm = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const onSaveRule = async () => {
|
||||
if (saving || !canSubmit) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
await api.alerts.rules.create({
|
||||
const body = {
|
||||
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);
|
||||
cooldown_seconds: cooldownVal,
|
||||
};
|
||||
if (editingId) await api.alerts.rules.update(editingId, body);
|
||||
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
|
||||
closeForm();
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
@@ -318,7 +364,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle alert rule:", err);
|
||||
toast.error(t("errors.toggleRule", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -329,7 +375,8 @@ export function AlertsNotifications() {
|
||||
loadRules();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete alert rule:", err);
|
||||
setConfirmRule(null);
|
||||
toast.error(t("errors.deleteRule", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,7 +385,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.ack(id);
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alert:", err);
|
||||
toast.error(t("errors.ack", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,7 +394,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.ackAll();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alerts:", err);
|
||||
toast.error(t("errors.ack", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,8 +404,12 @@ export function AlertsNotifications() {
|
||||
const tokensVal = parseInt(form.total_tokens, 10);
|
||||
const countVal = parseInt(form.count, 10);
|
||||
const windowVal = parseFloat(form.window_minutes);
|
||||
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
|
||||
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
|
||||
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
|
||||
const canSubmit =
|
||||
form.name.trim().length > 0 &&
|
||||
cooldownOk &&
|
||||
(form.rule_type !== "event_pattern" ||
|
||||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
|
||||
Number.isFinite(countVal) &&
|
||||
@@ -431,8 +482,11 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormOpen((open) => !open);
|
||||
setFormError(null);
|
||||
if (formOpen) closeForm();
|
||||
else {
|
||||
setFormOpen(true);
|
||||
setFormError(null);
|
||||
}
|
||||
}}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
|
||||
>
|
||||
@@ -466,7 +520,9 @@ export function AlertsNotifications() {
|
||||
<select
|
||||
value={form.rule_type}
|
||||
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
disabled={!!editingId}
|
||||
title={editingId ? t("rules.typeFixed") : undefined}
|
||||
className="select w-full"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
@@ -474,7 +530,6 @@ export function AlertsNotifications() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -577,12 +632,11 @@ export function AlertsNotifications() {
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
className="select w-full"
|
||||
>
|
||||
<option value="working">working</option>
|
||||
<option value="waiting">waiting</option>
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
@@ -635,18 +689,25 @@ export function AlertsNotifications() {
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.cooldown_seconds}
|
||||
onChange={(e) => set({ cooldown_seconds: e.target.value })}
|
||||
aria-invalid={!cooldownOk}
|
||||
className="input w-40"
|
||||
/>
|
||||
{!cooldownOk && (
|
||||
<span className="mt-1 block text-[11px] text-status-danger">
|
||||
{t("rules.cooldownInvalid")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
onClick={onCreateRule}
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{saving ? t("rules.saving") : t("rules.create")}
|
||||
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||
@@ -663,12 +724,14 @@ export function AlertsNotifications() {
|
||||
icon={ListChecks}
|
||||
title={t("rules.empty")}
|
||||
description={ts("alertsHub.rulesEmptyHint")}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
onContextMenu={(e) => ruleCtx.open(e, rule)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -699,6 +762,14 @@ export function AlertsNotifications() {
|
||||
>
|
||||
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditRule(rule)}
|
||||
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
|
||||
title={t("rules.edit")}
|
||||
aria-label={t("rules.edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmRule(rule)}
|
||||
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
|
||||
@@ -712,6 +783,23 @@ export function AlertsNotifications() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ruleCtx.menu && (
|
||||
<ContextMenu
|
||||
x={ruleCtx.menu.x}
|
||||
y={ruleCtx.menu.y}
|
||||
onClose={ruleCtx.close}
|
||||
testId="alert-rule-menu"
|
||||
>
|
||||
<MenuItem onSelect={pickRule(onToggleRule)}>
|
||||
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
|
||||
{t("rules.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -766,6 +854,7 @@ export function AlertsNotifications() {
|
||||
icon={unackedOnly ? BellOff : BellRing}
|
||||
title={t("feed.emptyTitle")}
|
||||
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -74,29 +74,54 @@ export interface CheckboxProps {
|
||||
className?: string;
|
||||
/** Classes applied to the label `<span>` when `label` is set. */
|
||||
labelClassName?: string;
|
||||
/** `danger` / `warning` for an opt-in that makes an action riskier
|
||||
* (bypass permissions, purge data, force). */
|
||||
tone?: "accent" | "danger" | "warning";
|
||||
disabled?: boolean;
|
||||
/** Tooltip (the app turns every `title` into its themed bubble). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Fill + tick per tone. Accent fills with `accent-solid` under a white tick.
|
||||
// The status colours are TEXT shades — light in dark mode, dark in light mode —
|
||||
// so their tick takes the page colour (surface-0), which contrasts both ways.
|
||||
const TONE = {
|
||||
accent: "bg-accent-solid border-accent-solid text-white",
|
||||
danger: "bg-status-danger border-status-danger text-surface-0",
|
||||
warning: "bg-status-warning border-status-warning text-surface-0",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Themed checkbox control.
|
||||
* Themed checkbox control — the app's ONE checkbox; never a native
|
||||
* `<input type="checkbox">`, which renders as the browser's white box.
|
||||
* @param props See {@link CheckboxProps}.
|
||||
*/
|
||||
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
tone = "accent",
|
||||
disabled = false,
|
||||
title,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
|
||||
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
||||
checked
|
||||
? "bg-accent border-accent"
|
||||
: "bg-surface-2 border-border group-hover:border-border-light"
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/60 ${
|
||||
checked ? TONE[tone] : "bg-surface-2 border-fg-muted group-hover:border-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
{checked && <Check className="h-3 w-3" strokeWidth={3} aria-hidden />}
|
||||
</span>
|
||||
{label != null && (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file ConfirmHost.tsx
|
||||
* @description Renders the one pending `confirmDialog()` from `lib/confirm.ts`
|
||||
* through `ConfirmModal`. Mounted once in `App.tsx`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../lib/confirm";
|
||||
|
||||
const cancel = () => settleConfirm(false);
|
||||
const confirm = () => settleConfirm(true);
|
||||
|
||||
/** The single host for `confirmDialog()`. */
|
||||
export function ConfirmHost() {
|
||||
const { t } = useTranslation("common");
|
||||
const [p, setP] = useState<PendingConfirm | null>(null);
|
||||
useEffect(() => subscribeConfirm(setP), []);
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={p !== null}
|
||||
title={p?.title ?? ""}
|
||||
message={p?.message}
|
||||
confirmLabel={p?.confirmLabel ?? t("confirm")}
|
||||
cancelLabel={p?.cancelLabel ?? t("cancel")}
|
||||
destructive={p?.destructive ?? true}
|
||||
onConfirm={confirm}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,14 @@
|
||||
*
|
||||
* ## 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.
|
||||
* Escape cancels, and focus restores to the previously focused element on close
|
||||
* (the shared `hooks/useDialog.ts` contract).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { useId, useRef, type ReactNode } from "react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
/** Props for {@link ConfirmModal}. */
|
||||
@@ -71,54 +73,13 @@ export function ConfirmModal({
|
||||
const messageId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(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<HTMLElement>(
|
||||
'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]);
|
||||
useDialog(open, panelRef, onCancel, cancelRef);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -134,7 +95,7 @@ export function ConfirmModal({
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -151,10 +112,10 @@ export function ConfirmModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
|
||||
className="btn-icon -mt-1 -mr-1"
|
||||
aria-label={cancelLabel}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-5 pb-5">
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
|
||||
* in: a fixed list at a viewport point, rendered into `document.body` so no
|
||||
* transformed or clipped ancestor moves it, nudged back inside the viewport
|
||||
* once measured, closed by Escape, a press outside it, or after an item runs.
|
||||
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
|
||||
* turns a mouse event into that point (and stops the browser's own menu).
|
||||
*
|
||||
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
|
||||
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
|
||||
* what held it before the menu opened — unless an item moved it elsewhere
|
||||
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
|
||||
* the Menu key reports clientX/Y 0) anchors under the element instead of the
|
||||
* viewport's corner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
export type MenuPoint = { x: number; y: number };
|
||||
|
||||
/** The pointer of a right-click, the browser's own menu suppressed. A
|
||||
* keyboard-opened menu has no pointer, so it anchors under the element. */
|
||||
export function menuPoint(e: ReactMouseEvent): MenuPoint {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
return { x: r.left, y: r.bottom };
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
/** Menu state for a list of rows: which row's menu is open, and where. */
|
||||
export function useContextMenu<T>() {
|
||||
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
|
||||
const open = useCallback((e: ReactMouseEvent, item: T) => {
|
||||
setMenu({ ...menuPoint(e), item });
|
||||
}, []);
|
||||
const close = useCallback(() => setMenu(null), []);
|
||||
return { menu, open, close };
|
||||
}
|
||||
|
||||
const MARGIN = 8;
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
|
||||
|
||||
export function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
testId,
|
||||
className = "w-56",
|
||||
children,
|
||||
}: MenuPoint & {
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Focus in on open, back out on close. Runs once per mount: a menu that
|
||||
// moves to a new point keeps its focus.
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
ref.current?.querySelector<HTMLElement>(ITEMS)?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
// Only when focus would otherwise be lost (it sat on a removed item).
|
||||
const now = document.activeElement;
|
||||
if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const inField = (e.target as HTMLElement).closest(
|
||||
"input, textarea, select, [contenteditable='true']"
|
||||
);
|
||||
if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
|
||||
// Handled here so a dialog behind the menu does not close as well.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// A field inside a menu (a size slider, a search box) keeps its own keys.
|
||||
if (inField) return;
|
||||
const items = [...(ref.current?.querySelectorAll<HTMLElement>(ITEMS) ?? [])];
|
||||
if (items.length === 0) return;
|
||||
const i = items.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (i + 1) % items.length
|
||||
: e.key === "ArrowUp"
|
||||
? (i - 1 + items.length) % items.length
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? items.length - 1
|
||||
: -1;
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
items[next]!.focus();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
setPos({
|
||||
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
|
||||
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
|
||||
});
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid={testId}
|
||||
style={pos}
|
||||
// A portal still bubbles React events to the component that rendered
|
||||
// it — a menu opened from a clickable card must not click the card too.
|
||||
onClick={stop}
|
||||
onDoubleClick={stop}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export const MENU_ITEM =
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function MenuItem({
|
||||
onSelect,
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator() {
|
||||
return <div role="separator" className="my-1 border-t border-border" />;
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
@@ -52,16 +53,22 @@ interface DateTimePickerProps {
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
title?: string;
|
||||
/** A "to" bound: picking a day with no time yet means the END of that day
|
||||
* (23:59), not its first minute. */
|
||||
endOfDay?: boolean;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
placeholder,
|
||||
className = "",
|
||||
"aria-label": ariaLabel,
|
||||
title,
|
||||
endOfDay = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { t, i18n } = useTranslation("sessions");
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [alignment, setAlignment] = useState<"left" | "right">("left");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -97,9 +104,18 @@ export function DateTimePicker({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplay = (d: Date | null) => {
|
||||
if (!d || isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -112,7 +128,9 @@ export function DateTimePicker({
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
|
||||
} else {
|
||||
newDate.setHours(0, 0); // Default midnight
|
||||
// Default: midnight, or 23:59 for an end bound so the day is included.
|
||||
if (endOfDay) newDate.setHours(23, 59, 59, 999);
|
||||
else newDate.setHours(0, 0);
|
||||
}
|
||||
updateValue(newDate);
|
||||
};
|
||||
@@ -135,7 +153,9 @@ export function DateTimePicker({
|
||||
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}`);
|
||||
// An end bound carries its :59.999 so the whole last minute is included.
|
||||
const tail = endOfDay ? ":59.999" : "";
|
||||
onChange(`${y}-${mo}-${day}T${h}:${mi}${tail}`);
|
||||
};
|
||||
|
||||
const clearValue = (e: React.MouseEvent) => {
|
||||
@@ -168,13 +188,18 @@ export function DateTimePicker({
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDateClick(i)}
|
||||
aria-label={new Date(viewDate.getFullYear(), viewDate.getMonth(), i).toLocaleDateString(
|
||||
locale,
|
||||
{ weekday: "long", year: "numeric", month: "long", day: "numeric" }
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
|
||||
${
|
||||
isSelected
|
||||
? "bg-accent text-white font-medium"
|
||||
? "bg-accent-solid text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
@@ -193,17 +218,27 @@ export function DateTimePicker({
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
title={title}
|
||||
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`}
|
||||
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left ${dateObj ? "pr-7" : ""}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
|
||||
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||
)}
|
||||
</button>
|
||||
{/* A sibling, not a child: a button inside a button is not reachable by keyboard. */}
|
||||
{dateObj && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearValue}
|
||||
aria-label={t("datePicker.clear")}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
@@ -216,19 +251,21 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
aria-label={t("datePicker.prevMonth")}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
||||
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
aria-label={t("datePicker.nextMonth")}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -237,8 +274,15 @@ export function DateTimePicker({
|
||||
{/* Calendar Grid */}
|
||||
<div>
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
|
||||
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
|
||||
{Array.from({ length: 7 }, (_, d) =>
|
||||
new Date(2026, 0, 4 + d).toLocaleDateString(locale, { weekday: "narrow" })
|
||||
).map((day, d) => (
|
||||
<div
|
||||
key={d}
|
||||
aria-hidden
|
||||
className="w-6 text-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
@@ -250,10 +294,11 @@ export function DateTimePicker({
|
||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">Time</span>
|
||||
<span className="text-[11px] font-medium">{t("datePicker.time")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("datePicker.time")}
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
|
||||
|
||||
@@ -24,8 +24,6 @@ export function DocumentTitle() {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/workflows")) {
|
||||
title = t("workflows");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
|
||||
@@ -75,20 +75,26 @@ export interface EmptyStateProps {
|
||||
description: string;
|
||||
/** Optional CTA rendered below the description (button, link, or form). */
|
||||
action?: React.ReactNode;
|
||||
/** Inside a card (a settings panel) rather than a whole page: less padding. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a vertically centered empty state inside the main content column.
|
||||
* @param props See {@link EmptyStateProps}.
|
||||
*/
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
export function EmptyState({ icon: Icon, title, description, action, compact }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
|
||||
<Icon className="w-6 h-6 text-fg-muted" />
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center ${compact ? "py-8" : "py-20"}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-fg-muted" aria-hidden />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
|
||||
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
|
||||
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -277,7 +277,7 @@ function SummaryBlock({
|
||||
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
|
||||
return (
|
||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
||||
<div className="px-3 py-1 border-b border-border bg-black/20">
|
||||
<div className="px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
|
||||
{t("eventDetail.summary")}
|
||||
</span>
|
||||
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||
if (typeof value === "boolean") {
|
||||
const color = value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
|
||||
const text = typeof value === "string" ? value : safeStringify(value);
|
||||
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
|
||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||
</span>
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -114,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
|
||||
to: "",
|
||||
};
|
||||
|
||||
/** URL key per filter field (short, so a shared link stays readable). */
|
||||
const PARAM_OF: Record<keyof EventFiltersValue, string> = {
|
||||
event_type: "et",
|
||||
tool_name: "tn",
|
||||
agent_id: "ag",
|
||||
session_id: "sid",
|
||||
status: "st",
|
||||
q: "q",
|
||||
from: "from",
|
||||
to: "to",
|
||||
};
|
||||
|
||||
/** Filters read from the URL (lists are comma-joined). */
|
||||
export function filtersFromParams(params: URLSearchParams): EventFiltersValue {
|
||||
const list = (k: string) => (params.get(k) ?? "").split(",").filter(Boolean);
|
||||
return {
|
||||
event_type: list(PARAM_OF.event_type),
|
||||
tool_name: list(PARAM_OF.tool_name),
|
||||
agent_id: list(PARAM_OF.agent_id),
|
||||
session_id: list(PARAM_OF.session_id),
|
||||
status: list(PARAM_OF.status),
|
||||
q: params.get(PARAM_OF.q) ?? "",
|
||||
from: params.get(PARAM_OF.from) ?? "",
|
||||
to: params.get(PARAM_OF.to) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A copy of `base` with the filter keys rewritten; empty filters are dropped. */
|
||||
export function filtersToParams(f: EventFiltersValue, base: URLSearchParams): URLSearchParams {
|
||||
const next = new URLSearchParams(base);
|
||||
for (const [field, key] of Object.entries(PARAM_OF) as [keyof EventFiltersValue, string][]) {
|
||||
const v = f[field];
|
||||
const str = Array.isArray(v) ? v.join(",") : v;
|
||||
if (str) next.set(key, str);
|
||||
else next.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isEmptyFilters(f: EventFiltersValue): boolean {
|
||||
return (
|
||||
f.event_type.length === 0 &&
|
||||
@@ -179,13 +219,18 @@ export function EventFilters({
|
||||
tool_names: [],
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState(value.q);
|
||||
// The debounced search merges into the LATEST value: a chip toggled during
|
||||
// the 300 ms must not be reverted by the value captured at keystroke time.
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Debounce text search - only push up after 300ms of inactivity.
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
|
||||
const latest = valueRef.current;
|
||||
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
@@ -247,6 +292,7 @@ export function EventFilters({
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<DateTimePicker
|
||||
endOfDay
|
||||
value={value.to}
|
||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||
aria-label={t("eventFilters.to")}
|
||||
@@ -300,11 +346,10 @@ export function EventFilters({
|
||||
onToggle={(item) => toggle("agent_id", item)}
|
||||
/>
|
||||
)}
|
||||
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
|
||||
<ChipGroup
|
||||
{!hideSessionFilter && (
|
||||
<SessionChipGroup
|
||||
label={t("eventFilters.sessionId")}
|
||||
options={sessionOptions.map((s) => s.id)}
|
||||
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
|
||||
known={sessionOptions}
|
||||
selected={value.session_id}
|
||||
onToggle={(item) => toggle("session_id", item)}
|
||||
/>
|
||||
@@ -314,22 +359,98 @@ export function EventFilters({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session filter: searched on the server (`q`, 20 rows), never a list of
|
||||
* every session. Selected ids stay listed (with the label last seen) so they
|
||||
* can be unticked while the search shows other rows.
|
||||
*/
|
||||
function SessionChipGroup({
|
||||
label,
|
||||
known,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
known?: Array<{ id: string; label: string }>;
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const labels = useRef(new Map<string, string>());
|
||||
for (const k of known ?? []) labels.current.set(k.id, k.label);
|
||||
const [open, setOpen] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const mine = ++seq.current;
|
||||
api.sessions
|
||||
.list({ q: query.trim() || undefined, limit: 20 })
|
||||
.then((res) => {
|
||||
if (mine !== seq.current) return;
|
||||
const next = res.sessions.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.name?.trim() || s.id.slice(0, 8),
|
||||
}));
|
||||
for (const r of next) labels.current.set(r.id, r.label);
|
||||
setRows(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
query ? 250 : 0
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, query]);
|
||||
|
||||
const options = [...selected, ...rows.map((r) => r.id).filter((id) => !selected.includes(id))];
|
||||
return (
|
||||
<ChipGroup
|
||||
label={label}
|
||||
options={options}
|
||||
labels={Object.fromEntries(options.map((id) => [id, labels.current.get(id) ?? id]))}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onOpenChange={setOpen}
|
||||
search={
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("eventFilters.searchPlaceholder")}
|
||||
aria-label={label}
|
||||
className="w-full mb-1 bg-surface-2 border border-border rounded px-2 py-1 text-[11px] text-fg-secondary focus:outline-none focus:border-accent"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGroup({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
labels,
|
||||
onOpenChange,
|
||||
search,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
labels?: Record<string, string>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Rendered above the options (a search box). */
|
||||
search?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
|
||||
|
||||
// Click-outside dismiss.
|
||||
useEffect(() => {
|
||||
@@ -367,6 +488,7 @@ function ChipGroup({
|
||||
aria-label={label}
|
||||
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
|
||||
>
|
||||
{search}
|
||||
{options.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
||||
{t("eventFilters.noOptions")}
|
||||
@@ -376,18 +498,14 @@ function ChipGroup({
|
||||
const checked = selected.includes(opt);
|
||||
const display = labels?.[opt] ?? opt;
|
||||
return (
|
||||
<label
|
||||
<Checkbox
|
||||
key={opt}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="font-mono break-all">{display}</span>
|
||||
</label>
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
label={display}
|
||||
className="flex w-full px-2 py-1 rounded hover:bg-surface-3"
|
||||
labelClassName="font-mono break-all text-[11px] text-fg-secondary"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
|
||||
<p className="text-[11px] text-fg-secondary mb-2">
|
||||
{t("eventFilters.help.lifecycleDesc")}
|
||||
</p>
|
||||
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
<code className="block bg-surface-1 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
{t("eventFilters.help.lifecycleFlow")}
|
||||
</code>
|
||||
</Section>
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -294,7 +295,7 @@ export function ImportHistory() {
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
|
||||
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
|
||||
<ListChecks className="w-3.5 h-3.5 text-accent" />
|
||||
{t("import.instructions")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
|
||||
@@ -426,14 +427,17 @@ export function ImportHistory() {
|
||||
{mode === "path" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder={t("import.folderPlaceholder")}
|
||||
className="input w-full text-sm font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder={t("import.folderPlaceholder")}
|
||||
className="input w-full text-sm font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<PathPickButton start={folderPath} onPick={setFolderPath} />
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
@@ -455,8 +459,8 @@ export function ImportHistory() {
|
||||
|
||||
{mode === "upload" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
<label
|
||||
htmlFor="import-upload-files"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -467,9 +471,9 @@ export function ImportHistory() {
|
||||
setDragging(false);
|
||||
onSelectFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
dragging
|
||||
? "border-blue-500 bg-blue-600/5"
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border hover:border-border-light bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
@@ -477,14 +481,15 @@ export function ImportHistory() {
|
||||
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
|
||||
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
|
||||
<input
|
||||
id="import-upload-files"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
|
||||
onChange={(e) => onSelectFiles(e.target.files)}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-fg-secondary">
|
||||
@@ -522,8 +527,8 @@ export function ImportHistory() {
|
||||
|
||||
{mode === "backup" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => backupInputRef.current?.click()}
|
||||
<label
|
||||
htmlFor="import-backup-file"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -535,9 +540,9 @@ export function ImportHistory() {
|
||||
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 ${
|
||||
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
dragging
|
||||
? "border-blue-500 bg-blue-600/5"
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border hover:border-border-light bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
@@ -545,13 +550,14 @@ export function ImportHistory() {
|
||||
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
|
||||
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
|
||||
<input
|
||||
id="import-backup-file"
|
||||
ref={backupInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{backupFile && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-fg-secondary min-w-0">
|
||||
@@ -591,7 +597,7 @@ export function ImportHistory() {
|
||||
{/* In-flight progress */}
|
||||
{running && progressText && (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent flex-shrink-0" />
|
||||
<span className="truncate">{progressText}</span>
|
||||
{progress?.current && (
|
||||
<code className="font-mono text-[11px] text-fg-muted truncate">
|
||||
@@ -625,7 +631,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-400"
|
||||
color="text-accent"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,12 +678,12 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
color="text-fg-primary"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
value={backupResult.model_pricing}
|
||||
color="text-cyan-300"
|
||||
color="text-cyan-700 dark:text-cyan-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
@@ -728,16 +734,16 @@ function ModeButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
active
|
||||
? "border-blue-600/40 bg-blue-600/10"
|
||||
: "border-border bg-surface-2 hover:bg-surface-3"
|
||||
active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-400" : "text-fg-secondary"
|
||||
active ? "text-accent" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file Layout.tsx
|
||||
* @description Application shell that frames every authenticated route: persistent
|
||||
* sidebar, main content column, update notifier, and the Sagi assistant overlay.
|
||||
* sidebar, main content column, the bottom status bar, update notifier, and the
|
||||
* Sagi assistant overlay. The connection details modal's open state lives here
|
||||
* because both the sidebar's rail button and the status bar open it.
|
||||
* The layout is the single parent route in {@link App} — child pages render inside
|
||||
* React Router's `<Outlet />` so navigation never remounts chrome.
|
||||
*
|
||||
@@ -10,6 +12,12 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Narrow viewports
|
||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||
* button in a slim top bar opens it as an off-canvas drawer (a modal dialog —
|
||||
* backdrop, Escape, focus trapped and restored), which closes on navigation.
|
||||
*
|
||||
* ## 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:
|
||||
@@ -62,12 +70,28 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||
|
||||
function useMedia(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
const mq = window.matchMedia?.(query);
|
||||
mq?.addEventListener?.("change", cb);
|
||||
return () => mq?.removeEventListener?.("change", cb);
|
||||
},
|
||||
() => window.matchMedia?.(query).matches ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for {@link Layout}. */
|
||||
interface LayoutProps {
|
||||
@@ -82,6 +106,19 @@ interface LayoutProps {
|
||||
export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const drawer = useMedia(DRAWER_QUERY);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
const { pathname, search } = useLocation();
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
||||
|
||||
// A pick in the drawer navigates; the drawer has done its job.
|
||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||
useEffect(() => {
|
||||
if (!drawer) setDrawerOpen(false);
|
||||
}, [drawer]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
@@ -100,16 +137,48 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="sidebar-backdrop"
|
||||
onClick={closeDrawer}
|
||||
className="fixed inset-0 z-40 bg-black/50 animate-fade-in"
|
||||
/>
|
||||
)}
|
||||
<Sidebar
|
||||
wsConnected={wsConnected}
|
||||
collapsed={drawer ? false : collapsed}
|
||||
onToggle={drawer ? closeDrawer : toggle}
|
||||
drawer={drawer ? { open: drawerOpen, onClose: closeDrawer } : undefined}
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="min-h-screen min-w-0 transition-[margin-left,width] duration-200 outline-none"
|
||||
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
|
||||
style={{
|
||||
marginLeft: collapsed ? "4.25rem" : "15rem",
|
||||
width: collapsed ? "calc(100% - 4.25rem)" : "calc(100% - 15rem)",
|
||||
marginLeft: sidebarWidth,
|
||||
width: `calc(100% - ${sidebarWidth})`,
|
||||
}}
|
||||
>
|
||||
{drawer && (
|
||||
<div className="sticky top-0 z-20 flex h-11 items-center gap-2 border-b border-border bg-surface-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-open"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label={t("openMenu")}
|
||||
aria-expanded={drawerOpen}
|
||||
title={t("openMenu")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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. */}
|
||||
@@ -117,6 +186,12 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<StatusBar
|
||||
wsConnected={wsConnected}
|
||||
left={sidebarWidth}
|
||||
onConnectionClick={() => setConnectionOpen(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file MarkdownPreview.tsx
|
||||
* @description A markdown file rendered for reading (GitHub-flavoured:
|
||||
* tables, task lists, strikethrough, autolinks), shared by the config
|
||||
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
|
||||
* shown as text, never run (react-markdown without rehype-raw), and links
|
||||
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
|
||||
* shown as a code block instead of being misread as a heading. `isMarkdown`
|
||||
* says which paths get the preview toggle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
/** Whether `path` names a markdown file. */
|
||||
export function isMarkdown(path: string | null | undefined): boolean {
|
||||
return !!path && /\.(md|markdown|mdx)$/i.test(path);
|
||||
}
|
||||
|
||||
/** Split a leading `---` frontmatter block off the body. */
|
||||
function splitFrontmatter(text: string): { front: string | null; body: string } {
|
||||
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
||||
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
|
||||
const { front, body } = splitFrontmatter(text);
|
||||
return (
|
||||
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
|
||||
{front !== null && <pre className="md-frontmatter">{front}</pre>}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noreferrer noopener" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
// The real count: only the first 20 are fetched and listed.
|
||||
const [total, setTotal] = useState(0);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("needsYou.title")}
|
||||
className={`card border-status-warning/40 ${compact ? "px-3 py-2" : "p-4"}`}
|
||||
>
|
||||
<div className={`flex flex-wrap items-center gap-2 ${compact ? "" : "mb-3"}`}>
|
||||
<BellRing className="w-4 h-4 text-status-warning flex-shrink-0" aria-hidden />
|
||||
<h2 className="text-sm font-medium text-fg-primary">
|
||||
{t("needsYou.title")}{" "}
|
||||
<span className="text-fg-muted font-normal">({Math.max(total, sessions.length)})</span>
|
||||
</h2>
|
||||
{compact &&
|
||||
sessions.slice(0, 5).map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-warning/10 border border-status-warning/30 text-fg-secondary hover:text-fg-primary truncate max-w-[14rem]"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!compact && (
|
||||
<ul className="divide-y divide-border">
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="flex items-center gap-3 py-2 px-1 hover:bg-surface-4 rounded"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-status-warning flex-shrink-0" />
|
||||
<span className="text-sm text-fg-secondary truncate flex-1">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted truncate max-w-[16rem]">{s.cwd}</span>
|
||||
{s.awaiting_input_since && (
|
||||
<span className="text-[11px] text-fg-muted flex-shrink-0">
|
||||
{t("needsYou.since", { ago: timeAgo(s.awaiting_input_since) })}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* @file PageTabs.tsx
|
||||
* @description Shared top-of-page tab bar used by the pages that merged several
|
||||
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
|
||||
* the owning page keeps the active tab in its own query param.
|
||||
* former routes into one (Settings hub, Sessions/Events history). The owning
|
||||
* page keeps the active tab in its own query param. `actions` sits at the bar's
|
||||
* right edge, outside the tablist.
|
||||
*
|
||||
* These are real in-page tabs (one view swapped in place), so they follow the
|
||||
* WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
|
||||
* move AND select. Pass the active view as `children` and it is wrapped in the
|
||||
* `role="tabpanel"` the tabs point at with `aria-controls`; without children no
|
||||
* `aria-controls` is emitted (it would name an element that does not exist).
|
||||
* The selected tab is marked by weight and an underline, not colour alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
|
||||
|
||||
export interface PageTab {
|
||||
id: string;
|
||||
@@ -18,32 +27,69 @@ interface PageTabsProps {
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
/** Buttons at the right edge of the bar (not tabs). */
|
||||
actions?: ReactNode;
|
||||
/** The active tab's view, rendered inside its `role="tabpanel"`. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal tab strip for a page that hosts several panels.
|
||||
* @param props - Tabs to render, the active tab id, and the selection callback.
|
||||
* @returns The tab bar element.
|
||||
*/
|
||||
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
|
||||
return (
|
||||
export function PageTabs({
|
||||
tabs,
|
||||
activeId,
|
||||
onSelect,
|
||||
ariaLabel,
|
||||
actions,
|
||||
children,
|
||||
}: PageTabsProps) {
|
||||
const base = useId();
|
||||
const tabId = (id: string) => `${base}-tab-${id}`;
|
||||
const panelId = `${base}-panel`;
|
||||
const hasPanel = children !== undefined;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const i = tabs.findIndex((t) => t.id === activeId);
|
||||
const last = tabs.length - 1;
|
||||
const next =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % tabs.length
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + tabs.length) % tabs.length
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? last
|
||||
: -1;
|
||||
const tab = tabs[next];
|
||||
if (!tab) return;
|
||||
e.preventDefault();
|
||||
onSelect(tab.id);
|
||||
document.getElementById(tabId(tab.id))?.focus();
|
||||
};
|
||||
|
||||
const list = (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
|
||||
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
|
||||
}`}
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const isActive = id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
id={tabId(id)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={hasPanel && isActive ? panelId : undefined}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onSelect(id)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
isActive
|
||||
? "bg-accent/15 border-accent/30 text-accent"
|
||||
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
@@ -54,4 +100,21 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const bar = actions ? (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
|
||||
{list}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
|
||||
</div>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
if (!hasPanel) return bar;
|
||||
return (
|
||||
<>
|
||||
{bar}
|
||||
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file PathPickButton.tsx
|
||||
* @description The one "Browse" button beside every path field: it asks the
|
||||
* server to open the operating system's own folder/file picker
|
||||
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
|
||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||
* opens on the server's desktop — which is only this user's screen when the
|
||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
||||
* the path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
||||
|
||||
export function PathPickButton({
|
||||
kind = "dir",
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
|
||||
|
||||
const pick = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||
if (r.path) onPick(r.path);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// A folder always has the in-app browser to fall back on, whatever went
|
||||
// wrong with the native one (a crashed dialog, one already open).
|
||||
if (kind === "dir") setFallback(true);
|
||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
||||
else {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{showLabel && t("browse.button")}
|
||||
</button>
|
||||
{/* Its clicks stay here: a backdrop click must close only this modal,
|
||||
not bubble on to a dialog this button sits in. */}
|
||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||
<FolderBrowseModal
|
||||
open={fallback}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
* 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.
|
||||
* `remote_source.status` WebSocket message. A right-click on a source opens
|
||||
* its Test / Sync / Enable-Disable / Edit / Delete (plus Copy host) as a menu.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -57,6 +58,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -79,9 +82,14 @@ import {
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
|
||||
const EMPTY_FORM: RemoteSourceInput = {
|
||||
label: "",
|
||||
@@ -134,6 +142,13 @@ export function RemoteSources() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<RemoteSource>();
|
||||
// A menu item: close the menu, then act on the source it was opened on.
|
||||
const pick = (fn: (s: RemoteSource) => unknown) => () => {
|
||||
const s = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (s) fn(s);
|
||||
};
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
||||
{}
|
||||
@@ -248,8 +263,8 @@ export function RemoteSources() {
|
||||
try {
|
||||
await api.remoteSources.update(s.id, { enabled: !s.enabled });
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -291,8 +306,10 @@ export function RemoteSources() {
|
||||
try {
|
||||
await api.remoteSources.syncAll();
|
||||
load();
|
||||
} catch {
|
||||
/* per-source errors surface via each source's status on reload */
|
||||
} catch (err) {
|
||||
// Per-source errors still surface on each source's status after the reload.
|
||||
toast.error(err);
|
||||
load();
|
||||
} finally {
|
||||
setSyncingAll(false);
|
||||
}
|
||||
@@ -306,8 +323,9 @@ export function RemoteSources() {
|
||||
await api.remoteSources.remove(id, purge);
|
||||
setConfirmDelete(null);
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} catch (err) {
|
||||
setConfirmDelete(null);
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -348,7 +366,26 @@ export function RemoteSources() {
|
||||
</p>
|
||||
{/* Card selector — one card per scope mode, each with a short explanation
|
||||
so the choice is self-describing rather than a bare radio label. */}
|
||||
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
|
||||
<div
|
||||
className="grid gap-2.5 sm:grid-cols-3"
|
||||
role="radiogroup"
|
||||
aria-label={t("remoteSources.scopeTitle", "Data scope")}
|
||||
onKeyDown={(e) => {
|
||||
// Arrow keys move the choice, like a native radio group.
|
||||
const step =
|
||||
e.key === "ArrowRight" || e.key === "ArrowDown"
|
||||
? 1
|
||||
: e.key === "ArrowLeft" || e.key === "ArrowUp"
|
||||
? -1
|
||||
: 0;
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
const i = SCOPE_MODES.indexOf(scope.mode);
|
||||
const next = SCOPE_MODES[(i + step + SCOPE_MODES.length) % SCOPE_MODES.length]!;
|
||||
setMode(next);
|
||||
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
@@ -384,8 +421,11 @@ export function RemoteSources() {
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
data-mode={mode}
|
||||
onClick={() => setMode(mode)}
|
||||
className={`relative text-left rounded-xl border p-3 transition-colors ${
|
||||
active
|
||||
@@ -482,7 +522,7 @@ export function RemoteSources() {
|
||||
</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="Dev box"
|
||||
placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
|
||||
value={form.label}
|
||||
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||
/>
|
||||
@@ -519,12 +559,19 @@ export function RemoteSources() {
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
||||
/>
|
||||
<PathPickButton
|
||||
kind="file"
|
||||
start={form.identity_file || "~/.ssh"}
|
||||
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
@@ -544,17 +591,12 @@ export function RemoteSources() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!!form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={!!form.enabled}
|
||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
label={t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{formError && (
|
||||
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
||||
{formError}
|
||||
@@ -602,7 +644,7 @@ export function RemoteSources() {
|
||||
const test = testResults[s.id];
|
||||
const busy = busyId === s.id;
|
||||
return (
|
||||
<div key={s.id} className="card p-4">
|
||||
<div key={s.id} onContextMenu={(e) => ctx.open(e, s)} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -723,22 +765,16 @@ export function RemoteSources() {
|
||||
<p className="text-xs text-fg-secondary mb-2">
|
||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-red-500"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(e) =>
|
||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-fg-secondary">
|
||||
{t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
className="mb-3"
|
||||
tone="danger"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(v) => setConfirmDelete((c) => c && { ...c, purge: v })}
|
||||
label={t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={doDelete}
|
||||
@@ -763,6 +799,34 @@ export function RemoteSources() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ctx.menu && (
|
||||
<ContextMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
testId="remote-source-menu"
|
||||
>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(testSource)}>
|
||||
{t("remoteSources.test", "Test")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(syncNow)}>
|
||||
{t("remoteSources.syncNow", "Sync")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(toggleEnabled)}>
|
||||
{ctx.menu.item.enabled
|
||||
? t("remoteSources.disable", "Disable")
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((s) => setConfirmDelete({ id: s.id, purge: false }))}>
|
||||
{t("common:delete", "Delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file RouteErrorBoundary.tsx
|
||||
* @description Route-level error boundary around the lazily loaded pages. After
|
||||
* a self-update an open tab still asks for the OLD `/assets/<Page>-<hash>.js`,
|
||||
* which the SPA fallback answers with index.html, so the dynamic import
|
||||
* rejects: that case reloads the page once (a sessionStorage guard stops a
|
||||
* reload loop). Any other render error — or a second chunk failure — shows a
|
||||
* themed "Something went wrong — Reload" panel instead of a blank app, until the
|
||||
* route changes (`resetKey`): the lazy routes share one boundary instance, so
|
||||
* an error on one page would otherwise stay on every other.
|
||||
* `reloadOnce` is also used by App's `vite:preloadError` listener.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const RELOAD_KEY = "agenthub.chunkReloadAt";
|
||||
/** A second chunk failure within this window means the reload did not help. */
|
||||
const RELOAD_WINDOW_MS = 30_000;
|
||||
|
||||
/** A failed dynamic `import()` (Chrome, Firefox, Safari wordings) or a ChunkLoadError. */
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return (
|
||||
err.name === "ChunkLoadError" ||
|
||||
/dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
|
||||
err.message
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Reload the page unless this tab already did so in the last 30 s. */
|
||||
export function reloadOnce(reload: () => void = () => window.location.reload()): boolean {
|
||||
try {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
|
||||
if (Date.now() - last < RELOAD_WINDOW_MS) return false;
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
return false; // no sessionStorage: no loop guard, so never auto-reload
|
||||
}
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Injected in tests; defaults to `window.location.reload`. */
|
||||
reload?: () => void;
|
||||
/** A change (the route's path) clears a caught error. */
|
||||
resetKey?: string;
|
||||
}
|
||||
|
||||
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const reload = this.props.reload ?? (() => window.location.reload());
|
||||
return (
|
||||
<div role="alert" className="card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{i18n.t("dashboard:errorBoundary.title")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted max-w-md">{i18n.t("dashboard:errorBoundary.body")}</p>
|
||||
<button type="button" className="btn-primary text-sm" onClick={reload}>
|
||||
{i18n.t("dashboard:errorBoundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @file PetMenu.tsx
|
||||
* @description The mascot's right-click menu: hide it, size it, choose the
|
||||
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
|
||||
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
|
||||
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
export function PetMenu({
|
||||
pet,
|
||||
size,
|
||||
onClose,
|
||||
onSettings,
|
||||
}: {
|
||||
pet: PetChoice;
|
||||
size: number;
|
||||
onClose: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
|
||||
const mark = (id: string) =>
|
||||
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid="pet-menu"
|
||||
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
|
||||
>
|
||||
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
|
||||
{t("sagi.pet.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
toast.info(t("sagi.pet.hiddenToast"));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
{t("sagi.pet.hide")}
|
||||
</button>
|
||||
<label className="block px-2.5 py-1.5">
|
||||
<span className="flex items-center justify-between text-sm text-fg-secondary">
|
||||
{t("sagi.pet.size")}
|
||||
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
data-testid="pet-size"
|
||||
min={PET_SIZE.min}
|
||||
max={PET_SIZE.max}
|
||||
value={size}
|
||||
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
||||
className="mt-1 w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
</label>
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-expanded={choosing}
|
||||
data-testid="pet-choose"
|
||||
className={ITEM}
|
||||
onClick={() => setChoosing((v) => !v)}
|
||||
>
|
||||
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{t("sagi.pet.choose")}
|
||||
</button>
|
||||
{choosing && (
|
||||
<div className="max-h-80 overflow-y-auto pl-2">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === "sagi"}
|
||||
className={ITEM}
|
||||
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
>
|
||||
Sagi
|
||||
{mark("sagi")}
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!!have && pet.id === have.id}
|
||||
data-testid={`pet-featured-${f.id}`}
|
||||
disabled={busy !== null}
|
||||
title={t("sagi.pet.featuredBy", { by: f.by })}
|
||||
className={ITEM}
|
||||
onClick={() => (have ? wear(have) : void install(f.id))}
|
||||
>
|
||||
<span className="min-w-0 truncate">{f.displayName}</span>
|
||||
{busy === f.id ? (
|
||||
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : have ? (
|
||||
mark(have.id)
|
||||
) : (
|
||||
<Download
|
||||
aria-label={t("sagi.pet.download")}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
|
||||
{own.map((p) => (
|
||||
<div key={p.id} className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === p.id}
|
||||
data-testid={`pet-own-${p.id}`}
|
||||
className={ITEM}
|
||||
onClick={() => wear(p)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-image-input"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], false);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={bundleInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-bundle-input"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], true);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("sagi.pet.settings")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
sleeping: 0,
|
||||
thinking: 7, // running — an agent is working
|
||||
watching: 8, // review
|
||||
happy: 4, // jumping
|
||||
stuck: 6, // waiting — for the user
|
||||
worried: 5, // failed
|
||||
disconnected: 5,
|
||||
};
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
}: {
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = ROW[mood] ?? 0;
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const timer = window.setInterval(
|
||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||||
mood === "sleeping" ? 400 : 125
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [kind, row, mood, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
const probe = new Image();
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, kind]);
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
onError={onBroken}
|
||||
style={{ width: size, height: size, objectFit: "contain" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
height: size,
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path reuses the existing Run page: unmatched Ask queries
|
||||
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,11 +73,14 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
import { PetMenu } from "./PetMenu";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -83,6 +89,17 @@ import "./sagi.css";
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
||||
|
||||
/** True while focus sits where typing happens (an input, a terminal's textarea). */
|
||||
function isEditable(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
return (
|
||||
el.isContentEditable ||
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -92,7 +109,8 @@ interface Anchor {
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
/** The OS "reduce motion" preference, followed live. */
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
@@ -114,7 +132,16 @@ function usePrefersReducedMotion(): boolean {
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
@@ -160,6 +187,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
// Mirrors the avatar button: eased left/top so the flyout glides along
|
||||
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
|
||||
data-dragging={anchor.dragging ? "1" : "0"}
|
||||
data-front={front ? "1" : undefined}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
@@ -168,20 +196,36 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
}
|
||||
|
||||
export function Sagi() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep enabled in sync with Settings / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
||||
useEffect(
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setEnabled(sagiPrefs.getEnabled());
|
||||
setPet(sagiPrefs.getPet());
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -190,7 +234,7 @@ export function Sagi() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
@@ -206,7 +250,7 @@ export function Sagi() {
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then jump to the Run page so the live pane is observable.
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const prompt = result.prompt;
|
||||
@@ -214,24 +258,24 @@ export function Sagi() {
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
await api.run.start({
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
});
|
||||
navigate(`/run?laneId=${lane.id}&autostart=1`);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
brain.setBubble(`couldn't start that session: ${msg}`);
|
||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
||||
} finally {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return null;
|
||||
},
|
||||
[brain, navigate]
|
||||
[brain, navigate, t]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
@@ -263,7 +307,21 @@ export function Sagi() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && brain.bubble && (
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
pet={pet}
|
||||
size={size}
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
</SagiFlyout>
|
||||
@@ -272,21 +330,40 @@ export function Sagi() {
|
||||
<button
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title="Sagi - ⌘B · drag to move"
|
||||
title={t("sagi.avatarTitle")}
|
||||
>
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.status.errorCount > 0 && (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
|
||||
interface SagiPanelProps {
|
||||
@@ -85,6 +86,7 @@ export function SagiPanel({
|
||||
onAsk,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
|
||||
@@ -97,9 +99,9 @@ export function SagiPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label="Sagi companion"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -121,13 +123,13 @@ export function SagiPanel({
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? "Live" : "Offline"}
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close Sagi"
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
@@ -138,19 +140,19 @@ export function SagiPanel({
|
||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label="live"
|
||||
label={t("sagi.panel.statLive")}
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label="waiting"
|
||||
label={t("sagi.panel.statWaiting")}
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label="errored"
|
||||
label={t("sagi.panel.statErrored")}
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
@@ -158,27 +160,35 @@ export function SagiPanel({
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} />
|
||||
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label="Sessions"
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label="Errored"
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Clear alerts"
|
||||
label={t("sagi.panel.clearAlerts")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
@@ -188,15 +198,15 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Ask Sagi… (e.g. any errors?)"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Ask Sagi"
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
|
||||
aria-label="Send"
|
||||
className="btn-primary justify-center px-2.5 py-2"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @file Pets.test.tsx
|
||||
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
|
||||
* row of Sagi's mood and steps through its frames (a still image bobs instead),
|
||||
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
|
||||
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
|
||||
* back to Sagi when it was the one worn), and uploads an image; Settings →
|
||||
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const own = {
|
||||
id: "p-aaaaaaaaaaaa",
|
||||
displayName: "My cat",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pets: {
|
||||
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
|
||||
list: vi.fn(async () => ({
|
||||
pets: [own],
|
||||
featured: [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
|
||||
],
|
||||
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
|
||||
})),
|
||||
installFeatured: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "cp-goku",
|
||||
displayName: "Goku",
|
||||
description: "",
|
||||
kind: "codex",
|
||||
source: "featured",
|
||||
featuredId: "goku",
|
||||
},
|
||||
})),
|
||||
upload: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "p-bbbbbbbbbbbb",
|
||||
displayName: "Dog",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
},
|
||||
})),
|
||||
importBundle: vi.fn(),
|
||||
remove: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { PetSprite } from "../PetSprite";
|
||||
import { PetMenu } from "../PetMenu";
|
||||
import { PetSettings } from "../../settings/PetSettings";
|
||||
import { sagiPrefs } from "../prefs";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
|
||||
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
|
||||
|
||||
/** Answer the pending confirmDialog (asserting one is open). */
|
||||
function answerConfirm(ok: boolean) {
|
||||
let pending: PendingConfirm | null = null;
|
||||
subscribeConfirm((p) => (pending = p))();
|
||||
expect(pending).not.toBeNull();
|
||||
act(() => settleConfirm(ok));
|
||||
}
|
||||
|
||||
describe("PetSprite", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("plays the row of the mood and steps through its frames", () => {
|
||||
const { rerender } = render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="thinking"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="worried"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("shows an uploaded image still, and reports one that will not load", () => {
|
||||
const broken = vi.fn();
|
||||
const { container } = render(
|
||||
<PetSprite
|
||||
id="p-aaaaaaaaaaaa"
|
||||
kind="image"
|
||||
mood="idle"
|
||||
size={80}
|
||||
reducedMotion={false}
|
||||
onBroken={broken}
|
||||
/>
|
||||
);
|
||||
const img = container.querySelector("img") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
|
||||
fireEvent.error(img);
|
||||
expect(broken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const renderMenu = () =>
|
||||
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
|
||||
|
||||
it("hides the mascot and sizes it", () => {
|
||||
renderMenu();
|
||||
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
|
||||
expect(sagiPrefs.getSize()).toBe(250);
|
||||
let toasts: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (toasts = i));
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// Says where to bring it back.
|
||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, then wears it", async () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
it("deleting the pet being worn puts Sagi back", async () => {
|
||||
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
|
||||
// Asks first: nothing is deleted on one click.
|
||||
expect(api.pets.remove).not.toHaveBeenCalled();
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
renderMenu();
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
|
||||
expect(sagiPrefs.getSize()).toBe(120);
|
||||
expect(screen.getByText("120px")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
|
||||
expect(sagiPrefs.getMuted()).toBe(true);
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// A change made elsewhere (the right-click menu) shows here at once.
|
||||
act(() => sagiPrefs.setSize(200));
|
||||
expect(screen.getByText("200px")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
render(<PetSettings />);
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
});
|
||||
|
||||
it("a cancelled delete keeps the pet", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(false);
|
||||
await act(async () => {});
|
||||
expect(api.pets.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables size and bubbles while hidden, and resets to defaults", async () => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
sagiPrefs.setSize(200);
|
||||
sagiPrefs.setMuted(true);
|
||||
render(<PetSettings />);
|
||||
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
|
||||
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
|
||||
expect(sagiPrefs.getSize()).toBe(68);
|
||||
expect(sagiPrefs.getMuted()).toBe(false);
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
|
||||
});
|
||||
});
|
||||
@@ -147,4 +147,48 @@ describe("Sagi widget", () => {
|
||||
// Left-docked → inline left equals the edge margin (16px).
|
||||
expect(btn.style.left).toBe("16px");
|
||||
});
|
||||
|
||||
it("Ctrl/Cmd+B is ignored while typing in a field", () => {
|
||||
renderSagi();
|
||||
const ta = document.createElement("textarea");
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
act(() => {
|
||||
fireEvent.keyDown(ta, { key: "b", ctrlKey: true });
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
ta.remove();
|
||||
});
|
||||
|
||||
it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true });
|
||||
window.dispatchEvent(ev);
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("docks bottom-right above the status bar by default", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
const top = parseFloat(btn.style.top);
|
||||
const size = parseFloat(btn.style.width);
|
||||
// Bottom edge sits above the 24px status bar plus the edge margin.
|
||||
expect(top + size).toBe(window.innerHeight - 16 - 24);
|
||||
});
|
||||
|
||||
it("arrow keys move the focused avatar and persist it", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
const before = parseFloat(btn.style.top);
|
||||
act(() => {
|
||||
fireEvent.keyDown(btn, { key: "ArrowUp" });
|
||||
});
|
||||
expect(parseFloat(btn.style.top)).toBeLessThan(before);
|
||||
act(() => {
|
||||
fireEvent.keyDown(btn, { key: "ArrowLeft" });
|
||||
});
|
||||
expect(btn.style.left).toBe("16px");
|
||||
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file prefs.ts
|
||||
* @description Tiny localStorage-backed preference store for Sagi (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.
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled,
|
||||
* muted, the pet it wears and its size). 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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -53,6 +54,54 @@
|
||||
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||||
const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
|
||||
: PET_SIZE.default;
|
||||
}
|
||||
|
||||
function writeAndNotify(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Best-effort, like every pref here.
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(EVENT));
|
||||
} catch {
|
||||
// SSR / non-DOM contexts: nothing to notify.
|
||||
}
|
||||
}
|
||||
const EVENT = "sagi:prefs";
|
||||
|
||||
/**
|
||||
@@ -118,6 +167,20 @@ export const sagiPrefs = {
|
||||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||||
getPos: readPos,
|
||||
setPos: writePos,
|
||||
getPet: readPet,
|
||||
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
|
||||
getSize: () => {
|
||||
try {
|
||||
return readSize();
|
||||
} catch {
|
||||
return PET_SIZE.default;
|
||||
}
|
||||
},
|
||||
setSize: (px: number) =>
|
||||
writeAndNotify(
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -369,3 +369,37 @@
|
||||
.sagi-bubble.sagi-no-anim {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ════════ a custom pet (PetSprite.tsx) ════════ */
|
||||
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
animation-duration: 0.9s;
|
||||
}
|
||||
.sagi-pet-image[data-mood="sleeping"] {
|
||||
animation-duration: 5s;
|
||||
}
|
||||
@keyframes sagi-pet-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-pet-image {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The pet menu may overlap the mascot; it sits above it while open. */
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
|
||||
* shown / speech-bubble prefs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import i18n from "../../i18n";
|
||||
import { sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
export function usePets() {
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
// Which action is running: a featured id, a pet id, "image" or "bundle".
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(message(err))),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
await reload();
|
||||
});
|
||||
const add = (file: File, bundle: boolean) =>
|
||||
run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
|
||||
wear(pet);
|
||||
await reload();
|
||||
});
|
||||
// One place both callers route through, so neither can delete on one click.
|
||||
const remove = async (p: PetView) => {
|
||||
const ok = await confirmDialog({
|
||||
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
|
||||
message: i18n.t("settings:sagi.pet.deleteMessage"),
|
||||
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
// Featured id → the installed copy, once downloaded.
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
return { list, own, installed, busy, error, wear, install, add, remove };
|
||||
}
|
||||
|
||||
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
|
||||
const readWorn = (): Worn => ({
|
||||
pet: sagiPrefs.getPet(),
|
||||
size: sagiPrefs.getSize(),
|
||||
shown: sagiPrefs.getEnabled(),
|
||||
bubbles: !sagiPrefs.getMuted(),
|
||||
});
|
||||
|
||||
/** The worn pet and the mascot's prefs, following every change (any tab). */
|
||||
export function useWornPet(): Worn {
|
||||
const [worn, setWorn] = useState(readWorn);
|
||||
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
|
||||
return worn;
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
* 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.
|
||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||
* It never rests over the fixed bottom status bar; by default it docks
|
||||
* bottom-right just above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,24 +73,29 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { sagiPrefs, type SagiPos } from "./prefs";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
||||
export const SAGI_SIZE = 68;
|
||||
export const SAGI_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
|
||||
const STATUS_BAR = 24;
|
||||
// One arrow-key step, in px.
|
||||
const KEY_STEP = 40;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
|
||||
function defaultPos(): SagiPos {
|
||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||
// Bottom-right, above the status bar: a centred dock covered row controls.
|
||||
return { side: "right", y: 1 };
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: SagiPos) {
|
||||
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
||||
function restingScreen(pos: SagiPos, size: number) {
|
||||
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
|
||||
const top = SAGI_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
}
|
||||
@@ -106,9 +114,12 @@ export interface SagiPlacement {
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
|
||||
onKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function useSagiPosition(): SagiPlacement {
|
||||
/** `size`: the avatar's current footprint (the pet size pref), in px. */
|
||||
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
@@ -127,7 +138,7 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const resting = restingScreen(pos);
|
||||
const resting = restingScreen(pos, size);
|
||||
const screen = drag ?? resting;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
@@ -146,40 +157,68 @@ export function useSagiPosition(): SagiPlacement {
|
||||
[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() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, 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() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(
|
||||
vh() - size - SAGI_MARGIN - STATUS_BAR,
|
||||
Math.max(SAGI_MARGIN, start.top + dy)
|
||||
);
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
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 + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
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 + size / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
let next: SagiPos | null = null;
|
||||
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
|
||||
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
|
||||
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
|
||||
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
}, []);
|
||||
},
|
||||
[pos, size]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
@@ -190,13 +229,14 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return {
|
||||
left: screen.left,
|
||||
top: screen.top,
|
||||
size: SAGI_SIZE,
|
||||
size,
|
||||
side: pos.side,
|
||||
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
||||
openUp: screen.top + size / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* @file Select.tsx
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, supports arrow-key
|
||||
* navigation, flips above the trigger when viewport space is tight, and marks
|
||||
* the active option with accent color plus a Lucide check.
|
||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
||||
* color plus a Lucide check.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
* The trigger is a `<button aria-haspopup="listbox" aria-expanded>`; opening it
|
||||
* moves focus to a `role="listbox"` popup that tracks the highlighted
|
||||
* `role="option"` through `aria-activedescendant`. Keys: ArrowUp/Down, Home/End,
|
||||
* Enter/Space pick, Escape cancels, Tab closes, and a typed character jumps to
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
@@ -63,7 +71,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
@@ -74,6 +82,8 @@ export interface SelectOption<T extends string> {
|
||||
label: string;
|
||||
/** Optional secondary line (smaller gray text). */
|
||||
hint?: string;
|
||||
/** Optional leading icon, shown in the trigger and the list. */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -86,23 +96,34 @@ export interface SelectProps<T extends string> {
|
||||
options: SelectOption<T>[];
|
||||
/** Disables opening the list. */
|
||||
disabled?: boolean;
|
||||
/** `ghost`: a borderless, content-wide trigger for a composer's toolbar. */
|
||||
variant?: "field" | "ghost";
|
||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible custom select control.
|
||||
* @typeParam T - string union of allowed option values.
|
||||
*/
|
||||
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
|
||||
export function Select<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
variant = "field",
|
||||
label,
|
||||
}: SelectProps<T>) {
|
||||
const ghost = variant === "ghost";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value)
|
||||
)
|
||||
);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -114,73 +135,127 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
// When opening, decide whether to render the popover above the trigger if
|
||||
// the viewport doesn't have room below (common when this select sits at
|
||||
// the bottom of a form). 288 px = `max-h-72`.
|
||||
// On open: highlight the chosen option, decide whether the popover goes
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const above = rect.top;
|
||||
setOpenUp(below < 288 && above > below);
|
||||
if (!open) return;
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
// Sync active highlight with current value when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
}
|
||||
}, [open, value, options]);
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
if (refocus) buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const choose = (opt: SelectOption<T>) => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
close();
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (
|
||||
!open &&
|
||||
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
const onButtonKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
Home: () => 0,
|
||||
End: () => last,
|
||||
PageDown: (a) => Math.min(last, a + 10),
|
||||
PageUp: (a) => Math.max(0, a - 10),
|
||||
};
|
||||
if (move[e.key]) {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(options.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
const t = typed.current;
|
||||
t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
|
||||
t.at = now;
|
||||
const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
|
||||
const prefix = cycling ? t.text[0]! : t.text;
|
||||
const start = cycling || t.text.length === 1 ? active + 1 : active;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const idx = (start + i) % options.length;
|
||||
if (options[idx]!.label.toLowerCase().startsWith(prefix)) {
|
||||
setActive(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const current = options.find((o) => o.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKey}
|
||||
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-list` : undefined}
|
||||
title={label}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onKeyDown={onButtonKey}
|
||||
className={
|
||||
ghost
|
||||
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
}
|
||||
>
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{current?.icon}
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
@@ -188,17 +263,20 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
type="button"
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
@@ -206,12 +284,14 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,11 @@ import {
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { fmt, formatDuration } from "../lib/format";
|
||||
import { fmt, formatDuration, parseDate } from "../lib/format";
|
||||
import { effectiveSessionStatus } from "../lib/types";
|
||||
import { styleForTool } from "./conversation/toolStyle";
|
||||
import type { Agent, Session, SessionStats } from "../lib/types";
|
||||
|
||||
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
const REFRESH_DEBOUNCE_MS = 600;
|
||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
const REFRESH_THROTTLE_MS = 1000;
|
||||
|
||||
/** Compact tile used in the top stat row. */
|
||||
function StatTile({
|
||||
@@ -93,24 +95,25 @@ function StatTile({
|
||||
icon: React.ReactNode;
|
||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||
}) {
|
||||
// Theme tokens only: status colours for states, syntax hues for categories.
|
||||
const palette = {
|
||||
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||
violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword",
|
||||
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
|
||||
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
|
||||
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
|
||||
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-fg-muted",
|
||||
violet: "text-violet-400",
|
||||
violet: "text-syntax-keyword",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
rose: "text-status-danger",
|
||||
cyan: "text-syntax-property",
|
||||
blue: "text-syntax-builtin",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
@@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
|
||||
// A refresh asked for while one is in flight is not dropped: it runs once
|
||||
// when the current one settles (that is the throttle's trailing call).
|
||||
const pendingRef = useRef(false);
|
||||
const fetchStats = async () => {
|
||||
if (fetchingRef.current) return;
|
||||
if (fetchingRef.current) {
|
||||
pendingRef.current = true;
|
||||
return;
|
||||
}
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const result = await api.sessions.stats(session.id);
|
||||
@@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// Non-fatal - overview just won't update this round.
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current = false;
|
||||
void fetchStats();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
|
||||
// Live refresh on websocket events (debounced).
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
const isRelevant =
|
||||
msg.type === "new_event" ||
|
||||
msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "session_updated";
|
||||
if (!isRelevant) return;
|
||||
// Live refresh on this session's websocket events, throttled (a steady
|
||||
// stream still refreshes) and again after a reconnect.
|
||||
useLiveRefresh(() => void fetchStats(), {
|
||||
throttleMs: REFRESH_THROTTLE_MS,
|
||||
match: (msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||
if (
|
||||
msg.type !== "new_event" &&
|
||||
msg.type !== "agent_created" &&
|
||||
msg.type !== "agent_updated" &&
|
||||
msg.type !== "session_updated"
|
||||
)
|
||||
return false;
|
||||
const data = msg.data as { session_id?: string; id?: string };
|
||||
// Match either by session_id (events) or by id (session_updated)
|
||||
const matchesSession = data.session_id === session.id || data.id === session.id;
|
||||
if (!matchesSession) return;
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
return data.session_id === session.id || data.id === session.id;
|
||||
},
|
||||
});
|
||||
|
||||
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
|
||||
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
|
||||
const toolCallCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
|
||||
return Math.round(total / 2);
|
||||
}, [stats]);
|
||||
// Exact totals from the server (`tools_used` is only the top 15). An older
|
||||
// server lacks them: fall back to the visible list's count.
|
||||
const toolCallCount = stats?.tool_call_count ?? 0;
|
||||
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
|
||||
|
||||
const maxToolCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// Avg event rate (events / minute)
|
||||
const eventRate = useMemo(() => {
|
||||
if (!stats || !session.started_at) return null;
|
||||
const start = new Date(session.started_at).getTime();
|
||||
const start = parseDate(session.started_at).getTime();
|
||||
const end = session.ended_at
|
||||
? new Date(session.ended_at).getTime()
|
||||
? parseDate(session.ended_at).getTime()
|
||||
: stats.last_event_at
|
||||
? new Date(stats.last_event_at).getTime()
|
||||
? parseDate(stats.last_event_at).getTime()
|
||||
: now;
|
||||
const minutes = Math.max(1, (end - start) / 60_000);
|
||||
return stats.total_events / minutes;
|
||||
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
</span>
|
||||
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
|
||||
<span className="text-xs text-status-success font-medium flex-shrink-0">
|
||||
{activeAgent.name || "Agent"}
|
||||
{activeAgent.name || t("overview.agent")}
|
||||
</span>
|
||||
{activeAgent.current_tool && (
|
||||
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
|
||||
<span className="text-fg-muted">running</span>
|
||||
<span className="text-fg-muted">{t("overview.running")}</span>
|
||||
<span className="text-status-success">{activeAgent.current_tool}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<StatTile
|
||||
label="Events"
|
||||
label={t("overview.events")}
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
|
||||
? t("overview.perMin", {
|
||||
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Activity className="w-3 h-3" />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tool calls"
|
||||
label={t("overview.toolCalls")}
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
|
||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
tone="violet"
|
||||
/>
|
||||
<StatTile
|
||||
label="Subagents"
|
||||
label={t("overview.subagents")}
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
|
||||
hint={
|
||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||
}
|
||||
icon={<GitBranch className="w-3 h-3" />}
|
||||
tone="cyan"
|
||||
/>
|
||||
<StatTile
|
||||
label="Compactions"
|
||||
label={t("overview.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label="Errors"
|
||||
label={t("overview.errors")}
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Duration"
|
||||
label={t("overview.duration")}
|
||||
value={durationLabel}
|
||||
hint={session.status === "active" ? "running" : "completed"}
|
||||
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
|
||||
defaultValue: effectiveSessionStatus(session),
|
||||
})}
|
||||
icon={<Clock className="w-3 h-3" />}
|
||||
tone={session.status === "active" ? "emerald" : "default"}
|
||||
/>
|
||||
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||
Top tools
|
||||
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
{t("overview.topTools")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{stats.tools_used.length} total
|
||||
{t("overview.distinctTotal", { count: distinctTools })}
|
||||
</span>
|
||||
</div>
|
||||
{stats.tools_used.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
{t("overview.noToolCalls")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{stats.tools_used.slice(0, 8).map((t) => (
|
||||
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
if (stats.agents.compaction > 0) {
|
||||
rows.push({
|
||||
key: "__compaction__",
|
||||
label: "Context Compaction",
|
||||
label: t("overview.contextCompaction"),
|
||||
count: stats.agents.compaction,
|
||||
isCompaction: true,
|
||||
});
|
||||
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Subagents
|
||||
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
|
||||
{t("overview.subagents")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.runs", { count: totalRuns })}
|
||||
</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
No subagents in this session.
|
||||
{t("overview.noSubagents")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{rows.slice(0, 8).map((r) => {
|
||||
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
||||
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
|
||||
const barClass = r.isCompaction
|
||||
? "bg-status-warning/60"
|
||||
: "bg-syntax-property/60";
|
||||
return (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||
Token flow
|
||||
{t("overview.tokenFlow")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.total", { value: fmt(totalTokens) })}
|
||||
</span>
|
||||
</div>
|
||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||
</div>
|
||||
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
|
||||
Event mix
|
||||
{t("overview.eventMix")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.events_by_type.slice(0, 12).map((e) => (
|
||||
@@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
}
|
||||
|
||||
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
|
||||
const { t } = useTranslation();
|
||||
const segments = [
|
||||
{
|
||||
key: "cache_read",
|
||||
label: "Cache read",
|
||||
label: t("common:token.cacheRead"),
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
cls: "bg-syntax-builtin",
|
||||
text: "text-syntax-builtin",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
label: t("common:token.cacheWrite"),
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
cls: "bg-syntax-keyword",
|
||||
text: "text-syntax-keyword",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
label: t("common:token.input"),
|
||||
value: tokens.input_tokens,
|
||||
cls: "bg-status-success",
|
||||
text: "text-status-success",
|
||||
},
|
||||
{
|
||||
key: "output",
|
||||
label: "Output",
|
||||
label: t("common:token.output"),
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
cls: "bg-syntax-number",
|
||||
text: "text-syntax-number",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file SessionRowMenu — the right-click menu of a session anywhere outside a
|
||||
* workspace's own lists (the Sessions table, the Dashboard's session and agent
|
||||
* cards): open its analysis page, open it in the workspace that owns it
|
||||
* (`sessions.lane_id` — an ended session opens its conversation there, never
|
||||
* a resume), copy its id or folder. Inside a workspace, `lanes/SessionMenu`
|
||||
* is used instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
|
||||
|
||||
/**
|
||||
* Where "Open in workspace" goes for a session: its live pane when a dashboard
|
||||
* run holds it (`&run=`), else its workspace's replay (`&session=`); null when
|
||||
* no workspace owns it.
|
||||
*/
|
||||
export function workspaceHref(
|
||||
session: SessionRowRef,
|
||||
runs: ReadonlyArray<{ id: string; laneId: number | null; sessionId: string | null }> = []
|
||||
): string | null {
|
||||
const run = runs.find((r) => r.sessionId === session.id && r.laneId != null);
|
||||
if (run) return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
|
||||
if (session.lane_id != null)
|
||||
return `/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SessionRowMenu({
|
||||
session,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: MenuPoint & { session: SessionRowRef; onClose: () => void }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const navigate = useNavigate();
|
||||
const pick = (fn: () => unknown) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const copy = (text: string) => pick(() => void copyText(text));
|
||||
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
{t("menu.details")}
|
||||
</MenuItem>
|
||||
{session.lane_id != null && (
|
||||
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
|
||||
{t("menu.inWorkspace")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={copy(session.id)}>{t("menu.copyId")}</MenuItem>
|
||||
{session.cwd && <MenuItem onSelect={copy(session.cwd)}>{t("menu.copyFolder")}</MenuItem>}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
|
||||
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
|
||||
export function SessionToolBadge({ tool }: { tool?: string | null }) {
|
||||
if (!tool || tool === "claude") return null;
|
||||
return <ToolChip tool={tool} />;
|
||||
}
|
||||
|
||||
// Split out so the catalog hook only mounts for a non-Claude session.
|
||||
function ToolChip({ tool }: { tool: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { label } = useAiTools();
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("toolBadgeTitle", { tool: label(tool) })}
|
||||
>
|
||||
{label(tool)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+217
-293
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -51,19 +51,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
Settings,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Languages,
|
||||
RefreshCw,
|
||||
X,
|
||||
Plug,
|
||||
@@ -71,13 +68,12 @@ import {
|
||||
Gauge,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Moon,
|
||||
FolderTree,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
@@ -87,16 +83,18 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
||||
{ to: "/workspace", icon: Play, key: "nav:workspace" },
|
||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
const SUPPORTED_LANGUAGES = ["en", "vi"] as const;
|
||||
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
interface PersistedStats {
|
||||
eventCount: number;
|
||||
@@ -106,12 +104,18 @@ interface PersistedStats {
|
||||
recentEvents: { type: string; at: number }[];
|
||||
}
|
||||
|
||||
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
|
||||
const NARROW_QUERY = "(max-width: 1023.98px)";
|
||||
|
||||
/** The stored choice, else collapsed on a narrow viewport. */
|
||||
function loadCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) return stored === "true";
|
||||
} catch {
|
||||
return false;
|
||||
/* storage off: fall through to the viewport */
|
||||
}
|
||||
return window.matchMedia?.(NARROW_QUERY).matches ?? false;
|
||||
}
|
||||
|
||||
function loadStats(): PersistedStats {
|
||||
@@ -155,23 +159,32 @@ function loadStats(): PersistedStats {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLanguage(language: string): SupportedLanguage {
|
||||
const base = language.toLowerCase().split("-")[0];
|
||||
if (base === "vi" || base === "en") {
|
||||
return base;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
wsConnected: boolean;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
/** The connection details modal, opened from here or from the status bar. */
|
||||
connectionOpen: boolean;
|
||||
onConnectionOpenChange: (open: boolean) => void;
|
||||
/** Told whether a newer release is out, for the status bar's dot. */
|
||||
onUpdateAvailableChange?: (available: boolean) => void;
|
||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||
drawer?: { open: boolean; onClose: () => void };
|
||||
}
|
||||
|
||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme, toggleTheme } = useTheme();
|
||||
export function Sidebar({
|
||||
wsConnected,
|
||||
collapsed,
|
||||
onToggle,
|
||||
connectionOpen,
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
// Track whether nav items are clipped by overflow so we can render
|
||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||
@@ -180,7 +193,6 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkError, setCheckError] = useState(false);
|
||||
const [statusModalOpen, setStatusModalOpen] = useState(false);
|
||||
const [connectedSince, setConnectedSince] = useState<number | null>(
|
||||
wsConnected ? Date.now() : null
|
||||
);
|
||||
@@ -353,57 +365,89 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
};
|
||||
|
||||
const updateAvailable = Boolean(updateStatus?.update_available);
|
||||
// Four resting states, not two: a release is out, this build is ahead of the
|
||||
// newest release (dev build), it matches the newest release, or no release
|
||||
// could be read at all. Collapsing the middle two into "Up to date" is what
|
||||
// made a checkout carrying unreleased commits look shipped.
|
||||
const checkTitle = checking
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: updateAvailable
|
||||
? t("nav:updateAvailable")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
const switchLanguageTitle = t("nav:switchLanguage", {
|
||||
language: t(`nav:languageNames.${nextLanguage}`),
|
||||
});
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
|
||||
: updateStatus?.installed_not_running
|
||||
? t("nav:installedNotRunning", {
|
||||
version: updateStatus.installed_not_running.version ?? "?",
|
||||
})
|
||||
: updateAvailable
|
||||
? updateStatus?.latest_release
|
||||
? t("nav:releaseAvailable", { version: updateStatus.latest_release })
|
||||
: t("nav:updateAvailable")
|
||||
: updateStatus?.dev_build
|
||||
? t("nav:devBuild", { count: updateStatus.commits_ahead_of_release ?? 0 })
|
||||
: // A plugin install has no commit count to fall back on: a release
|
||||
// that could not be read is "unknown", never "up to date".
|
||||
updateStatus?.install_kind === "plugin" &&
|
||||
updateStatus.release_check &&
|
||||
updateStatus.release_check !== "ok"
|
||||
? t("nav:releaseUnchecked")
|
||||
: updateStatus
|
||||
? t("nav:upToDate")
|
||||
: t("nav:checkForUpdates");
|
||||
useEffect(() => {
|
||||
onUpdateAvailableChange?.(updateAvailable);
|
||||
}, [updateAvailable, onUpdateAvailableChange]);
|
||||
|
||||
const toggleLang = () => {
|
||||
i18n.changeLanguage(nextLanguage);
|
||||
};
|
||||
|
||||
const changeLanguage = (language: SupportedLanguage) => {
|
||||
if (language !== currentLanguage) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
};
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
|
||||
collapsed ? "w-[4.25rem]" : "w-60"
|
||||
ref={asideRef}
|
||||
aria-label={t("nav:sidebar")}
|
||||
role={drawerOpen ? "dialog" : undefined}
|
||||
aria-modal={drawerOpen || undefined}
|
||||
data-testid="sidebar"
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
|
||||
drawer
|
||||
? // Visibility flips at once on open (a still-hidden panel cannot take
|
||||
// focus) and only after the slide-out on close.
|
||||
`z-50 w-60 max-w-[85vw] duration-200 ${
|
||||
drawer.open
|
||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||
}`
|
||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
||||
}`}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
|
||||
{/* Wordmark under the logo: small caps + wide tracking so it reads
|
||||
as a caption to the logo rather than competing with it. The
|
||||
hairlines either side keep it centred on a narrow rail. */}
|
||||
<div className="mt-3 flex w-full items-center gap-2 px-1">
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
|
||||
{t("nav:brand")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav - only this section scrolls when its items overflow; the rest of
|
||||
the sidebar (brand, language, collapse toggle, footer) stays pinned.
|
||||
the sidebar (brand, and the Settings + collapse footer) stays pinned.
|
||||
Chevron buttons appear at the edges when content is clipped, so the
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
@@ -416,21 +460,28 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
title={collapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`
|
||||
}
|
||||
className={navClass}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* The workspace → sessions list scrolls with the nav. In the rail it
|
||||
is one icon that opens the sidebar instead. */}
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<WorkspaceTree />
|
||||
)}
|
||||
</nav>
|
||||
{!collapsed && navOverflow.up && (
|
||||
<button
|
||||
@@ -456,219 +507,42 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language + theme controls. Theme sits next to language: same icon
|
||||
row when collapsed, same row as the EN/VI buttons when expanded -
|
||||
both are 2-state click-to-flip toggles, so they read as one family
|
||||
of controls rather than two unrelated settings. */}
|
||||
<div className="px-2 pb-2 flex-shrink-0">
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
||||
title={switchLanguageTitle}
|
||||
aria-label={switchLanguageTitle}
|
||||
>
|
||||
<Languages className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{t(`nav:languageShort.${currentLanguage}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
||||
title={switchThemeTitle}
|
||||
aria-label={switchThemeTitle}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{t(`nav:themeNames.${theme}`).slice(0, 1)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:language")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const active = language === currentLanguage;
|
||||
return (
|
||||
<button
|
||||
key={language}
|
||||
onClick={() => changeLanguage(language)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:languageNames.${language}`)}
|
||||
title={t(`nav:languageNames.${language}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`nav:languageShort.${language}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:theme")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{(["dark", "light"] as const).map((option) => {
|
||||
const active = option === theme;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:themeNames.${option}`)}
|
||||
title={t(`nav:themeNames.${option}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{option === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="px-2 py-2 flex-shrink-0">
|
||||
{/* Settings and the collapse toggle stick to the foot, out of the
|
||||
scrolling nav. On the rail they stack. */}
|
||||
<div
|
||||
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
|
||||
collapsed ? "flex-col" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
|
||||
collapsed
|
||||
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
data-testid="sidebar-toggle"
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
}`}
|
||||
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="w-4 h-4 flex-shrink-0" />
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
{t("nav:collapseShort")}
|
||||
</span>
|
||||
</>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusModalOpen(true)}
|
||||
aria-label={t("nav:connectionDetails")}
|
||||
title={t("nav:connectionDetails")}
|
||||
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
|
||||
collapsed
|
||||
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
|
||||
: "block w-full px-2.5 py-2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 ${
|
||||
wsConnected ? "text-status-success" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{wsConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="font-medium">
|
||||
{wsConnected ? t("nav:live") : t("nav:disconnected")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checking}
|
||||
title={checkTitle}
|
||||
aria-label={checkTitle}
|
||||
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
|
||||
updateAvailable
|
||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||
: checkError
|
||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||
{updateAvailable && !checking && (
|
||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checking}
|
||||
title={checkTitle}
|
||||
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
|
||||
updateAvailable
|
||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||
: checkError
|
||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 truncate">
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium truncate">{checkTitle}</span>
|
||||
</span>
|
||||
{updateAvailable && !checking && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConnectionStatusModal
|
||||
open={statusModalOpen}
|
||||
onClose={() => setStatusModalOpen(false)}
|
||||
open={connectionOpen}
|
||||
onClose={() => onConnectionOpenChange(false)}
|
||||
wsConnected={wsConnected}
|
||||
connectedSince={connectedSince}
|
||||
eventCountRef={eventCountRef}
|
||||
@@ -677,6 +551,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
eventTimestampsRef={eventTimestampsRef}
|
||||
typeCountRef={typeCountRef}
|
||||
recentEventsRef={recentEventsRef}
|
||||
update={{
|
||||
title: checkTitle,
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: onCheckUpdates,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
peakPerSecRef.current = 0;
|
||||
@@ -710,6 +591,15 @@ interface ConnectionStatusModalProps {
|
||||
eventTimestampsRef: React.MutableRefObject<number[]>;
|
||||
typeCountRef: React.MutableRefObject<Map<string, number>>;
|
||||
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
|
||||
/** The release check: its one-line state ("Up to date", "Dev build · N
|
||||
* ahead", a release out…) and the button that re-runs it. */
|
||||
update: {
|
||||
title: string;
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
failed: boolean;
|
||||
onCheck: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
|
||||
@@ -724,6 +614,7 @@ function ConnectionStatusModal({
|
||||
eventTimestampsRef,
|
||||
typeCountRef,
|
||||
recentEventsRef,
|
||||
update,
|
||||
onResetStats,
|
||||
}: ConnectionStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -739,15 +630,8 @@ function ConnectionStatusModal({
|
||||
}, [open]);
|
||||
|
||||
const close = useCallback(() => onClose(), [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, close]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panelRef, close);
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -776,14 +660,17 @@ function ConnectionStatusModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
@@ -825,13 +712,49 @@ function ConnectionStatusModal({
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t("nav:close")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
className="btn-icon -m-1 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-5 overflow-y-auto">
|
||||
{/* Version + release check */}
|
||||
<Section title={t("nav:version")} icon={RefreshCw}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
|
||||
<p
|
||||
data-testid="update-status"
|
||||
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
|
||||
update.available
|
||||
? "text-status-success"
|
||||
: update.failed
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{update.available && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
{update.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<KpiTile
|
||||
@@ -1018,6 +941,7 @@ function Sparkline({
|
||||
connected: boolean;
|
||||
avgLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const W = 320;
|
||||
const H = 56;
|
||||
const max = Math.max(1, ...buckets);
|
||||
@@ -1029,15 +953,15 @@ function Sparkline({
|
||||
});
|
||||
const linePath = points.length > 0 ? `M ${points.join(" L ")}` : "";
|
||||
const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : "";
|
||||
const stroke = connected ? "#34d399" : "#6b7280";
|
||||
const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
|
||||
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
|
||||
@@ -1045,7 +969,7 @@ function Sparkline({
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
style={{ stroke }}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
@@ -1056,7 +980,7 @@ function Sparkline({
|
||||
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
|
||||
<span>−60s</span>
|
||||
<span>{avgLabel}</span>
|
||||
<span>{"now"}</span>
|
||||
<span>{t("nav:now")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,13 +65,16 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Rounded shape variant. Defaults to "md". */
|
||||
rounded?: "sm" | "md" | "lg" | "full";
|
||||
/** Aria label for screen readers. Defaults to "Loading". */
|
||||
/** Aria label for screen readers. Defaults to the translated "Loading…";
|
||||
* `""` makes the block decorative (`aria-hidden`), for every block but one
|
||||
* in a group, so a reader hears "Loading" once, not once per shape. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -86,17 +89,15 @@ const ROUNDED_CLASS = {
|
||||
* Base shimmer block. Compose with width/height utilities for any shape:
|
||||
* <Skeleton className="h-6 w-24" />
|
||||
*/
|
||||
export function Skeleton({
|
||||
className = "",
|
||||
style,
|
||||
rounded = "md",
|
||||
label = "Loading",
|
||||
}: SkeletonProps) {
|
||||
export function Skeleton({ className = "", style, rounded = "md", label }: SkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
const hidden = label === "";
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
role={hidden ? undefined : "status"}
|
||||
aria-label={hidden ? undefined : (label ?? t("common:loading"))}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-busy={hidden ? undefined : "true"}
|
||||
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
@@ -133,7 +134,7 @@ export function TableRowSkeleton({ columns, widths }: { columns: number; widths?
|
||||
<tr className="border-b border-border/40">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-5 py-4">
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} label={i === 0 ? undefined : ""} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -68,7 +68,10 @@ export function SplashScreen() {
|
||||
const { t } = useTranslation("splash");
|
||||
// Show at most once per tab session. Read synchronously so we never flash an
|
||||
// empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.).
|
||||
// Never on a deep link (a shared /sessions/<id>, a notification's
|
||||
// /workspace?…): the user came for that page, not the welcome.
|
||||
const [mounted, setMounted] = useState(() => {
|
||||
if (window.location.pathname !== "/") return false;
|
||||
try {
|
||||
return !sessionStorage.getItem(SESSION_KEY);
|
||||
} catch {
|
||||
@@ -109,7 +112,11 @@ export function SplashScreen() {
|
||||
/* sessionStorage may be unavailable (private mode) - show anyway */
|
||||
}
|
||||
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
|
||||
// Any key dismisses it (not only a click), so a keyboard user is never stuck.
|
||||
const onKey = () => beginExit();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
if (exitTimer.current) window.clearTimeout(exitTimer.current);
|
||||
if (doneTimer.current) window.clearTimeout(doneTimer.current);
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Tip } from "./Tip";
|
||||
import { StatValueSkeleton } from "./Skeleton";
|
||||
@@ -74,6 +75,8 @@ interface StatCardProps {
|
||||
raw?: string;
|
||||
/** When true, render skeletons in place of value/trend. */
|
||||
loading?: boolean;
|
||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,9 +91,10 @@ export function StatCard({
|
||||
accentColor = "text-accent",
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
{label}
|
||||
@@ -109,6 +113,16 @@ export function StatCard({
|
||||
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="card p-5">{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
|
||||
/**
|
||||
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
|
||||
* reason's icon and short label. Urgent reasons (permission prompts,
|
||||
* interruptions) get a hotter amber fill than the calm idle-between-turns
|
||||
* ones so a scan of a list surfaces the rows that actually block on the human.
|
||||
* interruptions) get the amber warning fill; the calm idle-between-turns
|
||||
* ones stay neutral, so a scan of a list surfaces the rows that actually block on the human.
|
||||
*/
|
||||
function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||
cfg.urgent
|
||||
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||
: "bg-surface-3 border-border text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
||||
* every page: the live-connection state (click: the connection details modal,
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
|
||||
* costliest first: the hover text of a cost figure. */
|
||||
function perModel(r: CostResult): { tokens: number; lines: string } {
|
||||
const byModel = new Map<string, { tokens: number; cost: number }>();
|
||||
for (const b of r.breakdown) {
|
||||
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
|
||||
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
|
||||
m.cost += b.cost;
|
||||
byModel.set(b.model, m);
|
||||
}
|
||||
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
|
||||
return {
|
||||
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
|
||||
lines: rows
|
||||
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const { tokens, lines } = perModel(r);
|
||||
return (
|
||||
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
|
||||
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
onConnectionClick,
|
||||
updateAvailable = false,
|
||||
}: {
|
||||
wsConnected: boolean;
|
||||
/** Sidebar width, so the bar starts where the main column does. */
|
||||
left: string;
|
||||
onConnectionClick: () => void;
|
||||
/** A newer release is out: a dot beside the version. */
|
||||
updateAvailable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [active, setActive] = useState(0);
|
||||
const [needYou, setNeedYou] = useState(0);
|
||||
const running = Math.max(active - needYou, 0);
|
||||
const [today, setToday] = useState<CostResult | null>(null);
|
||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
const sessionId =
|
||||
shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const take = (e: Event) => setRuns((e as CustomEvent<RunHandle[]>).detail);
|
||||
window.addEventListener(LIVE_RUNS_EVENT, take);
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(loadCounts, [loadCounts]);
|
||||
useLiveRefresh(loadCounts, {
|
||||
intervalMs: 15_000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
setLaneCost(null);
|
||||
setSessionCost(null);
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, COST_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
data-testid="status-bar"
|
||||
style={{ left }}
|
||||
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
|
||||
>
|
||||
{/* What a screen reader is told without looking: the connection dropping
|
||||
or coming back, and agents waiting on the user. Polite, and nothing
|
||||
that ticks on its own (costs, running counts) — those stay silent. */}
|
||||
<span role="status" className="sr-only" data-testid="status-bar-live">
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectionClick}
|
||||
title={t("connectionDetails")}
|
||||
className={`flex items-center gap-1.5 hover:text-fg-primary ${
|
||||
wsConnected ? "text-status-success" : ""
|
||||
}`}
|
||||
>
|
||||
{wsConnected ? (
|
||||
<Wifi className="h-3 w-3" aria-hidden />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
data-testid="status-bar-update"
|
||||
title={t("updateAvailable")}
|
||||
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
||||
>
|
||||
<span className="sr-only">{t("updateAvailable")}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span data-testid="status-bar-counts">
|
||||
{t("statusBar.running", { count: running })}
|
||||
{" · "}
|
||||
<span className={needYou ? "text-status-warning" : ""}>
|
||||
{t("statusBar.needYou", { count: needYou })}
|
||||
</span>
|
||||
</span>
|
||||
{today !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-cost"
|
||||
label={t("statusBar.today", { cost: usd(today.total_cost) })}
|
||||
r={today}
|
||||
/>
|
||||
)}
|
||||
{lane !== null && laneCost !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-workspace-cost"
|
||||
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
|
||||
r={laneCost}
|
||||
/>
|
||||
)}
|
||||
{sessionId && sessionCost !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-session-cost"
|
||||
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file Switch.tsx
|
||||
* @description The app's ONE on/off switch (`role="switch"`): an accent track
|
||||
* with a white knob, the same size everywhere. Settings rows, webhook targets
|
||||
* and the AI Tools list all draw this — do not hand-roll another one; a
|
||||
* yes/no choice inside a form or list is a `Checkbox` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
title,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
/** Accessible name — a switch usually sits beside its visible label. */
|
||||
label?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Look
|
||||
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
|
||||
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
|
||||
* are visibly one thing. It stays cursor-following (and not anchored like the
|
||||
* global one) because it expands a NUMBER inside a line of text, where the
|
||||
* anchor is a few characters wide and the cursor is the real subject.
|
||||
*
|
||||
* ## 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
|
||||
@@ -56,6 +63,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TOOLTIP_SURFACE } from "./Tooltip";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Toaster.tsx
|
||||
* @description Renders the app-wide toast queue from `lib/toast.ts`, mounted
|
||||
* once in `App.tsx`. Two live regions — `status` for success/info, `alert` for
|
||||
* errors — so a screen reader announces each message at the right urgency.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
className="opacity-70 hover:opacity-100 p-1 -m-0.5"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The single toast renderer. */
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
))}
|
||||
</div>
|
||||
<div role="alert" aria-live="assertive" className="flex flex-col gap-2">
|
||||
{errors.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user