Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a88b74016 | |||
| 8ed5018a2f | |||
| aacd63b85b | |||
| 22de5009ff | |||
| e94038481e | |||
| b441f32949 | |||
| cb19d7e5d2 | |||
| 0cb5e41faf | |||
| 1c5521cf2e | |||
| c3f83d3922 | |||
| 0c2f49876d | |||
| 9c573f1e3c | |||
| d72a255294 | |||
| b08482c6aa | |||
| 763759189f | |||
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 | |||
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 | |||
| 46e3899945 | |||
| 5d8fc9bb98 | |||
| c8ea18f594 | |||
| 64173a5428 | |||
| 943ba03b76 | |||
| 3a8be55b55 | |||
| 2fd0c903a2 | |||
| f6f1bf4a76 | |||
| f0dc1445fc | |||
| 96e4d28f7c | |||
| fa156d6a59 | |||
| ce773a3f5a | |||
| 4407b3bb16 | |||
| 44fd664670 | |||
| 118eaee2ec | |||
| 3ab750c765 | |||
| 84e2a9f0ac | |||
| b7f3735b29 | |||
| a9c71cd905 | |||
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 | |||
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 | |||
| 03ecc9e29a | |||
| 4aae61297f | |||
| 837da6e5de | |||
| 7ca1134c3d | |||
| daf5b7be92 | |||
| 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 |
@@ -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`
|
||||
@@ -22,7 +22,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,7 +35,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
@@ -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,43 @@
|
||||
# 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
|
||||
|
||||
# The status bar's Usage panel reads Antigravity's quota by running
|
||||
# `agy --print /usage --output-format json` (no model call), which may let agy
|
||||
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
|
||||
# AGENTHUB_AGY_USAGE=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
|
||||
@@ -94,6 +136,20 @@
|
||||
# liveness/stale sweeps (which skip them). (default: 600000 = 10min).
|
||||
# DASHBOARD_REMOTE_ACTIVE_WINDOW_MS=600000
|
||||
|
||||
# ── Privacy and push ingest (Settings → Data) ────────────────────────────────
|
||||
# All three are normally saved from Settings into the settings file; set here,
|
||||
# they are pinned and the Settings page shows them read-only.
|
||||
# Redact secrets (API keys, tokens, bearer headers, private keys, password=…)
|
||||
# from stored event payloads and summaries: 1 = on (default: off).
|
||||
# AGENTHUB_REDACT_SECRETS=1
|
||||
# Folders whose hooks are dropped whole (and skipped by the history import and
|
||||
# the Codex reader) — absolute paths, ':'-separated (';' on Windows); a folder
|
||||
# and everything under it.
|
||||
# AGENTHUB_IGNORE_CWD=/home/you/clients/secret:/home/you/private
|
||||
# SHA-256 (hex) of the token POST /api/hooks/ingest-batch accepts. Unset = push
|
||||
# ingest off. Settings → Data → Push ingest makes the token and stores only this.
|
||||
# AGENTHUB_PUSH_TOKEN_SHA256=
|
||||
|
||||
# ── Lanes (durable parallel-work units and managed git worktrees) ────────────
|
||||
# Directory dashboard-managed worktrees are provisioned under. It is also the
|
||||
# boundary safety check 2 resolves every destructive lane path against: nothing
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# commit-msg hook – runs Commitlint
|
||||
npx --no-install commitlint --edit "$1"
|
||||
@@ -78,3 +78,6 @@ __pycache__/
|
||||
|
||||
# JetBrains IDE config — machine-specific, never shared
|
||||
.idea/
|
||||
|
||||
# Prometheus bearer token for docker-compose.monitoring.yml (docs/MONITORING.md)
|
||||
monitoring/prometheus/dashboard-token
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# Pre-commit: auto-format staged files, then run the full test suite.
|
||||
# The commit is ABORTED unless every test passes.
|
||||
# Pre-commit: auto-format staged files, then run the tests the commit needs —
|
||||
# both full suites for a big or risky commit, only the related tests for a
|
||||
# small one, none for docs alone (see step 2). The commit is ABORTED unless
|
||||
# every test it runs passes.
|
||||
#
|
||||
# Activated via `core.hooksPath=.husky` (set by the package.json "prepare"
|
||||
# script on `npm install`). No husky/lint-staged runtime required.
|
||||
@@ -44,13 +46,13 @@ fi
|
||||
# is deterministic: it fails both runs and still blocks the commit. This keeps
|
||||
# the gate strict without making commits a dice roll on machine load.
|
||||
run_suite() {
|
||||
# $1 = human label, $2 = npm script
|
||||
# $1 = human label, $2 = shell command
|
||||
echo "🧪 Running $1 tests..."
|
||||
if npm run "$2"; then
|
||||
if sh -c "$2"; then
|
||||
return 0
|
||||
fi
|
||||
echo "⚠️ $1 suite failed — retrying once to rule out machine-load flakiness..."
|
||||
if npm run "$2"; then
|
||||
if sh -c "$2"; then
|
||||
echo "✅ $1 suite passed on retry — treating the first failure as a load flake."
|
||||
echo " If this keeps happening, the flaky test above deserves a real fix."
|
||||
return 0
|
||||
@@ -59,7 +61,68 @@ run_suite() {
|
||||
return 1
|
||||
}
|
||||
|
||||
run_suite backend test:server
|
||||
run_suite frontend test:client
|
||||
# The frontend suite needs NODE_ENV=test: a shell exporting
|
||||
# NODE_ENV=production gets React's production build, where act() throws.
|
||||
CLIENT_FULL="NODE_ENV=test npm run test:client"
|
||||
|
||||
echo "✅ Formatting applied and all tests passed — proceeding with commit."
|
||||
# How much to run. A small commit runs only the tests that touch what it
|
||||
# stages; a big or risky one runs both full suites, as does
|
||||
# AGENTHUB_FULL_TESTS=1. Docs alone (*.md, docs/) run nothing.
|
||||
# client/ files → `vitest related`: vitest's own import graph.
|
||||
# anything else → every server/__tests__ file that names the file's module
|
||||
# (a string match: wide on purpose, `db` reaches most).
|
||||
# ponytail: name matching misses a test that reaches a module only through
|
||||
# another one; the full run before a release still catches that.
|
||||
SMALL_MAX=10
|
||||
CODE="$(git diff --cached --name-only --diff-filter=ACMRD | grep -vE '\.md$|^docs/' || true)"
|
||||
COUNT=$(printf '%s\n' "$CODE" | grep -c . || true)
|
||||
FULL=0
|
||||
[ "${AGENTHUB_FULL_TESTS:-0}" = "1" ] && FULL=1
|
||||
[ "$COUNT" -gt "$SMALL_MAX" ] && FULL=1
|
||||
# A dependency, build/test config, this hook or a deleted file can break
|
||||
# anything, whatever imports it.
|
||||
if printf '%s\n' "$CODE" | grep -qE '(^|/)package(-lock)?\.json$|(^|/)(vite|vitest|tailwind|postcss|tsconfig)[^/]*$|^\.husky/|^scripts/test-server\.js$'; then
|
||||
FULL=1
|
||||
fi
|
||||
if git diff --cached --name-only --diff-filter=D | grep -vE '\.md$|^docs/' | grep -q .; then
|
||||
FULL=1
|
||||
fi
|
||||
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "📝 Docs only — no tests to run."
|
||||
elif [ "$FULL" = "1" ]; then
|
||||
echo "🧪 Full run ($COUNT code files staged)."
|
||||
run_suite backend "npm run test:server"
|
||||
run_suite frontend "$CLIENT_FULL"
|
||||
else
|
||||
echo "🧪 Small commit ($COUNT code files) — running the related tests only."
|
||||
OTHER="$(printf '%s\n' "$CODE" | grep -v '^client/' || true)"
|
||||
CLIENT="$(printf '%s\n' "$CODE" | grep '^client/' | sed 's|^client/||' || true)"
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
RELATED="$(
|
||||
printf '%s\n' "$OTHER" | while IFS= read -r f; do
|
||||
case "$f" in
|
||||
server/__tests__/*.test.js) echo "$f" ;;
|
||||
*)
|
||||
name="$(basename "$f")"
|
||||
name="${name%.*}"
|
||||
grep -lF -- "$name" server/__tests__/*.test.js 2>/dev/null || true
|
||||
;;
|
||||
esac
|
||||
done | sort -u | tr '\n' ' '
|
||||
)"
|
||||
if [ -n "$RELATED" ]; then
|
||||
run_suite "backend ($(echo $RELATED | wc -w) files)" "node scripts/test-server.js $RELATED"
|
||||
else
|
||||
echo "⏭️ No server test names a staged file — backend skipped."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CLIENT" ]; then
|
||||
LIST="$(printf '%s\n' "$CLIENT" | tr '\n' ' ')"
|
||||
run_suite "frontend (related)" "cd client && NODE_ENV=test NODE_OPTIONS=--no-experimental-webstorage npx vitest related --run --passWithNoTests $LIST"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Formatting applied and the tests passed — proceeding with commit."
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
- Correctness over cleverness.
|
||||
- Small, scoped, reversible diffs.
|
||||
- Preserve existing behavior unless change is requested.
|
||||
- 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/*).
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, 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`.
|
||||
|
||||
## Lane stage
|
||||
## Workspace stage
|
||||
|
||||
AgentHub's `codex` (and `antigravity`) lanes carry no pipeline: the dashboard shows no stage for
|
||||
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.
|
||||
|
||||
@@ -23,8 +23,8 @@ make while you work here as Codex.
|
||||
- `scripts/` for hook/install/import/cleanup utilities.
|
||||
|
||||
## Validation expectations
|
||||
- Backend changes: run `npm run test:server` when possible.
|
||||
- Frontend changes: run `npm run test:client` when possible.
|
||||
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
|
||||
- If any check is skipped, report it explicitly.
|
||||
|
||||
## Safety expectations
|
||||
|
||||
@@ -18,7 +18,7 @@ result and `/agenthub-open` to build the UI and get the URL.
|
||||
|
||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
Follow the steps below instead when you want a checkout — to develop the
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
@@ -31,9 +31,9 @@ dashboard, run it from source, or stay on Node 20.
|
||||
| npm | 9+ | Comes with Node.js |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
|
||||
| **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 lanes ci status` |
|
||||
| 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) |
|
||||
|
||||
---
|
||||
@@ -42,7 +42,7 @@ dashboard, run it from source, or stay on Node 20.
|
||||
|
||||
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 lanes shell`
|
||||
no bundled fallback, so without it **Start Run** and `agenthub workspace shell`
|
||||
fail while everything else keeps working.
|
||||
|
||||
```bash
|
||||
@@ -54,6 +54,22 @@ 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:
|
||||
|
||||
@@ -72,11 +88,12 @@ 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 is set on the session explicitly). You can attach to the
|
||||
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 lanes shell # from inside the lane's directory
|
||||
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-
|
||||
```
|
||||
@@ -100,9 +117,10 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
`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
|
||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||
Settings → About → **Start AgentHub automatically** on, the plugin bootstrap
|
||||
installs `~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout:
|
||||
the same switch, or `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
|
||||
@@ -131,7 +149,7 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin put
|
||||
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 run form.
|
||||
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.
|
||||
@@ -191,7 +209,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 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.
|
||||
> 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,7 +232,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).
|
||||
|
||||
@@ -305,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
|
||||
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
|
||||
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# AgentHub
|
||||
|
||||
[Tiếng Việt](README.md) · **English**
|
||||
|
||||
A dashboard that runs on your machine to watch and drive your Claude Code (and
|
||||
Codex) sessions from the browser.
|
||||
|
||||
| Section | Tiếng Việt |
|
||||
| --- | --- |
|
||||
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
|
||||
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
|
||||
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
|
||||
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
|
||||
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
To install them:
|
||||
|
||||
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
## Option 1 — Install through Claude Code (recommended)
|
||||
|
||||
Send **each line on its own** straight into the Claude Code chat:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
```
|
||||
|
||||
```
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
The last one prints the dashboard's address (by default <http://localhost:4820>) —
|
||||
open it in the browser and you are done.
|
||||
|
||||
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||
To update to a new version: `/agenthub:agenthub-update`.
|
||||
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
|
||||
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
|
||||
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
|
||||
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Option 2 — Install from a terminal (without Claude Code)
|
||||
|
||||
Run these in a terminal, one after another:
|
||||
|
||||
```bash
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # install the dependencies + the agenthub command
|
||||
npm run build # build the UI
|
||||
npm start # run the dashboard
|
||||
```
|
||||
|
||||
Open <http://localhost:4820> in the browser.
|
||||
|
||||
`npm start` stops when the terminal closes. To have the dashboard start every time
|
||||
the machine boots:
|
||||
|
||||
```bash
|
||||
agenthub service install
|
||||
```
|
||||
|
||||
> ⚠️ Pick **only one** of the two options. With both installed, every event is
|
||||
> counted twice.
|
||||
|
||||
<a id="more-docs"></a>
|
||||
|
||||
## More docs
|
||||
|
||||
- Vietnamese README: [README.md](README.md)
|
||||
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
|
||||
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
|
||||
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
Internal build — all rights reserved.
|
||||
@@ -1,298 +1,94 @@
|
||||
# AgentHub
|
||||
|
||||
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, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, lane runtimes, and Claude Code's own configuration.
|
||||
**Tiếng Việt** · [English](README.en.md)
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
every decision.
|
||||
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
|
||||
(và Codex) ngay trên trình duyệt.
|
||||
|
||||
Internal build — all rights reserved.
|
||||
| Phần | English |
|
||||
| --- | --- |
|
||||
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
|
||||
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
|
||||
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
|
||||
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Main features
|
||||
## Yêu cầu
|
||||
|
||||
**Observe**
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **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`).
|
||||
Cài:
|
||||
|
||||
**Drive**
|
||||
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a lane 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
|
||||
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. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Git, from the dashboard.** Each lane 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 lane 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-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
**Configure**
|
||||
## Cách 1 — Cài qua Claude Code (khuyên dùng)
|
||||
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one panel 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; light:
|
||||
Default, Apple Light, IntelliJ Light, Canvas. Each mode remembers its own
|
||||
theme, so the sidebar's quick toggle swaps between the two. Per-browser only.
|
||||
- **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 lane operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes 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` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes 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. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||
v26.8.1).
|
||||
|
||||
### Installing tmux
|
||||
|
||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||
**Start Run** and `agenthub lanes shell` fail without it. Everything else
|
||||
(sessions, analytics, lanes, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
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 lane'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 lanes
|
||||
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
|
||||
|
||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
`npm run setup`:
|
||||
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
|
||||
|
||||
```
|
||||
/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
|
||||
PATH; it runs detached, so the session never waits on
|
||||
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
||||
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||
Details, including what to delete on uninstall:
|
||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
Open <http://localhost:4820>.
|
||||
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
|
||||
trên trình duyệt là xong.
|
||||
|
||||
Development, with hot reload:
|
||||
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
|
||||
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
|
||||
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
|
||||
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
|
||||
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
|
||||
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
|
||||
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Cách 2 — Cài bằng terminal (không qua Claude Code)
|
||||
|
||||
Chạy lần lượt trong terminal:
|
||||
|
||||
```bash
|
||||
npm run dev # server on :4820, Vite client on :5173
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # cài thư viện + lệnh agenthub
|
||||
npm run build # build giao diện
|
||||
npm start # chạy dashboard
|
||||
```
|
||||
|
||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
||||
every event is counted twice.
|
||||
Mở <http://localhost:4820> trên trình duyệt.
|
||||
|
||||
### 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. `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:
|
||||
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
|
||||
|
||||
## The CLI
|
||||
<a id="more-docs"></a>
|
||||
|
||||
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
||||
## Tài liệu thêm
|
||||
|
||||
```bash
|
||||
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)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
- README tiếng Anh: [README.en.md](README.en.md)
|
||||
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
|
||||
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
|
||||
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
`agenthub --help` lists the rest.
|
||||
|
||||
## Lanes
|
||||
|
||||
A lane 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 may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a lane — 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
|
||||
```
|
||||
|
||||
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. A Codex lane also carries **no pipeline** — no stage,
|
||||
no pipeline map, nothing inferred or advanced; the dashboard only mirrors its
|
||||
running/idle status from Codex's own rollout journals on disk
|
||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
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, 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
|
||||
|
||||
```bash
|
||||
npm run test:server # node:test
|
||||
npm run test:client # Vitest
|
||||
```
|
||||
|
||||
Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `server/` | Express API, SQLite schema, hook ingest, lane and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, lanes, 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/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
||||
Internal build — all rights reserved.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -132,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
|
||||
|
||||
@@ -52,13 +52,14 @@ 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/System switch (next to the language switcher in the sidebar, and in Settings → Appearance; `useTheme` hook) 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"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas"]` — 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
|
||||
- **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 → Appearance) 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 workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
**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.
|
||||
**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`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), 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 two steps down, `#a34a28` — 5.3:1 as text on a card, 5.9:1 white-on-accent.
|
||||
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||
- **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`.
|
||||
@@ -75,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
|
||||
|
||||
@@ -106,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
|
||||
@@ -184,17 +184,36 @@ 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
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ └── 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
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||||
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||||
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||||
│ │ ├── 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)
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
||||
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||||
│ │ ├── 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
|
||||
@@ -207,10 +226,11 @@ client/
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ ├── 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)
|
||||
@@ -238,11 +258,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 · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · 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]
|
||||
@@ -285,7 +306,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
|
||||
|
||||
@@ -402,8 +423,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
|
||||
@@ -455,11 +476,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)"]
|
||||
|
||||
@@ -484,16 +505,18 @@ function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
{/* 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>
|
||||
@@ -502,6 +525,8 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
```mermaid
|
||||
@@ -611,6 +636,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
|
||||
@@ -755,6 +782,23 @@ closes. Consequences worth knowing:
|
||||
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.
|
||||
|
||||
#### Metric tooltips (`common:insights.help.*`)
|
||||
|
||||
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
|
||||
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
|
||||
`vi`.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<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). -->
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount, and
|
||||
src/lib/fontSize.ts). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
@@ -17,6 +18,9 @@
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
|
||||
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
|
||||
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -30,7 +34,7 @@
|
||||
<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."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
@@ -45,23 +49,23 @@
|
||||
<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."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta property="og:image" content="/og-image.svg" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<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="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<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."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -70,7 +74,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"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.",
|
||||
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
"softwareVersion": "1.0.0",
|
||||
@@ -84,7 +88,7 @@
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -11,10 +11,18 @@
|
||||
"test:watch": "NODE_OPTIONS=--no-experimental-webstorage vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/merge": "^6.12.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@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 +31,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 |
|
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 |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 30 KiB |
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* @description Service Worker for caching static assets and handling push notifications.
|
||||
* @description Service Worker: network-first navigations with an offline fallback, and push
|
||||
* notifications. Hashed `/assets/` bundles are left to the HTTP cache (immutable).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
// Bump this any time the SW logic changes - old clients will install the new
|
||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||
// freshly-built bundle starts being served on the very next request.
|
||||
const CACHE_NAME = "dashboard-v2";
|
||||
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
|
||||
// and were never pruned.
|
||||
const CACHE_NAME = "dashboard-v3";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// No pre-cache: network-first below means the cache fills lazily, and
|
||||
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
|
||||
)
|
||||
return;
|
||||
|
||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
||||
// don't get re-requested.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ||
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Hashed bundles under /assets/ are immutable and the server marks them so:
|
||||
// the browser's HTTP cache keeps them. Caching them here too only grew a
|
||||
// second copy of every build, forever.
|
||||
if (url.pathname.startsWith("/assets/")) return;
|
||||
|
||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||
// with cache fallback. The user always gets the freshest UI while online and
|
||||
|
||||
@@ -38,12 +38,11 @@
|
||||
* ## Internal dependencies
|
||||
* - `./components/Layout`
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/History`
|
||||
* - `./pages/SessionDetail`
|
||||
* - `./pages/Workflows`
|
||||
* - `./pages/SettingsHub`
|
||||
* - `./pages/Workspace`
|
||||
* - `./pages/Dashboard` (lazy)
|
||||
* - `./pages/History` (lazy)
|
||||
* - `./pages/SessionDetail` (lazy)
|
||||
* - `./pages/SettingsHub` (lazy)
|
||||
* - `./pages/Workspace` (lazy)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -55,24 +54,73 @@
|
||||
* - `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 { 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 { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { usePermissions } from "./components/AuthGate";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||
import { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||
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 — the Dashboard and History too, so the
|
||||
// entry chunk holds only the shell.
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
|
||||
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 }))
|
||||
);
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||
function ServerOnly({ children }: { children: ReactNode }) {
|
||||
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
/** 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`.
|
||||
@@ -85,29 +133,125 @@ 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);
|
||||
}, []);
|
||||
|
||||
// The server came back as a newer version (any tab, any way it was updated):
|
||||
// load its UI build instead of leaving the user to press F5.
|
||||
useEffect(() => reloadOnNewBuild(), []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
return () => window.removeEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
}, []);
|
||||
|
||||
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
|
||||
index
|
||||
element={
|
||||
<Page>
|
||||
<Dashboard />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions"
|
||||
element={
|
||||
<Page>
|
||||
<History />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
<Page>
|
||||
<SessionDetailRoute />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="snapshot/:token"
|
||||
element={
|
||||
<Page>
|
||||
<SnapshotView />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
<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="artifacts"
|
||||
element={
|
||||
<Page>
|
||||
<Artifacts />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<ServerOnly>
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
</ServerOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
</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,21 +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-700 dark: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>
|
||||
@@ -247,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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,7 +71,9 @@ import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -85,8 +88,13 @@ import { WebhookSettings } from "./WebhookSettings";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -121,6 +129,8 @@ const RULE_TYPES: AlertRuleType[] = [
|
||||
"inactivity",
|
||||
"status_duration",
|
||||
"token_threshold",
|
||||
"cost_threshold",
|
||||
"budget",
|
||||
];
|
||||
|
||||
type TabKey = "rules" | "channels" | "activity";
|
||||
@@ -136,6 +146,11 @@ interface RuleFormState {
|
||||
minutes: string;
|
||||
status: "working" | "waiting";
|
||||
total_tokens: string;
|
||||
usd: string;
|
||||
period: "day" | "week" | "month";
|
||||
limit_usd: string;
|
||||
thresholds: string;
|
||||
budget_tool: string;
|
||||
cooldown_seconds: string;
|
||||
}
|
||||
|
||||
@@ -150,9 +165,51 @@ const EMPTY_FORM: RuleFormState = {
|
||||
minutes: "10",
|
||||
status: "working",
|
||||
total_tokens: "1000000",
|
||||
usd: "5",
|
||||
period: "month",
|
||||
limit_usd: "100",
|
||||
thresholds: "80, 100",
|
||||
budget_tool: "",
|
||||
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),
|
||||
usd: str(c.usd, EMPTY_FORM.usd),
|
||||
period: c.period === "day" || c.period === "week" ? c.period : "month",
|
||||
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
|
||||
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
|
||||
budget_tool: str(c.tool, ""),
|
||||
cooldown_seconds: String(rule.cooldown_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
|
||||
function parseThresholds(text: string): number[] | null {
|
||||
const parts = text
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
const nums = parts.map((p) => Number(p));
|
||||
if (nums.length === 0 || nums.length > 10) return null;
|
||||
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
|
||||
}
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
@@ -174,6 +231,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
return { status: form.status, minutes: parseFloat(form.minutes) };
|
||||
case "token_threshold":
|
||||
return { total_tokens: parseInt(form.total_tokens, 10) };
|
||||
case "cost_threshold":
|
||||
return { usd: parseFloat(form.usd) };
|
||||
case "budget": {
|
||||
const config: AlertRule["config"] = {
|
||||
period: form.period,
|
||||
limit_usd: parseFloat(form.limit_usd),
|
||||
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
|
||||
tz_offset: new Date().getTimezoneOffset(),
|
||||
};
|
||||
if (form.budget_tool) config.tool = form.budget_tool;
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,6 +270,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
|
||||
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
|
||||
case "token_threshold":
|
||||
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
|
||||
case "cost_threshold":
|
||||
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
|
||||
case "budget":
|
||||
return t("ruleDesc.budget", {
|
||||
limit: fmtCost(c.limit_usd ?? 0),
|
||||
period: t(`periods.${c.period ?? "month"}`),
|
||||
thresholds: (c.thresholds ?? []).join("%, ") + "%",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,16 +285,30 @@ export function AlertsNotifications() {
|
||||
const { t } = useTranslation("alerts");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("rules");
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
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 [preview, setPreview] = useState<RulePreview | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const aiTools = useAiTools();
|
||||
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 +323,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 +341,12 @@ 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 [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -267,13 +359,13 @@ 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();
|
||||
}, [loadRules]);
|
||||
if (isAdmin) loadRules();
|
||||
}, [loadRules, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
@@ -289,22 +381,61 @@ export function AlertsNotifications() {
|
||||
});
|
||||
}, [loadAlerts]);
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
// Any edit makes a shown preview stale.
|
||||
const set = (patch: Partial<RuleFormState>) => {
|
||||
setPreview(null);
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const onCreateRule = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
const onPreview = async () => {
|
||||
if (previewing) return;
|
||||
setPreviewing(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
await api.alerts.rules.create({
|
||||
setPreview(
|
||||
await api.alerts.rules.preview({
|
||||
rule_type: form.rule_type,
|
||||
config: buildConfig(form),
|
||||
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setPreviewing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setPreview(null);
|
||||
setEditingId(null);
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setPreview(null);
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const onSaveRule = async () => {
|
||||
if (saving || !canSubmit) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
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 +449,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 +460,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 +470,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 +479,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 +489,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) &&
|
||||
@@ -366,9 +502,12 @@ export function AlertsNotifications() {
|
||||
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
|
||||
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
|
||||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
|
||||
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
|
||||
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
key: "rules",
|
||||
label: ts("alertsHub.tabRules"),
|
||||
@@ -383,6 +522,7 @@ export function AlertsNotifications() {
|
||||
badge: unacked || undefined,
|
||||
},
|
||||
];
|
||||
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -431,8 +571,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 +609,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 +619,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 +721,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>
|
||||
)}
|
||||
@@ -626,6 +769,100 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "cost_threshold" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.usd")}
|
||||
<FieldHelp description={t("rules.help.usd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.usd}
|
||||
onChange={(e) => set({ usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.rule_type === "budget" && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.period")}
|
||||
<FieldHelp description={t("rules.help.period")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.period}
|
||||
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
{(["day", "week", "month"] as const).map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{t(`periods.${p}`)}
|
||||
</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>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.limitUsd")}
|
||||
<FieldHelp description={t("rules.help.limitUsd")} />
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={form.limit_usd}
|
||||
onChange={(e) => set({ limit_usd: e.target.value })}
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.thresholds")}
|
||||
<FieldHelp description={t("rules.help.thresholds")} />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.thresholds}
|
||||
onChange={(e) => set({ thresholds: e.target.value })}
|
||||
placeholder="80, 100"
|
||||
className="input mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{t("rules.form.budgetTool")}
|
||||
<FieldHelp description={t("rules.help.budgetTool")} />
|
||||
</span>
|
||||
<div className="relative mt-1">
|
||||
<select
|
||||
value={form.budget_tool}
|
||||
onChange={(e) => set({ budget_tool: e.target.value })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
>
|
||||
<option value="">{t("rules.form.allTools")}</option>
|
||||
{aiTools.tools.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.label}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{preview && <RulePreviewResult preview={preview} />}
|
||||
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<label className="block text-xs text-fg-secondary">
|
||||
<span className="mb-1.5 flex items-center gap-1">
|
||||
@@ -635,19 +872,41 @@ 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}
|
||||
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")}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPreview}
|
||||
disabled={!canSubmit || previewing}
|
||||
title={t("rules.previewHint")}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
{previewing ? t("rules.previewing") : t("rules.preview")}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||
</div>
|
||||
@@ -663,12 +922,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 +960,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 +981,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>
|
||||
)}
|
||||
|
||||
@@ -737,11 +1023,13 @@ export function AlertsNotifications() {
|
||||
label={t("feed.unackedOnly")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => loadAlerts()}
|
||||
onClick={refreshAlerts}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{unacked > 0 && (
|
||||
<button
|
||||
@@ -766,6 +1054,7 @@ export function AlertsNotifications() {
|
||||
icon={unackedOnly ? BellOff : BellRing}
|
||||
title={t("feed.emptyTitle")}
|
||||
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -801,7 +1090,7 @@ export function AlertsNotifications() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.acknowledged_at && (
|
||||
{!alert.acknowledged_at && alert.can_ack !== false && (
|
||||
<button
|
||||
onClick={() => onAck(alert.id)}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||
@@ -838,3 +1127,51 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
|
||||
function RulePreviewResult({ preview }: { preview: RulePreview }) {
|
||||
const { t } = useTranslation("alerts");
|
||||
if (!preview.supported) {
|
||||
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
|
||||
}
|
||||
if (preview.periods) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
|
||||
{preview.periods.map((p) => (
|
||||
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
|
||||
<span className="text-fg-muted">{p.period_start}</span>
|
||||
<span className="text-fg-secondary">
|
||||
{fmtCost(p.spend)} · {Math.round(p.pct)}%
|
||||
</span>
|
||||
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
|
||||
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
|
||||
<p className="text-fg-secondary">
|
||||
{t("rules.previewResult", {
|
||||
fires: preview.fires,
|
||||
sessions: preview.sessions,
|
||||
days: preview.window_days,
|
||||
})}
|
||||
</p>
|
||||
{preview.samples.map((s, i) => (
|
||||
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(s.session_id)}`}
|
||||
className="truncate hover:text-accent"
|
||||
>
|
||||
{s.session_id.slice(0, 8)}
|
||||
</Link>
|
||||
<span>{timeAgo(s.at)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @file AuthGate.tsx
|
||||
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
|
||||
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
|
||||
* mode — the default — it renders the app and nothing else changes, at once
|
||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||
* previous user's data. `useAuth()` gives the sidebar the user, `logout`, and
|
||||
* `updateUser` (Settings → Account's saved display name shows at once);
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = {
|
||||
state: AuthState;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (patch: Partial<AuthUser>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue>({
|
||||
state: { mode: "local" },
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
});
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
/**
|
||||
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
||||
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
||||
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
||||
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
||||
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
||||
* only their own — the one that matters is an analyst looking at another
|
||||
* user's.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { state } = useAuth();
|
||||
const user = state.mode === "server" ? state.user : null;
|
||||
const local = state.mode !== "server";
|
||||
const isAdmin = local || user?.role === "admin";
|
||||
return {
|
||||
isAdmin,
|
||||
// Local mode: one person on their own machine — what only serves a
|
||||
// shared server (File Explorer, uploads, AgentHub's own git logins) is
|
||||
// hidden; their desktop and their own git config already do it.
|
||||
local,
|
||||
seesAll: local || (!!user && user.role !== "developer"),
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||
const MODE_KEY = "agenthub.authMode";
|
||||
const RETRY_MS = 2000;
|
||||
|
||||
function cachedState(): AuthState | null {
|
||||
try {
|
||||
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// null = still asking the server. A browser that last saw local mode starts
|
||||
// there and is corrected by the answer (or by a 401) if that changed.
|
||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
let alive = true;
|
||||
const ask = () => {
|
||||
api.auth
|
||||
.me()
|
||||
.then((next) => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, next.mode);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
setState(next);
|
||||
})
|
||||
.catch((err: { status?: number; code?: string }) => {
|
||||
if (!alive) return;
|
||||
// An answer that is not this build's: an older server still running
|
||||
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||
// app as before and let it show its own state.
|
||||
if (
|
||||
err?.status === 404 ||
|
||||
err?.code === "EBADRESPONSE" ||
|
||||
(err?.status === 401 && err?.code !== "EAUTH")
|
||||
) {
|
||||
setState({ mode: "local" });
|
||||
return;
|
||||
}
|
||||
// No answer at all (a restart, a proxy's 502): ask again rather than
|
||||
// guess — "local" would hide a server-mode dashboard's login page.
|
||||
timer = window.setTimeout(ask, RETRY_MS);
|
||||
});
|
||||
};
|
||||
ask();
|
||||
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||
const onRequired = () => setState({ mode: "server", user: null });
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
||||
// per account, so a second login in this browser brings that account's.
|
||||
const account =
|
||||
state?.mode === "local"
|
||||
? "local"
|
||||
: state?.mode === "server" && state.user
|
||||
? `u${state.user.id}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
// Stable, so a page may list it as an effect dependency.
|
||||
const updateUser = useCallback(
|
||||
(patch: Partial<AuthUser>) =>
|
||||
setState((s) =>
|
||||
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!state) return null;
|
||||
if (state.mode === "server" && !state.user) {
|
||||
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||
}
|
||||
const logout = async () => {
|
||||
await api.auth.logout().catch(() => {});
|
||||
setState({ mode: "server", user: null });
|
||||
};
|
||||
return (
|
||||
<AuthContext.Provider value={{ state, logout, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onLoggedIn((await api.auth.login(username, password)).user);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
|
||||
setPassword("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="card relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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,70 +73,29 @@ 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"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
<div className="flex min-h-0 items-start gap-3 overflow-y-auto 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,10 +188,15 @@ 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-fg-primary"
|
||||
@@ -193,20 +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-fg-primary 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
|
||||
@@ -219,18 +251,20 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
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))
|
||||
}
|
||||
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" />
|
||||
@@ -240,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>
|
||||
))}
|
||||
@@ -253,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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* @file EventReplay.tsx
|
||||
* @description The session Replay tab: a scrubber over a session's recorded
|
||||
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
|
||||
* whose markers jump the cursor, the state derived up to the cursor
|
||||
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
|
||||
* events, so the live analysis page and a frozen snapshot share it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import {
|
||||
chronological,
|
||||
lifecycleMarkers,
|
||||
replayState,
|
||||
LIFECYCLE_TYPES,
|
||||
REPLAY_SPEEDS,
|
||||
REPLAY_STEP_MS,
|
||||
} from "../lib/replay";
|
||||
import { buildEventTitle } from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { formatDateTimeFull, formatMs } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
|
||||
/** The newest this many events are replayed; older ones are left out. */
|
||||
export const REPLAY_MAX_EVENTS = 5000;
|
||||
const PAGE = 500;
|
||||
|
||||
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
|
||||
export async function loadReplayEvents(
|
||||
sessionId: string
|
||||
): Promise<{ events: DashboardEvent[]; total: number }> {
|
||||
const all: DashboardEvent[] = [];
|
||||
let total = 0;
|
||||
do {
|
||||
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
|
||||
total = r.total;
|
||||
if (r.events.length === 0) break;
|
||||
all.push(...r.events);
|
||||
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
|
||||
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
|
||||
}
|
||||
|
||||
const MARKER_COLOR: Record<string, string> = {
|
||||
SessionStart: "bg-status-success",
|
||||
UserPromptSubmit: "bg-accent",
|
||||
Compaction: "bg-status-warning",
|
||||
SubagentStop: "bg-violet-500",
|
||||
APIError: "bg-status-danger",
|
||||
Stop: "bg-fg-muted",
|
||||
};
|
||||
|
||||
const btn =
|
||||
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
|
||||
|
||||
export function EventReplay({
|
||||
events: raw,
|
||||
total,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
/** The session's full event count, when more than `events` were recorded. */
|
||||
total?: number;
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const events = useMemo(() => chronological(raw), [raw]);
|
||||
const markers = useMemo(() => lifecycleMarkers(events), [events]);
|
||||
const last = events.length - 1;
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [speed, setSpeed] = useState<number>(1);
|
||||
const at = Math.min(cursor, Math.max(0, last));
|
||||
const state = useMemo(() => replayState(events, at), [events, at]);
|
||||
const event = events[at];
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
if (at >= last) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
|
||||
return () => clearTimeout(timer);
|
||||
}, [playing, at, last, speed]);
|
||||
|
||||
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
|
||||
|
||||
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
|
||||
const toggle = () => {
|
||||
if (!playing && at >= last) setCursor(0);
|
||||
setPlaying((p) => !p);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// The slider steps itself on arrow keys; only the rest is ours there.
|
||||
const onSlider = e.target instanceof HTMLInputElement;
|
||||
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
} else if (onSlider) return;
|
||||
else if (e.key === "ArrowLeft") go(at - 1);
|
||||
else if (e.key === "ArrowRight") go(at + 1);
|
||||
else if (e.key === "Home") go(0);
|
||||
else if (e.key === "End") go(last);
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const markerLabel = (type: string) => t(`replay.marker.${type}`);
|
||||
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-4 outline-none"
|
||||
tabIndex={0}
|
||||
onKeyDown={onKey}
|
||||
role="group"
|
||||
aria-label={t("replay.label")}
|
||||
data-testid="event-replay"
|
||||
>
|
||||
{total != null && total > events.length && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("replay.truncated", { shown: events.length, total })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="card space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at - 1)}
|
||||
disabled={at <= 0}
|
||||
title={t("replay.back")}
|
||||
>
|
||||
<StepBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${btn} bg-accent/15 text-accent`}
|
||||
onClick={toggle}
|
||||
title={playing ? t("replay.pause") : t("replay.play")}
|
||||
aria-pressed={playing}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
onClick={() => go(at + 1)}
|
||||
disabled={at >= last}
|
||||
title={t("replay.forward")}
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="ml-2 flex items-center gap-0.5"
|
||||
role="group"
|
||||
aria-label={t("replay.speed")}
|
||||
>
|
||||
{REPLAY_SPEEDS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeed(s)}
|
||||
aria-pressed={speed === s}
|
||||
className={`rounded px-2 py-1 text-xs font-mono ${
|
||||
speed === s
|
||||
? "bg-accent/15 text-accent"
|
||||
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="ml-auto font-mono text-xs text-fg-muted">
|
||||
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
|
||||
{formatDateTimeFull(event.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={last}
|
||||
value={at}
|
||||
onChange={(e) => go(Number(e.target.value))}
|
||||
aria-label={t("replay.scrubber")}
|
||||
className="w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative h-4 rounded bg-surface-2"
|
||||
role="group"
|
||||
aria-label={t("replay.minimap")}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
|
||||
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
|
||||
/>
|
||||
{markers.map((m) => (
|
||||
<button
|
||||
key={m.index}
|
||||
type="button"
|
||||
onClick={() => go(m.index)}
|
||||
title={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
|
||||
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
|
||||
style={{ left: `${m.pct}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{present.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
|
||||
{present.map((k) => (
|
||||
<span key={k} className="inline-flex items-center gap-1">
|
||||
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
|
||||
{markerLabel(k)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
|
||||
<Tile label={t("replay.state.status")}>
|
||||
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.tool")}>
|
||||
<span className="font-mono">{state.currentTool ?? "-"}</span>
|
||||
</Tile>
|
||||
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
|
||||
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
|
||||
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
|
||||
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
|
||||
</div>
|
||||
{state.toolCounts.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("replay.state.toolCounts")}
|
||||
>
|
||||
{state.toolCounts.map((x) => (
|
||||
<span
|
||||
key={x.tool}
|
||||
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{x.tool} ×{x.count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
|
||||
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
|
||||
{buildEventTitle(event)}
|
||||
</div>
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* @file EventTimeline.tsx
|
||||
* @description A session's events as a flat, expandable list — one row per
|
||||
* event (age, status badge, origin · title, tool), opening into EventDetail.
|
||||
* Used by the session analysis page's Timeline tab and the read-only snapshot
|
||||
* page.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { EventDetail } from "./EventDetail";
|
||||
import {
|
||||
agentOriginLabel,
|
||||
buildEventTitle,
|
||||
buildOriginLabel,
|
||||
projectFromCwd,
|
||||
projectFromEvent,
|
||||
statusFromEventType,
|
||||
} from "../lib/event-grouping";
|
||||
import type { AgentInfo } from "../lib/event-grouping";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import type { DashboardEvent } from "../lib/types";
|
||||
|
||||
export function EventTimeline({
|
||||
events,
|
||||
agentInfoById,
|
||||
sessionNameById,
|
||||
fallbackCwd,
|
||||
}: {
|
||||
events: DashboardEvent[];
|
||||
agentInfoById: Map<string, AgentInfo>;
|
||||
sessionNameById: Map<string, string>;
|
||||
/** The session's cwd, for events that carry none of their own. */
|
||||
fallbackCwd?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
|
||||
|
||||
function toggleEvent(id: number) {
|
||||
setExpandedEvents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
|
||||
const projectByEventId = useMemo(() => {
|
||||
const map = new Map<number, string | null>();
|
||||
for (const e of events) map.set(e.id, projectFromEvent(e));
|
||||
return map;
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
{events.map((event, i) => {
|
||||
const key = event.id ?? i;
|
||||
const isOpen = event.id != null && expandedEvents.has(event.id);
|
||||
return (
|
||||
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
|
||||
<div
|
||||
key={key}
|
||||
style={
|
||||
isOpen
|
||||
? undefined
|
||||
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => event.id != null && toggleEvent(event.id)}
|
||||
aria-expanded={isOpen}
|
||||
aria-label={
|
||||
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
|
||||
}
|
||||
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
|
||||
>
|
||||
<span
|
||||
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
|
||||
{timeAgo(event.created_at)}
|
||||
</div>
|
||||
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
|
||||
{(() => {
|
||||
// Session is implicit on this page - project is
|
||||
// still shown so the row identifies the working
|
||||
// directory when you share / search. Events without
|
||||
// their own cwd (e.g. TurnDuration) fall back to the
|
||||
// session's cwd so every row carries the dir prefix.
|
||||
const project =
|
||||
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
|
||||
const origin = buildOriginLabel(
|
||||
project,
|
||||
null,
|
||||
agentOriginLabel(event.agent_id, agentInfoById)
|
||||
);
|
||||
return (
|
||||
<span className="text-sm text-fg-secondary flex-1 truncate">
|
||||
{origin && (
|
||||
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
|
||||
{origin} ·
|
||||
</span>
|
||||
)}
|
||||
{buildEventTitle(event)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{event.tool_name && (
|
||||
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
|
||||
{event.tool_name}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<EventDetail
|
||||
event={event}
|
||||
agentInfoById={agentInfoById}
|
||||
sessionNameById={sessionNameById}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file ImageLightbox.tsx
|
||||
* @description One image shown full size over the whole page; a click or
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -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-700 dark:text-blue-400"
|
||||
color="text-accent"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,7 +678,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-700 dark:text-violet-300"
|
||||
color="text-fg-primary"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
@@ -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-700 dark: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,17 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Sidebar width
|
||||
* The expanded sidebar's right edge drags (or takes ←/→) between
|
||||
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
|
||||
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
|
||||
*
|
||||
* ## 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:
|
||||
@@ -38,7 +51,8 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./Sagi/Sagi`
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||
@@ -62,12 +76,51 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
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 { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
export const SIDEBAR_MIN = 240;
|
||||
/** Wide enough for long workspace names on a large screen, no wider. */
|
||||
export const SIDEBAR_MAX = 480;
|
||||
|
||||
/** A width inside the bounds, and at most 40% of the window. */
|
||||
export function clampSidebar(px: number): number {
|
||||
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
|
||||
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
|
||||
}
|
||||
|
||||
function loadWidth(): number {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(WIDTH_KEY));
|
||||
if (n) return clampSidebar(n);
|
||||
} catch {}
|
||||
return SIDEBAR_MIN;
|
||||
}
|
||||
|
||||
/** 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 +135,29 @@ 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 [width, setWidth] = useState(loadWidth);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
|
||||
|
||||
const saveWidth = useCallback((px: number) => {
|
||||
const next = clampSidebar(px);
|
||||
setWidth(next);
|
||||
try {
|
||||
localStorage.setItem(WIDTH_KEY, String(next));
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
// 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) => {
|
||||
@@ -99,17 +175,93 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
{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}
|
||||
width={width}
|
||||
resizing={resizing}
|
||||
/>
|
||||
{!drawer && !collapsed && (
|
||||
<div
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-valuenow={width}
|
||||
aria-valuemin={SIDEBAR_MIN}
|
||||
aria-valuemax={SIDEBAR_MAX}
|
||||
aria-label={t("resizeSidebar")}
|
||||
title={t("resizeSidebar")}
|
||||
data-testid="sidebar-resize"
|
||||
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
|
||||
resizing ? "bg-accent/60" : ""
|
||||
}`}
|
||||
style={{ left: width }}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
setResizing(true);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (resizing) setWidth(clampSidebar(e.clientX));
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!resizing) return;
|
||||
setResizing(false);
|
||||
saveWidth(e.clientX);
|
||||
}}
|
||||
onPointerCancel={() => setResizing(false)}
|
||||
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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 duration-200 outline-none ${
|
||||
resizing ? "" : "transition-[margin-left,width]"
|
||||
}`}
|
||||
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 +269,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,105 @@
|
||||
/**
|
||||
* @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 { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||
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
|
||||
}, []);
|
||||
// The live panes (for the links) move only when a lane's run columns do.
|
||||
const loadRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadRuns();
|
||||
}, [load, loadRuns]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||
|
||||
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,78 @@
|
||||
/**
|
||||
* @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) it falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure) — for a file field
|
||||
* too, which then lists files and picks one on click.
|
||||
* @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 { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
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 {
|
||||
// The in-app browser is the fallback whatever went wrong with the
|
||||
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
|
||||
setFallback(true);
|
||||
} 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}
|
||||
kind={kind}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file RemoteSources.tsx
|
||||
* @description Settings UI for the Remote Data Sources feature: manage the SSH
|
||||
* machines this dashboard pulls Claude Code history from, and choose the global
|
||||
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
|
||||
* history from, and choose the global
|
||||
* "data scope" (which machines' data the whole app shows).
|
||||
*
|
||||
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
|
||||
@@ -9,7 +10,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 +59,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 +83,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: "",
|
||||
@@ -89,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
|
||||
ssh_port: null,
|
||||
identity_file: "",
|
||||
remote_home: "",
|
||||
sync_codex: false,
|
||||
remote_codex_home: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
@@ -134,6 +145,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 }>>(
|
||||
{}
|
||||
@@ -204,6 +222,8 @@ export function RemoteSources() {
|
||||
ssh_port: s.ssh_port,
|
||||
identity_file: s.identity_file || "",
|
||||
remote_home: s.remote_home || "",
|
||||
sync_codex: !!s.sync_codex,
|
||||
remote_codex_home: s.remote_codex_home || "",
|
||||
enabled: s.enabled,
|
||||
});
|
||||
setEditingId(s.id);
|
||||
@@ -227,6 +247,8 @@ export function RemoteSources() {
|
||||
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
|
||||
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
|
||||
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
|
||||
sync_codex: !!form.sync_codex,
|
||||
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
try {
|
||||
@@ -248,8 +270,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 +313,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 +330,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 +373,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 +428,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 +529,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 +566,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 +598,37 @@ 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.sync_codex}
|
||||
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
|
||||
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{form.sync_codex && (
|
||||
<div>
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.codex"
|
||||
value={form.remote_codex_home ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
|
||||
/>
|
||||
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
|
||||
{t(
|
||||
"remoteSources.fieldRemoteCodexHomeHint",
|
||||
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<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 +676,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">
|
||||
@@ -632,6 +706,9 @@ export function RemoteSources() {
|
||||
{s.host}
|
||||
{s.ssh_port ? `:${s.ssh_port}` : ""}
|
||||
{s.remote_home ? ` · ${s.remote_home}` : ""}
|
||||
{s.sync_codex
|
||||
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
|
||||
: ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted mt-1">
|
||||
{s.last_sync_at
|
||||
@@ -647,9 +724,19 @@ export function RemoteSources() {
|
||||
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
|
||||
n: s.last_sync_counts.sessions_tagged,
|
||||
})}`}
|
||||
{s.last_sync_counts?.codex_sessions != null &&
|
||||
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
|
||||
n: s.last_sync_counts.codex_sessions,
|
||||
})}`}
|
||||
</div>
|
||||
{s.status === "error" && s.last_error && (
|
||||
<div className="text-[11px] text-status-danger mt-1 break-words">
|
||||
{/* A sync that pulled one CLI's history but not the other's is
|
||||
`ok` with the other's failure in last_error. */}
|
||||
{s.last_error && (s.status === "error" || s.status === "ok") && (
|
||||
<div
|
||||
className={`text-[11px] mt-1 break-words ${
|
||||
s.status === "error" ? "text-status-danger" : "text-status-warning"
|
||||
}`}
|
||||
>
|
||||
{s.last_error}
|
||||
</div>
|
||||
)}
|
||||
@@ -723,22 +810,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 +844,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,541 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* @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 → Appearance.
|
||||
* @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 { usePermissions } from "../AuthGate";
|
||||
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();
|
||||
// Adding or deleting a pet changes the whole install: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
// 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);
|
||||
if (!have && !isAdmin) return null;
|
||||
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>
|
||||
{isAdmin && (
|
||||
<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" />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<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,183 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
|
||||
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
|
||||
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. 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, PetMoment } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const COLS = 8;
|
||||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||
const frames = (n: number, step: number, last: number) =>
|
||||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||
* how a sleeping pet breathes here. */
|
||||
const DURATIONS = [
|
||||
[280, 110, 110, 140, 140, 320], // idle
|
||||
frames(8, 120, 220), // run right
|
||||
frames(8, 120, 220), // run left
|
||||
frames(4, 140, 280), // waving
|
||||
frames(5, 140, 280), // jumping
|
||||
frames(8, 140, 240), // failed
|
||||
frames(6, 150, 260), // waiting
|
||||
frames(6, 120, 220), // running (working)
|
||||
frames(6, 150, 280), // review
|
||||
];
|
||||
const SLEEP_SLOWDOWN = 6;
|
||||
/** Rows that play three times and settle into idle; the others loop. */
|
||||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||
const GAZE_ROW = 9;
|
||||
/** 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,
|
||||
};
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||
* and the user's own action outranks it. */
|
||||
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||
/** Loops of a row before the sprite rests on its first frame. */
|
||||
const SPRITE_LOOPS = 4;
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
moment,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
}: {
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
moment?: PetMoment | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && !moment && mood === "sleeping";
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
const [rows, setRows] = useState(9);
|
||||
|
||||
useEffect(() => {
|
||||
setCell({ of: row, row, col: 0 });
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const play = (r: number, slow = 1) =>
|
||||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let loops = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||||
// first frame rests — a sprite that never stops re-paints the page several
|
||||
// times a second for as long as it is open; a new mood or action plays again.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
}, list[i]!.ms);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [kind, row, sleeping, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||
const probe = new Image();
|
||||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onload = null;
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, kind]);
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
onError={onBroken}
|
||||
style={{ width: size, height: size, objectFit: "contain" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose =
|
||||
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
: cell.of === row
|
||||
? cell
|
||||
: { row, col: 0 };
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={shown.row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
height: size,
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* @file Sagi.tsx
|
||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), 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,19 +77,46 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
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 { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { PetChat, petChatTool } from "./PetChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
const STROKE_TURNS = 4;
|
||||
const STROKE_MS = 1200;
|
||||
const STROKE_COOLDOWN_MS = 1500;
|
||||
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 +126,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 +149,22 @@ 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.
|
||||
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||
*/
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
beside = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
beside?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
@@ -126,6 +176,18 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (beside) {
|
||||
let left =
|
||||
anchor.side === "left"
|
||||
? anchor.left + anchor.size + FLYOUT_GAP
|
||||
: anchor.left - w - FLYOUT_GAP;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
let top = anchor.top + anchor.size - h;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
@@ -138,7 +200,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
@@ -160,6 +222,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}
|
||||
@@ -167,31 +230,105 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
|
||||
export function Sagi() {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
return useSagiEnabled() ? <SagiBody /> : null;
|
||||
}
|
||||
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
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 the pet and its size in sync with Settings / other tabs (enabled is
|
||||
// the `Sagi` gate's: it unmounts this body when the mascot is turned off).
|
||||
useEffect(
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setPet(sagiPrefs.getPet());
|
||||
setName(petName(sagiPrefs.getPet()));
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||
// A pet worn before names rode along with the choice: look its name up once.
|
||||
const petId = pet.id;
|
||||
const unnamed = "kind" in pet && !pet.name;
|
||||
useEffect(() => {
|
||||
if (!unnamed) return;
|
||||
let live = true;
|
||||
api.pets
|
||||
.list()
|
||||
.then((list) => {
|
||||
const found = list.pets.find((p) => p.id === petId);
|
||||
const cur = sagiPrefs.getPet();
|
||||
if (live && found && cur.id === petId && "kind" in cur)
|
||||
sagiPrefs.setPet({ ...cur, name: found.displayName });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [petId, unnamed]);
|
||||
|
||||
// ⌘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(() => {
|
||||
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") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
|
||||
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
|
||||
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
const [gaze, setGaze] = useState<number | null>(null);
|
||||
const wearsSheet = "kind" in pet && pet.kind === "codex";
|
||||
useEffect(() => {
|
||||
if (!wearsSheet || reducedMotion) return;
|
||||
let rest = 0;
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const r = btn.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const deg =
|
||||
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
|
||||
Math.PI;
|
||||
setGaze(Math.round((deg + 360) / 22.5) % 16);
|
||||
window.clearTimeout(rest);
|
||||
rest = window.setTimeout(() => setGaze(null), 2000);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.clearTimeout(rest);
|
||||
};
|
||||
}, [wearsSheet, reducedMotion]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
navigate(route);
|
||||
@@ -200,41 +337,88 @@ export function Sagi() {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
// A session a bubble or the panel names: its live pane when a dashboard run
|
||||
// holds it, else its workspace's replay, else its analysis page. Opening
|
||||
// never resumes anything.
|
||||
const openSession = useCallback(
|
||||
(s: SagiSubject) => {
|
||||
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => go(r.items))
|
||||
.catch(() => go());
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
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.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
}
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/run?laneId=${lane.id}&autostart=1`);
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
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, name]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
if (e.buttons !== 0 || e.pointerType === "touch") return;
|
||||
const s = stroke.current;
|
||||
const now = performance.now();
|
||||
const dx = e.clientX - s.x;
|
||||
s.x = e.clientX;
|
||||
if (Math.abs(dx) < 2 || now < s.until) return;
|
||||
const dir = Math.sign(dx);
|
||||
if (s.dir && dir !== s.dir) {
|
||||
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
|
||||
if (s.turns.length >= STROKE_TURNS) {
|
||||
s.turns = [];
|
||||
s.until = now + STROKE_COOLDOWN_MS;
|
||||
brain.pat();
|
||||
}
|
||||
}
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
@@ -252,6 +436,13 @@ export function Sagi() {
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
@@ -263,34 +454,131 @@ export function Sagi() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && brain.bubble && (
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||
lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
pet={pet}
|
||||
size={size}
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate(href("appearance#pet"));
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
onDismiss={brain.dismissBubble}
|
||||
onOpen={openSession}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={btn}
|
||||
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 }}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerMove={(e) => {
|
||||
place.onPointerMove(e);
|
||||
onStroke(e);
|
||||
}}
|
||||
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);
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
|
||||
aria-expanded={open}
|
||||
title="Sagi - ⌘B · drag to move"
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
|
||||
{brain.status.errorCount > 0 && (
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar
|
||||
mood={brain.mood}
|
||||
moment={reducedMotion ? null : brain.moment?.kind}
|
||||
reducedMotion={reducedMotion}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
// A moment (a pat's hop) outranks the hover wave, or stroking the
|
||||
// pet would only ever show it waving.
|
||||
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
|
||||
moment={brain.moment?.kind}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.reaction && (
|
||||
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||
<span className="sagi-hearts" aria-hidden>
|
||||
<span>💜</span>
|
||||
<span>💗</span>
|
||||
<span>💜</span>
|
||||
</span>
|
||||
<span className="sagi-reaction-text" data-side={place.side}>
|
||||
{brain.reaction.text}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
) : (
|
||||
brain.status.waitingCount > 0 && (
|
||||
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
|
||||
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -36,13 +36,15 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
size?: number;
|
||||
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
|
||||
moment?: PetMoment | null;
|
||||
}
|
||||
|
||||
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||
@@ -76,10 +78,22 @@ if (typeof window !== "undefined") {
|
||||
});
|
||||
}
|
||||
|
||||
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
|
||||
// The pupil offset is written as CSS variables on the root, never as state, so
|
||||
// following the cursor costs no React render.
|
||||
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
|
||||
el?.style.setProperty("--sagi-px", `${x}px`);
|
||||
el?.style.setProperty("--sagi-py", `${y}px`);
|
||||
}
|
||||
|
||||
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
|
||||
export const SagiAvatar = memo(function SagiAvatar({
|
||||
mood,
|
||||
reducedMotion,
|
||||
size = 68,
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
@@ -89,7 +103,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracking) {
|
||||
setPupil({ x: 0, y: 0 });
|
||||
setPupil(rootRef.current, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +119,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
// Normalize then clamp to the eye socket range.
|
||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
||||
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||
};
|
||||
|
||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||
@@ -137,6 +151,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
ref={rootRef}
|
||||
className="sagi-avatar"
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -202,7 +217,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-left">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -226,7 +241,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-right">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -792,4 +807,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SagiMount.tsx
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -50,9 +50,6 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
@@ -61,11 +58,29 @@ import {
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { SagiStatus } from "./brain";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
@@ -77,6 +92,13 @@ interface SagiPanelProps {
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
@@ -85,8 +107,16 @@ export function SagiPanel({
|
||||
onAsk,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -97,9 +127,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", { name })}
|
||||
>
|
||||
{/* 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">
|
||||
@@ -107,7 +137,40 @@ export function SagiPanel({
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
@@ -121,16 +184,26 @@ 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"
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
@@ -138,47 +211,81 @@ 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"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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={LayoutList}
|
||||
label="Sessions"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label="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 +295,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", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Ask Sagi"
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<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>
|
||||
@@ -274,7 +381,7 @@ function ActionButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||
* it early; its Open button opens the session instead.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -51,28 +51,53 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiBubble } from "./useSagiBrain";
|
||||
import type { SagiSubject } from "./brain";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
bubble: SagiBubble;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
/** Opens the session the bubble is about. */
|
||||
onOpen?: (subject: SagiSubject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { text, detail, subject, tone } = bubble;
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
data-tone={tone}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
<div>{text}</div>
|
||||
{(detail || (subject && onOpen)) && (
|
||||
<div className="sagi-bubble-detail">
|
||||
{detail && <span className="sagi-bubble-subject">{detail}</span>}
|
||||
{subject && onOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="sagi-bubble-open"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(subject);
|
||||
}}
|
||||
>
|
||||
{t("sagi.bubbleOpen")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* @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(110));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
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("runs the way it is dragged and waves on hover, over its mood", () => {
|
||||
const props = {
|
||||
id: "cp-goku",
|
||||
kind: "codex" as const,
|
||||
mood: "thinking" as const,
|
||||
size: 208,
|
||||
reducedMotion: false,
|
||||
onBroken: () => {},
|
||||
};
|
||||
const { rerender } = render(<PetSprite {...props} action="run-left" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
|
||||
rerender(<PetSprite {...props} action="run-right" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
|
||||
rerender(<PetSprite {...props} action="wave" />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
|
||||
rerender(<PetSprite {...props} action={null} />);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(310));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||
expect(sprite.dataset.row).toBe("4");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(sprite.dataset.row).toBe("0");
|
||||
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalHeight = 2288;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// Pose 12 (270°, left) = row 10, column 4.
|
||||
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
gaze={4}
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
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 → Appearance/.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", name: "Goku" }));
|
||||
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", name: "Dog" })
|
||||
);
|
||||
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", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
|
||||
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", name: "Dog" })
|
||||
);
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,16 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
|
||||
import {
|
||||
render,
|
||||
renderHook,
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
cleanup,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
@@ -79,6 +88,93 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the panel", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
type: "session_updated",
|
||||
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
});
|
||||
const bubble = screen.getByRole("status");
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet about a cleared error when that session updates again", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the panel header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the chat window", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stroking the pet back and forth pats it", () => {
|
||||
renderSagi();
|
||||
const pet = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
const xs = [100, 130, 100, 130, 100, 130, 100];
|
||||
act(() => {
|
||||
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
|
||||
});
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
@@ -147,4 +243,67 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSagiPosition moving", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("names the drag's direction and settles once the pointer rests", () => {
|
||||
const { result } = renderHook(() => useSagiPosition(68));
|
||||
const target = { setPointerCapture() {}, releasePointerCapture() {} };
|
||||
const ev = (x: number) =>
|
||||
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
|
||||
act(() => result.current.onPointerDown(ev(500)));
|
||||
act(() => result.current.onPointerMove(ev(480)));
|
||||
expect(result.current.moving).toBe("left");
|
||||
act(() => result.current.onPointerMove(ev(520)));
|
||||
expect(result.current.moving).toBe("right");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(result.current.moving).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a cleared session quiet while it stays errored", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
|
||||
expect(again.pulse).toBeNull();
|
||||
expect(statusOf(again.state).errorCount).toBe(0);
|
||||
expect(again.state.worriedUntil).toBe(0);
|
||||
const ev = reduceSagi(
|
||||
again.state,
|
||||
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
|
||||
T0 + 2
|
||||
);
|
||||
expect(ev.pulse).toBeNull();
|
||||
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alerts again once a cleared session recovers and fails anew", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(s.dismissed).not.toHaveProperty("b");
|
||||
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error alerts fire on the transition only", () => {
|
||||
it("does not re-alert on each update of a still-errored session", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
|
||||
expect(r.pulse).toBeNull();
|
||||
expect(statusOf(r.state).errorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
|
||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
|
||||
it("understands Vietnamese keywords", () => {
|
||||
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
|
||||
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
|
||||
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("names the sessions it reports when it knows them", () => {
|
||||
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
|
||||
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
|
||||
});
|
||||
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
|
||||
});
|
||||
|
||||
it("introduces itself by the pet's name and reacts to a pat", () => {
|
||||
const who = matchIntent("who are you", status(), { name: "Mochi" });
|
||||
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
|
||||
if (who.kind === "answer") expect(who.text).toContain("Mochi");
|
||||
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
|
||||
// "pat" inside another word is not a pat.
|
||||
expect(matchIntent("update the path", status()).kind).toBe("handoff");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||
session_start: "wave",
|
||||
session_done: "jump",
|
||||
run_done: "jump",
|
||||
subagent_spawn: "review",
|
||||
waiting: "wait",
|
||||
error: "fail",
|
||||
};
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||
* updates, agent errors and failure events stay silent until the session
|
||||
* goes active again or ends — otherwise every update of a still-errored
|
||||
* session re-raised the alert the user had just dismissed. */
|
||||
dismissed: Record<string, true>;
|
||||
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||
* panel can say WHICH session it means and open it. */
|
||||
info: Record<string, SessionInfo>;
|
||||
}
|
||||
|
||||
/** What Sagi knows about a session beyond its status. */
|
||||
export interface SessionInfo {
|
||||
name: string | null;
|
||||
cwd: string | null;
|
||||
lane_id: number | null;
|
||||
}
|
||||
|
||||
/** The session a pulse is about, when it is about one. */
|
||||
export interface SagiSubject extends SessionInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A session that needs the user: errored, or waiting on their input. */
|
||||
export interface AttentionItem extends SagiSubject {
|
||||
kind: "error" | "waiting";
|
||||
}
|
||||
|
||||
const infoOf = (s: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||
|
||||
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||
export function subjectLabel(s: SagiSubject): string {
|
||||
if (s.name && s.name.trim()) return s.name.trim();
|
||||
const base = s.cwd
|
||||
?.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop();
|
||||
return base || s.id.slice(0, 8);
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||
// The tracked session an agent/event message belongs to, when known.
|
||||
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||
let dismissed = state.dismissed;
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
// Anything but a (still) errored status ends a dismissal: a later error
|
||||
// of that session is a new one and must alert again.
|
||||
if (s.status !== "error" && s.id in dismissed) {
|
||||
dismissed = { ...dismissed };
|
||||
delete dismissed[s.id];
|
||||
}
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
if (s.id in dismissed) {
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
// Alert on the transition INTO error only; a session that stays
|
||||
// errored keeps getting updates and must not re-alert on each one.
|
||||
if (sessions[s.id] !== "error") {
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
}
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
return {
|
||||
state: { ...state, lastActivityAt: now },
|
||||
pulse,
|
||||
subject: pulse ? known(a.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
const isFailure =
|
||||
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
rows: ReadonlyArray<{
|
||||
id: string;
|
||||
status: string;
|
||||
awaiting_input_since?: string | null;
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
sessions[r.id] = "error";
|
||||
} else if (r.status === "active") {
|
||||
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
} else continue; // completed / abandoned: leave untracked.
|
||||
info[r.id] = infoOf(r);
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
return { ...state, sessions, info, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||
const out: AttentionItem[] = [];
|
||||
for (const kind of ["error", "waiting"] as const) {
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== kind) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||
* remember them as dismissed, so their next update does not bring the alert
|
||||
* straight back. */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
else {
|
||||
dismissed[id] = true;
|
||||
delete info[id];
|
||||
}
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* Understands English and Vietnamese keywords, names the sessions it reports
|
||||
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
||||
* No network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,6 +30,8 @@
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -54,70 +58,157 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { SagiStatus } from "./brain";
|
||||
import i18n from "../../i18n";
|
||||
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
||||
import { pickLine } from "./quips";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
export type AskResult =
|
||||
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
/** What the answer may mention beyond the counts. */
|
||||
export interface AskContext {
|
||||
/** The pet's shown name. */
|
||||
name?: string;
|
||||
/** Errored and waiting sessions, named (`attentionOf`). */
|
||||
attention?: ReadonlyArray<AttentionItem>;
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||
|
||||
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
||||
if (names.length === 0) return "";
|
||||
const shown = names.slice(0, 3).join(", ");
|
||||
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
||||
}
|
||||
|
||||
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
||||
export function statusSummary(status: SagiStatus): string {
|
||||
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
||||
if (status.waitingCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
||||
if (status.errorCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
||||
const q = query.trim().toLowerCase().normalize("NFC");
|
||||
const name = ctx.name || "Sagi";
|
||||
if (!q) return { kind: "answer", text: t("empty") };
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
||||
const word = (...words: string[]) =>
|
||||
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||
return { kind: "answer", text: t("help", { name }) };
|
||||
}
|
||||
|
||||
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
||||
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
||||
}
|
||||
|
||||
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
||||
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
||||
}
|
||||
|
||||
if (
|
||||
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
||||
word("pat")
|
||||
) {
|
||||
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
if (
|
||||
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||
: t("errorsNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
if (
|
||||
has(
|
||||
"waiting",
|
||||
"stuck",
|
||||
"blocked",
|
||||
"input",
|
||||
"my turn",
|
||||
"paused",
|
||||
"chờ",
|
||||
"đợi",
|
||||
"kẹt",
|
||||
"tới lượt",
|
||||
"đến lượt"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||
: t("waitingNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
if (
|
||||
has(
|
||||
"running",
|
||||
"active",
|
||||
"live",
|
||||
"going on",
|
||||
"happening",
|
||||
"in progress",
|
||||
"đang chạy",
|
||||
"đang làm",
|
||||
"hoạt động"
|
||||
)
|
||||
) {
|
||||
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
if (
|
||||
has(
|
||||
"status",
|
||||
"summary",
|
||||
"overview",
|
||||
"how are things",
|
||||
"how's it",
|
||||
"how is it",
|
||||
"tình hình",
|
||||
"trạng thái",
|
||||
"tổng quan",
|
||||
"thế nào",
|
||||
"sao rồi"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
text: t("status", {
|
||||
live: status.liveCount,
|
||||
waiting: status.waitingCount,
|
||||
errors: status.errorCount,
|
||||
conn: t(status.connected ? "connected" : "offline"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
||||
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
|
||||
@@ -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,83 @@
|
||||
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";
|
||||
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
|
||||
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
|
||||
const NAMES_KEY = "agenthub.pet.names";
|
||||
export const PET_NAME_MAX = 40;
|
||||
/** Most recent dismissed error sessions kept across reloads. */
|
||||
const DISMISSED_MAX = 200;
|
||||
/** 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 (and its display name) ride along so the mascot renders, and
|
||||
* introduces itself, without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return typeof p.name === "string" && p.name
|
||||
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
|
||||
: { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readNames(): Record<string, string> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
|
||||
export function petName(pet: PetChoice): string {
|
||||
const nick = readNames()[pet.id];
|
||||
if (nick) return nick;
|
||||
return ("name" in pet && pet.name) || "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 +196,51 @@ 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))))
|
||||
),
|
||||
/** Name a pet; an empty name gives it back its own. */
|
||||
setNickname: (id: string, name: string) => {
|
||||
const names = readNames();
|
||||
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
|
||||
if (clean) names[id] = clean;
|
||||
else delete names[id];
|
||||
writeAndNotify(NAMES_KEY, JSON.stringify(names));
|
||||
},
|
||||
/** The user's nickname for a pet, "" when none. */
|
||||
getNickname: (id: string) => readNames()[id] ?? "",
|
||||
/** Errored session ids the user cleared, so a reload keeps them quiet. */
|
||||
getDismissed: (): Record<string, true> => {
|
||||
try {
|
||||
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
|
||||
const out: Record<string, true> = {};
|
||||
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
setDismissed: (dismissed: Record<string, true>) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISMISSED_KEY,
|
||||
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
|
||||
);
|
||||
} catch {
|
||||
// Best-effort, like every pref here; no prefs event — widget-local state.
|
||||
}
|
||||
},
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -24,10 +24,13 @@
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
* - `../../i18n`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -59,76 +62,60 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
"session_done",
|
||||
"session_start",
|
||||
"subagent_spawn",
|
||||
"waiting",
|
||||
"error",
|
||||
"run_done",
|
||||
// Moods (steady-state flavor)
|
||||
"disconnected",
|
||||
"worried",
|
||||
"stuck",
|
||||
"happy",
|
||||
"thinking",
|
||||
"watching",
|
||||
"sleeping",
|
||||
"idle",
|
||||
];
|
||||
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||
* `rand` is injectable for deterministic tests. Returns "" for a missing or
|
||||
* empty pool (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
export function pickLine(
|
||||
path: string,
|
||||
rand: () => number = Math.random,
|
||||
vars?: Record<string, unknown>
|
||||
): string {
|
||||
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
|
||||
if (!Array.isArray(pool) || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
const line = pool[i];
|
||||
return typeof line === "string" ? line : "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
/**
|
||||
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
|
||||
* key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
|
||||
}
|
||||
|
||||
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
|
||||
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||
* forever: each plays a few cycles when the mood begins and then rests — an
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -206,20 +210,20 @@
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.sagi-avatar[data-mood="idle"] {
|
||||
animation: sagi-breathe 4s ease-in-out infinite;
|
||||
animation: sagi-breathe 4s ease-in-out 0s 3;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||
note in SagiAvatar.tsx). */
|
||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
||||
animation: sagi-blink 5s ease-in-out infinite;
|
||||
animation: sagi-blink 5s ease-in-out 0s 3;
|
||||
}
|
||||
/* happy: whole-mascot bob + twinkling sparkle */
|
||||
.sagi-avatar[data-mood="happy"] {
|
||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||
}
|
||||
/* worried: alarmed shake */
|
||||
@@ -228,19 +232,97 @@
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
animation: sagi-droop 2.4s ease-in-out 0s 3;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
animation: sagi-rock 1.8s ease-in-out 0s 4;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
animation: sagi-sway 3.2s ease-in-out 0s 3;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.sagi-avatar[data-mood="sleeping"] {
|
||||
animation: sagi-breathe 5s ease-in-out infinite;
|
||||
animation: sagi-breathe 5s ease-in-out 0s 3;
|
||||
}
|
||||
|
||||
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
||||
Shared by Sagi's own art and an uploaded still image. ── */
|
||||
.sagi-avatar[data-moment="jump"],
|
||||
.sagi-pet-image[data-moment="jump"] {
|
||||
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
||||
}
|
||||
.sagi-avatar[data-moment="wave"],
|
||||
.sagi-pet-image[data-moment="wave"] {
|
||||
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-moment="fail"],
|
||||
.sagi-pet-image[data-moment="fail"] {
|
||||
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="wait"],
|
||||
.sagi-pet-image[data-moment="wait"] {
|
||||
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="review"],
|
||||
.sagi-pet-image[data-moment="review"] {
|
||||
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||
}
|
||||
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px;
|
||||
}
|
||||
@keyframes sagi-hop {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(0) scale(1.08, 0.92);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-14%) scale(0.96, 1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(0) scale(1.04, 0.96);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-peek {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) translateX(0);
|
||||
}
|
||||
25%,
|
||||
40% {
|
||||
transform: rotate(-7deg) translateX(-4%);
|
||||
}
|
||||
60%,
|
||||
75% {
|
||||
transform: rotate(7deg) translateX(4%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── reduced motion: kill all continuous animation ── */
|
||||
@@ -339,7 +421,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
|
||||
}
|
||||
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||
.sagi-error-dot[data-kind="waiting"] {
|
||||
background: #f59e0b;
|
||||
animation: none;
|
||||
}
|
||||
.sagi-pet-image {
|
||||
transform-origin: 50% 92%;
|
||||
}
|
||||
.sagi-bubble {
|
||||
max-width: 16rem;
|
||||
@@ -356,6 +446,41 @@
|
||||
.sagi-bubble-enter {
|
||||
animation: sagi-bubble-in 0.22s ease-out;
|
||||
}
|
||||
.sagi-bubble[data-tone="error"] {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
.sagi-bubble[data-tone="waiting"] {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
/* The session(s) a bubble is about, and the button that opens it. */
|
||||
.sagi-bubble-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: #a8a8c0;
|
||||
}
|
||||
.sagi-bubble-subject {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sagi-bubble-open {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sagi-bubble-open:hover {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
@keyframes sagi-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -369,3 +494,199 @@
|
||||
.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 0s 3;
|
||||
-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;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
||||
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
||||
rgb(var(--surface-1)) 100%
|
||||
);
|
||||
}
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
/* Hearts float up from the pet and its line shows beside it, inside the
|
||||
avatar button — so a pat reads even while the panel or the chat is open
|
||||
(the speech bubble hides then). */
|
||||
.sagi-reaction {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sagi-hearts {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sagi-hearts > span {
|
||||
animation: sagi-heart-up 1.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes sagi-heart-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.6);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-34px) scale(0.9);
|
||||
}
|
||||
}
|
||||
/* The line sits on the side away from the screen edge the pet docks to. */
|
||||
.sagi-reaction-text {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
||||
color: rgb(var(--fg-primary));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
animation: sagi-reaction-in 2.4s ease-out forwards;
|
||||
}
|
||||
.sagi-reaction-text[data-side="right"] {
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.sagi-reaction-text[data-side="left"] {
|
||||
left: calc(100% + 6px);
|
||||
}
|
||||
@keyframes sagi-reaction-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
10%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-hearts > span,
|
||||
.sagi-reaction-text {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Appearance (`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, name: p.displayName });
|
||||
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;
|
||||
}
|
||||
@@ -2,8 +2,12 @@
|
||||
* @file useSagiBrain.ts
|
||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||
* the current speech bubble (naming the session it is about, and coalescing a
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,124 +66,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
attentionOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
subjectLabel,
|
||||
MOMENT_OF_PULSE,
|
||||
type AttentionItem,
|
||||
type Mood,
|
||||
type PetMoment,
|
||||
type SagiPulse,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
type SagiSubject,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
||||
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
||||
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
||||
if (next === prev) return true;
|
||||
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
||||
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
||||
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// A bubble that names a session stays long enough to click it.
|
||||
const SUBJECT_BUBBLE_MS = 7000;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
// The same pulse again within this window joins the bubble on show as a count.
|
||||
const COALESCE_MS = 8000;
|
||||
// How long a moment (wave, hop, flinch…) plays over the mood.
|
||||
const MOMENT_MS = 2400;
|
||||
// An idle pet fidgets (waves or looks around) once in a while.
|
||||
const FIDGET_MIN_MS = 45_000;
|
||||
const FIDGET_SPAN_MS = 60_000;
|
||||
// Greet once per browser session, shortly after the snapshot lands.
|
||||
const GREETED_KEY = "agenthub.pet.greeted";
|
||||
const GREET_DELAY_MS = 1200;
|
||||
|
||||
/** What the speech bubble shows. */
|
||||
export interface SagiBubble {
|
||||
text: string;
|
||||
/** Which session(s) it is about, shown under the text. */
|
||||
detail?: string;
|
||||
/** The one session it is about, which a click opens. */
|
||||
subject?: SagiSubject;
|
||||
/** Errors and waits are drawn with an accent. */
|
||||
tone?: "error" | "waiting";
|
||||
}
|
||||
|
||||
export interface SagiBrain {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: AttentionItem[];
|
||||
bubble: SagiBubble | null;
|
||||
dismissBubble: () => void;
|
||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||
* errors). Bypasses the throttle because the caller decided this message
|
||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||
setBubble: (text: string) => void;
|
||||
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
||||
* repeat of the same one restarts it. */
|
||||
moment: { kind: PetMoment; key: number } | null;
|
||||
/** Play a moment by hand (an answer, a pat). */
|
||||
playMoment: (kind: PetMoment) => void;
|
||||
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
||||
* even while the panel or chat is open, and while muted (it was asked). */
|
||||
pat: () => void;
|
||||
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
||||
reaction: { text: string; key: number } | null;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
||||
|
||||
function burstBubble(b: Burst): SagiBubble {
|
||||
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
||||
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
||||
const labels = b.subjects.map(subjectLabel);
|
||||
const detail =
|
||||
labels.length > 2
|
||||
? i18n.t("settings:sagi.bubbleMore", {
|
||||
list: labels.slice(0, 2).join(", "),
|
||||
count: labels.length - 2,
|
||||
})
|
||||
: labels.join(", ") || undefined;
|
||||
const text =
|
||||
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
||||
return { text, detail, subject: one, tone };
|
||||
}
|
||||
|
||||
export function useSagiBrain(): SagiBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
connected: true,
|
||||
dismissed: sagiPrefs.getDismissed(),
|
||||
}));
|
||||
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
||||
// so a pulse notifies exactly once; this ref is the state it folds into.
|
||||
const stateRef = useRef(state);
|
||||
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
||||
const next = fn(stateRef.current);
|
||||
if (next === stateRef.current) return;
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubbleState] = useState<string | null>(null);
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep cleared alerts cleared across reloads.
|
||||
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
setBubbleState(b);
|
||||
bubbleTimer.current = setTimeout(() => {
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, ms);
|
||||
}, []);
|
||||
|
||||
const playMoment = useCallback((kind: PetMoment) => {
|
||||
clearTimeout(momentTimer.current);
|
||||
setMoment({ kind, key: ++momentKey.current });
|
||||
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
||||
playMoment(MOMENT_OF_PULSE[pulse]);
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
const b = burst.current;
|
||||
// The same kind of news again, while its bubble is still up: count it in.
|
||||
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
||||
b.count++;
|
||||
b.at = t;
|
||||
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
||||
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
||||
return;
|
||||
}
|
||||
const force = pulse === "error" || pulse === "waiting";
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
||||
burst.current = next;
|
||||
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
||||
},
|
||||
[playMoment, putBubble]
|
||||
);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
// Then say hello, once per browser session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const greet = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(GREETED_KEY)) return;
|
||||
sessionStorage.setItem(GREETED_KEY, "1");
|
||||
} catch {
|
||||
return; // No storage: rather silent than a hello on every page.
|
||||
}
|
||||
greetTimer = setTimeout(() => {
|
||||
if (cancelled || mutedRef.current) return;
|
||||
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
||||
if (!hello) return;
|
||||
lastBubbleAt.current = Date.now();
|
||||
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
||||
playMoment("wave");
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(greet);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(greetTimer);
|
||||
};
|
||||
}, []);
|
||||
}, [update, putBubble, playMoment]);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
// resolution: keep the old state then, so a hook burst (several frames a
|
||||
// second) does not re-render the mascot on every frame.
|
||||
if (!onlyActivityMoved(prev, next)) update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
}, [update, notify]);
|
||||
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
// Only a tick that changes the mood re-renders: an idle mascot is not
|
||||
// drawn again every second for nothing.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
clearTimeout(momentTimer.current);
|
||||
clearTimeout(reactionTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
// Judged at render time: `tick` only moves when the mood would change, so
|
||||
// it may be old when a frame re-renders.
|
||||
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
const attention = useMemo(() => attentionOf(state), [state]);
|
||||
|
||||
// An idle pet is not a statue: every minute or so it waves or looks around.
|
||||
const moodRef = useRef(mood);
|
||||
moodRef.current = mood;
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const schedule = () => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
||||
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
||||
schedule();
|
||||
},
|
||||
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
||||
);
|
||||
};
|
||||
schedule();
|
||||
return () => clearTimeout(timer);
|
||||
}, [playMoment]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
@@ -189,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
const clearAlerts = useCallback(() => {
|
||||
update((prev) => clearErrors(prev));
|
||||
if (burst.current?.pulse === "error") dismissBubble();
|
||||
}, [update, dismissBubble]);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[update]
|
||||
);
|
||||
|
||||
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||
// bypassing the throttled mood-driven showBubble path.
|
||||
const setBubble = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
}, []);
|
||||
const setBubble = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return;
|
||||
burst.current = null;
|
||||
putBubble({ text });
|
||||
},
|
||||
[putBubble]
|
||||
);
|
||||
|
||||
const pat = useCallback(() => {
|
||||
playMoment("jump");
|
||||
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
||||
clearTimeout(reactionTimer.current);
|
||||
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
||||
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
||||
}, [playMoment, update]);
|
||||
|
||||
return {
|
||||
mood,
|
||||
status,
|
||||
attention,
|
||||
bubble,
|
||||
dismissBubble,
|
||||
setBubble,
|
||||
moment,
|
||||
playMoment,
|
||||
pat,
|
||||
reaction,
|
||||
muted,
|
||||
toggleMute,
|
||||
clearAlerts,
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
* 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.
|
||||
* `moving` names the way it is heading — while dragged (cleared once the
|
||||
* pointer rests) and during the glide to its edge — so a pet can run that way.
|
||||
* 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 +75,31 @@
|
||||
|
||||
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;
|
||||
// A held but unmoving drag stops running after this; also the snap glide's length.
|
||||
const MOVE_SETTLE_MS = 300;
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -101,17 +113,31 @@ export interface SagiPlacement {
|
||||
/** True when the avatar sits in the lower half - flyouts open upward. */
|
||||
openUp: boolean;
|
||||
dragging: boolean;
|
||||
/** The way the avatar is moving horizontally right now, if it is. */
|
||||
moving: "left" | "right" | null;
|
||||
onPointerDown: (e: ReactPointerEvent) => void;
|
||||
onPointerMove: (e: ReactPointerEvent) => void;
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
/** 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
|
||||
const [moving, setMoving] = useState<"left" | "right" | null>(null);
|
||||
const moveTimer = useRef(0);
|
||||
const lastX = useRef(0);
|
||||
const heading = useCallback((dir: "left" | "right" | null) => {
|
||||
window.clearTimeout(moveTimer.current);
|
||||
setMoving(dir);
|
||||
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
|
||||
|
||||
const draggedRef = useRef(false);
|
||||
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
|
||||
@@ -127,7 +153,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(
|
||||
@@ -141,45 +167,84 @@ export function useSagiPosition(): SagiPlacement {
|
||||
/* capture unsupported - window-free fallback still works via props */
|
||||
}
|
||||
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
|
||||
lastX.current = e.clientX;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[screen.left, screen.top]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - 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 });
|
||||
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
|
||||
const stepX = e.clientX - lastX.current;
|
||||
if (Math.abs(stepX) >= 2) {
|
||||
lastX.current = e.clientX;
|
||||
heading(stepX > 0 ? "right" : "left");
|
||||
}
|
||||
},
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
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
|
||||
// Run along the glide to the edge, when there is a real distance to cover.
|
||||
const glide = restingScreen(next, size).left - live.left;
|
||||
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size, heading]
|
||||
);
|
||||
|
||||
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();
|
||||
if (next.side !== pos.side) heading(next.side);
|
||||
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, heading]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
@@ -190,13 +255,15 @@ 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,
|
||||
moving,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
* @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 space is tight — in its split pane (`data-menu-bounds`, see
|
||||
* `menuRoom`) or else the viewport — 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,9 +72,17 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
* over anything crossing its edge — else the viewport. */
|
||||
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
|
||||
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
|
||||
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
|
||||
}
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
export interface SelectOption<T extends string> {
|
||||
/** Stored value emitted via `onChange`. */
|
||||
@@ -74,6 +91,10 @@ 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;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -86,23 +107,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 +146,129 @@ 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 { above, below } = menuRoom(
|
||||
buttonRef.current,
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
);
|
||||
setOpenUp(below < 288 && above > 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 +276,22 @@ 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 ${
|
||||
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||
} ${
|
||||
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 +299,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,59 +65,72 @@ 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";
|
||||
|
||||
interface SessionOverviewProps {
|
||||
session: Session;
|
||||
agents: Agent[];
|
||||
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
|
||||
stats?: SessionStats;
|
||||
}
|
||||
|
||||
/** 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 = 5000;
|
||||
|
||||
/** Compact tile used in the top stat row. */
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
help,
|
||||
icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: string;
|
||||
/** Plain-language explanation, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
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-800 dark: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-800 dark:text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark: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-700 dark:text-violet-400",
|
||||
violet: "text-syntax-keyword",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-700 dark:text-rose-400",
|
||||
cyan: "text-cyan-700 dark:text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
rose: "text-status-danger",
|
||||
cyan: "text-syntax-property",
|
||||
blue: "text-syntax-builtin",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
<span className={iconTone}>{icon}</span>
|
||||
{label}
|
||||
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||
{value}
|
||||
@@ -157,21 +170,28 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [fetched, setStats] = useState<SessionStats | null>(null);
|
||||
const stats = frozen ?? fetched;
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (session.status !== "active") return;
|
||||
if (frozen || session.status !== "active") return;
|
||||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
}, [frozen, 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,56 +200,44 @@ 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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load + reload when the session id changes.
|
||||
useEffect(() => {
|
||||
if (frozen) return;
|
||||
fetchStats();
|
||||
// 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,
|
||||
enabled: !frozen,
|
||||
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 +257,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 +295,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 +317,56 @@ 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")}
|
||||
help={t("common:insights.help.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")}
|
||||
help={t("common:insights.help.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")}
|
||||
help={t("common:insights.help.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")}
|
||||
help={t("common:insights.help.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label="Errors"
|
||||
label={t("overview.errors")}
|
||||
help={t("common:insights.help.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 +378,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-700 dark: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 +427,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 +439,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-700 dark: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
|
||||
@@ -466,11 +491,16 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
{totalTokens > 0 && (
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<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">
|
||||
<h3
|
||||
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
|
||||
title={t("common:insights.help.tokenFlow")}
|
||||
>
|
||||
<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 +511,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 +532,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-700 dark: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-700 dark: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-700 dark: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>
|
||||
);
|
||||
}
|
||||
@@ -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 (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). 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>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,20 +49,21 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Gem,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
Settings,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Languages,
|
||||
RefreshCw,
|
||||
X,
|
||||
Plug,
|
||||
@@ -70,14 +71,17 @@ import {
|
||||
Gauge,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Monitor,
|
||||
Moon,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
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,23 +91,15 @@ 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" },
|
||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
|
||||
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
|
||||
] 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;
|
||||
@@ -113,12 +109,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 {
|
||||
@@ -162,23 +164,48 @@ 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 };
|
||||
/** Expanded width in px, chosen by dragging its edge (see Layout). */
|
||||
width?: number;
|
||||
/** True while that edge is dragged: no width transition lagging the pointer. */
|
||||
resizing?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, mode, setMode, toggleTheme } = useTheme();
|
||||
export function Sidebar({
|
||||
wsConnected,
|
||||
collapsed,
|
||||
onToggle,
|
||||
connectionOpen,
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
width = 240,
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||
// link's own match (the path) cannot tell when it is the one on screen.
|
||||
const location = useLocation();
|
||||
const settingsOn =
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const perms = usePermissions();
|
||||
const canUpdate = perms.isAdmin;
|
||||
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).
|
||||
@@ -187,7 +214,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
|
||||
);
|
||||
@@ -368,41 +394,60 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
? t("nav:checkingForUpdates")
|
||||
: checkError
|
||||
? t("nav:checkFailed")
|
||||
: 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 })
|
||||
: 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);
|
||||
}
|
||||
};
|
||||
// Same density and selection as a workspace row, so the whole sidebar
|
||||
// reads as one list: no border, the active item tinted with an accent icon.
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-2.5 rounded-md text-[13px] transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2" : "px-2.5 py-1.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 font-medium text-fg-primary [&>svg]:text-accent"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`;
|
||||
|
||||
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 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
|
||||
}`}
|
||||
style={drawer || collapsed ? undefined : { width }}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
@@ -426,12 +471,21 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
</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">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 space-y-1">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{[
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
<NavLink
|
||||
@@ -439,21 +493,29 @@ 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}
|
||||
data-testid="sidebar-tree-rail"
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 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
|
||||
@@ -479,223 +541,95 @@ 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}
|
||||
{/* Server mode: who is signed in — avatar, name and role, opening
|
||||
Settings → Account — and Log out. On the rail they stack. */}
|
||||
{auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
(() => {
|
||||
const user = auth.state.user;
|
||||
const name = user.displayName || user.username;
|
||||
const role = t(`users:roles.${user.role}`);
|
||||
const signedIn = t("common:auth.signedInAs", { name, role });
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar-account"
|
||||
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
|
||||
collapsed ? "flex-col items-center" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{/* One column per language: a wider grid squeezed each button
|
||||
below the width of its own label once Theme grew a third. */}
|
||||
<div className="mt-2 grid grid-cols-2 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-1 py-1.5 text-center 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-3 gap-1">
|
||||
{(["dark", "light", "system"] as const).map((option) => {
|
||||
const active = option === mode;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setMode(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" />
|
||||
) : option === "light" ? (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<NavLink
|
||||
to={settingsHref("account", location)}
|
||||
title={signedIn}
|
||||
aria-label={signedIn}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
|
||||
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
|
||||
}`}
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={user.avatarAt}
|
||||
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${signedIn} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</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={settingsHref("", location)}
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={() => `${navClass({ isActive: settingsOn })} ${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-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 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}
|
||||
@@ -704,6 +638,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
eventTimestampsRef={eventTimestampsRef}
|
||||
typeCountRef={typeCountRef}
|
||||
recentEventsRef={recentEventsRef}
|
||||
update={{
|
||||
title: checkTitle,
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: canUpdate ? onCheckUpdates : undefined,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
peakPerSecRef.current = 0;
|
||||
@@ -737,6 +678,16 @@ 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;
|
||||
/** Absent for a user who may not update (server mode, not an admin). */
|
||||
onCheck?: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
|
||||
@@ -751,6 +702,7 @@ function ConnectionStatusModal({
|
||||
eventTimestampsRef,
|
||||
typeCountRef,
|
||||
recentEventsRef,
|
||||
update,
|
||||
onResetStats,
|
||||
}: ConnectionStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -766,15 +718,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 "";
|
||||
@@ -803,14 +748,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
|
||||
@@ -852,13 +800,51 @@ 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>
|
||||
{update.onCheck && (
|
||||
<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
|
||||
@@ -1045,6 +1031,7 @@ function Sparkline({
|
||||
connected: boolean;
|
||||
avgLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const W = 320;
|
||||
const H = 56;
|
||||
const max = Math.max(1, ...buckets);
|
||||
@@ -1056,15 +1043,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)" />}
|
||||
@@ -1072,7 +1059,7 @@ function Sparkline({
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
style={{ stroke }}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
@@ -1083,7 +1070,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>
|
||||
);
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
* ## Public surface
|
||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -52,11 +51,6 @@
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TextSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TableRowSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
@@ -65,13 +59,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 +83,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}
|
||||
/>
|
||||
@@ -111,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text placeholder. Good for trend/sub labels.
|
||||
*/
|
||||
export function TextSkeleton({
|
||||
className = "",
|
||||
width = "w-16",
|
||||
}: {
|
||||
className?: string;
|
||||
width?: string;
|
||||
}) {
|
||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||
* the skeleton lines up under existing headers.
|
||||
@@ -133,7 +115,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>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file SnapshotButton.tsx
|
||||
* @description The session analysis page's **Snapshot** action: confirms with a
|
||||
* "redact file paths" choice, freezes the session through `POST
|
||||
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Camera } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { CopyButton } from "./event-views/primitives";
|
||||
|
||||
export function snapshotUrl(token: string): string {
|
||||
return `${window.location.origin}/snapshot/${token}`;
|
||||
}
|
||||
|
||||
export function SnapshotButton({ sessionId }: { sessionId: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [redact, setRedact] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
|
||||
setToken(snapshot.token);
|
||||
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{token && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
|
||||
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
|
||||
{t("snapshot.open")}
|
||||
</Link>
|
||||
<CopyButton text={snapshotUrl(token)} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
title={t("snapshot.hint")}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Camera className="h-4 w-4" />
|
||||
{t("snapshot.create")}
|
||||
</button>
|
||||
{error && (
|
||||
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
title={t("snapshot.title")}
|
||||
message={t("snapshot.body")}
|
||||
confirmLabel={t("snapshot.confirm")}
|
||||
cancelLabel={t("snapshot.cancel")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
onConfirm={() => void create()}
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SplashScreen.tsx
|
||||
* @description Branding splash shown once per browser session on app load. A
|
||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
||||
* dark-tech "constellation" overlay built around the SmartGift brand mark:
|
||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||
@@ -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);
|
||||
};
|
||||
@@ -136,7 +143,7 @@ export function SplashScreen() {
|
||||
<div className="splash-grain" aria-hidden="true" />
|
||||
|
||||
<div className="splash-content">
|
||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
||||
{/* Brand mark - the SmartGift logo */}
|
||||
<div className="splash-mark" aria-hidden="true">
|
||||
<span className="splash-mark-glow" />
|
||||
<BrandMark />
|
||||
@@ -160,74 +167,10 @@ export function SplashScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
||||
* with animated connector lines and pulsing outer nodes. */
|
||||
/** The SmartGift mark — the same file as the favicon, local so the splash
|
||||
* needs no network. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
||||
<defs>
|
||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
className="splash-hex"
|
||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
||||
fill="url(#splashBg)"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
||||
<line
|
||||
className="splash-line"
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="16"
|
||||
y2="7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-2"
|
||||
x1="18.6"
|
||||
y1="17.5"
|
||||
x2="24"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-3"
|
||||
x1="13.4"
|
||||
y1="17.5"
|
||||
x2="8"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
||||
<circle
|
||||
className="splash-node splash-node-2"
|
||||
cx="24.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
<circle
|
||||
className="splash-node splash-node-3"
|
||||
cx="7.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
|
||||
}
|
||||
|
||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||
@@ -344,13 +287,6 @@ const SPLASH_CSS = `
|
||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
||||
.splash-line-2 { animation-delay: 0.62s; }
|
||||
.splash-line-3 { animation-delay: 0.74s; }
|
||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
||||
|
||||
.splash-greeting {
|
||||
display: flex;
|
||||
@@ -428,11 +364,7 @@ const SPLASH_CSS = `
|
||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -442,9 +374,6 @@ const SPLASH_CSS = `
|
||||
.splash-star,
|
||||
.splash-mark,
|
||||
.splash-mark-glow,
|
||||
.splash-hex,
|
||||
.splash-line,
|
||||
.splash-node,
|
||||
.splash-dot,
|
||||
.splash-greeting,
|
||||
.splash-tagline,
|
||||
@@ -453,7 +382,6 @@ const SPLASH_CSS = `
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
opacity: 1 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,10 @@ 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;
|
||||
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,11 +93,16 @@ export function StatCard({
|
||||
accentColor = "text-accent",
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
help,
|
||||
}: 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">
|
||||
<span
|
||||
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||
@@ -109,6 +119,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-700/90 dark: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,516 @@
|
||||
/**
|
||||
* @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. At the right
|
||||
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||
* its fullest window, live account only — and, on hover or focus, a panel
|
||||
* above grouping every account per tool (saved Claude profiles too): name,
|
||||
* sign-in expiry, and a row per window with its bar, percent and reset time.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Re-run `load` every `ms` and whenever the tab becomes visible again. */
|
||||
function useVisiblePoll(load: () => void, ms: number, deps: unknown[]) {
|
||||
useEffect(() => {
|
||||
const tick = () => document.visibilityState === "visible" && load();
|
||||
tick();
|
||||
const id = setInterval(tick, ms);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
const fullest = (q: ToolQuota) => Math.max(0, ...q.windows.map((w) => w.usedPercent ?? 0));
|
||||
const quotaTone = (p: number) =>
|
||||
p >= 90 ? "text-status-danger" : p >= 70 ? "text-status-warning" : "";
|
||||
const barTone = (p: number) =>
|
||||
p >= 90 ? "bg-status-danger" : p >= 70 ? "bg-status-warning" : "bg-fg-muted";
|
||||
|
||||
function untilText(iso: string | null): string | null {
|
||||
const ms = iso ? Date.parse(iso) - Date.now() : NaN;
|
||||
if (!(ms > 0)) return null;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const d = Math.floor(m / 1440);
|
||||
const h = Math.floor((m % 1440) / 60);
|
||||
return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
}
|
||||
|
||||
/** "account · sign-in state · as of", whichever parts the entry has. */
|
||||
function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) => string) {
|
||||
const until = untilText(q.expiresAt ?? null);
|
||||
return [
|
||||
q.label && q.account,
|
||||
q.expiresAt &&
|
||||
q.error !== "ELIVEEXPIRED" &&
|
||||
(until
|
||||
? t("statusBar.quotaSignInExpires", { time: until })
|
||||
: t("statusBar.quotaSignInExpired")),
|
||||
q.asOf && t("statusBar.quotaAsOf", { ago: timeAgo(q.asOf) }),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const meta = quotaMeta(q, t);
|
||||
return (
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{q.plan && (
|
||||
<span
|
||||
data-testid="status-bar-quota-plan"
|
||||
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||
>
|
||||
{q.plan}
|
||||
</span>
|
||||
)}
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="truncate text-[10px] text-fg-muted">{meta}</div>}
|
||||
{q.error && !q.windows.length ? (
|
||||
<div className="text-status-warning">
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w, i) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
||||
{w.group && w.group !== q.windows[i - 1]?.group && (
|
||||
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
||||
{w.group}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
<span className="h-1.5 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className={`block h-full ${barTone(p)}`}
|
||||
style={{ width: `${Math.min(p, 100)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
||||
<span className="text-right text-fg-muted">
|
||||
{resets && t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuotaPanel({
|
||||
tools,
|
||||
onEnter,
|
||||
onLeave,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: {
|
||||
tools: ToolQuota[];
|
||||
/** The footer's Refresh: re-reads the quota, as a click on the chips does. */
|
||||
onRefresh: () => void;
|
||||
/** A forced read is in flight: the Refresh icon spins. */
|
||||
refreshing: boolean;
|
||||
/** The pointer moved onto the panel / off it: it stays open while read. */
|
||||
onEnter: () => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const groups = (Object.keys(QUOTA_TOOL_LABEL) as ToolQuota["id"][])
|
||||
.map((id) => [id, tools.filter((q) => q.id === id)] as const)
|
||||
.filter(([, list]) => list.length);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("statusBar.quotaTitle")}
|
||||
data-testid="status-bar-quota-panel"
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
onFocus={onEnter}
|
||||
onBlur={onLeave}
|
||||
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
<div key={id} className="mb-2 border-b border-border pb-2 last-of-type:border-0">
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<AiToolIcon tool={{ id, label: QUOTA_TOOL_LABEL[id] }} className="h-4 w-4 !p-0" />
|
||||
<span className="font-semibold">{QUOTA_TOOL_LABEL[id]}</span>
|
||||
</div>
|
||||
<div className="space-y-2 pl-[1.375rem]">
|
||||
{list.map((q, i) => (
|
||||
<QuotaAccount
|
||||
key={`${q.label ?? q.account ?? ""}-${i}`}
|
||||
q={q}
|
||||
many={list.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota-refresh"
|
||||
// Not `disabled`: a focused button that turns disabled loses focus, and
|
||||
// that blur would close the panel under the pointer.
|
||||
onClick={() => !refreshing && onRefresh()}
|
||||
aria-busy={refreshing}
|
||||
className={`flex items-center gap-1 text-[10px] text-fg-muted ${
|
||||
refreshing ? "cursor-default" : "hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${refreshing ? "animate-spin" : ""}`} aria-hidden />
|
||||
{t(refreshing ? "statusBar.quotaRefreshing" : "statusBar.quotaRefresh")}
|
||||
</button>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
/** The right-end quota chips; nothing at all until a tool reports one. */
|
||||
function QuotaChips() {
|
||||
const { t } = useTranslation("nav");
|
||||
const [tools, setTools] = useState<ToolQuota[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
};
|
||||
const hide = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined),
|
||||
[]
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={refresh}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
className="ml-auto flex items-center gap-3 tabular-nums hover:text-fg-primary"
|
||||
>
|
||||
{tools
|
||||
.filter((q) => q.active !== false)
|
||||
.map((q) => {
|
||||
const p = Math.round(fullest(q));
|
||||
return (
|
||||
<span key={q.id} className="flex items-center gap-1">
|
||||
<AiToolIcon
|
||||
tool={{ id: q.id, label: QUOTA_TOOL_LABEL[q.id] }}
|
||||
className="h-4 w-4 !p-0"
|
||||
/>
|
||||
<span className="sr-only">{QUOTA_TOOL_LABEL[q.id]}</span>
|
||||
{q.error ? (
|
||||
<span className="text-status-warning">!</span>
|
||||
) : (
|
||||
<span className={quotaTone(p)}>{t("statusBar.quotaUsed", { pct: p })}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</button>
|
||||
{open && (
|
||||
<QuotaPanel
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
// The budget furthest past one of its thresholds, if any.
|
||||
const [budget, setBudget] = useState<BudgetStatus | null>(null);
|
||||
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);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.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}
|
||||
/>
|
||||
)}
|
||||
{budget && (
|
||||
<Link
|
||||
to="/?tab=analytics&sub=cost"
|
||||
data-testid="status-bar-budget"
|
||||
title={t("statusBar.budgetTitle", {
|
||||
name: budget.name,
|
||||
spend: usd(budget.spend),
|
||||
limit: usd(budget.config.limit_usd ?? 0),
|
||||
})}
|
||||
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
|
||||
>
|
||||
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
|
||||
</Link>
|
||||
)}
|
||||
<QuotaChips />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
* next `title=` someone writes would be back to the browser's grey box.
|
||||
* {@link GlobalTooltip} instead listens once at the document: on hover or
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* by removing the attribute — its ancestors' too, or the browser falls back
|
||||
* to the nearest one's — for as long as the bubble is up, and restores them
|
||||
* on the way out. Nothing else in the app has to know it exists, and a control
|
||||
* keeps its native tooltip (and its accessible name) when JS has not run.
|
||||
* While the title is parked, a control with no other name (icon-only) carries
|
||||
@@ -50,6 +51,40 @@ const SELECTOR = "[title]:not([data-tip-off]), [data-tip]";
|
||||
type Anchored = { text: string; rect: DOMRect };
|
||||
type Placed = { left: number; top: number; arrow: number; above: boolean };
|
||||
|
||||
/**
|
||||
* Takes `el`'s title into `data-tip`. React removing `title` while it is
|
||||
* parked calls removeAttribute on an attribute that is already gone: no
|
||||
* MutationRecord, nothing to see — and putting the parked copy back would
|
||||
* write stale text for good. So, on THIS element and only while parked, that
|
||||
* call is taken as React's intent: the parked copy is dropped and `onDrop`
|
||||
* runs. (React DOM calls `node.removeAttribute`, the instance method; `unpark`
|
||||
* deletes the shim.) No global prototype is touched.
|
||||
*/
|
||||
function park(el: HTMLElement, text: string, onDrop?: () => void) {
|
||||
el.dataset.tip = text;
|
||||
el.removeAttribute("title");
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
onDrop?.();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
}
|
||||
|
||||
/** Undoes {@link park}. */
|
||||
function unpark(el: HTMLElement) {
|
||||
// Drop the instance shim; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text === undefined) return;
|
||||
// React re-set `title` while it was parked (a poll flipped the hint): that
|
||||
// value is current, the parked copy is stale. React never re-applies an
|
||||
// unchanged prop, so writing the old one back would win for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount ONCE, near the router root. Every `title` in the app becomes a
|
||||
* designed tooltip; nothing else has to change.
|
||||
@@ -62,6 +97,8 @@ export function GlobalTooltip() {
|
||||
// put back even if the pointer never leaves it (a route change, a re-render
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
// Ancestors of `parked` whose titles are parked with it (see `open`).
|
||||
const silenced = useRef<HTMLElement[]>([]);
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
// The element whose delay is running, so re-entering it does not restart it.
|
||||
const pending = useRef<HTMLElement | null>(null);
|
||||
@@ -69,19 +106,11 @@ export function GlobalTooltip() {
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
silenced.current.forEach(unpark);
|
||||
silenced.current = [];
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
// React re-set `title` while it was parked (a poll flipped the hint):
|
||||
// that value is current, the parked copy is stale. React never
|
||||
// re-applies an unchanged prop, so writing the old one back would win
|
||||
// for good.
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", text);
|
||||
delete el.dataset.tip;
|
||||
}
|
||||
unpark(el);
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
@@ -105,22 +134,16 @@ export function GlobalTooltip() {
|
||||
if (!el.isConnected) return;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
park(el, current, restore);
|
||||
// With its own title gone the browser shows the nearest ANCESTOR's
|
||||
// native one instead (a pane header's drag hint over its Close
|
||||
// button's bubble), so every ancestor title is parked too.
|
||||
for (let a = el.parentElement; a; a = a.parentElement) {
|
||||
const t = a.getAttribute("title");
|
||||
if (t === null) continue;
|
||||
park(a, t);
|
||||
silenced.current.push(a);
|
||||
}
|
||||
// The title was an icon-only control's ONLY name; keep it named.
|
||||
if (
|
||||
!el.hasAttribute("aria-label") &&
|
||||
|
||||