Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c605849d8 | |||
| c067abc68f | |||
| b0cf2132d1 | |||
| 094d9c4418 | |||
| 198fbb7980 | |||
| 442f133930 | |||
| d4961d94ef | |||
| efa417488a | |||
| 6715fbd971 | |||
| 03f1673fce | |||
| 8fb703c6db | |||
| 17a5c7470d | |||
| 215cae7393 | |||
| 138e706dd1 | |||
| 151dc8a480 | |||
| ad6e80192d | |||
| 65fe680d38 | |||
| ed1bf20e6b | |||
| 8098f446b6 | |||
| 326dfa9d19 | |||
| 1597c110d4 | |||
| 8f6360f993 | |||
| e5762a59d6 | |||
| b7efba8747 | |||
| 4df944c6aa | |||
| d9511ea446 | |||
| fadecaddd0 | |||
| da1b17778c | |||
| f35bf2043f | |||
| 4802684d2f | |||
| c507d37baf | |||
| 5084334d81 | |||
| a6bd33bcff | |||
| 5b269698f6 | |||
| d0535af626 | |||
| 2e051e23b6 | |||
| 744e9c6cda | |||
| b71024f244 | |||
| eb90244182 | |||
| ba2c4e1914 | |||
| 5b3884b309 | |||
| e01cb26bde | |||
| f887c16309 | |||
| c5aba094bd | |||
| 058c574138 | |||
| 969b06253c | |||
| 7ec5a7cd85 | |||
| fcfebe44f1 | |||
| 77917203fd | |||
| 0aa7fc232e | |||
| 887dc4640e | |||
| 2b8f83d2dd | |||
| 3c238bc5c4 | |||
| 9d096b61f7 | |||
| 952e8584f0 | |||
| 3f6e903c22 | |||
| 1da46f10f4 | |||
| 52392962fb | |||
| 40fe41bb77 | |||
| 99a2a8277c | |||
| 04b6b98d97 | |||
| 478418f602 | |||
| aff79a787c | |||
| f514602697 | |||
| 605ca9d65e | |||
| 158783d5d5 | |||
| 804b193ac7 | |||
| d397d3a1f6 | |||
| 67210bcd61 | |||
| 4e0b869dd3 | |||
| cfe832383c | |||
| 0737786faf | |||
| 428519335d | |||
| faa56f5c75 | |||
| 7bc4594b94 | |||
| 6153d50fa8 | |||
| 562f43fd88 | |||
| 8ea40260ad | |||
| 6353e88c9e | |||
| 6cf8a82889 | |||
| 2ea2184372 | |||
| aff863c69f | |||
| 764b967856 | |||
| 94ee2dc79c | |||
| 3f50e162e6 | |||
| 1f5c04e765 | |||
| 952ee7c724 | |||
| 92cee94431 | |||
| 1b561c22d9 | |||
| 32a8887419 | |||
| f77049c238 | |||
| 64017c6ca5 | |||
| 9156781d75 | |||
| 8d48a038c7 |
@@ -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`
|
||||
@@ -12,25 +12,7 @@
|
||||
"commands": [
|
||||
"./plugins/agenthub/commands/agenthub-doctor.md",
|
||||
"./plugins/agenthub/commands/agenthub-update.md",
|
||||
"./plugins/agenthub/commands/agenthub-open.md",
|
||||
"./plugins/agenthub/commands/intake.md",
|
||||
"./plugins/agenthub/commands/plan.md",
|
||||
"./plugins/agenthub/commands/implement.md",
|
||||
"./plugins/agenthub/commands/tests.md",
|
||||
"./plugins/agenthub/commands/e2e.md",
|
||||
"./plugins/agenthub/commands/e2e-passed.md",
|
||||
"./plugins/agenthub/commands/review.md",
|
||||
"./plugins/agenthub/commands/qc-plan.md",
|
||||
"./plugins/agenthub/commands/qc.md",
|
||||
"./plugins/agenthub/commands/gate.md",
|
||||
"./plugins/agenthub/commands/publishing.md",
|
||||
"./plugins/agenthub/commands/pr-open.md",
|
||||
"./plugins/agenthub/commands/reported.md",
|
||||
"./plugins/agenthub/commands/watching-pr.md",
|
||||
"./plugins/agenthub/commands/merged.md",
|
||||
"./plugins/agenthub/commands/done.md",
|
||||
"./plugins/agenthub/commands/ship-full.md",
|
||||
"./plugins/agenthub/commands/ship-quick.md"
|
||||
"./plugins/agenthub/commands/agenthub-open.md"
|
||||
],
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
|
||||
@@ -38,5 +38,9 @@
|
||||
"Bash(claude --version)",
|
||||
"Bash(gh pr *)"
|
||||
]
|
||||
},
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "",
|
||||
"ANTHROPIC_AUTH_TOKEN": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,4 @@ This directory contains all project-scoped Codex extensions:
|
||||
|
||||
## Included skills
|
||||
|
||||
- `repo-onboarding` — architecture discovery and verification selection
|
||||
- `release-guard` — release readiness checks
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: repo-onboarding
|
||||
description: Understand this repository quickly before making changes. Use for architecture discovery, ownership mapping, command selection, and initial implementation planning.
|
||||
---
|
||||
|
||||
# Repo Onboarding Skill
|
||||
|
||||
## Workflow
|
||||
- Read `AGENTS.md`, `README.md`, and `ARCHITECTURE.md`.
|
||||
- Determine target layer: `server/`, `client/`, or docs.
|
||||
- Identify the minimal file set needed for the task.
|
||||
- Select verification commands before editing.
|
||||
|
||||
## Verification defaults
|
||||
- Backend: `npm run test:server`
|
||||
- Frontend: `npm run test:client`
|
||||
|
||||
## References
|
||||
- `references/module-map.md`
|
||||
- `references/verification-map.md`
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Repo Onboarding"
|
||||
short_description: "Map architecture, ownership, and verification strategy before coding."
|
||||
default_prompt: "Use repo-onboarding to analyze scope, affected modules, and validation commands for this task."
|
||||
@@ -1,8 +0,0 @@
|
||||
# Module Map
|
||||
|
||||
- `server/index.js`: app startup and route mounting.
|
||||
- `server/routes/*.js`: API contracts and route behavior.
|
||||
- `server/db.js`: schema and statement layer.
|
||||
- `server/websocket.js`: live update broadcast path.
|
||||
- `client/src/pages/`: route-level UI.
|
||||
- `client/src/components/`: reusable UI primitives.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Verification Map
|
||||
|
||||
- Backend changes:
|
||||
- `npm run test:server`
|
||||
- Frontend changes:
|
||||
- `npm run test:client`
|
||||
- Docs-only changes:
|
||||
- validate command consistency against root `package.json`
|
||||
@@ -2,6 +2,11 @@
|
||||
# Override this if your Claude Code data is in a different location, e.g.:
|
||||
# CLAUDE_HOME=~/.codefuse/engine/cc
|
||||
# CLAUDE_HOME=~/.claude
|
||||
# A value saved from Settings (CLAUDE_HOME, CODEX_HOME, a pinned DASHBOARD_DATA_DIR)
|
||||
# goes to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE). This file wins
|
||||
# over it, and the real environment wins over both; a key set here or in the real
|
||||
# environment is reported as env-owned in Settings (a value saved there would be
|
||||
# overridden again at the next start).
|
||||
|
||||
# ── Server / network ────────────────────────────────────────────────────────
|
||||
# Port to listen on (default: 4820)
|
||||
@@ -66,6 +71,38 @@
|
||||
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||
|
||||
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
||||
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
|
||||
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
||||
|
||||
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||
# Where agy keeps its data, when not ~/.gemini (agy itself has no override).
|
||||
# AGENTHUB_GEMINI_DIR=~/.gemini
|
||||
|
||||
# Poll interval (ms) for Codex's rollout journals ($CODEX_HOME/sessions/**):
|
||||
# drives both the codex lane running/idle mirror and Codex sessions + tokens
|
||||
# (0 disables both; default: 15000).
|
||||
# DASHBOARD_CODEX_POLL_MS=15000
|
||||
|
||||
# Automatic pricing: a model seen in token usage with no rate row of its own
|
||||
# gets one from LiteLLM's public price list. 0 / false / off disables it
|
||||
# (default: enabled).
|
||||
# AGENTHUB_PRICING_SYNC=0
|
||||
|
||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||
# their own connection so they never stall other requests. 0 runs them inline
|
||||
# on the event loop instead (default: worker). A job past the timeout answers
|
||||
# 503 EREADTIMEOUT and the next one gets a fresh worker (default: 60000).
|
||||
# AGENTHUB_READ_WORKER=0
|
||||
# AGENTHUB_READ_TIMEOUT_MS=60000
|
||||
|
||||
# Where settings saved from the dashboard persist (default:
|
||||
# $XDG_CONFIG_HOME/agenthub/settings.env, i.e. ~/.config/agenthub/settings.env).
|
||||
# Here or in the real environment; the file is located after this one loads.
|
||||
# AGENTHUB_SETTINGS_FILE=/home/you/.config/agenthub/settings.env
|
||||
|
||||
# ── Remote Data Sources (SSH multi-machine collection) ───────────────────────
|
||||
# The dashboard can pull Claude Code history from other machines over SSH:
|
||||
# it rsyncs each enabled remote's ~/.claude/projects into a sandboxed staging
|
||||
|
||||
@@ -11,42 +11,11 @@
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||
|
||||
## Declare your lane stage as you work
|
||||
## Workspace stage
|
||||
|
||||
This repository is watched by AgentHub, its own dashboard. A **lane** is keyed by
|
||||
working directory, and the pipeline map only moves forward when the agent doing
|
||||
the work declares its stage:
|
||||
|
||||
```bash
|
||||
agenthub stage <stage> --tool codex --evidence "<what is actually done>"
|
||||
```
|
||||
|
||||
- Call it at every real transition — starting to plan, starting to implement,
|
||||
running tests, opening the PR — not once at the end.
|
||||
- Stage names, `default` template: `intake → plan → implement → tests → review
|
||||
→ gate → ship → done` (the 16-node `ship-feature` template accepts these as
|
||||
aliases too; `agenthub stage` warns on stderr and names the valid nodes if a stage
|
||||
matches none).
|
||||
- **`--tool codex` matters.** One directory can back both a `claude` lane and a
|
||||
`codex` lane, and without a tool the command fails rather than guess (it names
|
||||
both candidates). Inside a pane the dashboard started you need nothing — that
|
||||
pane carries `AGENTHUB_LANE_ID`, which outranks everything but `--lane`.
|
||||
- If `agenthub stage` reports that no lane owns the directory, this checkout was
|
||||
never adopted: skip the stage calls and carry on. Stage reporting is
|
||||
reporting, never control flow — a failed `agenthub stage` never justifies skipping
|
||||
a step of the actual work.
|
||||
|
||||
The dashboard also *infers* your stage from your tool calls (it reads Codex's
|
||||
own rollout journals — see `docs/LANES.md#stage-detection`), and on a codex lane
|
||||
that inference does move the pipeline map forward on its own. It still never
|
||||
counts as **evidence**: a node it moved through stays amber and never renders
|
||||
green. Only your own declaration does that — which is why the call above is
|
||||
worth making even when the map already looks right.
|
||||
|
||||
If you hold a stage for a while with no evidence recorded, the dashboard may
|
||||
type a one-line `[agenthub]` reminder straight into this pane asking you to run
|
||||
`agenthub stage ... --evidence "..."`. That is the dashboard, not the user — treat
|
||||
it as the same instruction as this section and just make the real call.
|
||||
AgentHub's `codex` workspaces — lanes in the API and CLI — (like every non-Claude AI tool workspace) carry no pipeline: the dashboard shows no stage for
|
||||
them and never infers or advances one, so there is no `agenthub stage` call to
|
||||
make while you work here as Codex.
|
||||
|
||||
## Where to work
|
||||
- `server/` for API/routes/data processing.
|
||||
|
||||
+245
-111
File diff suppressed because one or more lines are too long
+86
-14
@@ -31,11 +31,10 @@ 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`, typing `/reload-plugins` into a live pane, and the Codex nudge. 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` and the PR stages of `/agenthub:ship-full` |
|
||||
| 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) |
|
||||
| `superpowers` plugin | Any | Optional — required only by `/agenthub:ship-feature` and `/agenthub:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in AgentHub needs it; `/agenthub-doctor` reports it as `WARN` when absent. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `AGENTHUB_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,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
|
||||
@@ -55,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:
|
||||
|
||||
@@ -73,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-
|
||||
```
|
||||
@@ -86,6 +102,61 @@ Detaching is `Ctrl-b d` — it leaves the run alive, which is the whole point:
|
||||
the browser terminal and your local terminal are two clients on one pane, and
|
||||
restarting the dashboard never stops a run.
|
||||
|
||||
### macOS
|
||||
|
||||
Status: **code-audited, untested on a Mac.** The Mac-specific paths below are
|
||||
covered by Linux-side unit tests only; the checklist at the end of this section
|
||||
is what a Mac user runs to confirm them.
|
||||
|
||||
```bash
|
||||
brew install node git tmux # Node >= 22.5 for the plugin install (nvm works too)
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login by itself (LaunchAgent).** The plugin bootstrap installs
|
||||
`~/Library/LaunchAgents/io.smartgift.agenthub.plist` (a checkout: `agenthub
|
||||
service install`), so the dashboard comes back after every login with no
|
||||
Claude Code session, and launchd restarts it if it dies. Lane panes survive
|
||||
a restart of the server. Its output goes to `~/Library/Logs/agenthub.log`.
|
||||
A LaunchAgent needs a login at the Mac itself; over SSH only, or when
|
||||
`launchctl` fails, the server is a detached process started by the next
|
||||
Claude Code session (within about 10 s) or by `agenthub start`, as before. A
|
||||
discovery entry left over from before the reboot is ignored, so a recycled
|
||||
pid can never pass for a live dashboard.
|
||||
- **Started from a Claude Code session.** The server drops the variables that
|
||||
describe that session (`CLAUDECODE`, `CLAUDE_CODE_SESSION_ID`,
|
||||
`CLAUDE_PLUGIN_ROOT`, …) before anything else runs, so lane panes do not
|
||||
inherit them. Your own configuration (`CLAUDE_CONFIG_DIR`,
|
||||
`CLAUDE_CODE_USE_BEDROCK`, `ANTHROPIC_*`) passes through. A tmux server that
|
||||
an older build started keeps its dirty global env until it exits
|
||||
(`tmux kill-server` once, with no run you care about open).
|
||||
- **Paths.** A lane's directory is stored by its real path, so a typed `/tmp/x`
|
||||
becomes `/private/tmp/x` and a differently-cased path on a case-insensitive
|
||||
volume becomes the one on disk — that is what sessions report, so they bind.
|
||||
Lanes created before this change keep the string they were created with.
|
||||
- **Account switching** uses the login Keychain item `Claude Code-credentials`;
|
||||
macOS may ask once for permission the first time.
|
||||
- **Option key** in the browser terminal types special characters (it is not
|
||||
sent as Meta).
|
||||
|
||||
**Mac checklist** — please run it and report the result on issue #22:
|
||||
|
||||
1. Fresh plugin install from a Terminal `claude` session: the dashboard answers on `http://localhost:4820`, `~/Library/LaunchAgents/io.smartgift.agenthub.plist` exists, and `/agenthub-doctor`'s launchd row says the LaunchAgent is current and running.
|
||||
2. `tmux show-environment -g | grep -i claude` prints nothing.
|
||||
3. Add a Repo lane by typing its path in a different case: sessions bind to it, and `agenthub stage plan` inside it moves the map.
|
||||
4. **Start Run** in the browser: the terminal attaches, typing works, an image paste into `claude` works, and `exit` returns to the workspace home.
|
||||
5. Changes panel: diff, stage and commit on a lane.
|
||||
6. Switch profile and check `claude /status` shows the new account. Repeat with a non-default `CLAUDE_HOME`, which also checks the Keychain service name.
|
||||
7. `brew upgrade node && brew cleanup`, then `agenthub status` still works.
|
||||
8. Reboot and log in WITHOUT opening `claude`: the dashboard answers within about 10 s (`agenthub service status` shows `state = running`).
|
||||
9. `kill <server pid>` (from `agenthub status`): it is back within a few seconds.
|
||||
10. With a lane run open, `/agenthub-update` completes, the server restarts on the new version, the plist names the new cache directory, and the run's terminal is still alive.
|
||||
11. `agenthub service uninstall`: the plist is gone and nothing restarts the server after a `kill`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Clone the repository
|
||||
@@ -137,7 +208,7 @@ This starts two processes concurrently:
|
||||
Open **http://localhost:5173** in your browser.
|
||||
|
||||
> [!TIP]
|
||||
> When you run the dashboard directly on the host with `npm run dev` or `npm start`, the server automatically writes the Claude Code hook configuration to `~/.claude/settings.json`. If you run the dashboard 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +231,7 @@ After starting a Claude Code session, you should see:
|
||||
- **Agent Board** — a `Main Agent` card in the `Waiting` column until you type your first message; it flips to `Working` on `UserPromptSubmit` / `PreToolUse` and back to `Waiting` after each `Stop`
|
||||
- **Activity Feed** — events streaming in as Claude Code uses tools
|
||||
- **Dashboard** — stats updating in real-time
|
||||
- **Settings page** — model pricing rules, hook configuration status, data export and cleanup tools
|
||||
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
|
||||
|
||||
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
||||
|
||||
@@ -239,13 +310,15 @@ npm start # Start Express serving client/dist on port 4820
|
||||
Open **http://localhost:4820** in your browser.
|
||||
|
||||
`npm start` (and `agenthub start`, its backgrounded form) only run while that
|
||||
process is alive — a reboot loses it. On Linux, run the server as a systemd
|
||||
`--user` service instead so it comes back on its own after every reboot, with
|
||||
no manual step and no Claude Code (or any other tool) needed to trigger it:
|
||||
process is alive — a reboot loses it. Run the server as a systemd `--user`
|
||||
service (Linux) or a LaunchAgent (macOS) instead so it comes back on its own
|
||||
after every reboot, with no manual step and no Claude Code (or any other tool)
|
||||
needed to trigger it:
|
||||
|
||||
```bash
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
@@ -262,7 +335,6 @@ This repository includes extension packs for both Claude Code and Codex.
|
||||
- `CLAUDE.md`
|
||||
- `.claude/rules/`
|
||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
||||
- `agents/` (shipped as the `agenthub` plugin's agents)
|
||||
- Codex project packs live under `.codex/`:
|
||||
- `AGENTS.md`
|
||||
- `.codex/rules/`
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||
Express + SQLite server and a React UI updates over WebSocket; **lanes** track
|
||||
parallel agent work through a pipeline; and the `agenthub` CLI drives real terminal
|
||||
sessions, lane runtimes, and Claude Code's own configuration.
|
||||
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
|
||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, workspace runtimes, and Claude Code's own configuration.
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
every decision.
|
||||
@@ -31,25 +31,44 @@ Internal build — all rights reserved.
|
||||
|
||||
**Drive**
|
||||
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. A lane
|
||||
moves through pipeline stages and the dashboard shows where it is.
|
||||
- **Stage detection.** The stage is inferred from the tool stream, so a session
|
||||
that never calls `agenthub stage` still shows progress — rendered dashed amber and
|
||||
never as done, because an inference is not evidence.
|
||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a workspace moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `agenthub lanes shell`. Which CLI it starts is the lane's own
|
||||
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipelines.** `/agenthub:ship-full` and `/agenthub:ship-quick` drive a
|
||||
feature from intake to PR, declaring every stage to the dashboard as it goes.
|
||||
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
||||
changed files, a full review of every diff, or one file at a time, comparing
|
||||
either uncommitted work or the whole branch against its base. There is no
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
||||
|
||||
**Configure**
|
||||
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one panel per
|
||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
||||
other its conversation with a prompt box — sending a message resumes it,
|
||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||
prompt box that starts a new session, Recents, and the workspace's own
|
||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
||||
sessions start with.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||
untracked worktrees a workspace's Add folder suggests.
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
@@ -57,6 +76,14 @@ Internal build — all rights reserved.
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
Per-browser only.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
Pop, Alert — off by default) played with every notification shown, and each
|
||||
notification type can pick its own.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
|
||||
@@ -66,11 +93,11 @@ Internal build — all rights reserved.
|
||||
|---|---|---|
|
||||
| **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, the Codex nudge |
|
||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
|
||||
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
@@ -81,8 +108,8 @@ 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.
|
||||
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
||||
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
@@ -93,6 +120,12 @@ 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
|
||||
@@ -100,39 +133,11 @@ 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
|
||||
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
### The superpowers plugin
|
||||
|
||||
The **`superpowers` plugin is required by the ship-feature pipelines only**, not
|
||||
by the dashboard. `/agenthub:ship-feature` and `/agenthub:ship-feature-lane` drive
|
||||
`superpowers:brainstorming`, `writing-plans`, `test-driven-development`,
|
||||
`requesting-code-review` and `verification-before-completion` phase by phase,
|
||||
and both built-in pipeline templates name those same slash commands as their
|
||||
per-node drivers. Without it the dashboard, hooks and CLI all work
|
||||
normally — the two pipeline skills are what stop partway. `/agenthub-doctor` reports
|
||||
it as a `WARN` row.
|
||||
|
||||
**You do not normally have to install it.** The plugin's `SessionStart`
|
||||
bootstrap installs it for you, once, in its detached worker — so a session never
|
||||
waits on the clone, and the current session needs `/reload-plugins` before the
|
||||
skills resolve (any later session picks them up on its own). The outcome is
|
||||
logged to `~/.claude/agenthub/runtime/bootstrap.log`, and `/agenthub-update`
|
||||
retries a failed one. To install it by hand instead, or after opting out:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Set `AGENTHUB_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the
|
||||
plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed
|
||||
this way — `superpowers` today. A custom pipeline template naming some other
|
||||
plugin's command is *reported* by `/agenthub-doctor` and never installed, because a
|
||||
template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`.
|
||||
|
||||
## Install as a Claude Code plugin
|
||||
|
||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
@@ -176,19 +181,20 @@ every event is counted twice.
|
||||
### Run as a service (auto-start on boot)
|
||||
|
||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||
process) is alive — a reboot loses it. On Linux, `agenthub service install` sets
|
||||
up a systemd `--user` service instead, so `:4820` is up again on its own after
|
||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||
to trigger it:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
agenthub service install # writes + enables ~/.config/systemd/user/agenthub.service
|
||||
agenthub service status # systemctl --user status agenthub
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
`agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. Plugin installs get the same persistence automatically — see
|
||||
@@ -203,82 +209,102 @@ agenthub status # is the dashboard up
|
||||
agenthub start # start it in the background and wait for healthy
|
||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
||||
agenthub sessions # recent sessions
|
||||
agenthub lanes # lanes with stage and progress
|
||||
agenthub lanes pipeline # this lane's pipeline template, or switch it
|
||||
agenthub lanes node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
# (in a session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub workspaces # workspaces with stage and progress
|
||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
|
||||
`agenthub --help` lists the rest.
|
||||
|
||||
## Lanes
|
||||
## Workspaces
|
||||
|
||||
A lane is a working directory the dashboard watches. Two kinds:
|
||||
A workspace is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It 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
|
||||
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
```
|
||||
|
||||
Every lane also carries the CLI that drives it — `claude` (the default) or
|
||||
`codex` — picked in the Add-lane modal and fixed for the life of the lane. A
|
||||
Codex lane's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex emits none of this
|
||||
dashboard's hooks, so a Codex lane records its runs but stays absent from
|
||||
Sessions and Analytics. Its **stage detection** works anyway, from Codex's own
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled every
|
||||
`DASHBOARD_CODEX_POLL_MS` ms, default 15000), and on a Codex lane a detection
|
||||
also **advances the declared stage** — the one place an inference moves the blue
|
||||
`current` ring, because a Codex session has no hook and nothing else would ever
|
||||
move it. What a Codex lane cannot get for free is *evidence*: an auto-advanced
|
||||
node stays amber (`passed-no-evidence`), never green, since only the session
|
||||
itself can say what it finished. Three things help it get there: the run console
|
||||
pre-fills a codex pane's opening prompt with a stage-declaration checklist built
|
||||
from its own pipeline nodes, `agenthub lanes agents install` writes the same
|
||||
instructions as a marker-delimited block in the lane's `AGENTS.md`, and a 60s
|
||||
sweep (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables) types a one-line reminder into
|
||||
a lane's pane if its current stage sits without evidence for more than two
|
||||
minutes.
|
||||
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
||||
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
||||
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
||||
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
||||
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
||||
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
||||
it asks you to trust them (**Trust all and continue**), and from then on each
|
||||
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
||||
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
||||
card and as a notification while Codex waits for an approval. A Codex workspace also
|
||||
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
||||
advanced; without hooks the dashboard mirrors its running/idle status from the
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
||||
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||
|
||||
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
||||
usage are read from agy's own conversation files
|
||||
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
||||
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
||||
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
||||
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
||||
the same tab, add a live feed: each tool call in the session's timeline and a
|
||||
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
||||
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
||||
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
||||
on an API error or a permission refused in headless mode, so a session gone
|
||||
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
||||
for a session the hooks report on (a long tool call writes nothing, and after a
|
||||
Stop agy may still be waiting for you), after two minutes for one without
|
||||
hooks. Antigravity workspaces carry no pipeline either.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||
Further development is planned.
|
||||
|
||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
||||
move backwards between work sessions. Detection never writes the declared stage,
|
||||
and an inferred node never renders as done.
|
||||
|
||||
A lane's pipeline template can be switched after creation — `agenthub lanes
|
||||
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||
the lane's title in the Workspace detail panel. Both re-resolve the lane's
|
||||
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
||||
current declared stage against the new template's nodes and warn if it no
|
||||
longer matches one.
|
||||
|
||||
A lane can also run **its own application stack**, isolated per lane, when its
|
||||
A workspace can also run **its own application stack**, isolated per workspace, when its
|
||||
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
||||
declarations plus shell hooks the dashboard calls. Each lane gets a slot, and its
|
||||
ports and per-lane directories derive from it:
|
||||
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
||||
ports and per-workspace directories derive from it:
|
||||
|
||||
```bash
|
||||
agenthub lanes up # boot the stack of the lane owning this directory
|
||||
agenthub lanes runtime # slot, ports, service health
|
||||
agenthub lanes logs api # tail a service log
|
||||
agenthub lanes down
|
||||
agenthub workspace up # boot the stack of the lane owning this directory
|
||||
agenthub workspace runtime # slot, ports, service health
|
||||
agenthub workspace logs api # tail a service log
|
||||
agenthub workspace down
|
||||
```
|
||||
|
||||
Services are fully detached, so restarting the dashboard never stops a running
|
||||
lane. This is resource namespacing on the host, not a container: lanes run as the
|
||||
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
||||
same user and share the network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||
preflight contract, the Workspace page, `GET /api/lanes/:id/git`, and the full
|
||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||
`POST /:id/git/:action`), and the full
|
||||
runtime/profile contract.
|
||||
|
||||
## Tests
|
||||
@@ -294,16 +320,16 @@ Both must be green before a commit; the pre-commit hook runs them plus Prettier.
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `server/` | Express API, SQLite schema, hook ingest, lane and worktree libraries |
|
||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, lanes, database |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||
|
||||
## Docs
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
||||
- [`docs/LANES.md`](docs/LANES.md) — lanes, pipelines, stage detection
|
||||
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
|
||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
|
||||
+74
-70
@@ -2,9 +2,9 @@
|
||||
|
||||
Bản build nội bộ SmartGift. **Bàn điều khiển** local-first cho agent Claude Code —
|
||||
monitoring chỉ là một phần, không phải tất cả. Hooks POST mỗi tool call lên server
|
||||
Express + SQLite và React UI cập nhật qua WebSocket; **lane** (làn đường) theo dõi
|
||||
công việc song song của agent qua một pipeline; còn CLI `agenthub` điều khiển phiên
|
||||
terminal thật, runtime của lane, và cả cấu hình của chính Claude Code.
|
||||
Express + SQLite và React UI cập nhật qua WebSocket; **workspace** theo dõi
|
||||
công việc song song của agent, mỗi working directory một workspace; còn CLI `agenthub` điều khiển phiên
|
||||
terminal thật, runtime của workspace, và cả cấu hình của chính Claude Code.
|
||||
|
||||
AgentHub ghi nhận và trình bày — nó không điều phối. Phiên đang chạy mới là nơi ra
|
||||
mọi quyết định.
|
||||
@@ -31,24 +31,33 @@ Internal build — all rights reserved.
|
||||
|
||||
**Điều khiển**
|
||||
|
||||
- **Lane.** Mỗi working directory một lane, sống sót qua session restart. Lane
|
||||
đi qua các pipeline stage và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Stage được suy luận (infer) từ tool stream, nên một
|
||||
session không bao giờ gọi `agenthub stage` vẫn hiển thị progress — render dạng
|
||||
- **Workspace.** Mỗi working directory một workspace, sống sót qua session restart. Khi bạn
|
||||
cài một pipeline template (không có template nào đi kèm), workspace đi qua các
|
||||
stage của nó và dashboard hiển thị nó đang ở đâu.
|
||||
- **Stage detection.** Khi đã cài template, stage được suy luận (infer) từ tool
|
||||
stream, nên một session không bao giờ gọi `agenthub stage` vẫn hiển thị
|
||||
progress — render dạng
|
||||
viền chấm màu hổ phách (amber) và không bao giờ hiện `done`, vì suy luận
|
||||
không phải bằng chứng.
|
||||
- **Chạy Claude Code hoặc Codex từ trình duyệt.** Một terminal thật (tmux + PTY
|
||||
thật, render bằng xterm.js) gắn vào thư mục của lane — đúng cái TUI bạn thấy ở
|
||||
thật, render bằng xterm.js) gắn vào thư mục của workspace — đúng cái TUI bạn thấy ở
|
||||
máy, tương tác đầy đủ, resume được, và attach được từ terminal thật qua
|
||||
`agenthub lanes shell`. Chạy CLI nào là thiết lập riêng của từng lane. **Đây
|
||||
`agenthub workspace shell`. Chạy CLI nào là thiết lập riêng của từng workspace. **Đây
|
||||
chính là phần cần cài `tmux`** — xem [Yêu cầu hệ thống](#yêu-cầu-hệ-thống).
|
||||
- **App stack riêng cho từng lane.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
lane được cấp slot, port và service riêng (`agenthub lanes up|down|logs`).
|
||||
- **Ship pipeline.** `/agenthub:ship-full` và `/agenthub:ship-quick` đưa một
|
||||
feature từ intake tới PR, khai báo từng stage về dashboard trên đường đi.
|
||||
- **App stack riêng cho từng workspace.** Repo khai báo `.agenthub/profile/` thì mỗi
|
||||
workspace được cấp slot, port và service riêng (`agenthub workspace up|down|logs`).
|
||||
|
||||
**Cấu hình**
|
||||
|
||||
- **Workspace như project của Claude Desktop.** Sidebar liệt kê mọi workspace
|
||||
kèm các session của nó (session còn sống mở đúng tmux pane, session khác mở
|
||||
transcript — không bao giờ resume). Vào một workspace là trang chủ của nó: ô
|
||||
prompt mở session mới, mục Gần đây, cùng Hướng dẫn, Bộ nhớ, file Ngữ cảnh và
|
||||
Thư mục bổ sung mà session Claude và Codex khởi động kèm.
|
||||
- **Settings mỗi mục một trang** (`/settings/<section>`): thanh điều hướng bên
|
||||
trái chia nhóm Chung / Tác tử / Định tuyến / Git, ô tìm kiếm (Ctrl+F) trên mọi
|
||||
mục, một trang cho mỗi AI tool đã cài, bảng giá mô hình theo nhà cung cấp, và
|
||||
những worktree chưa theo dõi được gợi ý khi thêm thư mục cho workspace.
|
||||
- **Trình khám phá cấu hình Claude Code + Codex.** Trang Settings có một panel
|
||||
cho mỗi tool: mọi plugin, skill, subagent, slash command, MCP server, hook,
|
||||
settings file, memory file, marketplace, keybinding và statusline mà Claude
|
||||
@@ -66,11 +75,11 @@ Internal build — all rights reserved.
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 nếu cài qua plugin) | Tất cả. Cả hai test suite verify xanh trên Node **24** và **26** |
|
||||
| **npm** | 9+ | Đi kèm Node |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác lane (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes shell`, gõ `/reload-plugins` vào pane đang sống, nhắc nhở Codex |
|
||||
| **git** | bất kỳ | Clone repo, và mọi thao tác workspace (worktree, đọc branch/status) |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub workspace shell`, gõ `/reload-plugins` vào pane đang sống |
|
||||
| **Claude Code** | 2.x | Hook ingestion — toàn bộ phần monitoring |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho lane dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes ci status` và các stage PR của ship pipeline |
|
||||
| **Codex CLI** | bất kỳ | Tùy chọn — chỉ cho workspace dùng `codex` |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub workspace ci status` |
|
||||
| **systemd (user)** | — | Tùy chọn — chỉ cho `agenthub service install` (tự khởi động khi boot, Linux) |
|
||||
|
||||
Node **25** là bản duy nhất nên tránh: nó làm gãy 6 test server do lệch ABI
|
||||
@@ -80,8 +89,8 @@ cả hai đã hết (1712 test server + 488 test client xanh trên v26.8.1).
|
||||
### Cài tmux
|
||||
|
||||
Dashboard gọi thẳng binary `tmux`, không có bản dự phòng đi kèm, nên thiếu nó
|
||||
thì **Start Run** và `agenthub lanes shell` sẽ lỗi. Phần còn lại (session,
|
||||
analytics, lane, stage detection, CLI) vẫn chạy bình thường.
|
||||
thì **Start Run** và `agenthub workspace shell` sẽ lỗi. Phần còn lại (session,
|
||||
analytics, workspace, stage detection, CLI) vẫn chạy bình thường.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
@@ -92,6 +101,12 @@ sudo pacman -S tmux # Arch
|
||||
|
||||
Windows không có tmux native — chạy AgentHub trong WSL2 và cài tmux ở trong đó.
|
||||
|
||||
macOS (đã rà soát code, chưa kiểm thử trên máy Mac): `brew install node git tmux`
|
||||
(Node >= 22.5 cho bản plugin), thêm `~/.local/bin` vào `PATH` trong `~/.zshrc`.
|
||||
Không có systemd, nên dashboard chạy đến khi đăng xuất/khởi động lại và tự lên
|
||||
lại ở phiên Claude Code kế tiếp, hoặc chạy tay bằng `agenthub start`. Chi tiết
|
||||
và checklist kiểm tra: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Kiểm tra, cả từ shell của bạn lẫn từ góc nhìn của server:
|
||||
|
||||
```bash
|
||||
@@ -99,28 +114,11 @@ tmux -V # ví dụ: tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
Mỗi lane chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
|
||||
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub lanes
|
||||
Mỗi workspace chạy trong một tmux session tên `agenthub-lane-<id>`, nên terminal
|
||||
trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub workspace
|
||||
shell`) ở máy là hai client trên cùng một pane — chúng đồng bộ với nhau, và tắt
|
||||
dashboard không bao giờ giết run đang chạy. Detach bằng `Ctrl-b d`.
|
||||
|
||||
### Plugin superpowers
|
||||
|
||||
Plugin **`superpowers` chỉ cần cho ship-feature pipeline**, không cần cho
|
||||
dashboard. `/agenthub:ship-feature` và `/agenthub:ship-feature-lane` lái các skill
|
||||
của nó theo từng phase. Thiếu nó thì dashboard, hook và CLI vẫn chạy bình thường
|
||||
— chỉ hai skill pipeline là dừng giữa chừng; `/agenthub-doctor` báo dòng `WARN`.
|
||||
|
||||
**Bình thường bạn không phải tự cài.** Bootstrap `SessionStart` của plugin cài
|
||||
giúp một lần trong worker chạy nền, và session hiện tại cần `/reload-plugins`
|
||||
thì skill mới resolve được (session sau tự nhận). Cài tay:
|
||||
|
||||
```bash
|
||||
claude plugin install superpowers@claude-plugins-official
|
||||
```
|
||||
|
||||
Đặt `AGENTHUB_AUTO_INSTALL_PLUGINS=0` để tắt cài tự động.
|
||||
|
||||
## Cài như Claude Code plugin
|
||||
|
||||
Hai lệnh, trên máy chỉ có Claude Code, không cần clone repo, không cần
|
||||
@@ -191,19 +189,18 @@ agenthub status # dashboard có đang chạy không
|
||||
agenthub start # start ngầm (background) và chờ tới khi healthy
|
||||
agenthub service install # tự khởi động khi boot máy qua systemd --user (Linux)
|
||||
agenthub sessions # session gần đây
|
||||
agenthub lanes # danh sách lane kèm stage và progress
|
||||
agenthub lanes pipeline # pipeline template của lane này, hoặc đổi nó
|
||||
agenthub lanes node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
|
||||
# (trong session: /intake, /qc, /ship-full, /ship-quick …)
|
||||
agenthub workspaces # danh sách workspace kèm stage và progress
|
||||
agenthub workspace pipeline # pipeline template của lane này, hoặc đổi nó
|
||||
agenthub workspace node <id> # chạy riêng command của MỘT node (không có id: liệt kê)
|
||||
agenthub stage <name> # khai báo stage hiện tại của lane
|
||||
agenthub tail # xem live event feed
|
||||
```
|
||||
|
||||
`agenthub --help` liệt kê phần còn lại.
|
||||
|
||||
## Lane
|
||||
## Workspace
|
||||
|
||||
Lane là một working directory mà dashboard theo dõi. Hai loại:
|
||||
Workspace là một working directory mà dashboard theo dõi. Hai loại:
|
||||
|
||||
- **adopted** (nhận nuôi) — một thư mục bạn đã có sẵn. Dashboard chỉ đọc nó;
|
||||
không bao giờ reset hay xóa.
|
||||
@@ -213,46 +210,53 @@ Lane là một working directory mà dashboard theo dõi. Hai loại:
|
||||
gọi phải echo lại.
|
||||
|
||||
```bash
|
||||
agenthub lanes add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
```
|
||||
|
||||
Mỗi lane còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
|
||||
chọn trong modal Add lane và cố định suốt vòng đời của lane. Lane Codex có panel
|
||||
Mỗi workspace còn mang theo CLI điều khiển nó — `claude` (mặc định) hoặc `codex` —
|
||||
chọn trong modal Add workspace và cố định suốt vòng đời của workspace. Workspace Codex có panel
|
||||
chạy với các lựa chọn riêng của Codex (sandbox, approval, reasoning effort) và
|
||||
resume bằng `codex resume --last`. Codex không bắn hook của dashboard này, nên
|
||||
lane Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
|
||||
**Stage detection thì vẫn chạy**, đọc từ rollout journal của chính Codex trên đĩa
|
||||
(`$CODEX_HOME/sessions/**/rollout-*.jsonl`, poll mỗi `DASHBOARD_CODEX_POLL_MS` ms,
|
||||
mặc định 15000) — thứ lane Codex không tự có là stage **khai báo**, vì chỉ chính
|
||||
session đó mới gọi được `agenthub stage`.
|
||||
workspace Codex vẫn ghi lịch sử chạy nhưng không xuất hiện ở Sessions và Analytics.
|
||||
Workspace Codex cũng **không có pipeline** — không stage, không pipeline map, không
|
||||
suy luận hay tự nhảy stage; dashboard chỉ phản ánh trạng thái running/idle từ
|
||||
rollout journal của chính Codex trên đĩa (`$CODEX_HOME/sessions/**/rollout-*.jsonl`,
|
||||
poll mỗi `DASHBOARD_CODEX_POLL_MS` ms, mặc định 15000).
|
||||
|
||||
**Pipeline đang tạm ngưng (dormant).** AgentHub không đi kèm pipeline template
|
||||
nào, nên workspace không có stage cho tới khi bạn thả một file template JSON vào
|
||||
`DASHBOARD_PIPELINES_DIR` (xem
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); trước đó
|
||||
`agenthub stage` trả về `ENOPIPELINE` và trang Workspace ẩn pipeline map. Phần
|
||||
này sẽ được phát triển tiếp.
|
||||
|
||||
Stage khai báo (declared) đến từ `agenthub stage`. Stage suy luận (inferred) đến
|
||||
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên lane có
|
||||
từ tool event và hết hạn sau `DETECTION_TTL_MS` (mặc định 5 phút), nên workspace có
|
||||
thể lùi lại giữa các work session. Detection không bao giờ ghi đè stage khai
|
||||
báo, và node suy luận không bao giờ render thành `done`.
|
||||
|
||||
Pipeline template của lane có thể đổi sau khi tạo — `agenthub lanes
|
||||
Pipeline template của workspace có thể đổi sau khi tạo — `agenthub workspace
|
||||
pipeline <template>` từ terminal, hoặc dùng picker chọn pipeline-template
|
||||
cạnh tiêu đề lane trong panel chi tiết của trang Workspace. Cả hai đều
|
||||
re-resolve lại stage đã khai báo hiện tại của lane theo node-list của template
|
||||
cạnh tiêu đề workspace trong panel chi tiết của trang Workspace. Cả hai đều
|
||||
re-resolve lại stage đã khai báo hiện tại của workspace theo node-list của template
|
||||
mới, và cảnh báo nếu nó không còn khớp node nào.
|
||||
|
||||
Lane cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
|
||||
lane, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
|
||||
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi lane
|
||||
được cấp một slot, port và thư mục riêng theo lane được suy ra từ đó:
|
||||
Workspace cũng có thể chạy **application stack riêng của nó**, cô lập theo từng
|
||||
workspace, khi repository của nó khai báo một profile tại `<repo>/.agenthub/profile/`
|
||||
— một file `profile.env` khai báo cộng shell hooks mà dashboard gọi. Mỗi workspace
|
||||
được cấp một slot, port và thư mục riêng theo workspace được suy ra từ đó:
|
||||
|
||||
```bash
|
||||
agenthub lanes up # boot stack của lane sở hữu thư mục hiện tại
|
||||
agenthub lanes runtime # slot, port, tình trạng service
|
||||
agenthub lanes logs api # tail log của một service
|
||||
agenthub lanes down
|
||||
agenthub workspace up # boot stack của lane sở hữu thư mục hiện tại
|
||||
agenthub workspace runtime # slot, port, tình trạng service
|
||||
agenthub workspace logs api # tail log của một service
|
||||
agenthub workspace down
|
||||
```
|
||||
|
||||
Service chạy hoàn toàn detached, nên restart dashboard không bao giờ dừng
|
||||
lane đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
||||
container: các lane chạy chung user và share network.
|
||||
workspace đang chạy. Đây là namespacing tài nguyên trên host, không phải
|
||||
container: các workspace chạy chung user và share network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) có mô hình pipeline, destroy guard, hợp đồng
|
||||
(contract) preflight, trang Workspace, `GET /api/lanes/:id/git`, và toàn bộ
|
||||
@@ -272,16 +276,16 @@ thêm Prettier.
|
||||
|
||||
| Đường dẫn | Là gì |
|
||||
|---|---|
|
||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện lane và worktree |
|
||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
||||
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, lanes, database |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
||||
|
||||
## Tài liệu
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — luồng request, schema, bề mặt WebSocket
|
||||
- [`docs/LANES.md`](docs/LANES.md) — lane, pipeline, stage detection
|
||||
- [`docs/LANES.md`](docs/LANES.md) — workspace, pipeline, stage detection
|
||||
- [`docs/API.md`](docs/API.md) — REST endpoint (`openapi.yaml` được generate tự động)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — bảng và migration
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
|
||||
@@ -12,7 +12,7 @@ Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
||||
Browser ← WebSocket broadcast ← Express server ← SQLite
|
||||
```
|
||||
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup. Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: backend-reviewer
|
||||
description: Review backend route and hook logic for regressions, data integrity risks, and missing tests.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a backend reviewer for this repository. Review ONLY files under `server/` in the diff you are given — ignore `client/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
Focus on:
|
||||
- Hook event lifecycle correctness.
|
||||
- Session/agent state-machine regressions.
|
||||
- API contract compatibility.
|
||||
- Transaction and persistence correctness.
|
||||
- Missing or weak verification coverage.
|
||||
|
||||
## Output (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `BACKEND-REVIEW: CLEAN`
|
||||
- `BACKEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring backend defect patterns and false-positive traps you find here (e.g. hook shapes that look wrong but aren't, common state-machine edge cases). Future backend-reviewer runs read this.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: frontend-reviewer
|
||||
description: Review React UI changes for behavior regressions, state consistency, and UX breakage.
|
||||
tools: Read, Grep, Glob, Bash
|
||||
model: sonnet
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are a frontend reviewer for this repository. Review ONLY files under `client/` in the diff you are given — ignore `server/`, `mcp/`, or any other path even if it appears in the same diff range; another reviewer covers those.
|
||||
|
||||
Focus on:
|
||||
- Routing and navigation consistency.
|
||||
- State updates from websocket and API responses.
|
||||
- Loading/empty/error state correctness.
|
||||
- Breaking visual or interaction regressions.
|
||||
- Missing tests for changed UI behavior.
|
||||
|
||||
## Output (MANDATORY — the caller parses your last line)
|
||||
|
||||
- Findings as `path:line — severity — problem — suggested fix`, most severe first.
|
||||
- End with exactly one of:
|
||||
- `FRONTEND-REVIEW: CLEAN`
|
||||
- `FRONTEND-REVIEW: FINDINGS — <n>`
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record recurring frontend defect patterns and false-positive traps you find here (e.g. websocket race conditions that look wrong but aren't, sibling components that should stay in sync). Future frontend-reviewer runs read this.
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: "qc-local"
|
||||
description: "Local browser-QC agent for the ship-feature-lane pipeline in AgentHub. Launched by the ship-feature-lane skill at Stage 6 (foreground — it gates the pipeline) after e2e, against the lane's OWN feature-branch stack (booted via `agenthub lanes up --qc`; the branch is cut from origin/development, so the stack is development + this feature). Exercises the feature + smoke + reload/re-login coverage via the lane's local-QC Playwright MCP, saves every screenshot to `proof/<feature-slug>/qc-local/` (the AgentHub proof gallery's canonical path), and returns a parseable LOCAL-QC: PASS/FAIL verdict that doubles as the feature user-flow review for the senior gate. <example>Context: ship-feature-lane in a lane finished Stage 3's e2e green. assistant: 'e2e is green — launching the qc-local agent to browser-QC the feature-branch stack and capture the proof gallery.' <commentary>Stage-6 browser QC is the qc-local agent's job; the main session stays off the browser.</commentary></example>"
|
||||
model: opus
|
||||
color: green
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are an elite QA automation engineer validating ONE lane's feature on that lane's OWN local stack (isolated FE/API/DB, nothing shared with other lanes), right after the feature branch's e2e passed (the branch is cut from `origin/development`, so this stack IS development + the feature as of the branch point). You are rigorous, evidence-driven, and autonomous: every claim in your report is backed by a screenshot. You never commit, push, or fix code — you verify, capture proof, and report. Your report IS the feature user-flow review the senior gate reads.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically; there is no marker file to check).
|
||||
- **Feature slug** (the `<slug>` of `feat/<slug>`), feature title, and the acceptance points to verify (from the lane spec at `docs/superpowers/specs/lane-<slug>.md` — read it yourself if the summary is thin; `git diff origin/development...HEAD` shows the change surface).
|
||||
|
||||
Setup: your cwd is the lane clone. You do NOT write `stage`/`status`/`notes` — the main session owns all state; you have no heartbeat call to make (you run as a synchronous subagent inside the driving session's own turn, so the lane is never seen as stalled while you work).
|
||||
|
||||
## Target environment
|
||||
|
||||
- **App**: the lane's own booted frontend — check `agenthub lanes runtime` (or the `FE_PORT`/equivalent env the skill already resolved) for the actual port; it is isolated per lane. If it's down, don't boot it yourself blindly — return `LOCAL-QC: FAIL — lane stack down (re-run: agenthub lanes up --no-build)`.
|
||||
- **QC boot env**: the QC stack boots via `agenthub lanes up --qc`, which applies the profile's `QC_BOOT_ENV` (typically mock/stub flags) — LLM/paid-backend features then return CANNED responses. Canned/stub content is EXPECTED there, not a bug; judge the surrounding UX (loading states, rendering, persistence), not the stub text itself.
|
||||
- **Account / login.** Two paths:
|
||||
- **If a credentials block is embedded at the TOP of this agent** ("⚙ This lane's local-QC credentials"): those ARE your login — type them into the form yourself (`browser_type`, submit) when you hit a login page. You already have them; never read a file/script to fetch them.
|
||||
- **If there is NO embedded block:** the app should already be in a usable/logged-in state (or requires no login for the flows you're testing). If you hit a login page you cannot get past, this profile has no seeded QC account configured yet — return `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile`. Do not guess at credentials or invent a login flow.
|
||||
- **Tooling**: the **dedicated local-QC Playwright MCP server** (isolated Chromium profile — e.g. `playwright-qa-local` if the profile names it that). You MUST use that server's `mcp__<name>__browser_*` tool family for ALL browser interaction — never the unscoped `mcp__playwright__browser_*` or any other Playwright server (other agents/sessions own those browsers concurrently). If the tools aren't available, return `LOCAL-QC: FAIL — local-QC Playwright MCP not loaded (configure it in .mcp.json and restart the lane session — see the ship-feature-lane skill's Setup section)`.
|
||||
- **Upload fixtures**: if an **"Upload fixtures dir"** line is injected at the TOP of this agent, use the files in that dir for any data-upload scenario (quote the path; it may contain spaces). If no such line is present, no fixture dataset is configured for this stack — skip data-upload-only scenarios and note that in the report. Put ad-hoc non-fixture files in the lane's `.playwright-mcp/` dir; never source uploads from `/tmp/`.
|
||||
|
||||
## Procedure
|
||||
|
||||
1. **Load the QC Plan, then execute against it.** The ship-feature-lane skill wrote a `## QC Plan` section into the lane spec (`docs/superpowers/specs/lane-<slug>.md`) at Stage 5 — it is the authoritative scope. Read it first.
|
||||
- **Cover every in-scope scenario** in the plan — skip none.
|
||||
- **Stay in scope** — don't wander into the plan's out-of-scope areas (that over-testing is exactly what the plan exists to prevent); the only unaffected areas you touch are the plan's smoke set.
|
||||
- **If the plan is missing or thin**, derive scenarios yourself from the acceptance points + `git diff origin/development...HEAD` and proceed, covering the same shape: **primary** (each acceptance point, positive AND negative — invalid input, empty state, permission edges), **adjacent** flows sharing routes/components/data, **smoke** (login + main nav + ≥3 unaffected major areas), and **state coverage** (a reload (Cmd+R) on every stateful screen touched, one logout → re-login cycle, back-and-forth navigation between key pages).
|
||||
- **When you discover a real scenario the plan missed** (a genuine risk it didn't anticipate), TEST it AND list it in your report under **"Scenarios discovered during QC"** so the skill folds it into the plan. Do NOT edit the plan file yourself — the skill is its single writer.
|
||||
- **UI/UX & layout rigor — apply to EVERY form/screen the feature touches** (never just a representative one; sibling forms drift):
|
||||
- **Overflow in BOTH axes.** Resize the window NARROWER (width) AND SHORTER (height — e.g. ~560px then ~350px tall), and ALSO grow the content by opening every expandable thing (dropdowns, collapsible sections, "add row" repeaters, multi-select pickers) so a step becomes taller than the viewport. Then confirm: nothing is cut off at the **top** or bottom; any fixed/sticky chrome (page header, wizard **stepper/tabs**, toolbars) stays visible and is NOT clipped; the scroll container actually scrolls; and the primary actions (Save/Next/Submit/Cancel) stay reachable and clickable. A form taller than the page must never hide its header, its step nav, or its buttons.
|
||||
- **Every control has a visible label.** Each input/toggle/select/picker shows a field label. Compare sibling forms — if one labels a control and the other doesn't, that's a defect.
|
||||
- **Visual hierarchy is correct.** Section/group headers are MORE prominent than the field labels inside them (size/weight/color); field labels are consistent across steps and across sibling forms; helper text is least prominent. Flag any inverted or inconsistent hierarchy.
|
||||
- No text truncation/overlap; spacing, alignment, and contrast are reasonable; the form reads as natural, polished UI.
|
||||
2. **Execute with the local-QC MCP's tools**:
|
||||
- Use `browser_type` for React controlled inputs — never direct DOM value assignment (it doesn't fire onChange).
|
||||
- After each meaningful action: `browser_snapshot` to verify state, then screenshot (see proof convention).
|
||||
- Don't trust `browser_network_requests` alone for HTTP verification (it double-lists requests); prefer the lane's own backend/service logs if the profile exposes them (`agenthub lanes logs <svc>`).
|
||||
- **Failure triage**: capture a failure screenshot + `browser_snapshot` + `browser_console_messages`, note repro steps, expected vs actual, severity (blocker/major/minor/cosmetic) — then CONTINUE with remaining scenarios unless the app is unusable.
|
||||
3. **Proof convention (mandatory — the path is fixed infrastructure, NOT your choice):** EVERY screenshot via `browser_take_screenshot` with `filename: "proof/<feature-slug>/qc-local/<NN>-<what>.png"`, numbered in flow order (e.g. `proof/edit-report/qc-local/03-dialog-open.png`). The MCP server's pinned `--output-dir` lands these in `<lane clone>/.playwright-mcp/proof/...` — the ONLY place the dashboard's gallery reads (`agenthub lanes proof-link` converges a stray `proof/` symlink if needed). Absolute paths, other folders, or invented layouts = the proof is lost.
|
||||
|
||||
## Checkpoint & resume *(rate-limits and crashes must not zero your work)*
|
||||
|
||||
- **Checkpoint as you go:** after EACH scenario, append one line to `proof/<feature-slug>/qc-local/RESULTS.partial.md` (via the Write/Edit tools on `<lane clone>/.playwright-mcp/proof/...`): `<NN> <scenario> — ✅|❌ — <proof file> — <HEAD sha>`. A mid-run tool error or rate-limit then leaves a resumable trail instead of zeroing the run.
|
||||
- **Resume on start:** if `RESULTS.partial.md` already exists for this feature AND its HEAD sha matches the tree you're QCing, treat its ✅ scenarios as done (spot-check one) and continue from the first unfinished one. Sha differs → start fresh (delete the stale partial).
|
||||
- **Bounded stalls:** a single browser step hanging >2 min → close the browser, reload, retry that step ONCE; still stuck → record ❌ with proof and move on. If the run approaches ~45 min, finish the current scenario, mark the rest ⚠️ not-run, and return honestly rather than stalling silently.
|
||||
|
||||
## Quality bar (verify before returning)
|
||||
|
||||
- [ ] Every in-scope QC-Plan item was executed (or marked N/A with a reason); any discovered scenarios are listed for fold-back.
|
||||
- [ ] Every primary scenario has a screenshot of its end state (pass or fail).
|
||||
- [ ] Reload tested on every stateful screen touched; one full logout → re-login cycle done.
|
||||
- [ ] Smoke covered login, navigation, and ≥3 unaffected areas.
|
||||
- [ ] UI/UX pass done on EACH touched form: tested at narrow AND short viewports AND with dropdowns/expandables open (content taller than the page) — fixed header/stepper/buttons never cut off, scroll works, actions reachable; every control labeled; section headers more prominent than field labels; consistent label styling; no overlap/truncation; natural polished layout.
|
||||
- [ ] All screenshots under `proof/<feature-slug>/qc-local/` (check with `ls .playwright-mcp/proof/<feature-slug>/qc-local/`).
|
||||
- [ ] No commits made; password never echoed anywhere.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
A scannable report (this doubles as the user-flow review for the senior gate): a **QC-Plan coverage** view (each in-scope plan item → ✅/❌/⚠️), a **Scenarios discovered during QC** list (anything you tested that wasn't in the plan, for the skill to fold back), issues with severity + repro + which proof file shows each, proof filename list, reload/re-login coverage note, UX observations worth fixing. Then end with exactly one of:
|
||||
|
||||
- `LOCAL-QC: PASS`
|
||||
- `LOCAL-QC: FAIL — <comma-separated reasons>`
|
||||
|
||||
A FAIL must be specific enough for the lane's fix-loop to act on (page/flow + what broke + proof file).
|
||||
|
||||
## Agent memory
|
||||
|
||||
Record local-stack QA knowledge as you find it: flaky selectors/flows, features that break after reload or re-login, stub-mode quirks (what canned responses look like per feature), data-setup prerequisites, upload-fixture behaviors, noise-vs-real console errors. Future qc-local runs (any lane) read this.
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
name: "senior-gate-reviewer"
|
||||
description: "Final GO/NO-GO publish-readiness gate for the ship-feature-lane pipeline in AgentHub. Invoke AFTER the code-review and feature-user-flow-review (qc-local) and AFTER local CI gates + e2e + QC have passed, immediately BEFORE the feature branch is pushed and its PR is opened (base `development`). Returns a single GO or NO-GO verdict with a required-fix list. A GO is the sole authorization to publish the PR. <example>Context: ship-feature-lane in a lane has finished implementation, gates, and reviews and is about to publish the PR. assistant: 'I'll launch the senior-gate-reviewer agent to make the final go/no-go decision before pushing the branch and opening the PR.' <commentary>This is the publish gate — it decides whether the feature is allowed in front of reviewers.</commentary></example>"
|
||||
model: opus
|
||||
color: red
|
||||
memory: project
|
||||
---
|
||||
|
||||
You are the **Senior Gate Reviewer** — the final, independent publish-readiness authority for the ship-feature-lane pipeline. Nothing is pushed or PR'd without your **GO**; the PR a human later merges into `development` is exactly what you approved. You are deliberately skeptical: when in doubt, you return **NO-GO** with a precise, actionable fix list. You do NOT make fixes yourself — you judge, decide, and report.
|
||||
|
||||
## Context you will be given (by the ship-feature-lane skill)
|
||||
|
||||
- **The lane's working directory** (your cwd — AgentHub resolves the lane from it automatically).
|
||||
- **The original requirement** and the **upfront Q&A answers** (acceptance criteria).
|
||||
- The **feature branch name** (cut from `origin/development` — the PR base it will target).
|
||||
- A summary of the **two prior reviews** (code-review + the qc-local user-flow review) and which findings were resolved.
|
||||
- The **`agenthub lanes sync-base --check` preflight output** (migration-collision check + how far `origin/development` moved since the branch's merge-base).
|
||||
- Confirmation that **local CI gates** (the profile's lint / test / contract checks), **e2e**, and **browser QC** already passed.
|
||||
|
||||
If any of this context is missing, gather it yourself (read the lane spec file the skill wrote, run `git -C <lane> log/diff`).
|
||||
|
||||
## Your checks
|
||||
|
||||
**Review the PR the reviewers will see.** The PR is `<feature-branch>` based on `origin/development` — judge the change against **the PR diff**: `git -C <lane> diff origin/development...<feature-branch>` (`<feature-branch>` is in your context). There is NO local integration tree in this flow — the branch itself is what ships, and the first combined validation after a human merges it is dev CI + dev-QC; your merge-safety check (#3) is what stands between this branch and that merge. Run checks with `git -C <lane> ...`, `Read`, `Grep`, `make` as needed.
|
||||
|
||||
1. **Acceptance** — Does the implementation actually satisfy the original requirement + every upfront Q&A answer? Inspect **the PR diff** (defined above) and, where feasible, confirm the acceptance criteria are met in code. Missing/partial requirement coverage ⇒ NO-GO.
|
||||
|
||||
2. **Findings resolved** — Were the PR-code-review and user-flow-review findings actually addressed (not just acknowledged)? Spot-check **the PR diff** for each claimed fix. Unresolved material findings ⇒ NO-GO.
|
||||
|
||||
3. **Merge-safety** — Independently verify the branch is safe for a human to merge into `development`. **General principle (any stack):** "the branch is green standalone" is NOT proof the MERGE will be — check for collisions that DON'T surface as git conflicts — duplicate migration identifiers, duplicate fixture/test IDs, API/schema-contract drift, lockfile divergence — in whatever form this stack expresses them, comparing the branch against the CURRENT `origin/development`. Re-run the deterministic preflight yourself if the provided output is stale: `git -C <lane> fetch origin && agenthub lanes sync-base --check <feature-branch>` (exit 5 = collision ⇒ NO-GO with the printed rename; also read its `DEV_DELTA`/`DEV_OVERLAP` — a large overlapping upstream delta means the locally-QC'd behavior may not match post-merge dev, weigh it).
|
||||
- **Stack-specific checks (from the active profile):** read `<lane>/.agenthub/profile/review-checks.md` with the Read tool if it exists, and apply every check it lists (e.g. migration-identifier collisions, API/contract regeneration). Missing or empty → derive the equivalents yourself from the general principle by inspecting the branch vs `origin/development`.
|
||||
- **Obvious regressions / scope creep**: scan **the PR diff** for debug code, secrets, commented-out blocks, unrelated churn, `console.log`/`print` debugging, TODO/FIXME left in critical paths.
|
||||
|
||||
4. **UI/UX diligence (when the feature touches any form/screen)** — don't rubber-stamp the QC report; confirm it actually exercised layout rigor, because these defects slip through happy-path QC:
|
||||
- **Overflow** was tested at narrow AND **short** viewports, AND with dropdowns/collapsibles/repeaters **open** so content exceeds the viewport — and no fixed chrome (page header, wizard **stepper/tabs**, action bar) is cut off (especially at the **top**), scroll works, and primary actions stay reachable. A report that only shows one viewport / the happy path has NOT verified this.
|
||||
- Every control is **labeled**, and **section headers are more prominent than field labels** (no inverted hierarchy); labels are consistent across **sibling forms** (if the feature changed two similar forms, they must match).
|
||||
- Spot-check **the PR diff** yourself for UI/UX regressions in these classes (a scroll container that lost `min-h-0`, a removed/altered label, a muted section header, a form-wide container that scrolls the whole page instead of an inner region).
|
||||
If the feature is UI-heavy and the QC didn't demonstrably check the above ⇒ NO-GO with a specific "re-QC: verify <X> at short viewport / with <dropdown> open" instruction.
|
||||
|
||||
5. **QC-plan coverage** — read the `## QC Plan` section of the lane spec and the qc-local report (the user-flow review; on a follow-up fix run, also the parent's dev-QC findings the follow-up exists to fix). Confirm every **in-scope** plan item has a result (pass/fail, with proof) — any uncovered in-scope item ⇒ NO-GO ("re-QC: cover <item>"). If a QC report spent effort on the plan's **out-of-scope** areas while leaving in-scope items thin, flag it. Scenarios the agents discovered mid-run should appear folded into the plan.
|
||||
|
||||
## Output format (MANDATORY — the skill parses your last line)
|
||||
|
||||
Write a short report (acceptance ✓/✗, findings ✓/✗, merge-safety ✓/✗ with one line each), then end with **exactly one** of these as the FINAL line:
|
||||
|
||||
- `VERDICT: GO`
|
||||
- `VERDICT: NO-GO — <comma-separated required fixes>`
|
||||
|
||||
Rules:
|
||||
- Only return `GO` when all three checks pass with high confidence.
|
||||
- A NO-GO fix list must be specific and actionable (file/area + what to do), so the skill's fix-loop can act and re-submit.
|
||||
- Never push, merge, commit, or modify files. You are read-only. Your verdict is the deliverable.
|
||||
+195
-269
@@ -167,11 +167,13 @@ async function api(method, pathname, body, options = {}) {
|
||||
const url = `${baseUrl()}${pathname}`;
|
||||
let res;
|
||||
try {
|
||||
// A FormData body (a file upload) sets its own multipart Content-Type.
|
||||
const form = body instanceof FormData;
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
});
|
||||
} catch {
|
||||
throw new ServerDownError();
|
||||
@@ -1289,7 +1291,7 @@ async function cmdDoctor() {
|
||||
console.log(
|
||||
available
|
||||
? `${c.green("✔")} tmux available to the server`
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub lanes shell\` will fail. ` +
|
||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||
);
|
||||
} catch {
|
||||
@@ -1340,8 +1342,8 @@ async function cmdExport(positional) {
|
||||
}
|
||||
|
||||
// Restore a bundle produced by `agenthub export` (or the dashboard's Export data).
|
||||
// The local server reads the file from disk, so we pass an absolute path rather
|
||||
// than uploading — idempotent and non-destructive (existing sessions skipped).
|
||||
// Uploaded like the dashboard's Restore button does — the server never reads a
|
||||
// path from a request. Idempotent and non-destructive (existing sessions skipped).
|
||||
async function cmdImportData(positional) {
|
||||
const target = positional[0];
|
||||
if (!target) {
|
||||
@@ -1353,7 +1355,9 @@ async function cmdImportData(positional) {
|
||||
console.error(c.red(`✖ File not found: ${abs}`));
|
||||
process.exit(1);
|
||||
}
|
||||
const r = await post("/api/settings/import", { path: abs });
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([fs.readFileSync(abs)]), path.basename(abs));
|
||||
const r = await post("/api/settings/import", form, { timeoutMs: 10 * 60_000 });
|
||||
console.log(
|
||||
`${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` +
|
||||
`${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` +
|
||||
@@ -1463,16 +1467,24 @@ async function cmdStart(flags) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs/removes a systemd `--user` service so the dashboard survives
|
||||
* reboots without anything (Claude Code included) needing to start it first.
|
||||
* Linux/systemd only; other platforms are told to keep using `agenthub start`.
|
||||
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
||||
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
||||
* included) needing to start it first. Elsewhere, `agenthub start` by hand.
|
||||
*/
|
||||
async function cmdService(sub, flags) {
|
||||
const svc = require(path.join(REPO_ROOT, "scripts", "systemd-service.js"));
|
||||
const svc = require(path.join(REPO_ROOT, "scripts", "service-manager.js"));
|
||||
if (!svc.systemctlAvailable()) {
|
||||
console.error(c.red("✖ systemd --user is not available on this machine."));
|
||||
console.error(
|
||||
c.dim(" Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead.")
|
||||
c.red(
|
||||
`✖ ${svc.kind === "launchd" ? "launchd (a GUI login session)" : "systemd --user"} is not available here.`
|
||||
)
|
||||
);
|
||||
console.error(
|
||||
c.dim(
|
||||
svc.kind === "launchd"
|
||||
? " A LaunchAgent needs the user logged in at the Mac itself, not only over SSH. Use `agenthub start` meanwhile."
|
||||
: " Auto-start on boot needs Linux + systemd. Use `agenthub start` manually instead."
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1493,24 +1505,27 @@ async function cmdService(sub, flags) {
|
||||
description: "AgentHub dashboard server",
|
||||
workingDir: REPO_ROOT,
|
||||
execStart: `${process.execPath} ${path.join(REPO_ROOT, "server", "index.js")}`,
|
||||
argv: [process.execPath, path.join(REPO_ROOT, "server", "index.js")],
|
||||
env,
|
||||
});
|
||||
if (result === "foreign") {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ~/.config/systemd/user/${name}.service already exists and wasn't written by agenthub.`
|
||||
)
|
||||
c.red(`✖ ${svc.unitPath(name)} already exists and wasn't written by agenthub.`)
|
||||
);
|
||||
console.error(c.dim(" Remove or rename it, then re-run `agenthub service install`."));
|
||||
process.exit(1);
|
||||
}
|
||||
if (result === "failed") {
|
||||
console.error(c.red("✖ Failed to write/enable the systemd unit."));
|
||||
console.error(c.red(`✖ Failed to write/enable the ${svc.label}.`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} systemd --user service '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
|
||||
`${c.green("✔")} ${svc.label} '${name}' ${result === "unchanged" ? "already up to date" : "installed"} and enabled.`
|
||||
);
|
||||
if (svc.kind === "launchd") {
|
||||
console.log(c.dim(` It starts at every login. Server log: ${svc.logPath()}`));
|
||||
return;
|
||||
}
|
||||
const lingered = svc.enableLinger();
|
||||
if (lingered) {
|
||||
console.log(
|
||||
@@ -1531,20 +1546,24 @@ async function cmdService(sub, flags) {
|
||||
if (sub === "uninstall") {
|
||||
const result = svc.uninstallUserService(name);
|
||||
if (result === "foreign") {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ~/.config/systemd/user/${name}.service wasn't written by agenthub — not touching it.`
|
||||
)
|
||||
);
|
||||
console.error(c.red(`✖ ${svc.unitPath(name)} wasn't written by agenthub — not touching it.`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} systemd service ${result === "absent" ? "was not installed" : "removed"}.`
|
||||
`${c.green("✔")} ${svc.label} ${result === "absent" ? "was not installed" : "removed"}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sub === "status") {
|
||||
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], { stdio: "inherit" });
|
||||
if (svc.kind === "launchd") {
|
||||
spawn("launchctl", ["print", `gui/${process.getuid()}/io.smartgift.${name}`], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} else {
|
||||
spawn("systemctl", ["--user", "status", `${name}.service`, "--no-pager"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error(c.red("✖ Usage: agenthub service install|uninstall|status [--port N]"));
|
||||
@@ -1593,10 +1612,11 @@ async function cmdLanesAdd(args) {
|
||||
const pipeline = flag("pipeline");
|
||||
const base = flag("base");
|
||||
const slug = flag("slug");
|
||||
const tool = flag("tool");
|
||||
if (repo) {
|
||||
if (cwd || pipeline) {
|
||||
console.error(
|
||||
"usage: agenthub lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>]"
|
||||
"usage: agenthub workspace add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -1604,6 +1624,7 @@ async function cmdLanesAdd(args) {
|
||||
const body = { sourceRepo: repo, title: title || path.basename(repo) };
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lane = provisioning;
|
||||
@@ -1612,52 +1633,36 @@ async function cmdLanesAdd(args) {
|
||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
||||
}
|
||||
if (lane.status === "provisioning") {
|
||||
console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`);
|
||||
console.error(
|
||||
`✖ Timed out waiting for worktree workspace #${lane.id} to finish provisioning.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (lane.status === "failed") {
|
||||
console.error(
|
||||
`✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}`
|
||||
`✖ Worktree workspace #${lane.id} failed: ${lane.notes || "no failure details reported"}`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
|
||||
`${c.green("✔")} Worktree workspace #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!cwd || !title) {
|
||||
console.error("usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>]");
|
||||
console.error(
|
||||
"usage: agenthub workspace add --cwd <path> --title <text> [--pipeline <id>] [--tool <id>]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane } = await post("/api/lanes", body);
|
||||
console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub skills install` — copy this checkout's `skills/ship-feature-lane/`
|
||||
* into `~/.claude/skills/ship-feature-lane/`, so `/ship-feature-lane` is
|
||||
* discoverable from a session running inside any lane's own working
|
||||
* directory, not just inside this repo. Pure filesystem action; does not
|
||||
* talk to the dashboard server at all. Reinstall, not merge — overwrites an
|
||||
* existing global copy with the current one.
|
||||
*/
|
||||
function cmdSkillsInstall() {
|
||||
const { installShipFeatureLaneSkill } = require(
|
||||
path.join(REPO_ROOT, "server", "lib", "skills-install.js")
|
||||
);
|
||||
try {
|
||||
const result = installShipFeatureLaneSkill({ repoRoot: REPO_ROOT });
|
||||
console.log(`${c.green("✔")} installed ship-feature-lane skill -> ${result.path}`);
|
||||
} catch (err) {
|
||||
console.error(`✖ ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1699,7 +1704,7 @@ function cmdLanesProfileInit(args) {
|
||||
const repo = args.find((arg) => !arg.startsWith("--"));
|
||||
const force = args.includes("--force");
|
||||
if (!repo) {
|
||||
console.error("usage: agenthub lanes profile init <repo> [--force]");
|
||||
console.error("usage: agenthub workspace profile init <repo> [--force]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1741,7 +1746,7 @@ function cmdLanesProfileInit(args) {
|
||||
console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`);
|
||||
for (const todo of result.todos) console.log(` ${todo}`);
|
||||
}
|
||||
console.log(`\nNext: agenthub lanes profile check ${repo}`);
|
||||
console.log(`\nNext: agenthub workspace profile check ${repo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1790,13 +1795,13 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
if (!id) {
|
||||
const extra = action === "reset" ? " [--keep-db]" : "";
|
||||
console.error(`usage: agenthub lanes ${action} <id> [--force]${extra} --yes`);
|
||||
console.error(`usage: agenthub workspace ${action} <id> [--force]${extra} --yes`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||
printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields);
|
||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
const fate = action === "remove" ? "kept" : keepDb ? "kept as-is" : "dropped and recreated";
|
||||
@@ -1813,7 +1818,9 @@ async function cmdLanesLifecycle(action, args) {
|
||||
}
|
||||
|
||||
if (!args.includes("--yes")) {
|
||||
console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`);
|
||||
console.error(
|
||||
`Refusing to ${action} workspace #${id} without --yes. Re-run with --yes to proceed.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -1827,7 +1834,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
preflight.blocked.includes("adopted")
|
||||
) {
|
||||
console.error(
|
||||
`Lane #${id} points at a directory you own. It cannot be reset; use \`agenthub lanes remove ${id} --yes\` to drop only the dashboard's record of it.`
|
||||
`Workspace #${id} points at a directory you own. It cannot be reset; use \`agenthub workspace remove ${id} --yes\` to drop only the dashboard's record of it.`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -1840,7 +1847,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
const error = result.data?.error || {};
|
||||
console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
if (result.status === 409 && error.code === "ESTALE") {
|
||||
printLaneFacts("Expected preflight:", error.expected || {}, fields);
|
||||
printLaneFacts("Current preflight:", error.current || {}, fields);
|
||||
@@ -1849,12 +1856,12 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`);
|
||||
if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`);
|
||||
if (action === "reset") console.log(`${c.green("✔")} Reset workspace #${id}.`);
|
||||
if (action === "remove") console.log(`${c.green("✔")} Removed workspace #${id}.`);
|
||||
if (action === "purge") {
|
||||
const purged = result.purged || {};
|
||||
console.log(
|
||||
`${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
|
||||
`${c.green("✔")} Purged workspace #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1894,7 +1901,9 @@ function detectionLeadsDeclaration(l) {
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
if (!lanes.length) {
|
||||
console.log("no lanes yet — create one with: agenthub lanes add --cwd <path> --title <text>");
|
||||
console.log(
|
||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const l of lanes) {
|
||||
@@ -1907,7 +1916,7 @@ async function cmdLanes() {
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
|
||||
`\n${counts.total} workspaces · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1948,7 +1957,7 @@ async function resolveLaneArg(args) {
|
||||
return i > -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane");
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
@@ -1972,7 +1981,9 @@ async function resolveLaneArg(args) {
|
||||
.filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`))
|
||||
.sort((a, b) => b.cwd.length - a.cwd.length);
|
||||
if (!owning.length) {
|
||||
console.error(`no lane owns ${cwd} — create one with: agenthub lanes add --cwd ${cwd}`);
|
||||
console.error(
|
||||
`no workspace owns ${cwd} — create one with: agenthub workspace add --cwd ${cwd}`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return null;
|
||||
}
|
||||
@@ -1982,7 +1993,7 @@ async function resolveLaneArg(args) {
|
||||
const candidates = tool ? nearest.filter((l) => (l.tool || "claude") === tool) : nearest;
|
||||
if (!candidates.length) {
|
||||
console.error(
|
||||
`no ${tool} lane owns ${owning[0].cwd} — lanes here: ${nearest
|
||||
`no ${tool} workspace owns ${owning[0].cwd} — workspaces here: ${nearest
|
||||
.map((l) => `#${l.id} (${l.tool || "claude"})`)
|
||||
.join(", ")}`
|
||||
);
|
||||
@@ -1991,9 +2002,9 @@ async function resolveLaneArg(args) {
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
console.error(
|
||||
`${candidates.length} lanes own ${owning[0].cwd} — pass --tool <${candidates
|
||||
`${candidates.length} workspaces own ${owning[0].cwd} — pass --tool <${candidates
|
||||
.map((l) => l.tool || "claude")
|
||||
.join("|")}> or --lane <id>: ${candidates
|
||||
.join("|")}> or --workspace <id>: ${candidates
|
||||
.map((l) => `#${l.id} (${l.tool || "claude"})`)
|
||||
.join(", ")}`
|
||||
);
|
||||
@@ -2006,12 +2017,12 @@ async function resolveLaneArg(args) {
|
||||
/** One line per declared port: name, number, listening, and any base drift. */
|
||||
function printRuntime(runtime) {
|
||||
if (!runtime.available) {
|
||||
console.log("no .agenthub/profile for this lane — nothing to run.");
|
||||
console.log("no .agenthub/profile for this workspace — nothing to run.");
|
||||
if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`);
|
||||
return;
|
||||
}
|
||||
if (!runtime.provisioned) {
|
||||
console.log("profile found, runtime not provisioned yet — run: agenthub lanes up");
|
||||
console.log("profile found, runtime not provisioned yet — run: agenthub workspace up");
|
||||
return;
|
||||
}
|
||||
console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`);
|
||||
@@ -2065,14 +2076,14 @@ async function cmdLanesRuntime(sub, args) {
|
||||
if (laneArgs.includes("--qc")) body.qc = true;
|
||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
// The server answers 202 and boots in the background; poll until the stack
|
||||
// reports healthy or a boot error lands, so the command exits on a real
|
||||
// outcome rather than on "accepted".
|
||||
console.log(`lane #${laneId} booting…`);
|
||||
console.log(`workspace #${laneId} booting…`);
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
@@ -2099,18 +2110,20 @@ async function cmdLanesRuntime(sub, args) {
|
||||
if (sub === "down") {
|
||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`);
|
||||
console.log(`workspace #${laneId} down (${result.killed?.length || 0} processes stopped)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "logs") {
|
||||
const svc = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
if (!svc) {
|
||||
console.error("usage: agenthub lanes logs [<id>] <service> [--tail bytes]");
|
||||
console.error("usage: agenthub workspace logs [<id>] <service> [--tail bytes]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -2118,7 +2131,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
if (!log.available) {
|
||||
console.log("no logs for this lane yet.");
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
}
|
||||
if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`);
|
||||
@@ -2129,7 +2142,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
if (sub === "hook") {
|
||||
const name = laneArgs[0];
|
||||
if (!name || name.startsWith("--")) {
|
||||
console.error("usage: agenthub lanes hook [<id>] <name> [args…]");
|
||||
console.error("usage: agenthub workspace hook [<id>] <name> [args…]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -2144,7 +2157,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`lane #${laneId} running hook ${name} — follow it with: agenthub lanes logs ${laneId} ${name}`
|
||||
`workspace #${laneId} running hook ${name} — follow it with: agenthub workspace logs ${laneId} ${name}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2167,7 +2180,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (result.code === 5) {
|
||||
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
|
||||
console.error(`✖ workspace #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
|
||||
for (const c of result.collisions) {
|
||||
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
|
||||
}
|
||||
@@ -2176,11 +2189,11 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (result.code === 4) {
|
||||
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
|
||||
console.error(`✖ workspace #${laneId} — MERGE CONFLICT (left in place).`);
|
||||
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
|
||||
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
|
||||
console.error(
|
||||
` then: agenthub lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
|
||||
` then: agenthub workspace sync-base --continue ${branch ? branch + " " : ""}${laneId}`
|
||||
);
|
||||
process.exitCode = 4;
|
||||
return;
|
||||
@@ -2203,12 +2216,12 @@ async function cmdLanesRuntime(sub, args) {
|
||||
console.log("DEV_OVERLAP: none");
|
||||
}
|
||||
}
|
||||
console.log(`lane #${laneId} preflight vs origin/development: OK`);
|
||||
console.log(`workspace #${laneId} preflight vs origin/development: OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
|
||||
`workspace #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -2383,7 +2396,7 @@ async function cmdFeatureActivate(args) {
|
||||
title: flag("title"),
|
||||
});
|
||||
console.log(
|
||||
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
|
||||
`${c.green("✔")} workspace #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2402,25 +2415,6 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes agents install [<id>] [--cwd path]` — write the ship-feature-lane
|
||||
* agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/,
|
||||
* git-excluded. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesAgentsInstall(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(
|
||||
`/api/lanes/${resolved.laneId}/agents/install`,
|
||||
{},
|
||||
{ allowError: true }
|
||||
);
|
||||
if (result.status) {
|
||||
console.error(`✖ agents install → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log(`${c.green("✔")} installed: ${result.installed.join(", ")}`);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
@@ -2445,7 +2439,7 @@ async function cmdLanesMcpSync(args) {
|
||||
async function cmdLanesIntegration(args) {
|
||||
const name = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!name) {
|
||||
console.error("usage: agenthub lanes integration <name> [<id>]");
|
||||
console.error("usage: agenthub workspace integration <name> [<id>]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -2464,13 +2458,13 @@ async function cmdLanesIntegration(args) {
|
||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||
async function cmdLanesCi(args) {
|
||||
if (args[0] !== "status") {
|
||||
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
|
||||
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const ref = args[1];
|
||||
if (!ref) {
|
||||
console.error("usage: agenthub lanes ci status <branch-or-sha> [<lane-id>]");
|
||||
console.error("usage: agenthub workspace ci status <branch-or-sha> [<id>]");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -2499,8 +2493,7 @@ async function cmdLanesCi(args) {
|
||||
* was display-only: the dashboard rendered a copy button and the operator
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command (`/superpowers:brainstorming`) starts `claude` with it
|
||||
* as the prompt, exactly like `agenthub ship-full` does with the whole skill;
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
@@ -2536,7 +2529,7 @@ async function cmdLanesNode(args) {
|
||||
const rest = positional.filter((p) => p !== String(resolved.laneId));
|
||||
const key = rest[0];
|
||||
const listNodes = () => {
|
||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${nodes.length} nodes)`);
|
||||
for (const n of nodes) {
|
||||
const mark = n.state === "current" ? c.cyan("→") : " ";
|
||||
console.log(
|
||||
@@ -2547,7 +2540,7 @@ async function cmdLanesNode(args) {
|
||||
|
||||
if (!key) {
|
||||
listNodes();
|
||||
console.error(`\nusage: agenthub lanes node <node-id> [extra args…] [<id>] [--print]`);
|
||||
console.error(`\nusage: agenthub workspace node <node-id> [extra args…] [<id>] [--print]`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
@@ -2583,7 +2576,7 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${c.dim(`lane #${lane.id} · node ${node.id} (${node.label})`)}\n` +
|
||||
`${c.dim(`workspace #${lane.id} · node ${node.id} (${node.label})`)}\n` +
|
||||
`${c.cyan("→")} ${argv.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ")}`
|
||||
);
|
||||
const child = spawn(argv[0], argv.slice(1), { stdio: "inherit", cwd: lane.cwd });
|
||||
@@ -2614,7 +2607,7 @@ async function cmdLanesPipeline(args) {
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
console.log(`lane #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
@@ -2622,7 +2615,7 @@ async function cmdLanesPipeline(args) {
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} lane #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
);
|
||||
// Switching templates re-resolves the SAME declared stage string against a
|
||||
@@ -2631,7 +2624,7 @@ async function cmdLanesPipeline(args) {
|
||||
if (!lane.pipeline_nodes.some((n) => n.state === "current")) {
|
||||
console.error(
|
||||
c.yellow(
|
||||
`! the lane's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
|
||||
`! the workspace's current stage "${lane.stage}" matches no node in "${lane.pipeline}" — ` +
|
||||
`declare one of: ${lane.pipeline_nodes.map((n) => n.id).join(", ")}`
|
||||
)
|
||||
);
|
||||
@@ -2663,88 +2656,6 @@ async function cmdLanesShell(args) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a `claude` session in this lane driven by a pipeline's own skill.
|
||||
* Two commands, two depths of work:
|
||||
*
|
||||
* agenthub ship-full → the 16-node `ship-feature` template, `/agenthub:ship-feature-lane`
|
||||
* — autonomous to a published PR, then watched until a
|
||||
* human merges it.
|
||||
* agenthub ship-quick → the 8-node `default` template, `/agenthub:ship-feature`
|
||||
* — in-repo feature work that stops at review/gate.
|
||||
*
|
||||
* Each command puts the lane on its own template, so the choice is the command
|
||||
* you type; the skill string itself lives in the template's `skill` field and
|
||||
* is never duplicated here.
|
||||
*
|
||||
* The prompt is passed as ONE argv element to `claude`, never through a shell —
|
||||
* a requirement containing quotes, `$`, or `;` is text, not code. `--print`
|
||||
* shows the exact command, switches nothing, and runs nothing.
|
||||
*/
|
||||
const SHIP_TEMPLATES = {
|
||||
"ship-full": "ship-feature",
|
||||
"ship-quick": "default",
|
||||
};
|
||||
|
||||
async function cmdShip(command, args) {
|
||||
const templateId = SHIP_TEMPLATES[command];
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
let { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const skill = pipelines.find((p) => p.id === templateId)?.skill;
|
||||
if (!skill) {
|
||||
console.error(
|
||||
`✖ pipeline template "${templateId}" declares no driving skill — ` +
|
||||
`add a "skill" field to it (see docs/LANES.md § Custom template format)`
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// `resolveLaneArg` only strips a leading bare lane id; the rest of the
|
||||
// positionals are the requirement, joined back into one prompt line.
|
||||
const requirement = positional
|
||||
.filter((p) => p !== String(resolved.laneId))
|
||||
.join(" ")
|
||||
.trim();
|
||||
const prompt = requirement ? `${skill} ${requirement}` : skill;
|
||||
|
||||
if (flags.print || !requirement) {
|
||||
// `--print` shows what would run, so it switches nothing.
|
||||
if (!requirement) console.error(`usage: agenthub ${command} "<requirement>" [<id>] [--print]`);
|
||||
console.log(`cd ${lane.cwd} && claude ${JSON.stringify(prompt)}`);
|
||||
if (!requirement) process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The command picks the pipeline, not the lane's current setting: put the
|
||||
// lane on the matching template first so the dashboard renders the right
|
||||
// node count from the start rather than after the skill self-corrects.
|
||||
if (lane.pipeline !== templateId) {
|
||||
({ lane } = await patch(`/api/lanes/${lane.id}`, { pipeline: templateId }));
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${c.dim(`lane #${lane.id} · pipeline ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`)}\n` +
|
||||
`${c.cyan("→")} claude ${JSON.stringify(prompt)}`
|
||||
);
|
||||
const child = spawn("claude", [prompt], { stdio: "inherit", cwd: lane.cwd });
|
||||
await new Promise((resolve) => {
|
||||
child.on("close", (code) => {
|
||||
if (code) process.exitCode = code;
|
||||
resolve();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
console.error(c.red(`✖ ${err?.message || err}`));
|
||||
process.exitCode = 1;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cmdFeatureShow(args) {
|
||||
const slug = args.find((arg) => !arg.startsWith("--"));
|
||||
if (!slug) {
|
||||
@@ -2774,7 +2685,7 @@ async function cmdStage(args) {
|
||||
const stage = args[0];
|
||||
if (!stage || stage.startsWith("--")) {
|
||||
console.error(
|
||||
"usage: agenthub stage <stage> [--lane <id>] [--cwd <path>] [--tool claude|codex] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
|
||||
"usage: agenthub stage <stage> [--workspace <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -2797,7 +2708,7 @@ async function cmdStage(args) {
|
||||
note: flag("note"),
|
||||
result: flag("result"),
|
||||
});
|
||||
console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||
console.log(`workspace #${lane.id} → ${lane.stage} (${lane.progress}%)`);
|
||||
}
|
||||
|
||||
// ── Command catalog ─────────────────────────────────────────────────────────
|
||||
@@ -2894,114 +2805,106 @@ const COMMAND_GROUPS = [
|
||||
],
|
||||
],
|
||||
[
|
||||
"Lanes",
|
||||
"Workspaces",
|
||||
[
|
||||
["lanes", "", "List lanes with stage, liveness and progress"],
|
||||
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
|
||||
["workspaces", "", "List workspaces with stage, liveness and progress"],
|
||||
[
|
||||
"lanes add",
|
||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
||||
"Provision a managed worktree lane",
|
||||
"workspace add",
|
||||
"--cwd <path> --title <text> [--tool <id>]",
|
||||
"Create a new workspace bound to a directory (--tool: an AI Tools id, default claude)",
|
||||
],
|
||||
[
|
||||
"lanes profile init",
|
||||
"workspace add",
|
||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
|
||||
"Provision a managed worktree workspace",
|
||||
],
|
||||
[
|
||||
"workspace profile init",
|
||||
"<repo> [--force]",
|
||||
"Detect a Node.js project and scaffold .agenthub/profile/",
|
||||
],
|
||||
[
|
||||
"lanes profile check",
|
||||
"workspace profile check",
|
||||
"[<path>]",
|
||||
"Validate a profile (path defaults to cwd, not a lane id)",
|
||||
"Validate a profile (path defaults to cwd, not a workspace id)",
|
||||
],
|
||||
[
|
||||
"lanes pipeline",
|
||||
"workspace pipeline",
|
||||
"[<template-id>] [<id>]",
|
||||
"Show, or switch, which pipeline template a lane renders against",
|
||||
"Show, or switch, which pipeline template a workspace renders against",
|
||||
],
|
||||
[
|
||||
"lanes node",
|
||||
"workspace node",
|
||||
"[<node-id>] [extra…] [<id>] [--print]",
|
||||
"Run ONE pipeline node's own command in the lane's cwd (no id: list every node and its command)",
|
||||
"Run ONE pipeline node's own command in the workspace's cwd (no id: list every node and its command)",
|
||||
],
|
||||
[
|
||||
"ship-full",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 16-stage lane pipeline: implement → gates → e2e → review → QC → senior gate → PR, watched until a human merges (--print: show the command, run nothing)",
|
||||
],
|
||||
[
|
||||
"ship-quick",
|
||||
'"<requirement>" [<id>] [--print]',
|
||||
"Run the 8-stage in-repo pipeline: plan → implement → tests → review → gate → ship. No PR, no browser QC",
|
||||
],
|
||||
[
|
||||
"lanes shell",
|
||||
"workspace shell",
|
||||
"[<id>]",
|
||||
"Attach a real terminal to the exact tmux session the dashboard uses for a lane's run",
|
||||
"Attach a real terminal to the exact tmux session the dashboard uses for a workspace's run",
|
||||
],
|
||||
[
|
||||
"lanes reset|remove|purge",
|
||||
"workspace reset|remove|purge",
|
||||
"<id> [--force] [--keep-db] --yes",
|
||||
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
|
||||
"Show preflight facts, then perform a destructive workspace action (--keep-db: reset only; remove drops the dashboard's record only — worktree, branch and database are left alone)",
|
||||
],
|
||||
[
|
||||
"lanes up|down",
|
||||
"workspace up|down",
|
||||
"[<id>] [--no-build] [--qc]",
|
||||
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
|
||||
"Boot or stop the workspace's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the workspace owning this directory)",
|
||||
],
|
||||
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
|
||||
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
|
||||
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
|
||||
["workspace runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
|
||||
[
|
||||
"lanes sync-base",
|
||||
"workspace logs",
|
||||
"[<id>] <svc> [--tail N]",
|
||||
"Tail one of the workspace's hook or service logs",
|
||||
],
|
||||
["workspace hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
|
||||
[
|
||||
"workspace sync-base",
|
||||
"[<id>] [--check|--continue] [branch]",
|
||||
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
|
||||
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the workspace's current branch)",
|
||||
],
|
||||
[
|
||||
"lanes proof-link",
|
||||
"workspace proof-link",
|
||||
"[<id>]",
|
||||
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
|
||||
],
|
||||
[
|
||||
"lanes agents install",
|
||||
"workspace mcp sync",
|
||||
"[<id>]",
|
||||
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
|
||||
"Relocate the source repo's MCP servers into <workspace>/.mcp.json and seed Chromium profiles",
|
||||
],
|
||||
[
|
||||
"lanes mcp sync",
|
||||
"[<id>]",
|
||||
"Relocate the source repo's MCP servers into <lane>/.mcp.json and seed Chromium profiles",
|
||||
],
|
||||
[
|
||||
"lanes integration",
|
||||
"workspace integration",
|
||||
"<name> [<id>]",
|
||||
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1",
|
||||
"Check whether a named integration (tracker, dev_qc, ci_wait) is on for this workspace — exit 0/1",
|
||||
],
|
||||
[
|
||||
"lanes ci status",
|
||||
"workspace ci status",
|
||||
"<branch-or-sha> [<id>]",
|
||||
"One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending",
|
||||
],
|
||||
[
|
||||
"lanes gc",
|
||||
"workspace gc",
|
||||
"[--dry-run]",
|
||||
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every lane",
|
||||
],
|
||||
[
|
||||
"skills install",
|
||||
"",
|
||||
"Install skills/ship-feature-lane/ into ~/.claude/skills/ so /ship-feature-lane works from any lane",
|
||||
"Reap orphaned Playwright MCP processes + cap oversized hook logs, across every workspace",
|
||||
],
|
||||
[
|
||||
"lock status|acquire|release",
|
||||
"[<name>] [--holder X] [--timeout N]",
|
||||
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
|
||||
"Cross-workspace named lock (serialize builds/e2e across all workspaces; holder defaults to the calling workspace)",
|
||||
],
|
||||
[
|
||||
"stage <stage> [flags]",
|
||||
"",
|
||||
"Report the current pipeline stage for a lane (--tool claude|codex when one cwd has both)",
|
||||
"Report the current pipeline stage for a workspace (--tool <id> when one cwd has workspaces for two tools)",
|
||||
],
|
||||
[
|
||||
"feature list",
|
||||
"[<id>]",
|
||||
"List every feature this workspace has activated, archived or live",
|
||||
],
|
||||
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
||||
[
|
||||
"feature activate",
|
||||
"<slug> [--title text] [<id>]",
|
||||
@@ -3047,6 +2950,9 @@ falling back to http://127.0.0.1:4820.
|
||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||
Prefer an interactive session? Run ${c.bold("agenthub repl")} for a shell with completion,
|
||||
@@ -3455,10 +3361,18 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
};
|
||||
const want = canon(token);
|
||||
const matches = [];
|
||||
for (const [group, items] of COMMAND_GROUPS) {
|
||||
for (const [name, args, desc] of items) {
|
||||
if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]);
|
||||
const have = canon(name);
|
||||
if (have === want || have.startsWith(want + " ")) matches.push([group, name, args, desc]);
|
||||
}
|
||||
}
|
||||
if (!matches.length) return false;
|
||||
@@ -3709,6 +3623,26 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
||||
* release after the first one that shipped these aliases. */
|
||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
||||
const canonicalArgv = (argv) =>
|
||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||
|
||||
/** One stderr line when an old name is typed, so people and agents move over
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
}
|
||||
}
|
||||
|
||||
/** All top-level command tokens, derived from COMMAND_GROUPS (first word of
|
||||
* each invocation) plus the always-available `help`. Used by the REPL
|
||||
* completer and unknown-command detection so they never drift from help. */
|
||||
@@ -3718,6 +3652,8 @@ const COMMANDS = (() => {
|
||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||
}
|
||||
set.add("help");
|
||||
// The old names stay typeable (and completable) for scripts written before.
|
||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||
return [...set];
|
||||
})();
|
||||
|
||||
@@ -3735,7 +3671,8 @@ const SUBCOMMANDS = {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
const [cmd, ...rest] = argv;
|
||||
warnOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
case "health":
|
||||
@@ -3805,7 +3742,7 @@ async function runCommand(argv) {
|
||||
if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2));
|
||||
if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2));
|
||||
console.error(
|
||||
"usage: agenthub lanes profile init <repo> [--force] | agenthub lanes profile check [<path>]"
|
||||
"usage: agenthub workspace profile init <repo> [--force] | agenthub workspace profile check [<path>]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -3817,9 +3754,6 @@ async function runCommand(argv) {
|
||||
return cmdLanesRuntime(rest[0], rest.slice(1));
|
||||
}
|
||||
if (rest[0] === "proof-link") return cmdLanesProofLink(rest.slice(1));
|
||||
if (rest[0] === "agents" && rest[1] === "install") {
|
||||
return cmdLanesAgentsInstall(rest.slice(2));
|
||||
}
|
||||
if (rest[0] === "mcp" && rest[1] === "sync") {
|
||||
return cmdLanesMcpSync(rest.slice(2));
|
||||
}
|
||||
@@ -3842,12 +3776,6 @@ async function runCommand(argv) {
|
||||
return cmdLanesGc(rest.slice(1));
|
||||
}
|
||||
return cmdLanes();
|
||||
case "skills": {
|
||||
if (rest[0] === "install") return cmdSkillsInstall();
|
||||
console.error("usage: agenthub skills install");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
case "lock": {
|
||||
const sub = rest[0];
|
||||
if (sub === "status") return cmdLockStatus(rest.slice(1));
|
||||
@@ -3861,9 +3789,6 @@ async function runCommand(argv) {
|
||||
}
|
||||
case "stage":
|
||||
return cmdStage(rest);
|
||||
case "ship-full":
|
||||
case "ship-quick":
|
||||
return cmdShip(cmd, rest);
|
||||
case "feature": {
|
||||
const sub = rest[0];
|
||||
if (sub === "list") return cmdFeatureList(rest.slice(1));
|
||||
@@ -3901,6 +3826,7 @@ async function runCommand(argv) {
|
||||
* an embedding loop can stay alive.
|
||||
*/
|
||||
async function handleServerDown(argv) {
|
||||
argv = canonicalArgv(argv);
|
||||
const cmd = argv[0];
|
||||
const { flags, positional } = parseArgs(argv.slice(1));
|
||||
const handler = OFFLINE_HANDLERS[cmd];
|
||||
|
||||
+81
-20
@@ -52,7 +52,18 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light toggle (next to the language switcher in the sidebar, `useTheme` hook) re-themes the whole app by flipping one class
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → General) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
- Anything that assumes a dark background — a hardcoded near-black panel, `hover:text-white`, `filter: invert(1)` on a form control glyph — is a light-mode bug. Use tokens, or scope the rule under `.dark`.
|
||||
- **A status/accent FILL is pre-mixed, never translucent.** `bg-status-success/10` and `border-status-danger/40` compile to `color-mix(… , rgb(var(--tint-base)))`, not to `rgb(… / 0.1)` — see `tint()` in `tailwind.config.js`. A translucent tint composites with whatever is behind it, so one "success" chip rendered as five different colours over the five surface steps and washed out on light cards. `--tint-base` is the theme's card surface, so the chip reads the same on the page, in a card, in a modal and inside an input row. This is scoped to `backgroundColor`/`borderColor`: `text-status-warning/80` and `ring-accent/40` stay genuinely translucent (they sit *on* a fill), and the `surface` family keeps its alpha or every scrim would turn solid. Light `--status-*` is two steps darker than Radix 11 so a label clears 4.5:1 against its own densest chip (a 20% tint of itself), not just against the card.
|
||||
- **React Router 6.28** - Client-side routing with nested layouts
|
||||
- **WebSocket** - Real-time event streaming from server
|
||||
- **Lucide Icons** - Modern, consistent icon set
|
||||
@@ -65,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
|
||||
|
||||
@@ -96,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
|
||||
@@ -174,17 +184,32 @@ client/
|
||||
│ │ ├── EventDetail.tsx # Inline hook payload viewer (used by ActivityFeed + SessionDetail)
|
||||
│ │ ├── EmptyState.tsx
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── WorkspaceTree.tsx # Sidebar Workspaces list: one row per workspace, expanding to its sessions (live → its pane, else the transcript), tool filter, search over workspace + session names, row menu
|
||||
│ │ ├── lanes/WorkspaceHome.tsx # /workspace?lane=<id>: repo facts, prompt box (new session in a free pane slot), Recents with cost, Instructions / Memory / Context / Folder
|
||||
│ │ ├── lanes/SessionResumeButton.tsx # The transcript page's explicit Resume (own-folder Claude sessions; warns, then a free pane) / Open pane
|
||||
│ │ ├── StatusBar.tsx # Fixed bottom bar: Live/Disconnected, version, running / needs-you counts, today's cost
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ └── workflows/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||
│ │ ├── settings/ # Settings panels, among them:
|
||||
│ │ │ ├── SettingsRow.tsx # Row primitives: SettingsSection, SettingsRow, SettingsSwitchRow, SettingsSegmented
|
||||
│ │ │ ├── AiToolsSettings.tsx # Settings → Agents → AI tools: installed/available CLIs, enable/default, install/update
|
||||
│ │ │ ├── WorktreeVisibilitySettings.tsx # Settings → Git → Worktrees: which untracked worktrees the sidebar lists
|
||||
│ │ │ ├── PricingSettings.tsx # Model Pricing table, `vendor` filter + Show all models
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs
|
||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers)
|
||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||
│ │ ├── Workflows.tsx
|
||||
│ │ ├── Workflows.tsx # Workflow analytics, rendered in Dashboard → Analytics → Workflow
|
||||
│ │ ├── Settings.tsx
|
||||
│ │ ├── AiToolConfig.tsx # Settings → <tool>: the generic tab of every installed, enabled catalog tool (path, Update link, sessions/cost/lanes, default model, vendor pricing, catalog-declared config files; Antigravity adds its hooks card)
|
||||
│ │ └── NotFound.tsx
|
||||
│ │
|
||||
│ ├── lib/ # Core utilities & business logic
|
||||
@@ -197,10 +222,11 @@ client/
|
||||
│ │
|
||||
│ ├── hooks/
|
||||
│ │ ├── useWebSocket.ts # Auto-reconnecting WebSocket hook
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── useTheme.ts # Dark/light mode: toggles the `dark` class, persists to localStorage
|
||||
│ │ ├── useLanes.ts # ONE shared lane list (GET /api/lanes + `lane_update` frames, refetch on reconnect) for the sidebar tree, status bar and Workspace page
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers; a shown one plays its type's sound (`notifPrefs.sound`/`soundByType`, `playSound` in lib/notify.ts, WAVs in public/sounds/ generated by scripts/gen-notification-sounds.js)
|
||||
│ │ └── useTheme.ts # Dark/light/system mode + per-mode named theme: `dark` class, `data-mode`, `data-theme` on <html>, persisted to localStorage
|
||||
│ │
|
||||
│ ├── i18n/ # Internationalization (en / zh / vi / ko)
|
||||
│ ├── i18n/ # Internationalization (en / vi)
|
||||
│ ├── App.tsx # Root component + router setup
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Tailwind + CSS-variable color tokens (dark/light)
|
||||
@@ -228,11 +254,12 @@ graph TB
|
||||
Sessions[Sessions<br/>filterable table]
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Lane pipelines + D3 analytics]
|
||||
Settings[Settings<br/>pricing + notifications + hooks]
|
||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Router, Pricing · Git: credentials, worktree suggestions]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
Dashboard --> Workflows
|
||||
|
||||
Dashboard --> StatCard[StatCard × 6]
|
||||
Dashboard --> AgentCard[AgentCard × N]
|
||||
@@ -275,7 +302,7 @@ sequenceDiagram
|
||||
|
||||
The client uses **local component state** and **React hooks** for state management. No global state library (Redux, Zustand) is used to keep the architecture simple. The one small exception is the **data-scope store** (`lib/dataScope.ts`): a lightweight app-wide store holding the current set of data sources (`local` plus any configured [Remote Data Sources](../server/README.md#remote-data-sources)). Pages read it and append `?sources=` to their API requests, so a single selector narrows the whole app to one or more machines' data. Remote sources are managed from the Settings page via the `RemoteSources` component (`components/RemoteSources.tsx`), which drives the `/api/remote-sources` CRUD/test/sync endpoints and reflects live `remote_source.status` WebSocket updates. When a sync finishes, stats pages refetch via `lib/remoteDataEvents.ts` (`remote_data.updated`, `remote_source.status` with `ok`, or remote `import.progress` complete).
|
||||
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME, Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
**Cursor sessions (informational):** Settings surfaces a subtle note on the CLAUDE_HOME (Settings → Claude Code → Overview), Import History, and Remote Data Sources panels — **Cursor** agent sessions count too because Cursor stores transcripts under the same `~/.claude` paths as Claude Code locally (and on synced remotes).
|
||||
|
||||
### State Strategy
|
||||
|
||||
@@ -392,8 +419,8 @@ Server broadcasts these event types over WebSocket:
|
||||
| `notification.received` | Notification object | Notification hook |
|
||||
| `remote_source.status` | `{ id, status, error?, last_sync_at? }` (`status`: `idle`/`syncing`/`ok`/`error`/`deleted`) | Remote Data Source sync poller + `/api/remote-sources` routes |
|
||||
| `remote_data.updated` | `{ sourceId, source, label?, counters?, last_sync_at? }` | Emitted once per successful remote sync; triggers stats/cost/session refetches. The server also broadcasts `session_created` / `session_updated` (and main-agent frames) for each mirrored session so Kanban/Sessions update immediately |
|
||||
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a lane's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
|
||||
| `lane_runtime` | `{ laneId, runtime? , error? }` | A lane's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
|
||||
| `lane_hook_output` | `{ laneId, hook, stream, line }` | One output line from a workspace's profile hook, pushed while it still runs. A build can take minutes; `LaneCard` shows the latest line so a boot does not read as a hang |
|
||||
| `lane_runtime` | `{ laneId, runtime? , error? }` | A workspace's stack finished coming up or failed to. Carries the fresh runtime facts, so a listener need not re-request them |
|
||||
| `lane_hook_result` | `{ laneId, hook, code, error? }` | A profile hook exited (`POST /api/lanes/:id/hook/:name`). `code` is `null` when the hook could not be started |
|
||||
|
||||
### EventBus Pattern
|
||||
@@ -445,11 +472,11 @@ graph TB
|
||||
Sessions["/sessions (tabs: sessions | events)"]
|
||||
Detail["/sessions/:id"]
|
||||
Feed["/activity (redirect)"]
|
||||
Workflows["/workflows"]
|
||||
Workflows["/workflows (redirect → /?tab=analytics&sub=workflow)"]
|
||||
CcConfig["/cc-config (redirect)"]
|
||||
Lanes["/lanes (redirect)"]
|
||||
Run["/run (redirect)"]
|
||||
Workspace["/workspace"]
|
||||
Workspace["/workspace?lane=<id> (home) · &run=<runId> (session)"]
|
||||
Settings["/settings (tabs: dashboard | claude-code | ...)"]
|
||||
NF["/* (NotFound)"]
|
||||
|
||||
@@ -476,14 +503,14 @@ function App() {
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Workflows />} />
|
||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||
{/* legacy path - Claude config now lives in a Settings tab */}
|
||||
<Route path="cc-config" element={<Navigate to="/settings?tool=claude-code" replace />} />
|
||||
<Route path="cc-config" element={<Navigate to="/settings/agents/claude-code" replace />} />
|
||||
{/* legacy paths - lanes and runs are one page now */}
|
||||
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="workspace" element={<Workspace />} />
|
||||
<Route path="settings" element={<SettingsHub />} />
|
||||
<Route path="settings/*" element={<SettingsHub />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
@@ -492,6 +519,8 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
```mermaid
|
||||
@@ -601,6 +630,8 @@ graph TB
|
||||
style Success fill:#10B981
|
||||
```
|
||||
|
||||
A thrown error carries the server's `error.code` as `code` and the HTTP `status` (plus `runId` from a `409 ESESSIONLIVE` start, `auth` from `409 EGITAUTH`). A `200` that is not JSON — the SPA's `index.html` from a proxy or a dev server that missed the route — throws `code: "EBADRESPONSE"` with the translated `errors:badResponse` message instead of `Unexpected token '<'`. Pages surface a failure through `toast` (`lib/toast.ts`, rendered by `components/Toaster.tsx`; an error toast stays until dismissed) rather than `alert()`, and ask for confirmation through `confirmDialog`/`confirmDiscard` (`lib/confirm.ts`, rendered by `components/ConfirmHost.tsx`) rather than `window.confirm`.
|
||||
|
||||
---
|
||||
|
||||
## UI Components
|
||||
@@ -726,6 +757,36 @@ flowchart LR
|
||||
|
||||
Renders the hook payload for a single event inline below its row. Scalars appear as `key: value` pairs; objects and arrays render in a terminal-styled code block with a copy button.
|
||||
|
||||
#### Tooltips (`components/Tooltip.tsx`)
|
||||
|
||||
`GlobalTooltip` is mounted once in `App.tsx` and gives **every** control in the
|
||||
app the same designed tooltip — there is no wrapper to add at the call site.
|
||||
It listens at the document, and on hover (after a 350 ms rest) or on keyboard
|
||||
focus (immediately) it takes the element's native `title`, shows it as an
|
||||
anchored bubble with an arrow, and puts the attribute back when the bubble
|
||||
closes. Consequences worth knowing:
|
||||
|
||||
- **Write `title="…"` exactly as before.** That is the whole API. A control
|
||||
keeps its native tooltip, and its accessible name, when JS has not run.
|
||||
- **Opt out with `data-tip-off`** where the browser's own tooltip is wanted.
|
||||
- The bubble is built from theme tokens (`TOOLTIP_SURFACE`), so it is legible
|
||||
in light mode; it flips above/below, clamps to the viewport, and closes on
|
||||
scroll, resize, pointer-down or `Escape`.
|
||||
- `Tip` (`components/Tip.tsx`) stays separate and cursor-following for
|
||||
expanding a truncated **number** inside a line of text, but draws the same
|
||||
surface so the two read as one thing.
|
||||
|
||||
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
|
||||
|
||||
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
|
||||
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
|
||||
behind a **Preview** / **Raw** toggle in two places: the config explorer's
|
||||
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
|
||||
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
|
||||
in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
+28
-10
@@ -1,15 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
var d =
|
||||
m === "light"
|
||||
? false
|
||||
: m === "system"
|
||||
? !(window.matchMedia && !matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
: true;
|
||||
var r = document.documentElement;
|
||||
var t = localStorage.getItem("agenthub.theme." + (d ? "dark" : "light"));
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/favicon.ico" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#6366f1" />
|
||||
<title>Agent Dashboard - SmartGift Solution</title>
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -23,8 +41,8 @@
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Agent Dashboard" />
|
||||
<meta property="og:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta property="og:site_name" content="AgentHub" />
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
@@ -33,17 +51,17 @@
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agent Dashboard - SmartGift Solution" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="Agent Dashboard - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -51,7 +69,7 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "Agent Dashboard",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
@@ -66,7 +84,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>
|
||||
|
||||
Generated
+1523
-8
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -14,7 +14,10 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
@@ -23,7 +26,9 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-router-dom": "^6.28.2"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.2",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="248" height="248" viewBox="0 0 248 248" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" fill="#D97757"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(0,249.335,-249.128,0,125,.332)"><stop stop-color="#b1a7ff"/><stop offset=".5" stop-color="#7a9dff"/><stop offset="1" stop-color="#3941ff"/></linearGradient></defs><style>.a{fill:url(#g1)}</style><path class="a" d="m84.3 5.1q3.7-1.5 7.7-2.6 3.9-1 7.9-1.6 4-0.5 8.1-0.6 4 0 8 0.5 20.7 2.4 37.1 17.7 0.1 0.1 0.4 0.3 0.1 0 0.2 0 0 0 0.2 0 0 0 0.1 0 0 0 0.1 0 5.2-1.4 10.7-1.9 5.4-0.4 10.7 0.1 5.5 0.4 10.7 1.9 5.2 1.3 10.1 3.6l0.6 0.4 1.6 0.8q5.2 2.5 9.7 6.1 4.7 3.4 8.6 7.7 3.8 4.3 6.9 9.2 3 4.8 5.2 10.2 4.3 10.5 4.3 22.1 0.2 2.1 0 4.2-0.1 2.2-0.2 4.3-0.3 2.1-0.7 4.3-0.4 2.1-0.9 4.1 0 0.2 0 0.4 0 0.2 0 0.5 0 0.1 0.1 0.4 0.1 0.1 0.3 0.3 12.3 12.6 16.3 30 6 29.7-12.2 53.5l-1.9 2.2q-3 3.5-6.5 6.4-3.4 3.1-7.3 5.5-3.8 2.4-8.1 4.2-4.1 1.9-8.5 3.2-0.3 0-0.4 0.2-0.3 0-0.4 0.1-0.1 0.1-0.3 0.4 0 0.1-0.1 0.3c-2.7 7.7-5.3 14.2-10.2 20.7-12.5 16.5-30.8 25.5-51.5 25.5q-24.6-0.1-43.6-18.1-0.2-0.1-0.4-0.2-0.2-0.1-0.4-0.1-0.2 0-0.3 0-0.3 0-0.4 0c-5.4 1.7-10.9 1.9-16.7 1.9q-3.5 0-7-0.5-3.4-0.4-6.9-1.2-3.3-0.8-6.6-2-3.3-1.2-6.4-2.8-3.3-1.6-6.4-3.6-3-2-5.8-4.3-3-2.3-5.5-5-2.5-2.6-4.6-5.6c-2.2-2.7-4.3-5.4-5.8-8.5q-0.8-1.6-1.6-3.2-0.6-1.7-1.3-3.3-0.7-1.7-1.2-3.4-0.5-1.6-1-3.4-1.1-4-1.6-7.9-0.6-4-0.6-8 0-4 0.6-8 0.4-4 1.4-8 0 0 0-0.1 0-0.1 0-0.1 0.2-0.2 0.2-0.3 0-0.1-0.2-0.1 0-0.2 0-0.3 0-0.1-0.1-0.1 0-0.2 0-0.2-0.1-0.1-0.1-0.1-2.4-2.5-4.6-5.2-2.1-2.7-4-5.4-1.7-3-3.2-6-1.5-3.1-2.6-6.3-0.8-2-1.3-4.1-0.7-2-1.1-4-0.4-2.1-0.7-4.2-0.2-2.2-0.4-4.3-0.2-2.8-0.1-5.6 0-2.8 0.3-5.4 0.1-2.8 0.6-5.6 0.4-2.8 1.1-5.5 7-23.1 26.9-36.3 4.3-2.9 8.2-4.5 4.5-1.9 9-3.2 0.2 0 0.3-0.1 0.1-0.2 0.3-0.3 0.1 0 0.1-0.3 0.1-0.1 0.1-0.2 1-3.1 2.2-6 1-2.9 2.5-5.7 1.5-3 3.2-5.6 1.7-2.7 3.7-5.1 2.5-3.2 5.3-5.9 3-2.8 6.1-5.4 3.2-2.4 6.8-4.4 3.5-2 7.2-3.5zm48.3 146.4c-2.3 0.1-4.4 1-6 2.8-1.5 1.6-2.4 3.7-2.4 5.9 0 2.3 0.9 4.4 2.4 6.2 1.6 1.6 3.7 2.5 6 2.6h50.4c2.4 0.1 4.8-0.6 6.5-2.4 1.7-1.6 2.8-4 2.8-6.4 0-2.4-1.1-4.7-2.8-6.3-1.7-1.8-4.1-2.6-6.5-2.4zm-56.7-64.9c-1.2-1.9-3-3.4-5.3-3.9-2.2-0.5-4.5-0.3-6.5 0.9-2 1.1-3.5 3-4.1 5.2-0.7 2.2-0.4 4.6 0.6 6.5l17.7 30.9-17.5 29.5c-1.2 2-1.6 4.5-1.1 6.8 0.7 2.3 2.1 4.1 4.1 5.3 2 1.2 4.4 1.6 6.7 0.9 2.2-0.5 4.2-1.9 5.4-3.9l20.1-34.1q0.7-0.9 0.9-2.1 0.3-1.1 0.3-2.3 0-1.2-0.3-2.2-0.2-1.2-0.8-2.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 250 250" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
|
||||
<path d="M0,4.391C-0,1.982 1.982,0 4.391,0C4.391,0 19.609,-0 19.609,-0C22.018,-0 24,1.982 24,4.391C24,4.391 24,19.609 24,19.609C24,22.018 22.018,24 19.609,24C19.609,24 4.391,24 4.391,24C1.982,24 -0,22.018 -0,19.609C-0,19.609 0,4.391 0,4.391Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
||||
</g>
|
||||
<g transform="matrix(10.416667,0,0,10.416667,0,0)">
|
||||
<path d="M19.74,1.444C21.285,1.444 22.556,2.715 22.556,4.26L22.556,19.74C22.556,21.285 21.285,22.556 19.74,22.556L4.26,22.556C2.715,22.556 1.444,21.285 1.444,19.74L1.444,4.26C1.444,2.715 2.715,1.444 4.26,1.444L19.74,1.444ZM7.236,8.564L14.988,12.292L7.236,16.019L7.236,18.821L16.793,14.225L16.793,10.359L7.236,5.763L7.236,8.564Z" style="fill:rgb(30,30,46);"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-24,9.907,-9.907,-24,24,6.587)"><stop offset="0" style="stop-color:rgb(238,77,93);stop-opacity:1"/><stop offset="0.33" style="stop-color:rgb(179,129,221);stop-opacity:1"/><stop offset="0.48" style="stop-color:rgb(32,124,254);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(32,124,254);stop-opacity:1"/></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 250" width="250" height="250"><defs><linearGradient id="P" gradientUnits="userSpaceOnUse"/><linearGradient id="g1" x2="1" href="#P" gradientTransform="matrix(288.354,233.302,-248.835,307.552,-6.865,4.771)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g2" x2="1" href="#P" gradientTransform="matrix(44.906,54.969,-62.556,51.105,0,100.75)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient><linearGradient id="g3" x2="1" href="#P" gradientTransform="matrix(52.229,46.969,-41.209,45.824,201.927,103.677)"><stop stop-color="#ff4d4d"/><stop offset="1" stop-color="#991b1b"/></linearGradient></defs><style>.a{fill:url(#g1)}.b{fill:url(#g2)}.c{fill:url(#g3)}.d{fill:#ff4d4d}.e{fill:#050810}.f{fill:#00e5cc}</style><path class="a" d="m125 26.8c-65.9-0.1-98.9 54.9-98.9 98.9 0 43.9 33 87.9 65.9 98.9v21.9h22v-21.9c0 0 11 4.3 22 0v21.9h22v-21.9c32.9-11 65.9-55 65.9-98.9 0-44-33-99-98.9-99z"/><path class="b" d="m37.1 103.7c-33-11-44 11-33 32.9 11 22 33 11 44-10.9 6.6-15.4 0-22-11-22z"/><path class="c" d="m212.9 103.7c33-11 44 11 33 32.9-11 22-33 11-44-10.9-6.6-15.4 0-22 11-22z"/><path class="d" d="m57.4 19.5c4.9-2.9 10.8-2.4 16.8 0.4 6 2.8 12.7 8.1 20.2 15.5 0.6 0.6 0.9 1.5 0.9 2.3 0 0.9-0.3 1.8-0.9 2.4-0.7 0.6-1.5 0.9-2.4 0.9-0.8 0-1.7-0.3-2.3-0.9-7.2-7.2-13.3-11.9-18.3-14.2-4.9-2.3-8.3-2.1-10.6-0.7-0.8 0.4-1.6 0.4-2.4 0.2-0.9-0.2-1.5-0.7-2-1.5-0.4-0.7-0.5-1.5-0.4-2.3 0.2-0.9 0.7-1.6 1.4-2.1zm118.4 0.4c6-2.8 11.9-3.3 16.8-0.4 0.7 0.5 1.2 1.2 1.4 2.1 0.1 0.8 0 1.6-0.4 2.3-0.5 0.8-1.1 1.3-2 1.5-0.8 0.2-1.6 0.2-2.4-0.2-2.3-1.4-5.7-1.6-10.6 0.7-5 2.3-11.1 7-18.3 14.2-0.6 0.6-1.5 0.9-2.3 0.9-0.9 0-1.7-0.3-2.4-0.9-0.6-0.6-0.9-1.5-0.9-2.4 0-0.8 0.3-1.7 0.9-2.3 7.5-7.4 14.2-12.7 20.2-15.5z"/><path class="e" d="m92 94.9c3.5 0 6.9-1.4 9.4-3.9 2.4-2.5 3.8-5.8 3.8-9.3 0-3.5-1.4-6.9-3.8-9.3-2.5-2.5-5.9-3.9-9.4-3.9-3.5 0-6.8 1.4-9.3 3.9-2.5 2.4-3.9 5.8-3.9 9.3 0 3.5 1.4 6.8 3.9 9.3 2.5 2.5 5.8 3.9 9.3 3.9zm66 0c3.5 0 6.8-1.4 9.3-3.9 2.5-2.5 3.9-5.8 3.9-9.3 0-3.5-1.4-6.9-3.9-9.3-2.5-2.5-5.8-3.9-9.3-3.9-3.5 0-6.9 1.4-9.4 3.9-2.4 2.4-3.8 5.8-3.8 9.3 0 3.5 1.4 6.8 3.8 9.3 2.5 2.5 5.9 3.9 9.4 3.9z"/><path class="f" d="m94.2 85c1.5 0 2.9-0.6 3.9-1.6 1-1 1.6-2.4 1.6-3.9 0-1.5-0.6-2.9-1.6-3.9-1-1-2.4-1.6-3.9-1.6-1.4 0-2.8 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.8 1.6 3.9 1.1 1 2.5 1.6 3.9 1.6zm66 0c1.4 0 2.8-0.6 3.8-1.6 1.1-1 1.7-2.4 1.7-3.9 0-1.5-0.6-2.8-1.7-3.9-1-1-2.4-1.6-3.8-1.6-1.5 0-2.9 0.6-3.9 1.6-1 1-1.6 2.4-1.6 3.9 0 1.5 0.6 2.9 1.6 3.9 1 1 2.4 1.6 3.9 1.6z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -13,15 +13,15 @@
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"src": "/favicon.ico",
|
||||
"sizes": "32x32",
|
||||
"type": "image/x-icon",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7
-1
@@ -87,13 +87,19 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: "window" }).then((windowClients) => {
|
||||
for (const client of windowClients) {
|
||||
if (client.focus) {
|
||||
if ("focus" in client) {
|
||||
// Reuse an already-open tab instead of stacking a new one — that's
|
||||
// also why `navigate` (same-origin only) beats postMessage: it works
|
||||
// even if the page hasn't wired up a listener for this.
|
||||
if (url && "navigate" in client) client.navigate(url).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (url && clients.openWindow) return clients.openWindow(url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
+89
-15
@@ -40,10 +40,9 @@
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/History`
|
||||
* - `./pages/SessionDetail`
|
||||
* - `./pages/Workflows`
|
||||
* - `./pages/SettingsHub`
|
||||
* - `./pages/Workspace`
|
||||
* - `./pages/SessionDetail` (lazy)
|
||||
* - `./pages/SettingsHub` (lazy)
|
||||
* - `./pages/Workspace` (lazy)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -55,23 +54,58 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { useCallback } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams, useLocation } from "react-router-dom";
|
||||
import { lazy, Suspense, useCallback, useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { DocumentTitle } from "./components/DocumentTitle";
|
||||
import { SplashScreen } from "./components/SplashScreen";
|
||||
import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
import { SessionDetail } from "./pages/SessionDetail";
|
||||
import { Workflows } from "./pages/Workflows";
|
||||
import { SettingsHub } from "./pages/SettingsHub";
|
||||
import { Workspace } from "./pages/Workspace";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
// visit instead of in the initial bundle.
|
||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||
const SettingsHub = lazy(() =>
|
||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||
);
|
||||
const SessionDetail = lazy(() =>
|
||||
import("./pages/SessionDetail").then((m) => ({ default: m.SessionDetail }))
|
||||
);
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div role="status" className="flex items-center justify-center py-20 text-sm text-fg-muted">
|
||||
{t("common:loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Page({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
return (
|
||||
<RouteErrorBoundary resetKey={pathname}>
|
||||
<Suspense fallback={<PageFallback />}>{children}</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
return <SessionDetail key={id} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application root component mounted by {@link main.tsx}.
|
||||
* @returns Routed dashboard UI inside `BrowserRouter`.
|
||||
@@ -84,26 +118,66 @@ export default function App() {
|
||||
const { connected } = useWebSocket(onMessage);
|
||||
useNotifications();
|
||||
|
||||
// A self-update replaced the hashed assets this tab still references: Vite
|
||||
// reports the failed preload here; reload once (guarded against loops).
|
||||
useEffect(() => {
|
||||
const onPreloadError = (e: Event) => {
|
||||
if (reloadOnce()) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("vite:preloadError", onPreloadError);
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SplashScreen />
|
||||
{/* One renderer gives every `title` in the app the designed bubble —
|
||||
see components/Tooltip.tsx for why it is not a wrapper component. */}
|
||||
<GlobalTooltip />
|
||||
{/* App-wide toasts and promise-based confirms: lib/toast.ts, lib/confirm.ts. */}
|
||||
<Toaster />
|
||||
<ConfirmHost />
|
||||
<BrowserRouter>
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
<Page>
|
||||
<SessionDetailRoute />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Workflows />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
element={<Navigate to="/?tab=analytics&sub=workflow" replace />}
|
||||
/>
|
||||
<Route path="lanes" element={<Navigate to="/workspace" replace />} />
|
||||
<Route
|
||||
path="cc-config"
|
||||
element={<Navigate to="/settings?tool=claude-code" replace />}
|
||||
element={<Navigate to="/settings/agents/claude-code" replace />}
|
||||
/>
|
||||
<Route path="run" element={<Navigate to="/workspace" replace />} />
|
||||
<Route path="workspace" element={<Workspace />} />
|
||||
<Route path="settings" element={<SettingsHub />} />
|
||||
<Route
|
||||
path="workspace"
|
||||
element={
|
||||
<Page>
|
||||
<Workspace />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
<Page>
|
||||
<SettingsHub />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AgentCard.tsx
|
||||
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked. It also visually distinguishes active agents with a border highlight.
|
||||
* @description Defines the AgentCard component that displays a summary of an agent's information, including its name, status, task, current tool, and timestamps. The card is clickable and navigates to the agent's session details when clicked; a right-click opens `SessionRowMenu`. It also visually distinguishes active agents with a border highlight.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,8 +49,11 @@
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, GitBranch, Clock, Wrench, Cpu, Coins } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { useContextMenu } from "./ContextMenu";
|
||||
import { SessionRowMenu } from "./SessionRowMenu";
|
||||
import { effectiveAgentStatus, isAgentAwaitingInput, agentAwaitingReason } from "../lib/types";
|
||||
import type { Agent, Session } from "../lib/types";
|
||||
import { formatDuration, timeAgo, formatModelName, pathBasename, fmtCost } from "../lib/format";
|
||||
@@ -80,9 +83,17 @@ interface AgentCardProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
/** Memoized: the Dashboard re-renders on every live update, and a card only
|
||||
* changes with its own agent/session. */
|
||||
export const AgentCard = memo(function AgentCard({
|
||||
agent,
|
||||
session,
|
||||
label,
|
||||
onClick,
|
||||
}: AgentCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation("kanban");
|
||||
const ctx = useContextMenu<true>();
|
||||
const isWaiting = agent.status === "waiting" || isAgentAwaitingInput(agent);
|
||||
const status = effectiveAgentStatus(agent);
|
||||
const isActive = agent.status === "working";
|
||||
@@ -116,15 +127,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
// not the parent session's — see issue #185). Use it everywhere this card
|
||||
// shows a model so a Haiku QA agent under an Opus orchestrator reads as
|
||||
// Haiku, not Opus. Falls back to the session model only for the main agent.
|
||||
let subagentModel: string | null = null;
|
||||
if (!isMain && agent.metadata) {
|
||||
const subagentModel = useMemo(() => {
|
||||
if (isMain || !agent.metadata) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(agent.metadata) as { model?: string };
|
||||
subagentModel = parsed?.model ? formatModelName(parsed.model) : null;
|
||||
return parsed?.model ? formatModelName(parsed.model) : null;
|
||||
} catch {
|
||||
subagentModel = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, [isMain, agent.metadata]);
|
||||
// The model badge (footer) must reflect THIS card's agent: the session model
|
||||
// for main, the subagent's own model for subagents.
|
||||
const displayModel = isMain ? model : subagentModel;
|
||||
@@ -140,15 +151,15 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
// "29 agents" looks like it should equal a 29-subagent stat when the session
|
||||
// actually has 28 subagents + 1 main.
|
||||
const subagentCount = Math.max(0, agentCount - 1);
|
||||
let sessionTurns = 0;
|
||||
if (isMain && session?.metadata) {
|
||||
const sessionTurns = useMemo(() => {
|
||||
if (!isMain || !session?.metadata) return 0;
|
||||
try {
|
||||
const m = JSON.parse(session.metadata) as { turn_count?: number };
|
||||
if (typeof m?.turn_count === "number") sessionTurns = m.turn_count;
|
||||
return typeof m?.turn_count === "number" ? m.turn_count : 0;
|
||||
} catch {
|
||||
sessionTurns = 0;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}, [isMain, session?.metadata]);
|
||||
const subtitle = isMain
|
||||
? [
|
||||
cwdBase,
|
||||
@@ -159,22 +170,27 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
.join(" · ") || null
|
||||
: [label || agent.subagent_type, cwdBase].filter(Boolean).join(" · ") || null;
|
||||
|
||||
function handleClick() {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else {
|
||||
navigate(`/sessions/${agent.session_id}`);
|
||||
}
|
||||
const sessionHref = `/sessions/${agent.session_id}`;
|
||||
// The whole card is clickable with the mouse; the title is the focusable
|
||||
// control (a real link, or a button when the caller handles the click).
|
||||
function handleClick(e: React.MouseEvent) {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
if (onClick) onClick();
|
||||
else navigate(sessionHref);
|
||||
}
|
||||
const titleText = isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name;
|
||||
const titleClass =
|
||||
"block text-sm font-medium text-fg-secondary truncate text-left max-w-full rounded focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => ctx.open(e, true)}
|
||||
className={`card-hover p-4 cursor-pointer overflow-hidden ${
|
||||
isWaiting
|
||||
? "border-l-2 border-l-yellow-500/60"
|
||||
? "border-l-2 border-l-status-warning/60"
|
||||
: isActive
|
||||
? "border-l-2 border-l-emerald-500/50"
|
||||
? "border-l-2 border-l-status-success/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
@@ -182,19 +198,25 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
|
||||
<div
|
||||
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
|
||||
isMain ? "bg-accent/15 text-accent" : "bg-syntax-keyword/15 text-syntax-keyword"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<p className="text-sm font-medium text-fg-secondary truncate">
|
||||
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
||||
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
||||
placeholder for the real session name when one exists; custom
|
||||
(sub)agent names are left untouched. */}
|
||||
{isMain ? mainAgentDisplayName(agent.name, realSessionName) : agent.name}
|
||||
</p>
|
||||
{/* Auto-generated main-agent titles (e.g. "Main Agent - Session
|
||||
229d93fd" or "Main Agent - work - e3f8e613") swap the
|
||||
placeholder for the real session name when one exists; custom
|
||||
(sub)agent names are left untouched. */}
|
||||
{onClick ? (
|
||||
<button type="button" onClick={onClick} className={titleClass}>
|
||||
{titleText}
|
||||
</button>
|
||||
) : (
|
||||
<Link to={sessionHref} className={titleClass}>
|
||||
{titleText}
|
||||
</Link>
|
||||
)}
|
||||
{subtitle && <p className="text-[11px] text-fg-muted truncate">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,11 +267,19 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
|
||||
{timeAgo(agent.updated_at || agent.started_at)}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0 opacity-50">
|
||||
<span className="ml-auto flex items-center gap-1 min-w-0">
|
||||
{realSessionName && <span className="truncate max-w-[10rem]">{realSessionName} ·</span>}
|
||||
<span className="font-mono flex-shrink-0">{agent.session_id.slice(0, 8)}</span>
|
||||
</span>
|
||||
</div>
|
||||
{ctx.menu && (
|
||||
<SessionRowMenu
|
||||
session={{ id: agent.session_id, lane_id: session?.lane_id, cwd: session?.cwd }}
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @file AiToolIcon.tsx
|
||||
* @description An AI tool's icon: the mark shipped in `client/public/ai-tools/`
|
||||
* when there is one, else
|
||||
* the server's cached favicon (GET /api/ai-tools/:id/icon), else the label's
|
||||
* first letter. Every tool icon in the app renders through here.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, type AiTool } from "../lib/api";
|
||||
|
||||
/** Shipped marks by tool id. */
|
||||
const BUNDLED: Record<string, string> = {
|
||||
claude: "/ai-tools/claude.svg",
|
||||
codex: "/ai-tools/codex.svg",
|
||||
gemini: "/ai-tools/gemini.svg",
|
||||
openclaw: "/ai-tools/openclaw.svg",
|
||||
hermes: "/ai-tools/hermes.svg",
|
||||
};
|
||||
|
||||
/**
|
||||
* @param props.tool - The catalog entry.
|
||||
* @param props.className - Size classes (default `h-8 w-8`).
|
||||
*/
|
||||
export function AiToolIcon({
|
||||
tool,
|
||||
className = "h-8 w-8",
|
||||
}: {
|
||||
tool: Pick<AiTool, "id" | "label">;
|
||||
className?: string;
|
||||
}) {
|
||||
// Keyed by id: one instance showing another tool must retry its icon.
|
||||
const [failedId, setFailedId] = useState<string | null>(null);
|
||||
const box = `${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`;
|
||||
const bundled = BUNDLED[tool.id];
|
||||
if (bundled) return <img src={bundled} alt="" className={box} />;
|
||||
if (failedId !== tool.id) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailedId(tool.id)}
|
||||
className={box}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${className} flex shrink-0 items-center justify-center rounded-md bg-surface-4 text-xs font-semibold text-fg-secondary`}
|
||||
>
|
||||
{tool.label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* • Activity - the live fired-alert feed with acknowledge controls
|
||||
* Tab badges reflect live state (rule count, unacked alert count), and the feed
|
||||
* + counts refetch on alert_triggered / alert_updated WebSocket messages.
|
||||
* A right-click on a rule opens its Enable/Disable and Delete as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -69,8 +70,8 @@ import {
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ListChecks,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
@@ -85,7 +86,9 @@ import { WebhookSettings } from "./WebhookSettings";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { FieldHelp } from "./FieldHelp";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -153,6 +156,27 @@ const EMPTY_FORM: RuleFormState = {
|
||||
cooldown_seconds: "300",
|
||||
};
|
||||
|
||||
/** A rule's stored values as form fields, for editing it. */
|
||||
function formFromRule(rule: AlertRule): RuleFormState {
|
||||
const c = rule.config;
|
||||
const str = (v: unknown, fallback: string) => (v == null ? fallback : String(v));
|
||||
return {
|
||||
name: rule.name,
|
||||
rule_type: rule.rule_type,
|
||||
event_type: str(c.event_type, ""),
|
||||
tool_name: str(c.tool_name, ""),
|
||||
summary_contains: str(c.summary_contains, ""),
|
||||
count: str(c.count, EMPTY_FORM.count),
|
||||
window_minutes: str(c.window_minutes, EMPTY_FORM.window_minutes),
|
||||
minutes: str(c.minutes, EMPTY_FORM.minutes),
|
||||
status: c.status === "waiting" ? "waiting" : "working",
|
||||
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
|
||||
cooldown_seconds: String(rule.cooldown_seconds),
|
||||
};
|
||||
}
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
function buildConfig(form: RuleFormState): AlertRule["config"] {
|
||||
switch (form.rule_type) {
|
||||
case "event_pattern": {
|
||||
@@ -215,9 +239,18 @@ export function AlertsNotifications() {
|
||||
const [loadingRules, setLoadingRules] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [form, setForm] = useState<RuleFormState>(EMPTY_FORM);
|
||||
// The rule being edited, or null while the form creates a new one.
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
|
||||
const ruleCtx = useContextMenu<AlertRule>();
|
||||
// A rule-menu item: close the menu, then act on the rule it was opened on.
|
||||
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
|
||||
const rule = ruleCtx.menu?.item;
|
||||
ruleCtx.close();
|
||||
if (rule) fn(rule);
|
||||
};
|
||||
|
||||
// Feed
|
||||
const [alerts, setAlerts] = useState<AlertEvent[]>([]);
|
||||
@@ -232,11 +265,11 @@ export function AlertsNotifications() {
|
||||
const res = await api.alerts.rules.list();
|
||||
setRules(res.rules);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alert rules:", err);
|
||||
toast.error(t("errors.loadRules", { error: errText(err) }));
|
||||
} finally {
|
||||
setLoadingRules(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const loadAlerts = useCallback(async () => {
|
||||
setLoadingAlerts(true);
|
||||
@@ -250,11 +283,11 @@ export function AlertsNotifications() {
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load alerts:", err);
|
||||
toast.error(t("errors.loadAlerts", { error: errText(err) }));
|
||||
} finally {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly]);
|
||||
}, [unackedOnly, t]);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -267,9 +300,9 @@ export function AlertsNotifications() {
|
||||
setTotal(res.total);
|
||||
setUnacked(res.unacked);
|
||||
} catch (err) {
|
||||
console.error("Failed to load more alerts:", err);
|
||||
toast.error(t("errors.loadAlerts", { error: errText(err) }));
|
||||
}
|
||||
}, [unackedOnly, alerts.length]);
|
||||
}, [unackedOnly, alerts.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
@@ -291,20 +324,33 @@ export function AlertsNotifications() {
|
||||
|
||||
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const onCreateRule = async () => {
|
||||
if (saving) return;
|
||||
const closeForm = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setEditingId(null);
|
||||
setFormOpen(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const openEditRule = (rule: AlertRule) => {
|
||||
setForm(formFromRule(rule));
|
||||
setEditingId(rule.id);
|
||||
setFormError(null);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const onSaveRule = async () => {
|
||||
if (saving || !canSubmit) return;
|
||||
setSaving(true);
|
||||
setFormError(null);
|
||||
try {
|
||||
const cooldown = parseInt(form.cooldown_seconds, 10);
|
||||
await api.alerts.rules.create({
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
rule_type: form.rule_type,
|
||||
config: buildConfig(form),
|
||||
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
|
||||
});
|
||||
setForm(EMPTY_FORM);
|
||||
setFormOpen(false);
|
||||
cooldown_seconds: cooldownVal,
|
||||
};
|
||||
if (editingId) await api.alerts.rules.update(editingId, body);
|
||||
else await api.alerts.rules.create({ ...body, rule_type: form.rule_type });
|
||||
closeForm();
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof Error ? err.message : String(err));
|
||||
@@ -318,7 +364,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.rules.update(rule.id, { enabled: !rule.enabled });
|
||||
loadRules();
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle alert rule:", err);
|
||||
toast.error(t("errors.toggleRule", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -329,7 +375,8 @@ export function AlertsNotifications() {
|
||||
loadRules();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete alert rule:", err);
|
||||
setConfirmRule(null);
|
||||
toast.error(t("errors.deleteRule", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,7 +385,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.ack(id);
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alert:", err);
|
||||
toast.error(t("errors.ack", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,7 +394,7 @@ export function AlertsNotifications() {
|
||||
await api.alerts.ackAll();
|
||||
loadAlerts();
|
||||
} catch (err) {
|
||||
console.error("Failed to acknowledge alerts:", err);
|
||||
toast.error(t("errors.ack", { error: errText(err) }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,8 +404,12 @@ export function AlertsNotifications() {
|
||||
const tokensVal = parseInt(form.total_tokens, 10);
|
||||
const countVal = parseInt(form.count, 10);
|
||||
const windowVal = parseFloat(form.window_minutes);
|
||||
// Cooldown is a whole number of seconds ≥ 0 — never silently replaced by 300.
|
||||
const cooldownOk = /^\d+$/.test(form.cooldown_seconds.trim());
|
||||
const cooldownVal = cooldownOk ? parseInt(form.cooldown_seconds, 10) : 0;
|
||||
const canSubmit =
|
||||
form.name.trim().length > 0 &&
|
||||
cooldownOk &&
|
||||
(form.rule_type !== "event_pattern" ||
|
||||
(Boolean(form.event_type.trim() || form.tool_name.trim() || form.summary_contains.trim()) &&
|
||||
Number.isFinite(countVal) &&
|
||||
@@ -431,8 +482,11 @@ export function AlertsNotifications() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFormOpen((open) => !open);
|
||||
setFormError(null);
|
||||
if (formOpen) closeForm();
|
||||
else {
|
||||
setFormOpen(true);
|
||||
setFormError(null);
|
||||
}
|
||||
}}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs flex-shrink-0"
|
||||
>
|
||||
@@ -466,7 +520,9 @@ export function AlertsNotifications() {
|
||||
<select
|
||||
value={form.rule_type}
|
||||
onChange={(e) => set({ rule_type: e.target.value as AlertRuleType })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
disabled={!!editingId}
|
||||
title={editingId ? t("rules.typeFixed") : undefined}
|
||||
className="select w-full"
|
||||
>
|
||||
{RULE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
@@ -474,7 +530,6 @@ export function AlertsNotifications() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -577,12 +632,11 @@ export function AlertsNotifications() {
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(e) => set({ status: e.target.value as "working" | "waiting" })}
|
||||
className="input w-full appearance-none pr-8"
|
||||
className="select w-full"
|
||||
>
|
||||
<option value="working">working</option>
|
||||
<option value="waiting">waiting</option>
|
||||
</select>
|
||||
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
@@ -635,18 +689,25 @@ export function AlertsNotifications() {
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
value={form.cooldown_seconds}
|
||||
onChange={(e) => set({ cooldown_seconds: e.target.value })}
|
||||
aria-invalid={!cooldownOk}
|
||||
className="input w-40"
|
||||
/>
|
||||
{!cooldownOk && (
|
||||
<span className="mt-1 block text-[11px] text-status-danger">
|
||||
{t("rules.cooldownInvalid")}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<button
|
||||
onClick={onCreateRule}
|
||||
onClick={onSaveRule}
|
||||
disabled={!canSubmit || saving}
|
||||
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{saving ? t("rules.saving") : t("rules.create")}
|
||||
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
|
||||
</button>
|
||||
</div>
|
||||
{formError && <p className="text-xs text-status-danger">{formError}</p>}
|
||||
@@ -663,12 +724,14 @@ export function AlertsNotifications() {
|
||||
icon={ListChecks}
|
||||
title={t("rules.empty")}
|
||||
description={ts("alertsHub.rulesEmptyHint")}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rules.map((rule) => (
|
||||
<li
|
||||
key={rule.id}
|
||||
onContextMenu={(e) => ruleCtx.open(e, rule)}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface-2 px-3 py-2.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@@ -699,6 +762,14 @@ export function AlertsNotifications() {
|
||||
>
|
||||
{rule.enabled ? t("rules.enabled") : t("rules.disabled")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditRule(rule)}
|
||||
className="p-1.5 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors"
|
||||
title={t("rules.edit")}
|
||||
aria-label={t("rules.edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmRule(rule)}
|
||||
className="p-1.5 rounded-md text-fg-muted hover:text-status-danger hover:bg-status-danger/10 transition-colors"
|
||||
@@ -712,6 +783,23 @@ export function AlertsNotifications() {
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ruleCtx.menu && (
|
||||
<ContextMenu
|
||||
x={ruleCtx.menu.x}
|
||||
y={ruleCtx.menu.y}
|
||||
onClose={ruleCtx.close}
|
||||
testId="alert-rule-menu"
|
||||
>
|
||||
<MenuItem onSelect={pickRule(onToggleRule)}>
|
||||
{ruleCtx.menu.item.enabled ? t("rules.disable") : t("rules.enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pickRule(openEditRule)}>{t("rules.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pickRule(setConfirmRule)}>
|
||||
{t("rules.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -766,6 +854,7 @@ export function AlertsNotifications() {
|
||||
icon={unackedOnly ? BellOff : BellRing}
|
||||
title={t("feed.emptyTitle")}
|
||||
description={unackedOnly ? t("feed.emptyUnacked") : t("feed.emptyDescription")}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -74,29 +74,54 @@ export interface CheckboxProps {
|
||||
className?: string;
|
||||
/** Classes applied to the label `<span>` when `label` is set. */
|
||||
labelClassName?: string;
|
||||
/** `danger` / `warning` for an opt-in that makes an action riskier
|
||||
* (bypass permissions, purge data, force). */
|
||||
tone?: "accent" | "danger" | "warning";
|
||||
disabled?: boolean;
|
||||
/** Tooltip (the app turns every `title` into its themed bubble). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// Fill + tick per tone. Accent fills with `accent-solid` under a white tick.
|
||||
// The status colours are TEXT shades — light in dark mode, dark in light mode —
|
||||
// so their tick takes the page colour (surface-0), which contrasts both ways.
|
||||
const TONE = {
|
||||
accent: "bg-accent-solid border-accent-solid text-white",
|
||||
danger: "bg-status-danger border-status-danger text-surface-0",
|
||||
warning: "bg-status-warning border-status-warning text-surface-0",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Themed checkbox control.
|
||||
* Themed checkbox control — the app's ONE checkbox; never a native
|
||||
* `<input type="checkbox">`, which renders as the browser's white box.
|
||||
* @param props See {@link CheckboxProps}.
|
||||
*/
|
||||
export function Checkbox({ checked, onChange, label, className, labelClassName }: CheckboxProps) {
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
tone = "accent",
|
||||
disabled = false,
|
||||
title,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`group inline-flex items-center gap-2 text-left ${className ?? ""}`}
|
||||
className={`group inline-flex items-center gap-2 text-left disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none ${className ?? ""}`}
|
||||
>
|
||||
<span
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
|
||||
checked
|
||||
? "bg-accent border-accent"
|
||||
: "bg-surface-2 border-border group-hover:border-border-light"
|
||||
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors group-focus-visible:ring-2 group-focus-visible:ring-accent/60 ${
|
||||
checked ? TONE[tone] : "bg-surface-2 border-fg-muted group-hover:border-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{checked && <Check className="h-3 w-3 text-white" strokeWidth={3} />}
|
||||
{checked && <Check className="h-3 w-3" strokeWidth={3} aria-hidden />}
|
||||
</span>
|
||||
{label != null && (
|
||||
<span
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @file ConfirmHost.tsx
|
||||
* @description Renders the one pending `confirmDialog()` from `lib/confirm.ts`
|
||||
* through `ConfirmModal`. Mounted once in `App.tsx`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../lib/confirm";
|
||||
|
||||
const cancel = () => settleConfirm(false);
|
||||
const confirm = () => settleConfirm(true);
|
||||
|
||||
/** The single host for `confirmDialog()`. */
|
||||
export function ConfirmHost() {
|
||||
const { t } = useTranslation("common");
|
||||
const [p, setP] = useState<PendingConfirm | null>(null);
|
||||
useEffect(() => subscribeConfirm(setP), []);
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={p !== null}
|
||||
title={p?.title ?? ""}
|
||||
message={p?.message}
|
||||
confirmLabel={p?.confirmLabel ?? t("confirm")}
|
||||
cancelLabel={p?.cancelLabel ?? t("cancel")}
|
||||
destructive={p?.destructive ?? true}
|
||||
onConfirm={confirm}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,14 @@
|
||||
*
|
||||
* ## Accessibility
|
||||
* Focus moves to Cancel on open (safer default), Tab cycles within the dialog,
|
||||
* Escape cancels, and focus restores to the previously focused element on close.
|
||||
* Escape cancels, and focus restores to the previously focused element on close
|
||||
* (the shared `hooks/useDialog.ts` contract).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { useId, useRef, type ReactNode } from "react";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
/** Props for {@link ConfirmModal}. */
|
||||
@@ -71,54 +73,13 @@ export function ConfirmModal({
|
||||
const messageId = useId();
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
previouslyFocused.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
// Prefer Cancel so Enter/activation doesn't immediately destroy data.
|
||||
const focusTimer = window.setTimeout(() => cancelRef.current?.focus(), 0);
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab" || !panelRef.current) return;
|
||||
|
||||
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable.item(0);
|
||||
const last = focusable.item(focusable.length - 1);
|
||||
if (!first || !last) return;
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
previouslyFocused.current?.focus?.();
|
||||
previouslyFocused.current = null;
|
||||
};
|
||||
}, [open, onCancel]);
|
||||
useDialog(open, panelRef, onCancel, cancelRef);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||
className="fixed inset-0 bg-black/60 z-[60] flex items-center justify-center p-4"
|
||||
onClick={onCancel}
|
||||
role="presentation"
|
||||
>
|
||||
@@ -134,7 +95,7 @@ export function ConfirmModal({
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" />
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -151,10 +112,10 @@ export function ConfirmModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-fg-muted hover:text-fg-secondary p-1 -mt-1 -mr-1"
|
||||
className="btn-icon -mt-1 -mr-1"
|
||||
aria-label={cancelLabel}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-5 pb-5">
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* @file ContextMenu — the one shell every right-click (and `⋯`) menu opens
|
||||
* in: a fixed list at a viewport point, rendered into `document.body` so no
|
||||
* transformed or clipped ancestor moves it, nudged back inside the viewport
|
||||
* once measured, closed by Escape, a press outside it, or after an item runs.
|
||||
* `useContextMenu` keeps "which item, where" for a list of rows; `menuPoint`
|
||||
* turns a mouse event into that point (and stops the browser's own menu).
|
||||
*
|
||||
* Keyboard (WAI-ARIA menu): the first enabled item takes focus on open;
|
||||
* Up/Down wrap, Home/End jump, Escape and Tab close, and focus goes back to
|
||||
* what held it before the menu opened — unless an item moved it elsewhere
|
||||
* (a rename field, a dialog). A menu opened from the keyboard (Shift+F10 or
|
||||
* the Menu key reports clientX/Y 0) anchors under the element instead of the
|
||||
* viewport's corner.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
export type MenuPoint = { x: number; y: number };
|
||||
|
||||
/** The pointer of a right-click, the browser's own menu suppressed. A
|
||||
* keyboard-opened menu has no pointer, so it anchors under the element. */
|
||||
export function menuPoint(e: ReactMouseEvent): MenuPoint {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.clientX === 0 && e.clientY === 0 && e.currentTarget instanceof Element) {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
return { x: r.left, y: r.bottom };
|
||||
}
|
||||
return { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
|
||||
/** Menu state for a list of rows: which row's menu is open, and where. */
|
||||
export function useContextMenu<T>() {
|
||||
const [menu, setMenu] = useState<(MenuPoint & { item: T }) | null>(null);
|
||||
const open = useCallback((e: ReactMouseEvent, item: T) => {
|
||||
setMenu({ ...menuPoint(e), item });
|
||||
}, []);
|
||||
const close = useCallback(() => setMenu(null), []);
|
||||
return { menu, open, close };
|
||||
}
|
||||
|
||||
const MARGIN = 8;
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
const ITEMS = '[role="menuitem"]:not([disabled]):not([aria-disabled="true"])';
|
||||
|
||||
export function ContextMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
testId,
|
||||
className = "w-56",
|
||||
children,
|
||||
}: MenuPoint & {
|
||||
onClose: () => void;
|
||||
testId?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ left: x, top: y });
|
||||
|
||||
// Focus in on open, back out on close. Runs once per mount: a menu that
|
||||
// moves to a new point keeps its focus.
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
ref.current?.querySelector<HTMLElement>(ITEMS)?.focus({ preventScroll: true });
|
||||
return () => {
|
||||
// Only when focus would otherwise be lost (it sat on a removed item).
|
||||
const now = document.activeElement;
|
||||
if (!now || now === document.body || ref.current?.contains(now)) previous?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
const inField = (e.target as HTMLElement).closest(
|
||||
"input, textarea, select, [contenteditable='true']"
|
||||
);
|
||||
if (e.key === "Escape" || (e.key === "Tab" && !inField)) {
|
||||
// Handled here so a dialog behind the menu does not close as well.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// A field inside a menu (a size slider, a search box) keeps its own keys.
|
||||
if (inField) return;
|
||||
const items = [...(ref.current?.querySelectorAll<HTMLElement>(ITEMS) ?? [])];
|
||||
if (items.length === 0) return;
|
||||
const i = items.indexOf(document.activeElement as HTMLElement);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (i + 1) % items.length
|
||||
: e.key === "ArrowUp"
|
||||
? (i - 1 + items.length) % items.length
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? items.length - 1
|
||||
: -1;
|
||||
if (next < 0) return;
|
||||
e.preventDefault();
|
||||
items[next]!.focus();
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
setPos({
|
||||
left: Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)),
|
||||
top: Math.max(MARGIN, Math.min(y, window.innerHeight - r.height - MARGIN)),
|
||||
});
|
||||
}, [x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", onClose);
|
||||
window.addEventListener("resize", onClose);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", onClose);
|
||||
window.removeEventListener("resize", onClose);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid={testId}
|
||||
style={pos}
|
||||
// A portal still bubbles React events to the component that rendered
|
||||
// it — a menu opened from a clickable card must not click the card too.
|
||||
onClick={stop}
|
||||
onDoubleClick={stop}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={`fixed z-50 rounded-lg border border-border bg-surface-2 p-1 shadow-lg ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export const MENU_ITEM =
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-fg-secondary hover:bg-surface-4 hover:text-fg-primary focus:outline-none focus-visible:bg-surface-4 focus-visible:text-fg-primary focus-visible:ring-1 focus-visible:ring-accent/60 disabled:pointer-events-none disabled:opacity-50";
|
||||
|
||||
export function MenuItem({
|
||||
onSelect,
|
||||
danger,
|
||||
disabled,
|
||||
testId,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuSeparator() {
|
||||
return <div role="separator" className="my-1 border-t border-border" />;
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Calendar, Clock, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
|
||||
interface DateTimePickerProps {
|
||||
@@ -52,16 +53,22 @@ interface DateTimePickerProps {
|
||||
className?: string;
|
||||
"aria-label"?: string;
|
||||
title?: string;
|
||||
/** A "to" bound: picking a day with no time yet means the END of that day
|
||||
* (23:59), not its first minute. */
|
||||
endOfDay?: boolean;
|
||||
}
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select date & time",
|
||||
placeholder,
|
||||
className = "",
|
||||
"aria-label": ariaLabel,
|
||||
title,
|
||||
endOfDay = false,
|
||||
}: DateTimePickerProps) {
|
||||
const { t, i18n } = useTranslation("sessions");
|
||||
const locale = i18n.resolvedLanguage ?? i18n.language;
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [alignment, setAlignment] = useState<"left" | "right">("left");
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -97,9 +104,18 @@ export function DateTimePicker({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplay = (d: Date | null) => {
|
||||
if (!d || isNaN(d.getTime())) return "";
|
||||
return d.toLocaleString(undefined, {
|
||||
return d.toLocaleString(locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
@@ -112,7 +128,9 @@ export function DateTimePicker({
|
||||
if (dateObj && !isNaN(dateObj.getTime())) {
|
||||
newDate.setHours(dateObj.getHours(), dateObj.getMinutes());
|
||||
} else {
|
||||
newDate.setHours(0, 0); // Default midnight
|
||||
// Default: midnight, or 23:59 for an end bound so the day is included.
|
||||
if (endOfDay) newDate.setHours(23, 59, 59, 999);
|
||||
else newDate.setHours(0, 0);
|
||||
}
|
||||
updateValue(newDate);
|
||||
};
|
||||
@@ -135,7 +153,9 @@ export function DateTimePicker({
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const h = String(d.getHours()).padStart(2, "0");
|
||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||
onChange(`${y}-${mo}-${day}T${h}:${mi}`);
|
||||
// An end bound carries its :59.999 so the whole last minute is included.
|
||||
const tail = endOfDay ? ":59.999" : "";
|
||||
onChange(`${y}-${mo}-${day}T${h}:${mi}${tail}`);
|
||||
};
|
||||
|
||||
const clearValue = (e: React.MouseEvent) => {
|
||||
@@ -168,13 +188,18 @@ export function DateTimePicker({
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleDateClick(i)}
|
||||
aria-label={new Date(viewDate.getFullYear(), viewDate.getMonth(), i).toLocaleDateString(
|
||||
locale,
|
||||
{ weekday: "long", year: "numeric", month: "long", day: "numeric" }
|
||||
)}
|
||||
aria-pressed={isSelected}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded text-[11px] transition-colors
|
||||
${
|
||||
isSelected
|
||||
? "bg-accent text-white font-medium"
|
||||
? "bg-accent-solid text-white font-medium"
|
||||
: isToday
|
||||
? "bg-surface-3 text-accent font-medium"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-white"
|
||||
: "hover:bg-surface-2 text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{i}
|
||||
@@ -193,17 +218,27 @@ export function DateTimePicker({
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="dialog"
|
||||
title={title}
|
||||
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left`}
|
||||
className={`flex items-center gap-2 bg-surface-2 border ${isOpen ? "border-accent" : "border-border"} rounded px-2 py-1.5 min-w-[150px] text-xs focus:outline-none focus:border-accent transition-colors w-full text-left ${dateObj ? "pr-7" : ""}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-fg-secondary shrink-0" />
|
||||
<span className={`flex-1 truncate ${!dateObj ? "text-fg-muted" : "text-fg-secondary"}`}>
|
||||
{dateObj ? formatDisplay(dateObj) : placeholder}
|
||||
{dateObj ? formatDisplay(dateObj) : (placeholder ?? t("datePicker.placeholder"))}
|
||||
</span>
|
||||
{dateObj && (
|
||||
<X className="w-3 h-3 text-fg-muted hover:text-white shrink-0" onClick={clearValue} />
|
||||
)}
|
||||
</button>
|
||||
{/* A sibling, not a child: a button inside a button is not reachable by keyboard. */}
|
||||
{dateObj && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearValue}
|
||||
aria-label={t("datePicker.clear")}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
@@ -216,19 +251,21 @@ export function DateTimePicker({
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
aria-label={t("datePicker.prevMonth")}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs font-medium text-fg-secondary">
|
||||
{viewDate.toLocaleString(undefined, { month: "long", year: "numeric" })}
|
||||
{viewDate.toLocaleString(locale, { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1))
|
||||
}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-white"
|
||||
aria-label={t("datePicker.nextMonth")}
|
||||
className="p-1 hover:bg-surface-2 rounded text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -237,8 +274,15 @@ export function DateTimePicker({
|
||||
{/* Calendar Grid */}
|
||||
<div>
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((day) => (
|
||||
<div key={day} className="w-6 text-center text-[10px] font-medium text-fg-muted">
|
||||
{/* 2026-01-04 is a Sunday: seven local days give the weekday initials. */}
|
||||
{Array.from({ length: 7 }, (_, d) =>
|
||||
new Date(2026, 0, 4 + d).toLocaleDateString(locale, { weekday: "narrow" })
|
||||
).map((day, d) => (
|
||||
<div
|
||||
key={d}
|
||||
aria-hidden
|
||||
className="w-6 text-center text-[10px] font-medium text-fg-muted"
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
@@ -250,10 +294,11 @@ export function DateTimePicker({
|
||||
<div className="pt-3 border-t border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-medium">Time</span>
|
||||
<span className="text-[11px] font-medium">{t("datePicker.time")}</span>
|
||||
</div>
|
||||
<input
|
||||
type="time"
|
||||
aria-label={t("datePicker.time")}
|
||||
value={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
className="bg-surface-2 border border-border rounded px-2 py-1 text-xs text-fg-secondary focus:outline-none focus:border-accent w-[85px] time-input-custom"
|
||||
|
||||
@@ -24,8 +24,6 @@ export function DocumentTitle() {
|
||||
title = `${t("sessions")} · ${sessionId.slice(0, 8)}`;
|
||||
} else if (path.startsWith("/sessions")) {
|
||||
title = t("sessions");
|
||||
} else if (path.startsWith("/workflows")) {
|
||||
title = t("workflows");
|
||||
} else if (path.startsWith("/workspace")) {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
|
||||
@@ -75,20 +75,26 @@ export interface EmptyStateProps {
|
||||
description: string;
|
||||
/** Optional CTA rendered below the description (button, link, or form). */
|
||||
action?: React.ReactNode;
|
||||
/** Inside a card (a settings panel) rather than a whole page: less padding. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a vertically centered empty state inside the main content column.
|
||||
* @param props See {@link EmptyStateProps}.
|
||||
*/
|
||||
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
export function EmptyState({ icon: Icon, title, description, action, compact }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-surface-4 flex items-center justify-center mb-5">
|
||||
<Icon className="w-6 h-6 text-fg-muted" />
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center text-center ${compact ? "py-8" : "py-20"}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-2xl bg-surface-4 flex items-center justify-center ${compact ? "w-10 h-10 mb-3" : "w-14 h-14 mb-5"}`}
|
||||
>
|
||||
<Icon className="w-6 h-6 text-fg-muted" aria-hidden />
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-fg-secondary mb-2">{title}</h3>
|
||||
<p className="text-sm text-fg-muted max-w-md mb-6">{description}</p>
|
||||
<p className={`text-sm text-fg-muted max-w-md ${compact ? "" : "mb-6"}`}>{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -277,7 +277,7 @@ function SummaryBlock({
|
||||
refs.length > 0 ? t("eventDetail.seeDetailsBelow", { fields: refs.join(" · ") }) : null;
|
||||
return (
|
||||
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
|
||||
<div className="px-3 py-1 border-b border-border bg-black/20">
|
||||
<div className="px-3 py-1 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
|
||||
{t("eventDetail.summary")}
|
||||
</span>
|
||||
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
|
||||
if (value == null) return <span className="text-fg-muted italic">null</span>;
|
||||
if (typeof value === "boolean") {
|
||||
const color = value
|
||||
? "text-green-400 border-green-500/30 bg-green-500/10"
|
||||
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
|
||||
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
|
||||
@@ -394,8 +394,8 @@ function CodeView({ value }: { value: unknown }) {
|
||||
const text = typeof value === "string" ? value : safeStringify(value);
|
||||
|
||||
return (
|
||||
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
|
||||
<div className="relative bg-surface-0 border border-border rounded font-mono text-[11px] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-surface-1">
|
||||
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
|
||||
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
|
||||
</span>
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Search, X, Filter } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
@@ -114,6 +115,45 @@ export const EMPTY_FILTERS: EventFiltersValue = {
|
||||
to: "",
|
||||
};
|
||||
|
||||
/** URL key per filter field (short, so a shared link stays readable). */
|
||||
const PARAM_OF: Record<keyof EventFiltersValue, string> = {
|
||||
event_type: "et",
|
||||
tool_name: "tn",
|
||||
agent_id: "ag",
|
||||
session_id: "sid",
|
||||
status: "st",
|
||||
q: "q",
|
||||
from: "from",
|
||||
to: "to",
|
||||
};
|
||||
|
||||
/** Filters read from the URL (lists are comma-joined). */
|
||||
export function filtersFromParams(params: URLSearchParams): EventFiltersValue {
|
||||
const list = (k: string) => (params.get(k) ?? "").split(",").filter(Boolean);
|
||||
return {
|
||||
event_type: list(PARAM_OF.event_type),
|
||||
tool_name: list(PARAM_OF.tool_name),
|
||||
agent_id: list(PARAM_OF.agent_id),
|
||||
session_id: list(PARAM_OF.session_id),
|
||||
status: list(PARAM_OF.status),
|
||||
q: params.get(PARAM_OF.q) ?? "",
|
||||
from: params.get(PARAM_OF.from) ?? "",
|
||||
to: params.get(PARAM_OF.to) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** A copy of `base` with the filter keys rewritten; empty filters are dropped. */
|
||||
export function filtersToParams(f: EventFiltersValue, base: URLSearchParams): URLSearchParams {
|
||||
const next = new URLSearchParams(base);
|
||||
for (const [field, key] of Object.entries(PARAM_OF) as [keyof EventFiltersValue, string][]) {
|
||||
const v = f[field];
|
||||
const str = Array.isArray(v) ? v.join(",") : v;
|
||||
if (str) next.set(key, str);
|
||||
else next.delete(key);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isEmptyFilters(f: EventFiltersValue): boolean {
|
||||
return (
|
||||
f.event_type.length === 0 &&
|
||||
@@ -179,13 +219,18 @@ export function EventFilters({
|
||||
tool_names: [],
|
||||
});
|
||||
const [searchDraft, setSearchDraft] = useState(value.q);
|
||||
// The debounced search merges into the LATEST value: a chip toggled during
|
||||
// the 300 ms must not be reverted by the value captured at keystroke time.
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
|
||||
// Debounce text search - only push up after 300ms of inactivity.
|
||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
if (searchDraft !== value.q) onChange({ ...value, q: searchDraft });
|
||||
const latest = valueRef.current;
|
||||
if (searchDraft !== latest.q) onChange({ ...latest, q: searchDraft });
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
||||
@@ -247,6 +292,7 @@ export function EventFilters({
|
||||
/>
|
||||
<span className="text-xs text-fg-muted">→</span>
|
||||
<DateTimePicker
|
||||
endOfDay
|
||||
value={value.to}
|
||||
onChange={(val: string) => onChange({ ...value, to: val })}
|
||||
aria-label={t("eventFilters.to")}
|
||||
@@ -300,11 +346,10 @@ export function EventFilters({
|
||||
onToggle={(item) => toggle("agent_id", item)}
|
||||
/>
|
||||
)}
|
||||
{!hideSessionFilter && sessionOptions && sessionOptions.length > 0 && (
|
||||
<ChipGroup
|
||||
{!hideSessionFilter && (
|
||||
<SessionChipGroup
|
||||
label={t("eventFilters.sessionId")}
|
||||
options={sessionOptions.map((s) => s.id)}
|
||||
labels={Object.fromEntries(sessionOptions.map((s) => [s.id, s.label]))}
|
||||
known={sessionOptions}
|
||||
selected={value.session_id}
|
||||
onToggle={(item) => toggle("session_id", item)}
|
||||
/>
|
||||
@@ -314,22 +359,98 @@ export function EventFilters({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session filter: searched on the server (`q`, 20 rows), never a list of
|
||||
* every session. Selected ids stay listed (with the label last seen) so they
|
||||
* can be unticked while the search shows other rows.
|
||||
*/
|
||||
function SessionChipGroup({
|
||||
label,
|
||||
known,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
known?: Array<{ id: string; label: string }>;
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const labels = useRef(new Map<string, string>());
|
||||
for (const k of known ?? []) labels.current.set(k.id, k.label);
|
||||
const [open, setOpen] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
const mine = ++seq.current;
|
||||
api.sessions
|
||||
.list({ q: query.trim() || undefined, limit: 20 })
|
||||
.then((res) => {
|
||||
if (mine !== seq.current) return;
|
||||
const next = res.sessions.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.name?.trim() || s.id.slice(0, 8),
|
||||
}));
|
||||
for (const r of next) labels.current.set(r.id, r.label);
|
||||
setRows(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
},
|
||||
query ? 250 : 0
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [open, query]);
|
||||
|
||||
const options = [...selected, ...rows.map((r) => r.id).filter((id) => !selected.includes(id))];
|
||||
return (
|
||||
<ChipGroup
|
||||
label={label}
|
||||
options={options}
|
||||
labels={Object.fromEntries(options.map((id) => [id, labels.current.get(id) ?? id]))}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onOpenChange={setOpen}
|
||||
search={
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("eventFilters.searchPlaceholder")}
|
||||
aria-label={label}
|
||||
className="w-full mb-1 bg-surface-2 border border-border rounded px-2 py-1 text-[11px] text-fg-secondary focus:outline-none focus:border-accent"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipGroup({
|
||||
label,
|
||||
options,
|
||||
selected,
|
||||
onToggle,
|
||||
labels,
|
||||
onOpenChange,
|
||||
search,
|
||||
}: {
|
||||
label: string;
|
||||
options: string[];
|
||||
selected: string[];
|
||||
onToggle: (item: string) => void;
|
||||
labels?: Record<string, string>;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** Rendered above the options (a search box). */
|
||||
search?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation("common");
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => onOpenChange?.(open), [open, onOpenChange]);
|
||||
|
||||
// Click-outside dismiss.
|
||||
useEffect(() => {
|
||||
@@ -367,6 +488,7 @@ function ChipGroup({
|
||||
aria-label={label}
|
||||
className="absolute left-0 mt-1 z-20 min-w-[220px] max-h-64 overflow-auto bg-surface-1 border border-border rounded shadow-xl p-1.5"
|
||||
>
|
||||
{search}
|
||||
{options.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted px-2 py-1 italic">
|
||||
{t("eventFilters.noOptions")}
|
||||
@@ -376,18 +498,14 @@ function ChipGroup({
|
||||
const checked = selected.includes(opt);
|
||||
const display = labels?.[opt] ?? opt;
|
||||
return (
|
||||
<label
|
||||
<Checkbox
|
||||
key={opt}
|
||||
className="flex items-center gap-2 px-2 py-1 text-[11px] text-fg-secondary rounded hover:bg-surface-3 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="font-mono break-all">{display}</span>
|
||||
</label>
|
||||
checked={checked}
|
||||
onChange={() => onToggle(opt)}
|
||||
label={display}
|
||||
className="flex w-full px-2 py-1 rounded hover:bg-surface-3"
|
||||
labelClassName="font-mono break-all text-[11px] text-fg-secondary"
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function EventFiltersInfo() {
|
||||
<p className="text-[11px] text-fg-secondary mb-2">
|
||||
{t("eventFilters.help.lifecycleDesc")}
|
||||
</p>
|
||||
<code className="block bg-black/40 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
<code className="block bg-surface-1 border border-border rounded p-2 text-[11px] font-mono text-fg-secondary whitespace-pre-wrap">
|
||||
{t("eventFilters.help.lifecycleFlow")}
|
||||
</code>
|
||||
</Section>
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
import { api, type ImportResult, type ImportBackupResult } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import type { WSMessage, ImportProgressMessage } from "../lib/types";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
|
||||
type Mode = "rescan" | "path" | "upload" | "backup";
|
||||
|
||||
@@ -294,7 +295,7 @@ export function ImportHistory() {
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
|
||||
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
|
||||
<ListChecks className="w-3.5 h-3.5 text-accent" />
|
||||
{t("import.instructions")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
|
||||
@@ -426,14 +427,17 @@ export function ImportHistory() {
|
||||
{mode === "path" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder={t("import.folderPlaceholder")}
|
||||
className="input w-full text-sm font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder={t("import.folderPlaceholder")}
|
||||
className="input w-full text-sm font-mono"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<PathPickButton start={folderPath} onPick={setFolderPath} />
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
@@ -455,8 +459,8 @@ export function ImportHistory() {
|
||||
|
||||
{mode === "upload" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
<label
|
||||
htmlFor="import-upload-files"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -467,9 +471,9 @@ export function ImportHistory() {
|
||||
setDragging(false);
|
||||
onSelectFiles(e.dataTransfer.files);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
dragging
|
||||
? "border-blue-500 bg-blue-600/5"
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border hover:border-border-light bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
@@ -477,14 +481,15 @@ export function ImportHistory() {
|
||||
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
|
||||
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
|
||||
<input
|
||||
id="import-upload-files"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
|
||||
onChange={(e) => onSelectFiles(e.target.files)}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-fg-secondary">
|
||||
@@ -522,8 +527,8 @@ export function ImportHistory() {
|
||||
|
||||
{mode === "backup" && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => backupInputRef.current?.click()}
|
||||
<label
|
||||
htmlFor="import-backup-file"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
@@ -535,9 +540,9 @@ export function ImportHistory() {
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) setBackupFile(f);
|
||||
}}
|
||||
className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${
|
||||
className={`block border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors focus-within:ring-2 focus-within:ring-accent/50 ${
|
||||
dragging
|
||||
? "border-blue-500 bg-blue-600/5"
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border hover:border-border-light bg-surface-1"
|
||||
}`}
|
||||
>
|
||||
@@ -545,13 +550,14 @@ export function ImportHistory() {
|
||||
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
|
||||
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
|
||||
<input
|
||||
id="import-backup-file"
|
||||
ref={backupInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
|
||||
className="hidden"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{backupFile && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
||||
<span className="text-fg-secondary min-w-0">
|
||||
@@ -591,7 +597,7 @@ export function ImportHistory() {
|
||||
{/* In-flight progress */}
|
||||
{running && progressText && (
|
||||
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent flex-shrink-0" />
|
||||
<span className="truncate">{progressText}</span>
|
||||
{progress?.current && (
|
||||
<code className="font-mono text-[11px] text-fg-muted truncate">
|
||||
@@ -625,7 +631,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-400"
|
||||
color="text-accent"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,12 +678,12 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
color="text-fg-primary"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
||||
value={backupResult.model_pricing}
|
||||
color="text-cyan-300"
|
||||
color="text-cyan-700 dark:text-cyan-300"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
@@ -728,16 +734,16 @@ function ModeButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={`text-left p-3 rounded-lg border transition-colors ${
|
||||
active
|
||||
? "border-blue-600/40 bg-blue-600/10"
|
||||
: "border-border bg-surface-2 hover:bg-surface-3"
|
||||
active ? "border-accent/40 bg-accent/10" : "border-border bg-surface-2 hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-400" : "text-fg-secondary"
|
||||
active ? "text-accent" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file Layout.tsx
|
||||
* @description Application shell that frames every authenticated route: persistent
|
||||
* sidebar, main content column, update notifier, and the Sagi assistant overlay.
|
||||
* sidebar, main content column, the bottom status bar, update notifier, and the
|
||||
* Sagi assistant overlay. The connection details modal's open state lives here
|
||||
* because both the sidebar's rail button and the status bar open it.
|
||||
* The layout is the single parent route in {@link App} — child pages render inside
|
||||
* React Router's `<Outlet />` so navigation never remounts chrome.
|
||||
*
|
||||
@@ -10,6 +12,12 @@
|
||||
* written back on every toggle. Failures to access storage are swallowed so a
|
||||
* private-browsing quota error never breaks the UI.
|
||||
*
|
||||
* ## Narrow viewports
|
||||
* With no stored preference the sidebar starts collapsed below `lg` (see
|
||||
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
|
||||
* button in a slim top bar opens it as an off-canvas drawer (a modal dialog —
|
||||
* backdrop, Escape, focus trapped and restored), which closes on navigation.
|
||||
*
|
||||
* ## Sticky descendants
|
||||
* The inner content wrapper uses `overflow-x-clip` (not `hidden`) so horizontal
|
||||
* overflow is clipped without creating a scroll container. That keeps `position:
|
||||
@@ -62,12 +70,28 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
|
||||
export const DRAWER_QUERY = "(max-width: 767.98px)";
|
||||
|
||||
function useMedia(query: string): boolean {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
const mq = window.matchMedia?.(query);
|
||||
mq?.addEventListener?.("change", cb);
|
||||
return () => mq?.removeEventListener?.("change", cb);
|
||||
},
|
||||
() => window.matchMedia?.(query).matches ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for {@link Layout}. */
|
||||
interface LayoutProps {
|
||||
@@ -82,6 +106,19 @@ interface LayoutProps {
|
||||
export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const drawer = useMedia(DRAWER_QUERY);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
|
||||
const { pathname, search } = useLocation();
|
||||
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
|
||||
|
||||
// A pick in the drawer navigates; the drawer has done its job.
|
||||
useEffect(closeDrawer, [pathname, search, closeDrawer]);
|
||||
useEffect(() => {
|
||||
if (!drawer) setDrawerOpen(false);
|
||||
}, [drawer]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setCollapsed((prev) => {
|
||||
@@ -100,16 +137,48 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<Sidebar wsConnected={wsConnected} collapsed={collapsed} onToggle={toggle} />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="sidebar-backdrop"
|
||||
onClick={closeDrawer}
|
||||
className="fixed inset-0 z-40 bg-black/50 animate-fade-in"
|
||||
/>
|
||||
)}
|
||||
<Sidebar
|
||||
wsConnected={wsConnected}
|
||||
collapsed={drawer ? false : collapsed}
|
||||
onToggle={drawer ? closeDrawer : toggle}
|
||||
drawer={drawer ? { open: drawerOpen, onClose: closeDrawer } : undefined}
|
||||
connectionOpen={connectionOpen}
|
||||
onConnectionOpenChange={setConnectionOpen}
|
||||
onUpdateAvailableChange={setUpdateAvailable}
|
||||
/>
|
||||
<main
|
||||
id="main-content"
|
||||
tabIndex={-1}
|
||||
className="min-h-screen min-w-0 transition-[margin-left,width] duration-200 outline-none"
|
||||
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
|
||||
style={{
|
||||
marginLeft: collapsed ? "4.25rem" : "15rem",
|
||||
width: collapsed ? "calc(100% - 4.25rem)" : "calc(100% - 15rem)",
|
||||
marginLeft: sidebarWidth,
|
||||
width: `calc(100% - ${sidebarWidth})`,
|
||||
}}
|
||||
>
|
||||
{drawer && (
|
||||
<div className="sticky top-0 z-20 flex h-11 items-center gap-2 border-b border-border bg-surface-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-open"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
aria-label={t("openMenu")}
|
||||
aria-expanded={drawerOpen}
|
||||
title={t("openMenu")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<Menu className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-fg-primary">{t("brand")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* overflow-x-clip (not -hidden) clips horizontal overflow without
|
||||
creating a scroll container, so descendant `position: sticky`
|
||||
elements (e.g. the Settings page TOC) still pin to the window. */}
|
||||
@@ -117,6 +186,12 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
<StatusBar
|
||||
wsConnected={wsConnected}
|
||||
left={sidebarWidth}
|
||||
onConnectionClick={() => setConnectionOpen(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file MarkdownPreview.tsx
|
||||
* @description A markdown file rendered for reading (GitHub-flavoured:
|
||||
* tables, task lists, strikethrough, autolinks), shared by the config
|
||||
* explorer's file viewer and the lane Changes panel. Raw HTML in the file is
|
||||
* shown as text, never run (react-markdown without rehype-raw), and links
|
||||
* open in a new tab. A leading YAML frontmatter block (skills, agents) is
|
||||
* shown as a code block instead of being misread as a heading. `isMarkdown`
|
||||
* says which paths get the preview toggle.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
/** Whether `path` names a markdown file. */
|
||||
export function isMarkdown(path: string | null | undefined): boolean {
|
||||
return !!path && /\.(md|markdown|mdx)$/i.test(path);
|
||||
}
|
||||
|
||||
/** Split a leading `---` frontmatter block off the body. */
|
||||
function splitFrontmatter(text: string): { front: string | null; body: string } {
|
||||
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
|
||||
return m ? { front: m[1]!, body: text.slice(m[0].length) } : { front: null, body: text };
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ text, className = "" }: { text: string; className?: string }) {
|
||||
const { front, body } = splitFrontmatter(text);
|
||||
return (
|
||||
<div data-testid="markdown-preview" className={`md-preview ${className}`}>
|
||||
{front !== null && <pre className="md-frontmatter">{front}</pre>}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ node: _node, ...props }) => (
|
||||
<a {...props} target="_blank" rel="noreferrer noopener" />
|
||||
),
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file NeedsYouStrip.tsx
|
||||
* @description The Dashboard's pinned "Needs you" strip: every active session
|
||||
* blocked on the user (`awaiting_input_since`), each linking straight to its
|
||||
* workspace pane (or replay), else its analysis page. Renders nothing while no
|
||||
* session is waiting. `compact` is the one-line form shown above Analytics.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
// The real count: only the first 20 are fetched and listed.
|
||||
const [total, setTotal] = useState(0);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 20 })
|
||||
.then((r) => {
|
||||
setSessions(r.sessions);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={t("needsYou.title")}
|
||||
className={`card border-status-warning/40 ${compact ? "px-3 py-2" : "p-4"}`}
|
||||
>
|
||||
<div className={`flex flex-wrap items-center gap-2 ${compact ? "" : "mb-3"}`}>
|
||||
<BellRing className="w-4 h-4 text-status-warning flex-shrink-0" aria-hidden />
|
||||
<h2 className="text-sm font-medium text-fg-primary">
|
||||
{t("needsYou.title")}{" "}
|
||||
<span className="text-fg-muted font-normal">({Math.max(total, sessions.length)})</span>
|
||||
</h2>
|
||||
{compact &&
|
||||
sessions.slice(0, 5).map((s) => (
|
||||
<Link
|
||||
key={s.id}
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-status-warning/10 border border-status-warning/30 text-fg-secondary hover:text-fg-primary truncate max-w-[14rem]"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{!compact && (
|
||||
<ul className="divide-y divide-border">
|
||||
{sessions.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
to={workspaceHref(s, runs) ?? `/sessions/${s.id}`}
|
||||
className="flex items-center gap-3 py-2 px-1 hover:bg-surface-4 rounded"
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-status-warning flex-shrink-0" />
|
||||
<span className="text-sm text-fg-secondary truncate flex-1">
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted truncate max-w-[16rem]">{s.cwd}</span>
|
||||
{s.awaiting_input_since && (
|
||||
<span className="text-[11px] text-fg-muted flex-shrink-0">
|
||||
{t("needsYou.since", { ago: timeAgo(s.awaiting_input_since) })}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* @file PageTabs.tsx
|
||||
* @description Shared top-of-page tab bar used by the pages that merged several
|
||||
* former routes into one (Settings hub, Sessions/Events history). Rendering only -
|
||||
* the owning page keeps the active tab in its own query param.
|
||||
* former routes into one (Settings hub, Sessions/Events history). The owning
|
||||
* page keeps the active tab in its own query param. `actions` sits at the bar's
|
||||
* right edge, outside the tablist.
|
||||
*
|
||||
* These are real in-page tabs (one view swapped in place), so they follow the
|
||||
* WAI-ARIA tabs pattern: one tab stop (roving tabindex), Left/Right/Home/End
|
||||
* move AND select. Pass the active view as `children` and it is wrapped in the
|
||||
* `role="tabpanel"` the tabs point at with `aria-controls`; without children no
|
||||
* `aria-controls` is emitted (it would name an element that does not exist).
|
||||
* The selected tab is marked by weight and an underline, not colour alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { useId, type ComponentType, type KeyboardEvent, type ReactNode } from "react";
|
||||
|
||||
export interface PageTab {
|
||||
id: string;
|
||||
@@ -18,32 +27,69 @@ interface PageTabsProps {
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
/** Buttons at the right edge of the bar (not tabs). */
|
||||
actions?: ReactNode;
|
||||
/** The active tab's view, rendered inside its `role="tabpanel"`. */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal tab strip for a page that hosts several panels.
|
||||
* @param props - Tabs to render, the active tab id, and the selection callback.
|
||||
* @returns The tab bar element.
|
||||
*/
|
||||
export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps) {
|
||||
return (
|
||||
export function PageTabs({
|
||||
tabs,
|
||||
activeId,
|
||||
onSelect,
|
||||
ariaLabel,
|
||||
actions,
|
||||
children,
|
||||
}: PageTabsProps) {
|
||||
const base = useId();
|
||||
const tabId = (id: string) => `${base}-tab-${id}`;
|
||||
const panelId = `${base}-panel`;
|
||||
const hasPanel = children !== undefined;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const i = tabs.findIndex((t) => t.id === activeId);
|
||||
const last = tabs.length - 1;
|
||||
const next =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % tabs.length
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + tabs.length) % tabs.length
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? last
|
||||
: -1;
|
||||
const tab = tabs[next];
|
||||
if (!tab) return;
|
||||
e.preventDefault();
|
||||
onSelect(tab.id);
|
||||
document.getElementById(tabId(tab.id))?.focus();
|
||||
};
|
||||
|
||||
const list = (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex items-center gap-1.5 overflow-x-auto no-scrollbar border-b border-border/60 pb-2"
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex items-center gap-1.5 overflow-x-auto no-scrollbar ${
|
||||
actions ? "min-w-0 flex-1" : "border-b border-border/60 pb-2"
|
||||
}`}
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => {
|
||||
const isActive = id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
id={tabId(id)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={hasPanel && isActive ? panelId : undefined}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onSelect(id)}
|
||||
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
|
||||
className={`inline-flex items-center gap-1.5 text-xs font-medium whitespace-nowrap px-3 py-1.5 rounded-lg border transition-colors flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${
|
||||
isActive
|
||||
? "bg-accent/15 border-accent/30 text-accent"
|
||||
? "bg-accent/15 border-accent/30 text-accent shadow-[inset_0_-2px_0_rgb(var(--accent))]"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
@@ -54,4 +100,21 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const bar = actions ? (
|
||||
<div className="flex items-center gap-3 border-b border-border/60 pb-2">
|
||||
{list}
|
||||
<div className="flex flex-shrink-0 items-center gap-1.5">{actions}</div>
|
||||
</div>
|
||||
) : (
|
||||
list
|
||||
);
|
||||
if (!hasPanel) return bar;
|
||||
return (
|
||||
<>
|
||||
{bar}
|
||||
<div role="tabpanel" id={panelId} aria-labelledby={tabId(activeId)}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* @file PathPickButton.tsx
|
||||
* @description The one "Browse" button beside every path field: it asks the
|
||||
* server to open the operating system's own folder/file picker
|
||||
* (`POST /api/lanes/pick-path`) and fills the field with the absolute path the
|
||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||
* opens on the server's desktop — which is only this user's screen when the
|
||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
||||
* the path.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
||||
|
||||
export function PathPickButton({
|
||||
kind = "dir",
|
||||
start,
|
||||
onPick,
|
||||
showLabel = false,
|
||||
}: {
|
||||
kind?: "dir" | "file";
|
||||
/** The field's current value — the picker opens there. */
|
||||
start?: string;
|
||||
onPick: (path: string) => void;
|
||||
showLabel?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fallback, setFallback] = useState(false);
|
||||
const title = kind === "dir" ? t("browse.title") : t("browse.fileTitle");
|
||||
|
||||
const pick = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||
if (r.path) onPick(r.path);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// A folder always has the in-app browser to fall back on, whatever went
|
||||
// wrong with the native one (a crashed dialog, one already open).
|
||||
if (kind === "dir") setFallback(true);
|
||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
||||
else {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={pick}
|
||||
disabled={busy}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="flex shrink-0 items-center gap-1 self-stretch rounded-md border border-border-light px-2 text-xs text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
{showLabel && t("browse.button")}
|
||||
</button>
|
||||
{/* Its clicks stay here: a backdrop click must close only this modal,
|
||||
not bubble on to a dialog this button sits in. */}
|
||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||
<FolderBrowseModal
|
||||
open={fallback}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@
|
||||
* here — authentication defers to the host's SSH stack (~/.ssh/config, agent,
|
||||
* keys, known_hosts); a source is just a label + ssh destination (+ optional
|
||||
* port / identity file / remote home). Live status/sync updates arrive over the
|
||||
* `remote_source.status` WebSocket message.
|
||||
* `remote_source.status` WebSocket message. A right-click on a source opens
|
||||
* its Test / Sync / Enable-Disable / Edit / Delete (plus Copy host) as a menu.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -57,6 +58,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -79,9 +82,14 @@ import {
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource, RemoteSourceInput } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import type { ScopeMode } from "../lib/dataScope";
|
||||
import { PathPickButton } from "./PathPickButton";
|
||||
|
||||
/** Data-scope choices in display order (the radio group's arrow-key order). */
|
||||
const SCOPE_MODES: ScopeMode[] = ["all", "local", "selected"];
|
||||
|
||||
const EMPTY_FORM: RemoteSourceInput = {
|
||||
label: "",
|
||||
@@ -134,6 +142,13 @@ export function RemoteSources() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<RemoteSource>();
|
||||
// A menu item: close the menu, then act on the source it was opened on.
|
||||
const pick = (fn: (s: RemoteSource) => unknown) => () => {
|
||||
const s = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (s) fn(s);
|
||||
};
|
||||
const [syncingAll, setSyncingAll] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>(
|
||||
{}
|
||||
@@ -248,8 +263,8 @@ export function RemoteSources() {
|
||||
try {
|
||||
await api.remoteSources.update(s.id, { enabled: !s.enabled });
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -291,8 +306,10 @@ export function RemoteSources() {
|
||||
try {
|
||||
await api.remoteSources.syncAll();
|
||||
load();
|
||||
} catch {
|
||||
/* per-source errors surface via each source's status on reload */
|
||||
} catch (err) {
|
||||
// Per-source errors still surface on each source's status after the reload.
|
||||
toast.error(err);
|
||||
load();
|
||||
} finally {
|
||||
setSyncingAll(false);
|
||||
}
|
||||
@@ -306,8 +323,9 @@ export function RemoteSources() {
|
||||
await api.remoteSources.remove(id, purge);
|
||||
setConfirmDelete(null);
|
||||
load();
|
||||
} catch {
|
||||
/* surfaced via reload */
|
||||
} catch (err) {
|
||||
setConfirmDelete(null);
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -348,7 +366,26 @@ export function RemoteSources() {
|
||||
</p>
|
||||
{/* Card selector — one card per scope mode, each with a short explanation
|
||||
so the choice is self-describing rather than a bare radio label. */}
|
||||
<div className="grid gap-2.5 sm:grid-cols-3" role="radiogroup" aria-label="Data scope">
|
||||
<div
|
||||
className="grid gap-2.5 sm:grid-cols-3"
|
||||
role="radiogroup"
|
||||
aria-label={t("remoteSources.scopeTitle", "Data scope")}
|
||||
onKeyDown={(e) => {
|
||||
// Arrow keys move the choice, like a native radio group.
|
||||
const step =
|
||||
e.key === "ArrowRight" || e.key === "ArrowDown"
|
||||
? 1
|
||||
: e.key === "ArrowLeft" || e.key === "ArrowUp"
|
||||
? -1
|
||||
: 0;
|
||||
if (!step) return;
|
||||
e.preventDefault();
|
||||
const i = SCOPE_MODES.indexOf(scope.mode);
|
||||
const next = SCOPE_MODES[(i + step + SCOPE_MODES.length) % SCOPE_MODES.length]!;
|
||||
setMode(next);
|
||||
e.currentTarget.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus();
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
@@ -384,8 +421,11 @@ export function RemoteSources() {
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
data-mode={mode}
|
||||
onClick={() => setMode(mode)}
|
||||
className={`relative text-left rounded-xl border p-3 transition-colors ${
|
||||
active
|
||||
@@ -482,7 +522,7 @@ export function RemoteSources() {
|
||||
</label>
|
||||
<input
|
||||
className="input w-full"
|
||||
placeholder="Dev box"
|
||||
placeholder={t("remoteSources.fieldLabelPlaceholder", "Dev box")}
|
||||
value={form.label}
|
||||
onChange={(e) => setForm((f) => ({ ...f, label: e.target.value }))}
|
||||
/>
|
||||
@@ -519,12 +559,19 @@ export function RemoteSources() {
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
|
||||
</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, identity_file: e.target.value }))}
|
||||
/>
|
||||
<PathPickButton
|
||||
kind="file"
|
||||
start={form.identity_file || "~/.ssh"}
|
||||
onPick={(p) => setForm((f) => ({ ...f, identity_file: p }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-xs text-fg-muted mb-1">
|
||||
@@ -544,17 +591,12 @@ export function RemoteSources() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-accent"
|
||||
checked={!!form.enabled}
|
||||
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary">
|
||||
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={!!form.enabled}
|
||||
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
|
||||
label={t("remoteSources.fieldEnabled", "Sync automatically in the background")}
|
||||
labelClassName="text-sm text-fg-secondary"
|
||||
/>
|
||||
{formError && (
|
||||
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
|
||||
{formError}
|
||||
@@ -602,7 +644,7 @@ export function RemoteSources() {
|
||||
const test = testResults[s.id];
|
||||
const busy = busyId === s.id;
|
||||
return (
|
||||
<div key={s.id} className="card p-4">
|
||||
<div key={s.id} onContextMenu={(e) => ctx.open(e, s)} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -723,22 +765,16 @@ export function RemoteSources() {
|
||||
<p className="text-xs text-fg-secondary mb-2">
|
||||
{t("remoteSources.confirmDelete", "Remove this source?")}
|
||||
</p>
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-red-500"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(e) =>
|
||||
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
|
||||
}
|
||||
/>
|
||||
<span className="text-xs text-fg-secondary">
|
||||
{t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<Checkbox
|
||||
className="mb-3"
|
||||
tone="danger"
|
||||
checked={confirmDelete.purge}
|
||||
onChange={(v) => setConfirmDelete((c) => c && { ...c, purge: v })}
|
||||
label={t(
|
||||
"remoteSources.purgeData",
|
||||
"Also delete the sessions imported from this source (cannot be undone)"
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={doDelete}
|
||||
@@ -763,6 +799,34 @@ export function RemoteSources() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ctx.menu && (
|
||||
<ContextMenu
|
||||
x={ctx.menu.x}
|
||||
y={ctx.menu.y}
|
||||
onClose={ctx.close}
|
||||
testId="remote-source-menu"
|
||||
>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(testSource)}>
|
||||
{t("remoteSources.test", "Test")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(syncNow)}>
|
||||
{t("remoteSources.syncNow", "Sync")}
|
||||
</MenuItem>
|
||||
<MenuItem disabled={busyId === ctx.menu.item.id} onSelect={pick(toggleEnabled)}>
|
||||
{ctx.menu.item.enabled
|
||||
? t("remoteSources.disable", "Disable")
|
||||
: t("remoteSources.enable", "Enable")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("common:edit", "Edit")}</MenuItem>
|
||||
<MenuItem onSelect={pick((s) => void navigator.clipboard.writeText(s.host))}>
|
||||
{t("rowMenu.copyHost")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((s) => setConfirmDelete({ id: s.id, purge: false }))}>
|
||||
{t("common:delete", "Delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file RouteErrorBoundary.tsx
|
||||
* @description Route-level error boundary around the lazily loaded pages. After
|
||||
* a self-update an open tab still asks for the OLD `/assets/<Page>-<hash>.js`,
|
||||
* which the SPA fallback answers with index.html, so the dynamic import
|
||||
* rejects: that case reloads the page once (a sessionStorage guard stops a
|
||||
* reload loop). Any other render error — or a second chunk failure — shows a
|
||||
* themed "Something went wrong — Reload" panel instead of a blank app, until the
|
||||
* route changes (`resetKey`): the lazy routes share one boundary instance, so
|
||||
* an error on one page would otherwise stay on every other.
|
||||
* `reloadOnce` is also used by App's `vite:preloadError` listener.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
import i18n from "../i18n";
|
||||
|
||||
const RELOAD_KEY = "agenthub.chunkReloadAt";
|
||||
/** A second chunk failure within this window means the reload did not help. */
|
||||
const RELOAD_WINDOW_MS = 30_000;
|
||||
|
||||
/** A failed dynamic `import()` (Chrome, Firefox, Safari wordings) or a ChunkLoadError. */
|
||||
export function isChunkLoadError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return (
|
||||
err.name === "ChunkLoadError" ||
|
||||
/dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS/i.test(
|
||||
err.message
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Reload the page unless this tab already did so in the last 30 s. */
|
||||
export function reloadOnce(reload: () => void = () => window.location.reload()): boolean {
|
||||
try {
|
||||
const last = Number(sessionStorage.getItem(RELOAD_KEY) || 0);
|
||||
if (Date.now() - last < RELOAD_WINDOW_MS) return false;
|
||||
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
|
||||
} catch {
|
||||
return false; // no sessionStorage: no loop guard, so never auto-reload
|
||||
}
|
||||
reload();
|
||||
return true;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
/** Injected in tests; defaults to `window.location.reload`. */
|
||||
reload?: () => void;
|
||||
/** A change (the route's path) clears a caught error. */
|
||||
resetKey?: string;
|
||||
}
|
||||
|
||||
export class RouteErrorBoundary extends Component<Props, { error: Error | null }> {
|
||||
state = { error: null as Error | null };
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error) {
|
||||
if (isChunkLoadError(error)) reloadOnce(this.props.reload);
|
||||
}
|
||||
|
||||
componentDidUpdate(prev: Props) {
|
||||
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.error) return this.props.children;
|
||||
const reload = this.props.reload ?? (() => window.location.reload());
|
||||
return (
|
||||
<div role="alert" className="card flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{i18n.t("dashboard:errorBoundary.title")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted max-w-md">{i18n.t("dashboard:errorBoundary.body")}</p>
|
||||
<button type="button" className="btn-primary text-sm" onClick={reload}>
|
||||
{i18n.t("dashboard:errorBoundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @file PetMenu.tsx
|
||||
* @description The mascot's right-click menu: hide it, size it, choose the
|
||||
* pet it wears — Sagi, a featured pet (downloaded from codex-pets.net into
|
||||
* this install on the first pick, `POST /api/pets/featured/:id`), or one of
|
||||
* your own (deletable) — upload an image or import a `.codex-pet.zip`, browse
|
||||
* codex-pets.net for more, and open the Sagi settings. The choice and the size
|
||||
* are viewer preferences (`prefs.ts`); the pets themselves live on the server
|
||||
* (`server/lib/pets.js`); the list and its actions are `usePets`, shared
|
||||
* with Settings → Pet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
PackageOpen,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const ITEM =
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
export function PetMenu({
|
||||
pet,
|
||||
size,
|
||||
onClose,
|
||||
onSettings,
|
||||
}: {
|
||||
pet: PetChoice;
|
||||
size: number;
|
||||
onClose: () => void;
|
||||
onSettings: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const imageInput = useRef<HTMLInputElement>(null);
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const addFile = (file: File | undefined, bundle: boolean) => file && void add(file, bundle);
|
||||
const mark = (id: string) =>
|
||||
pet.id === id ? <Check className="ml-auto h-3.5 w-3.5 shrink-0 text-accent" /> : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
data-testid="pet-menu"
|
||||
className="w-72 rounded-xl border border-border bg-surface-2 p-1.5 text-sm shadow-lg"
|
||||
>
|
||||
<div className="px-2.5 pb-1 pt-0.5 text-xs font-medium text-fg-muted">
|
||||
{t("sagi.pet.title")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={ITEM}
|
||||
onClick={() => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
toast.info(t("sagi.pet.hiddenToast"));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
{t("sagi.pet.hide")}
|
||||
</button>
|
||||
<label className="block px-2.5 py-1.5">
|
||||
<span className="flex items-center justify-between text-sm text-fg-secondary">
|
||||
{t("sagi.pet.size")}
|
||||
<span className="tabular-nums text-xs text-fg-muted">{size}px</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
data-testid="pet-size"
|
||||
min={PET_SIZE.min}
|
||||
max={PET_SIZE.max}
|
||||
value={size}
|
||||
onChange={(e) => sagiPrefs.setSize(Number(e.target.value))}
|
||||
className="mt-1 w-full accent-[rgb(var(--accent))]"
|
||||
/>
|
||||
</label>
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-expanded={choosing}
|
||||
data-testid="pet-choose"
|
||||
className={ITEM}
|
||||
onClick={() => setChoosing((v) => !v)}
|
||||
>
|
||||
{choosing ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
{t("sagi.pet.choose")}
|
||||
</button>
|
||||
{choosing && (
|
||||
<div className="max-h-80 overflow-y-auto pl-2">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === "sagi"}
|
||||
className={ITEM}
|
||||
onClick={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
>
|
||||
Sagi
|
||||
{mark("sagi")}
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={!!have && pet.id === have.id}
|
||||
data-testid={`pet-featured-${f.id}`}
|
||||
disabled={busy !== null}
|
||||
title={t("sagi.pet.featuredBy", { by: f.by })}
|
||||
className={ITEM}
|
||||
onClick={() => (have ? wear(have) : void install(f.id))}
|
||||
>
|
||||
<span className="min-w-0 truncate">{f.displayName}</span>
|
||||
{busy === f.id ? (
|
||||
<Loader2 className="ml-auto h-3.5 w-3.5 shrink-0 animate-spin" />
|
||||
) : have ? (
|
||||
mark(have.id)
|
||||
) : (
|
||||
<Download
|
||||
aria-label={t("sagi.pet.download")}
|
||||
className="ml-auto h-3.5 w-3.5 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{own.length > 0 && <div className="my-1.5 border-t border-border" />}
|
||||
{own.map((p) => (
|
||||
<div key={p.id} className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={pet.id === p.id}
|
||||
data-testid={`pet-own-${p.id}`}
|
||||
className={ITEM}
|
||||
onClick={() => wear(p)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={imageInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-image-input"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], false);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={bundleInput}
|
||||
type="file"
|
||||
hidden
|
||||
data-testid="pet-bundle-input"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) => {
|
||||
addFile(e.target.files?.[0], true);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="px-2.5 py-1 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${ITEM} !text-fg-muted`}
|
||||
onClick={onSettings}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{t("sagi.pet.settings")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
sleeping: 0,
|
||||
thinking: 7, // running — an agent is working
|
||||
watching: 8, // review
|
||||
happy: 4, // jumping
|
||||
stuck: 6, // waiting — for the user
|
||||
worried: 5, // failed
|
||||
disconnected: 5,
|
||||
};
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
}: {
|
||||
id: string;
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = ROW[mood] ?? 0;
|
||||
const [frame, setFrame] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const timer = window.setInterval(
|
||||
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
|
||||
mood === "sleeping" ? 400 : 125
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [kind, row, mood, reducedMotion]);
|
||||
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
const probe = new Image();
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, kind]);
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
draggable={false}
|
||||
onError={onBroken}
|
||||
style={{ width: size, height: size, objectFit: "contain" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
height: size,
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path reuses the existing Run page: unmatched Ask queries
|
||||
* deep-link to /run?prompt=…&autostart=1 - no new LLM backend.
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,11 +73,14 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
import { PetMenu } from "./PetMenu";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition, SAGI_SIZE } from "./useSagiPosition";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -83,6 +89,17 @@ import "./sagi.css";
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
const VIEWPORT_MARGIN = 12; // min gap from any screen edge
|
||||
|
||||
/** True while focus sits where typing happens (an input, a terminal's textarea). */
|
||||
function isEditable(el: EventTarget | null): boolean {
|
||||
if (!(el instanceof HTMLElement)) return false;
|
||||
return (
|
||||
el.isContentEditable ||
|
||||
el.tagName === "INPUT" ||
|
||||
el.tagName === "TEXTAREA" ||
|
||||
el.tagName === "SELECT"
|
||||
);
|
||||
}
|
||||
|
||||
interface Anchor {
|
||||
left: number;
|
||||
top: number;
|
||||
@@ -92,7 +109,8 @@ interface Anchor {
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
/** The OS "reduce motion" preference, followed live. */
|
||||
export function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
@@ -114,7 +132,16 @@ function usePrefersReducedMotion(): boolean {
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode }) {
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
|
||||
@@ -160,6 +187,7 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
// Mirrors the avatar button: eased left/top so the flyout glides along
|
||||
// with the mascot when it snaps to an edge, 1:1 (no easing) mid-drag.
|
||||
data-dragging={anchor.dragging ? "1" : "0"}
|
||||
data-front={front ? "1" : undefined}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
@@ -168,20 +196,36 @@ function SagiFlyout({ anchor, children }: { anchor: Anchor; children: ReactNode
|
||||
}
|
||||
|
||||
export function Sagi() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const brain = useSagiBrain();
|
||||
const place = useSagiPosition();
|
||||
const place = useSagiPosition(size);
|
||||
|
||||
// Keep enabled in sync with Settings / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
||||
useEffect(
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setEnabled(sagiPrefs.getEnabled());
|
||||
setPet(sagiPrefs.getPet());
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it.
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -190,7 +234,7 @@ export function Sagi() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
@@ -206,7 +250,7 @@ export function Sagi() {
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then jump to the Run page so the live pane is observable.
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const prompt = result.prompt;
|
||||
@@ -214,24 +258,24 @@ export function Sagi() {
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
await api.run.start({
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
});
|
||||
navigate(`/run?laneId=${lane.id}&autostart=1`);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
brain.setBubble(`couldn't start that session: ${msg}`);
|
||||
brain.setBubble(t("sagi.startFailed", { error: msg }));
|
||||
} finally {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return null;
|
||||
},
|
||||
[brain, navigate]
|
||||
[brain, navigate, t]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
@@ -263,7 +307,21 @@ export function Sagi() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && brain.bubble && (
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
pet={pet}
|
||||
size={size}
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/pet");
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
</SagiFlyout>
|
||||
@@ -272,21 +330,40 @@ export function Sagi() {
|
||||
<button
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: SAGI_SIZE, height: SAGI_SIZE }}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
aria-label={open ? "Close Sagi" : "Open Sagi companion"}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title="Sagi - ⌘B · drag to move"
|
||||
title={t("sagi.avatarTitle")}
|
||||
>
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} />
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.status.errorCount > 0 && (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
Radio,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
|
||||
interface SagiPanelProps {
|
||||
@@ -85,6 +86,7 @@ export function SagiPanel({
|
||||
onAsk,
|
||||
onClose,
|
||||
}: SagiPanelProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
|
||||
@@ -97,9 +99,9 @@ export function SagiPanel({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl shadow-black/50 backdrop-blur-md animate-slide-up"
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label="Sagi companion"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -121,13 +123,13 @@ export function SagiPanel({
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{status.connected ? "Live" : "Offline"}
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label="Close Sagi"
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
@@ -138,19 +140,19 @@ export function SagiPanel({
|
||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
||||
<StatChip
|
||||
icon={Radio}
|
||||
label="live"
|
||||
label={t("sagi.panel.statLive")}
|
||||
value={status.liveCount}
|
||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={Hourglass}
|
||||
label="waiting"
|
||||
label={t("sagi.panel.statWaiting")}
|
||||
value={status.waitingCount}
|
||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||
/>
|
||||
<StatChip
|
||||
icon={AlertTriangle}
|
||||
label="errored"
|
||||
label={t("sagi.panel.statErrored")}
|
||||
value={status.errorCount}
|
||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||
/>
|
||||
@@ -158,27 +160,35 @@ export function SagiPanel({
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton icon={Play} label="Run Claude" onClick={() => onNavigate("/workspace")} />
|
||||
<ActionButton icon={Activity} label="Activity" onClick={() => onNavigate("/activity")} />
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label="Sessions"
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label="Errored"
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
onClick={onToggleMute}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Trash2}
|
||||
label="Clear alerts"
|
||||
label={t("sagi.panel.clearAlerts")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={onClearAlerts}
|
||||
/>
|
||||
@@ -188,15 +198,15 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Ask Sagi… (e.g. any errors?)"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label="Ask Sagi"
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center justify-center rounded-lg bg-accent px-2.5 py-2 text-white transition-colors hover:bg-accent-hover"
|
||||
aria-label="Send"
|
||||
className="btn-primary justify-center px-2.5 py-2"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
@@ -64,13 +66,14 @@ interface SpeechBubbleProps {
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title="Dismiss"
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @file Pets.test.tsx
|
||||
* @description The mascot's custom pets: `PetSprite` plays the spritesheet
|
||||
* row of Sagi's mood and steps through its frames (a still image bobs instead),
|
||||
* and `PetMenu` hides the mascot, sizes it, picks Sagi / a featured pet
|
||||
* (downloading it on the first pick) / an own pet, deletes an own pet (falling
|
||||
* back to Sagi when it was the one worn), and uploads an image; Settings →
|
||||
* Pet (`PetSettings`) does the same from a grid, plus show / bubbles / size.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor } from "@testing-library/react";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
const own = {
|
||||
id: "p-aaaaaaaaaaaa",
|
||||
displayName: "My cat",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
};
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pets: {
|
||||
spriteUrl: (id: string) => `/api/pets/${id}/sprite`,
|
||||
list: vi.fn(async () => ({
|
||||
pets: [own],
|
||||
featured: [
|
||||
{ id: "goku", displayName: "Goku", by: "hackminit", installed: false, page: "" },
|
||||
],
|
||||
sheet: { width: 1536, height: 1872, cellWidth: 192, cellHeight: 208 },
|
||||
})),
|
||||
installFeatured: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "cp-goku",
|
||||
displayName: "Goku",
|
||||
description: "",
|
||||
kind: "codex",
|
||||
source: "featured",
|
||||
featuredId: "goku",
|
||||
},
|
||||
})),
|
||||
upload: vi.fn(async () => ({
|
||||
pet: {
|
||||
id: "p-bbbbbbbbbbbb",
|
||||
displayName: "Dog",
|
||||
description: "",
|
||||
kind: "image",
|
||||
source: "upload",
|
||||
},
|
||||
})),
|
||||
importBundle: vi.fn(),
|
||||
remove: vi.fn(async () => ({ ok: true })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { api } from "../../../lib/api";
|
||||
import { PetSprite } from "../PetSprite";
|
||||
import { PetMenu } from "../PetMenu";
|
||||
import { PetSettings } from "../../settings/PetSettings";
|
||||
import { sagiPrefs } from "../prefs";
|
||||
import { settleConfirm, subscribeConfirm, type PendingConfirm } from "../../../lib/confirm";
|
||||
import { subscribeToasts, type ToastItem } from "../../../lib/toast";
|
||||
|
||||
/** Answer the pending confirmDialog (asserting one is open). */
|
||||
function answerConfirm(ok: boolean) {
|
||||
let pending: PendingConfirm | null = null;
|
||||
subscribeConfirm((p) => (pending = p))();
|
||||
expect(pending).not.toBeNull();
|
||||
act(() => settleConfirm(ok));
|
||||
}
|
||||
|
||||
describe("PetSprite", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("plays the row of the mood and steps through its frames", () => {
|
||||
const { rerender } = render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="thinking"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="worried"
|
||||
size={104}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
size={208}
|
||||
reducedMotion
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("shows an uploaded image still, and reports one that will not load", () => {
|
||||
const broken = vi.fn();
|
||||
const { container } = render(
|
||||
<PetSprite
|
||||
id="p-aaaaaaaaaaaa"
|
||||
kind="image"
|
||||
mood="idle"
|
||||
size={80}
|
||||
reducedMotion={false}
|
||||
onBroken={broken}
|
||||
/>
|
||||
);
|
||||
const img = container.querySelector("img") as HTMLImageElement;
|
||||
expect(img.getAttribute("src")).toBe("/api/pets/p-aaaaaaaaaaaa/sprite");
|
||||
fireEvent.error(img);
|
||||
expect(broken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
const renderMenu = () =>
|
||||
render(<PetMenu pet={sagiPrefs.getPet()} size={68} onClose={() => {}} onSettings={() => {}} />);
|
||||
|
||||
it("hides the mascot and sizes it", () => {
|
||||
renderMenu();
|
||||
fireEvent.change(screen.getByTestId("pet-size"), { target: { value: "250" } });
|
||||
expect(sagiPrefs.getSize()).toBe(250);
|
||||
let toasts: ToastItem[] = [];
|
||||
const off = subscribeToasts((i) => (toasts = i));
|
||||
fireEvent.click(screen.getByText("Hide pet"));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// Says where to bring it back.
|
||||
expect(toasts.some((x) => /Settings → Pet/.test(x.message))).toBe(true);
|
||||
off();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, then wears it", async () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
it("deleting the pet being worn puts Sagi back", async () => {
|
||||
sagiPrefs.setPet({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-delete-p-aaaaaaaaaaaa"));
|
||||
// Asks first: nothing is deleted on one click.
|
||||
expect(api.pets.remove).not.toHaveBeenCalled();
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
renderMenu();
|
||||
const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], "Dog.png", {
|
||||
type: "image/png",
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PetSettings", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows, mutes and sizes the mascot, following the prefs live", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.change(screen.getByTestId("pet-settings-size"), { target: { value: "120" } });
|
||||
expect(sagiPrefs.getSize()).toBe(120);
|
||||
expect(screen.getByText("120px")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Speech bubbles/ }));
|
||||
expect(sagiPrefs.getMuted()).toBe(true);
|
||||
fireEvent.click(screen.getByRole("switch", { name: /Show Sagi/ }));
|
||||
expect(sagiPrefs.getEnabled()).toBe(false);
|
||||
// A change made elsewhere (the right-click menu) shows here at once.
|
||||
act(() => sagiPrefs.setSize(200));
|
||||
expect(screen.getByText("200px")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
expect(api.pets.remove).toHaveBeenCalledWith("p-aaaaaaaaaaaa");
|
||||
});
|
||||
|
||||
it("uploads an image and wears it", async () => {
|
||||
render(<PetSettings />);
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
);
|
||||
});
|
||||
|
||||
it("a cancelled delete keeps the pet", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(false);
|
||||
await act(async () => {});
|
||||
expect(api.pets.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables size and bubbles while hidden, and resets to defaults", async () => {
|
||||
sagiPrefs.setEnabled(false);
|
||||
sagiPrefs.setSize(200);
|
||||
sagiPrefs.setMuted(true);
|
||||
render(<PetSettings />);
|
||||
expect(screen.getByTestId("pet-settings-size")).toBeDisabled();
|
||||
expect(screen.getByRole("switch", { name: /Speech bubbles/ })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Reset to defaults/ }));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getEnabled()).toBe(true));
|
||||
expect(sagiPrefs.getSize()).toBe(68);
|
||||
expect(sagiPrefs.getMuted()).toBe(false);
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "sagi" });
|
||||
});
|
||||
});
|
||||
@@ -147,4 +147,48 @@ describe("Sagi widget", () => {
|
||||
// Left-docked → inline left equals the edge margin (16px).
|
||||
expect(btn.style.left).toBe("16px");
|
||||
});
|
||||
|
||||
it("Ctrl/Cmd+B is ignored while typing in a field", () => {
|
||||
renderSagi();
|
||||
const ta = document.createElement("textarea");
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
act(() => {
|
||||
fireEvent.keyDown(ta, { key: "b", ctrlKey: true });
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
ta.remove();
|
||||
});
|
||||
|
||||
it("Ctrl/Cmd+B is left to the browser while the mascot is hidden", () => {
|
||||
localStorage.setItem("agent-dashboard-sagi-enabled", "false");
|
||||
renderSagi();
|
||||
const ev = new KeyboardEvent("keydown", { key: "b", ctrlKey: true, cancelable: true });
|
||||
window.dispatchEvent(ev);
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("docks bottom-right above the status bar by default", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
const top = parseFloat(btn.style.top);
|
||||
const size = parseFloat(btn.style.width);
|
||||
// Bottom edge sits above the 24px status bar plus the edge margin.
|
||||
expect(top + size).toBe(window.innerHeight - 16 - 24);
|
||||
});
|
||||
|
||||
it("arrow keys move the focused avatar and persist it", () => {
|
||||
renderSagi();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i }) as HTMLElement;
|
||||
const before = parseFloat(btn.style.top);
|
||||
act(() => {
|
||||
fireEvent.keyDown(btn, { key: "ArrowUp" });
|
||||
});
|
||||
expect(parseFloat(btn.style.top)).toBeLessThan(before);
|
||||
act(() => {
|
||||
fireEvent.keyDown(btn, { key: "ArrowLeft" });
|
||||
});
|
||||
expect(btn.style.left).toBe("16px");
|
||||
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file prefs.ts
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled +
|
||||
* muted). Broadcasts changes via a window CustomEvent so the Settings toggle
|
||||
* and the live widget stay in sync within the same tab without a reload.
|
||||
* @description Tiny localStorage-backed preference store for Sagi (enabled,
|
||||
* muted, the pet it wears and its size). Broadcasts changes via a window
|
||||
* CustomEvent so the Settings toggle and the live widget stay in sync within
|
||||
* the same tab without a reload.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -53,6 +54,54 @@
|
||||
const ENABLED_KEY = "agent-dashboard-sagi-enabled";
|
||||
const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
typeof p.id === "string" &&
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
? Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, n)))
|
||||
: PET_SIZE.default;
|
||||
}
|
||||
|
||||
function writeAndNotify(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
// Best-effort, like every pref here.
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(EVENT));
|
||||
} catch {
|
||||
// SSR / non-DOM contexts: nothing to notify.
|
||||
}
|
||||
}
|
||||
const EVENT = "sagi:prefs";
|
||||
|
||||
/**
|
||||
@@ -118,6 +167,20 @@ export const sagiPrefs = {
|
||||
setMuted: (v: boolean) => writeBool(MUTED_KEY, v),
|
||||
getPos: readPos,
|
||||
setPos: writePos,
|
||||
getPet: readPet,
|
||||
setPet: (pet: PetChoice) => writeAndNotify(PET_KEY, JSON.stringify(pet)),
|
||||
getSize: () => {
|
||||
try {
|
||||
return readSize();
|
||||
} catch {
|
||||
return PET_SIZE.default;
|
||||
}
|
||||
},
|
||||
setSize: (px: number) =>
|
||||
writeAndNotify(
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -369,3 +369,37 @@
|
||||
.sagi-bubble.sagi-no-anim {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* ════════ a custom pet (PetSprite.tsx) ════════ */
|
||||
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
animation-duration: 0.9s;
|
||||
}
|
||||
.sagi-pet-image[data-mood="sleeping"] {
|
||||
animation-duration: 5s;
|
||||
}
|
||||
@keyframes sagi-pet-bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-pet-image {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The pet menu may overlap the mascot; it sits above it while open. */
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @file usePets — the pet list and its actions, shared by the mascot's
|
||||
* right-click menu (`PetMenu.tsx`) and Settings → Pet (`settings/PetSettings`):
|
||||
* `GET /api/pets`, wearing a pet (a viewer preference, `prefs.ts`), installing
|
||||
* a featured pet (downloaded on its first pick), uploading an image or a
|
||||
* `.codex-pet.zip` (worn at once), and deleting your own after a confirm
|
||||
* (Sagi comes back when it was the one worn). Plus `useWornPet`, the live worn pet / size /
|
||||
* shown / speech-bubble prefs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type PetList, type PetView } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import i18n from "../../i18n";
|
||||
import { sagiPrefs, type PetChoice } from "./prefs";
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
export function usePets() {
|
||||
const [list, setList] = useState<PetList | null>(null);
|
||||
// Which action is running: a featured id, a pet id, "image" or "bundle".
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.pets
|
||||
.list()
|
||||
.then(setList)
|
||||
.catch((err: unknown) => setError(message(err))),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const run = async (key: string, work: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
await reload();
|
||||
});
|
||||
const add = (file: File, bundle: boolean) =>
|
||||
run(bundle ? "bundle" : "image", async () => {
|
||||
const { pet } = bundle ? await api.pets.importBundle(file) : await api.pets.upload(file);
|
||||
wear(pet);
|
||||
await reload();
|
||||
});
|
||||
// One place both callers route through, so neither can delete on one click.
|
||||
const remove = async (p: PetView) => {
|
||||
const ok = await confirmDialog({
|
||||
title: i18n.t("settings:sagi.pet.deleteTitle", { name: p.displayName }),
|
||||
message: i18n.t("settings:sagi.pet.deleteMessage"),
|
||||
confirmLabel: i18n.t("settings:sagi.pet.deleteConfirm"),
|
||||
});
|
||||
if (!ok) return;
|
||||
await run(p.id, async () => {
|
||||
await api.pets.remove(p.id);
|
||||
if (sagiPrefs.getPet().id === p.id) sagiPrefs.setPet({ id: "sagi" });
|
||||
await reload();
|
||||
});
|
||||
};
|
||||
|
||||
const own = list?.pets.filter((p) => p.source === "upload") ?? [];
|
||||
// Featured id → the installed copy, once downloaded.
|
||||
const installed = new Map(
|
||||
(list?.pets ?? []).filter((p) => p.featuredId).map((p) => [p.featuredId as string, p])
|
||||
);
|
||||
return { list, own, installed, busy, error, wear, install, add, remove };
|
||||
}
|
||||
|
||||
type Worn = { pet: PetChoice; size: number; shown: boolean; bubbles: boolean };
|
||||
const readWorn = (): Worn => ({
|
||||
pet: sagiPrefs.getPet(),
|
||||
size: sagiPrefs.getSize(),
|
||||
shown: sagiPrefs.getEnabled(),
|
||||
bubbles: !sagiPrefs.getMuted(),
|
||||
});
|
||||
|
||||
/** The worn pet and the mascot's prefs, following every change (any tab). */
|
||||
export function useWornPet(): Worn {
|
||||
const [worn, setWorn] = useState(readWorn);
|
||||
useEffect(() => sagiPrefs.subscribe(() => setWorn(readWorn())), []);
|
||||
return worn;
|
||||
}
|
||||
@@ -5,7 +5,10 @@
|
||||
* keeps tracking even if the cursor outruns it), and on release snaps to the
|
||||
* nearest left/right edge, remembering its vertical offset (persisted as a
|
||||
* viewport fraction so it survives resizes). A small movement threshold tells
|
||||
* a drag apart from a tap so dragging never opens the panel.
|
||||
* a drag apart from a tap so dragging never opens the panel. The arrow keys
|
||||
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
|
||||
* It never rests over the fixed bottom status bar; by default it docks
|
||||
* bottom-right just above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -70,24 +73,29 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { sagiPrefs, type SagiPos } from "./prefs";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
// Avatar footprint + edge gap, in px. SIZE matches SagiAvatar's default size.
|
||||
export const SAGI_SIZE = 68;
|
||||
export const SAGI_MARGIN = 16;
|
||||
const DRAG_THRESHOLD = 5;
|
||||
// The fixed bottom StatusBar (`h-6`); the avatar never rests over it.
|
||||
const STATUS_BAR = 24;
|
||||
// One arrow-key step, in px.
|
||||
const KEY_STEP = 40;
|
||||
|
||||
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
|
||||
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
|
||||
|
||||
function defaultPos(): SagiPos {
|
||||
return { side: "right", y: 0.5 }; // right edge, vertically centered
|
||||
// Bottom-right, above the status bar: a centred dock covered row controls.
|
||||
return { side: "right", y: 1 };
|
||||
}
|
||||
|
||||
/** Resting top-left screen coords for a docked position. */
|
||||
function restingScreen(pos: SagiPos) {
|
||||
const avail = Math.max(0, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - SAGI_SIZE - SAGI_MARGIN;
|
||||
function restingScreen(pos: SagiPos, size: number) {
|
||||
const avail = Math.max(0, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const left = pos.side === "left" ? SAGI_MARGIN : vw() - size - SAGI_MARGIN;
|
||||
const top = SAGI_MARGIN + pos.y * avail;
|
||||
return { left, top };
|
||||
}
|
||||
@@ -106,9 +114,12 @@ export interface SagiPlacement {
|
||||
onPointerUp: (e: ReactPointerEvent) => void;
|
||||
/** Returns true (once) if a drag just ended, so the click handler can skip. */
|
||||
consumeDrag: () => boolean;
|
||||
/** Arrow keys on the focused avatar: ←/→ dock to an edge, ↑/↓ step. */
|
||||
onKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export function useSagiPosition(): SagiPlacement {
|
||||
/** `size`: the avatar's current footprint (the pet size pref), in px. */
|
||||
export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
|
||||
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
|
||||
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
|
||||
const [, force] = useState(0); // re-derive resting coords on resize
|
||||
@@ -127,7 +138,7 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const resting = restingScreen(pos);
|
||||
const resting = restingScreen(pos, size);
|
||||
const screen = drag ?? resting;
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
@@ -146,40 +157,68 @@ export function useSagiPosition(): SagiPlacement {
|
||||
[screen.left, screen.top]
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(vh() - SAGI_SIZE - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.top + dy));
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
}, []);
|
||||
const onPointerMove = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
const start = startRef.current;
|
||||
if (!start) return;
|
||||
const dx = e.clientX - start.px;
|
||||
const dy = e.clientY - start.py;
|
||||
if (!movedRef.current && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
movedRef.current = true;
|
||||
const left = Math.min(vw() - size - SAGI_MARGIN, Math.max(SAGI_MARGIN, start.left + dx));
|
||||
const top = Math.min(
|
||||
vh() - size - SAGI_MARGIN - STATUS_BAR,
|
||||
Math.max(SAGI_MARGIN, start.top + dy)
|
||||
);
|
||||
liveRef.current = { left, top };
|
||||
setDrag({ left, top });
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const onPointerUp = useCallback((e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + SAGI_SIZE / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - SAGI_SIZE - 2 * SAGI_MARGIN);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
const onPointerUp = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
try {
|
||||
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const live = liveRef.current;
|
||||
if (live) {
|
||||
draggedRef.current = true;
|
||||
const side: "left" | "right" = live.left + size / 2 < vw() / 2 ? "left" : "right";
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
const y = Math.min(1, Math.max(0, (live.top - SAGI_MARGIN) / avail));
|
||||
const next: SagiPos = { side, y };
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
},
|
||||
[size]
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: ReactKeyboardEvent) => {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
const avail = Math.max(1, vh() - size - 2 * SAGI_MARGIN - STATUS_BAR);
|
||||
let next: SagiPos | null = null;
|
||||
if (e.key === "ArrowLeft") next = { ...pos, side: "left" };
|
||||
else if (e.key === "ArrowRight") next = { ...pos, side: "right" };
|
||||
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
const dy = (e.key === "ArrowUp" ? -KEY_STEP : KEY_STEP) / avail;
|
||||
next = { ...pos, y: Math.min(1, Math.max(0, pos.y + dy)) };
|
||||
}
|
||||
if (!next) return;
|
||||
e.preventDefault();
|
||||
sagiPrefs.setPos(next);
|
||||
setPos(next);
|
||||
setDrag(null); // leave drag mode; resting coords (with transition) take over
|
||||
}
|
||||
liveRef.current = null;
|
||||
startRef.current = null;
|
||||
movedRef.current = false;
|
||||
}, []);
|
||||
},
|
||||
[pos, size]
|
||||
);
|
||||
|
||||
const consumeDrag = useCallback(() => {
|
||||
const was = draggedRef.current;
|
||||
@@ -190,13 +229,14 @@ export function useSagiPosition(): SagiPlacement {
|
||||
return {
|
||||
left: screen.left,
|
||||
top: screen.top,
|
||||
size: SAGI_SIZE,
|
||||
size,
|
||||
side: pos.side,
|
||||
openUp: screen.top + SAGI_SIZE / 2 > vh() / 2,
|
||||
openUp: screen.top + size / 2 > vh() / 2,
|
||||
dragging: drag !== null,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
consumeDrag,
|
||||
onKeyDown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
* @file Select.tsx
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, supports arrow-key
|
||||
* navigation, flips above the trigger when viewport space is tight, and marks
|
||||
* the active option with accent color plus a Lucide check.
|
||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
||||
* color plus a Lucide check.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
* The trigger is a `<button aria-haspopup="listbox" aria-expanded>`; opening it
|
||||
* moves focus to a `role="listbox"` popup that tracks the highlighted
|
||||
* `role="option"` through `aria-activedescendant`. Keys: ArrowUp/Down, Home/End,
|
||||
* Enter/Space pick, Escape cancels, Tab closes, and a typed character jumps to
|
||||
* the next option whose label starts with it. Focus returns to the trigger on
|
||||
* every close except Tab.
|
||||
*
|
||||
* ## Consumers
|
||||
* Used on the Run Claude page and webhook settings form wherever a compact
|
||||
@@ -63,7 +71,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
@@ -74,6 +82,8 @@ export interface SelectOption<T extends string> {
|
||||
label: string;
|
||||
/** Optional secondary line (smaller gray text). */
|
||||
hint?: string;
|
||||
/** Optional leading icon, shown in the trigger and the list. */
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -86,23 +96,34 @@ export interface SelectProps<T extends string> {
|
||||
options: SelectOption<T>[];
|
||||
/** Disables opening the list. */
|
||||
disabled?: boolean;
|
||||
/** `ghost`: a borderless, content-wide trigger for a composer's toolbar. */
|
||||
variant?: "field" | "ghost";
|
||||
/** Accessible name and tooltip of the trigger (a ghost one has no label). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accessible custom select control.
|
||||
* @typeParam T - string union of allowed option values.
|
||||
*/
|
||||
export function Select<T extends string>({ value, onChange, options, disabled }: SelectProps<T>) {
|
||||
export function Select<T extends string>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled,
|
||||
variant = "field",
|
||||
label,
|
||||
}: SelectProps<T>) {
|
||||
const ghost = variant === "ghost";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [active, setActive] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value)
|
||||
)
|
||||
);
|
||||
const [active, setActive] = useState(0);
|
||||
const [openUp, setOpenUp] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const typed = useRef({ text: "", at: 0 });
|
||||
const id = useId();
|
||||
const optionId = (idx: number) => `${id}-opt-${idx}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -114,73 +135,127 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
// When opening, decide whether to render the popover above the trigger if
|
||||
// the viewport doesn't have room below (common when this select sits at
|
||||
// the bottom of a form). 288 px = `max-h-72`.
|
||||
// On open: highlight the chosen option, decide whether the popover goes
|
||||
// above the trigger (288 px = `max-h-72`), and move focus into the list.
|
||||
useEffect(() => {
|
||||
if (!open || !buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
const above = rect.top;
|
||||
setOpenUp(below < 288 && above > below);
|
||||
if (!open) return;
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
}, [open]);
|
||||
|
||||
// Sync active highlight with current value when reopening
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
}
|
||||
}, [open, value, options]);
|
||||
if (open) document.getElementById(optionId(active))?.scrollIntoView?.({ block: "nearest" });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- id is stable
|
||||
}, [open, active]);
|
||||
|
||||
const close = (refocus = true) => {
|
||||
setOpen(false);
|
||||
if (refocus) buttonRef.current?.focus();
|
||||
};
|
||||
|
||||
const choose = (opt: SelectOption<T>) => {
|
||||
onChange(opt.value);
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
close();
|
||||
};
|
||||
|
||||
const onKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (
|
||||
!open &&
|
||||
(e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter" || e.key === " ")
|
||||
) {
|
||||
const onButtonKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
};
|
||||
|
||||
const onListKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const last = options.length - 1;
|
||||
const move: Record<string, (a: number) => number> = {
|
||||
ArrowDown: (a) => Math.min(last, a + 1),
|
||||
ArrowUp: (a) => Math.max(0, a - 1),
|
||||
Home: () => 0,
|
||||
End: () => last,
|
||||
PageDown: (a) => Math.min(last, a + 10),
|
||||
PageUp: (a) => Math.max(0, a - 10),
|
||||
};
|
||||
if (move[e.key]) {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.min(options.length - 1, a + 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((a) => Math.max(0, a - 1));
|
||||
} else if (e.key === "Enter") {
|
||||
setActive(move[e.key]!);
|
||||
} else if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
const opt = options[active];
|
||||
if (opt) choose(opt);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
// Stop here: a Select inside a dialog must not close the dialog too.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
} else if (e.key === "Tab") {
|
||||
close(false);
|
||||
} else if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Type-ahead: keys typed within 500 ms build one prefix; a repeated single
|
||||
// character cycles through the options that start with it.
|
||||
const now = Date.now();
|
||||
const t = typed.current;
|
||||
t.text = now - t.at > 500 ? e.key.toLowerCase() : t.text + e.key.toLowerCase();
|
||||
t.at = now;
|
||||
const cycling = t.text.length > 1 && [...t.text].every((c) => c === t.text[0]);
|
||||
const prefix = cycling ? t.text[0]! : t.text;
|
||||
const start = cycling || t.text.length === 1 ? active + 1 : active;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const idx = (start + i) % options.length;
|
||||
if (options[idx]!.label.toLowerCase().startsWith(prefix)) {
|
||||
setActive(idx);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const current = options.find((o) => o.value === value) || options[0];
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<div ref={containerRef} className={ghost ? "relative inline-block max-w-[11rem]" : "relative"}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onKeyDown={onKey}
|
||||
className="w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent/50 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? `${id}-list` : undefined}
|
||||
title={label}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onKeyDown={onButtonKey}
|
||||
className={
|
||||
ghost
|
||||
? "flex max-w-full items-center gap-1.5 rounded-md px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
: "w-full flex items-center justify-between gap-2 bg-surface-2 border border-border rounded-md px-3 py-1.5 text-[11px] text-fg-primary focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 hover:bg-surface-3 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
}
|
||||
>
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{current?.icon}
|
||||
<span className="truncate">{current?.label ?? "-"}</span>
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" aria-hidden />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`absolute z-30 left-0 right-0 rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 ${
|
||||
ref={listRef}
|
||||
id={`${id}-list`}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
aria-activedescendant={options[active] ? optionId(active) : undefined}
|
||||
onKeyDown={onListKey}
|
||||
onBlur={(e) => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node | null)) setOpen(false);
|
||||
}}
|
||||
className={`absolute z-30 left-0 ${ghost ? "w-max min-w-[12rem] max-w-[20rem]" : "right-0"} rounded-md border border-border bg-surface-1 shadow-lg shadow-black/40 max-h-72 overflow-auto py-1 outline-none ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
>
|
||||
@@ -188,17 +263,20 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
const isSelected = opt.value === value;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={opt.value || "__default__"}
|
||||
type="button"
|
||||
id={optionId(idx)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
className={`w-full text-left px-3 py-1.5 transition-colors ${
|
||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{opt.icon}
|
||||
<span
|
||||
className={`text-[11px] flex-1 truncate ${
|
||||
isSelected ? "text-accent font-medium" : "text-fg-secondary"
|
||||
@@ -206,12 +284,14 @@ export function Select<T extends string>({ value, onChange, options, disabled }:
|
||||
>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isSelected && <Check className="w-3 h-3 text-accent flex-shrink-0" />}
|
||||
{isSelected && (
|
||||
<Check className="w-3 h-3 text-accent flex-shrink-0" aria-hidden />
|
||||
)}
|
||||
</div>
|
||||
{opt.hint && (
|
||||
<div className="text-[10px] text-fg-muted truncate mt-0.5">{opt.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -65,9 +65,11 @@ import {
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { fmt, formatDuration } from "../lib/format";
|
||||
import { fmt, formatDuration, parseDate } from "../lib/format";
|
||||
import { effectiveSessionStatus } from "../lib/types";
|
||||
import { styleForTool } from "./conversation/toolStyle";
|
||||
import type { Agent, Session, SessionStats } from "../lib/types";
|
||||
|
||||
@@ -76,8 +78,8 @@ interface SessionOverviewProps {
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/** Debounce window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
const REFRESH_DEBOUNCE_MS = 600;
|
||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||
const REFRESH_THROTTLE_MS = 1000;
|
||||
|
||||
/** Compact tile used in the top stat row. */
|
||||
function StatTile({
|
||||
@@ -93,24 +95,25 @@ function StatTile({
|
||||
icon: React.ReactNode;
|
||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||
}) {
|
||||
// Theme tokens only: status colours for states, syntax hues for categories.
|
||||
const palette = {
|
||||
default: "border-surface-3 bg-surface-2 text-fg-secondary",
|
||||
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
|
||||
violet: "border-syntax-keyword/20 bg-syntax-keyword/5 text-syntax-keyword",
|
||||
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
|
||||
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
|
||||
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
|
||||
rose: "border-status-danger/20 bg-status-danger/5 text-status-danger",
|
||||
cyan: "border-syntax-property/20 bg-syntax-property/5 text-syntax-property",
|
||||
blue: "border-syntax-builtin/20 bg-syntax-builtin/5 text-syntax-builtin",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-fg-muted",
|
||||
violet: "text-violet-400",
|
||||
violet: "text-syntax-keyword",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
rose: "text-status-danger",
|
||||
cyan: "text-syntax-property",
|
||||
blue: "text-syntax-builtin",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
@@ -158,8 +161,8 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
|
||||
}
|
||||
|
||||
export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [stats, setStats] = useState<SessionStats | null>(null);
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
|
||||
// Tick clock every 30s so a still-active session's "duration" tile stays current.
|
||||
@@ -170,8 +173,14 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
return () => window.clearInterval(id);
|
||||
}, [session.status]);
|
||||
|
||||
// A refresh asked for while one is in flight is not dropped: it runs once
|
||||
// when the current one settles (that is the throttle's trailing call).
|
||||
const pendingRef = useRef(false);
|
||||
const fetchStats = async () => {
|
||||
if (fetchingRef.current) return;
|
||||
if (fetchingRef.current) {
|
||||
pendingRef.current = true;
|
||||
return;
|
||||
}
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const result = await api.sessions.stats(session.id);
|
||||
@@ -180,6 +189,10 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// Non-fatal - overview just won't update this round.
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (pendingRef.current) {
|
||||
pendingRef.current = false;
|
||||
void fetchStats();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -189,47 +202,29 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
|
||||
// Live refresh on websocket events (debounced).
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
return;
|
||||
}
|
||||
const isRelevant =
|
||||
msg.type === "new_event" ||
|
||||
msg.type === "agent_created" ||
|
||||
msg.type === "agent_updated" ||
|
||||
msg.type === "session_updated";
|
||||
if (!isRelevant) return;
|
||||
// Live refresh on this session's websocket events, throttled (a steady
|
||||
// stream still refreshes) and again after a reconnect.
|
||||
useLiveRefresh(() => void fetchStats(), {
|
||||
throttleMs: REFRESH_THROTTLE_MS,
|
||||
match: (msg) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) return true;
|
||||
if (
|
||||
msg.type !== "new_event" &&
|
||||
msg.type !== "agent_created" &&
|
||||
msg.type !== "agent_updated" &&
|
||||
msg.type !== "session_updated"
|
||||
)
|
||||
return false;
|
||||
const data = msg.data as { session_id?: string; id?: string };
|
||||
// Match either by session_id (events) or by id (session_updated)
|
||||
const matchesSession = data.session_id === session.id || data.id === session.id;
|
||||
if (!matchesSession) return;
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
refreshTimerRef.current = setTimeout(() => {
|
||||
refreshTimerRef.current = null;
|
||||
fetchStats();
|
||||
}, REFRESH_DEBOUNCE_MS);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session.id]);
|
||||
return data.session_id === session.id || data.id === session.id;
|
||||
},
|
||||
});
|
||||
|
||||
// Tool calls = sum of all tool counts (PreToolUse+PostToolUse events have a tool_name).
|
||||
// We approximate "tool calls" as half of that (each call produces a Pre + Post event).
|
||||
const toolCallCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
const total = stats.tools_used.reduce((s, t) => s + t.count, 0);
|
||||
return Math.round(total / 2);
|
||||
}, [stats]);
|
||||
// Exact totals from the server (`tools_used` is only the top 15). An older
|
||||
// server lacks them: fall back to the visible list's count.
|
||||
const toolCallCount = stats?.tool_call_count ?? 0;
|
||||
const distinctTools = stats?.distinct_tool_count ?? stats?.tools_used.length ?? 0;
|
||||
|
||||
const maxToolCount = useMemo(() => {
|
||||
if (!stats) return 0;
|
||||
@@ -249,11 +244,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
// Avg event rate (events / minute)
|
||||
const eventRate = useMemo(() => {
|
||||
if (!stats || !session.started_at) return null;
|
||||
const start = new Date(session.started_at).getTime();
|
||||
const start = parseDate(session.started_at).getTime();
|
||||
const end = session.ended_at
|
||||
? new Date(session.ended_at).getTime()
|
||||
? parseDate(session.ended_at).getTime()
|
||||
: stats.last_event_at
|
||||
? new Date(stats.last_event_at).getTime()
|
||||
? parseDate(stats.last_event_at).getTime()
|
||||
: now;
|
||||
const minutes = Math.max(1, (end - start) / 60_000);
|
||||
return stats.total_events / minutes;
|
||||
@@ -287,11 +282,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
</span>
|
||||
<Bot className="w-3.5 h-3.5 text-status-success flex-shrink-0" />
|
||||
<span className="text-xs text-status-success font-medium flex-shrink-0">
|
||||
{activeAgent.name || "Agent"}
|
||||
{activeAgent.name || t("overview.agent")}
|
||||
</span>
|
||||
{activeAgent.current_tool && (
|
||||
<span className="text-[11px] text-fg-secondary font-mono inline-flex items-center gap-1">
|
||||
<span className="text-fg-muted">running</span>
|
||||
<span className="text-fg-muted">{t("overview.running")}</span>
|
||||
<span className="text-status-success">{activeAgent.current_tool}</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -309,45 +304,51 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
{/* Stat tiles */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||
<StatTile
|
||||
label="Events"
|
||||
label={t("overview.events")}
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
? `${eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate)}/min`
|
||||
? t("overview.perMin", {
|
||||
rate: eventRate < 1 ? eventRate.toFixed(2) : Math.round(eventRate),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
icon={<Activity className="w-3 h-3" />}
|
||||
/>
|
||||
<StatTile
|
||||
label="Tool calls"
|
||||
label={t("overview.toolCalls")}
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={stats.tools_used.length > 0 ? `${stats.tools_used.length} unique` : undefined}
|
||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
tone="violet"
|
||||
/>
|
||||
<StatTile
|
||||
label="Subagents"
|
||||
label={t("overview.subagents")}
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={stats.agents.main > 0 ? `+${stats.agents.main} main` : undefined}
|
||||
hint={
|
||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||
}
|
||||
icon={<GitBranch className="w-3 h-3" />}
|
||||
tone="cyan"
|
||||
/>
|
||||
<StatTile
|
||||
label="Compactions"
|
||||
label={t("overview.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label="Errors"
|
||||
label={t("overview.errors")}
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
/>
|
||||
<StatTile
|
||||
label="Duration"
|
||||
label={t("overview.duration")}
|
||||
value={durationLabel}
|
||||
hint={session.status === "active" ? "running" : "completed"}
|
||||
hint={t(`common:status.${effectiveSessionStatus(session)}`, {
|
||||
defaultValue: effectiveSessionStatus(session),
|
||||
})}
|
||||
icon={<Clock className="w-3 h-3" />}
|
||||
tone={session.status === "active" ? "emerald" : "default"}
|
||||
/>
|
||||
@@ -359,15 +360,17 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-400" />
|
||||
Top tools
|
||||
<Wrench className="w-3.5 h-3.5 text-syntax-keyword" />
|
||||
{t("overview.topTools")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{stats.tools_used.length} total
|
||||
{t("overview.distinctTotal", { count: distinctTools })}
|
||||
</span>
|
||||
</div>
|
||||
{stats.tools_used.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">No tool calls yet.</div>
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
{t("overview.noToolCalls")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{stats.tools_used.slice(0, 8).map((t) => (
|
||||
@@ -406,7 +409,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
if (stats.agents.compaction > 0) {
|
||||
rows.push({
|
||||
key: "__compaction__",
|
||||
label: "Context Compaction",
|
||||
label: t("overview.contextCompaction"),
|
||||
count: stats.agents.compaction,
|
||||
isCompaction: true,
|
||||
});
|
||||
@@ -418,20 +421,24 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
|
||||
Subagents
|
||||
<GitBranch className="w-3.5 h-3.5 text-syntax-property" />
|
||||
{t("overview.subagents")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.runs", { count: totalRuns })}
|
||||
</span>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-fg-muted">
|
||||
No subagents in this session.
|
||||
{t("overview.noSubagents")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{rows.slice(0, 8).map((r) => {
|
||||
const pct = max > 0 ? Math.max(4, Math.round((r.count / max) * 100)) : 0;
|
||||
const barClass = r.isCompaction ? "bg-status-warning/60" : "bg-cyan-500/60";
|
||||
const barClass = r.isCompaction
|
||||
? "bg-status-warning/60"
|
||||
: "bg-syntax-property/60";
|
||||
return (
|
||||
<div key={r.key} className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -468,9 +475,11 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||
Token flow
|
||||
{t("overview.tokenFlow")}
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{fmt(totalTokens)} total</span>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
{t("overview.total", { value: fmt(totalTokens) })}
|
||||
</span>
|
||||
</div>
|
||||
<TokenFlowBar tokens={tokens} total={totalTokens} />
|
||||
</div>
|
||||
@@ -481,7 +490,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||
<Activity className="w-3.5 h-3.5 text-fg-secondary" />
|
||||
Event mix
|
||||
{t("overview.eventMix")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.events_by_type.slice(0, 12).map((e) => (
|
||||
@@ -502,34 +511,35 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
|
||||
}
|
||||
|
||||
function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total: number }) {
|
||||
const { t } = useTranslation();
|
||||
const segments = [
|
||||
{
|
||||
key: "cache_read",
|
||||
label: "Cache read",
|
||||
label: t("common:token.cacheRead"),
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
cls: "bg-syntax-builtin",
|
||||
text: "text-syntax-builtin",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
label: t("common:token.cacheWrite"),
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
cls: "bg-syntax-keyword",
|
||||
text: "text-syntax-keyword",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
label: "Input",
|
||||
label: t("common:token.input"),
|
||||
value: tokens.input_tokens,
|
||||
cls: "bg-status-success",
|
||||
text: "text-status-success",
|
||||
},
|
||||
{
|
||||
key: "output",
|
||||
label: "Output",
|
||||
label: t("common:token.output"),
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
cls: "bg-syntax-number",
|
||||
text: "text-syntax-number",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* @file SessionRowMenu — the right-click menu of a session anywhere outside a
|
||||
* workspace's own lists (the Sessions table, the Dashboard's session and agent
|
||||
* cards): open its analysis page, open it in the workspace that owns it
|
||||
* (`sessions.lane_id` — an ended session opens its conversation there, never
|
||||
* a resume), copy its id or folder. Inside a workspace, `lanes/SessionMenu`
|
||||
* is used instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "./ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
export type SessionRowRef = { id: string; lane_id?: number | null; cwd?: string | null };
|
||||
|
||||
/**
|
||||
* Where "Open in workspace" goes for a session: its live pane when a dashboard
|
||||
* run holds it (`&run=`), else its workspace's replay (`&session=`); null when
|
||||
* no workspace owns it.
|
||||
*/
|
||||
export function workspaceHref(
|
||||
session: SessionRowRef,
|
||||
runs: ReadonlyArray<{ id: string; laneId: number | null; sessionId: string | null }> = []
|
||||
): string | null {
|
||||
const run = runs.find((r) => r.sessionId === session.id && r.laneId != null);
|
||||
if (run) return `/workspace?lane=${run.laneId}&run=${encodeURIComponent(run.id)}`;
|
||||
if (session.lane_id != null)
|
||||
return `/workspace?lane=${session.lane_id}&session=${encodeURIComponent(session.id)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SessionRowMenu({
|
||||
session,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
}: MenuPoint & { session: SessionRowRef; onClose: () => void }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const navigate = useNavigate();
|
||||
const pick = (fn: () => unknown) => () => {
|
||||
onClose();
|
||||
fn();
|
||||
};
|
||||
const copy = (text: string) => pick(() => void copyText(text));
|
||||
|
||||
return (
|
||||
<ContextMenu x={x} y={y} onClose={onClose} testId={`session-row-menu-${session.id}`}>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
{t("menu.details")}
|
||||
</MenuItem>
|
||||
{session.lane_id != null && (
|
||||
<MenuItem onSelect={pick(() => navigate(workspaceHref(session)!))}>
|
||||
{t("menu.inWorkspace")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={copy(session.id)}>{t("menu.copyId")}</MenuItem>
|
||||
{session.cwd && <MenuItem onSelect={copy(session.cwd)}>{t("menu.copyFolder")}</MenuItem>}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
|
||||
/** A chip with the tool's name, or nothing for `claude` / an unset tool. */
|
||||
export function SessionToolBadge({ tool }: { tool?: string | null }) {
|
||||
if (!tool || tool === "claude") return null;
|
||||
return <ToolChip tool={tool} />;
|
||||
}
|
||||
|
||||
// Split out so the catalog hook only mounts for a non-Claude session.
|
||||
function ToolChip({ tool }: { tool: string }) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const { label } = useAiTools();
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center text-[10px] font-semibold text-violet-700 dark:text-violet-300 bg-violet-500/10 border border-violet-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("toolBadgeTitle", { tool: label(tool) })}
|
||||
>
|
||||
{label(tool)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+213
-297
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description Defines the Sidebar component that provides navigation links to different sections of the application, displays the connection status, and includes a toggle button for collapsing or expanding the sidebar. The component uses React Router's NavLink for navigation and Lucide icons for visual representation. The collapsed state of the sidebar is stored in localStorage to persist user preferences across sessions.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -51,19 +51,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
Settings,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Languages,
|
||||
RefreshCw,
|
||||
X,
|
||||
Plug,
|
||||
@@ -71,13 +68,12 @@ import {
|
||||
Gauge,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Sun,
|
||||
Moon,
|
||||
FolderTree,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
@@ -87,16 +83,18 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
{ to: "/workflows", icon: Workflow, key: "nav:workflows" },
|
||||
{ to: "/workspace", icon: Play, key: "nav:workspace" },
|
||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
const SUPPORTED_LANGUAGES = ["en", "vi"] as const;
|
||||
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
interface PersistedStats {
|
||||
eventCount: number;
|
||||
@@ -106,12 +104,18 @@ interface PersistedStats {
|
||||
recentEvents: { type: string; at: number }[];
|
||||
}
|
||||
|
||||
/** Below Tailwind's `lg` a full 15rem sidebar eats too much of the page. */
|
||||
const NARROW_QUERY = "(max-width: 1023.98px)";
|
||||
|
||||
/** The stored choice, else collapsed on a narrow viewport. */
|
||||
function loadCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === "true";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored !== null) return stored === "true";
|
||||
} catch {
|
||||
return false;
|
||||
/* storage off: fall through to the viewport */
|
||||
}
|
||||
return window.matchMedia?.(NARROW_QUERY).matches ?? false;
|
||||
}
|
||||
|
||||
function loadStats(): PersistedStats {
|
||||
@@ -155,23 +159,32 @@ function loadStats(): PersistedStats {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLanguage(language: string): SupportedLanguage {
|
||||
const base = language.toLowerCase().split("-")[0];
|
||||
if (base === "vi" || base === "en") {
|
||||
return base;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
wsConnected: boolean;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
/** The connection details modal, opened from here or from the status bar. */
|
||||
connectionOpen: boolean;
|
||||
onConnectionOpenChange: (open: boolean) => void;
|
||||
/** Told whether a newer release is out, for the status bar's dot. */
|
||||
onUpdateAvailableChange?: (available: boolean) => void;
|
||||
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
|
||||
drawer?: { open: boolean; onClose: () => void };
|
||||
}
|
||||
|
||||
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { theme, setTheme, toggleTheme } = useTheme();
|
||||
export function Sidebar({
|
||||
wsConnected,
|
||||
collapsed,
|
||||
onToggle,
|
||||
connectionOpen,
|
||||
onConnectionOpenChange,
|
||||
onUpdateAvailableChange,
|
||||
drawer,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
// Track whether nav items are clipped by overflow so we can render
|
||||
// chevron affordances pointing toward the hidden items. Recomputed on
|
||||
// scroll, resize, and any structural change (e.g. collapse toggle).
|
||||
@@ -180,7 +193,6 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [checkError, setCheckError] = useState(false);
|
||||
const [statusModalOpen, setStatusModalOpen] = useState(false);
|
||||
const [connectedSince, setConnectedSince] = useState<number | null>(
|
||||
wsConnected ? Date.now() : null
|
||||
);
|
||||
@@ -361,57 +373,81 @@ 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);
|
||||
}
|
||||
};
|
||||
const navClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col z-30 overflow-hidden transition-[width] duration-200 ${
|
||||
collapsed ? "w-[4.25rem]" : "w-60"
|
||||
ref={asideRef}
|
||||
aria-label={t("nav:sidebar")}
|
||||
role={drawerOpen ? "dialog" : undefined}
|
||||
aria-modal={drawerOpen || undefined}
|
||||
data-testid="sidebar"
|
||||
className={`fixed left-0 top-0 bottom-0 bg-surface-1 border-r border-border flex flex-col overflow-hidden ${
|
||||
drawer
|
||||
? // Visibility flips at once on open (a still-hidden panel cannot take
|
||||
// focus) and only after the slide-out on close.
|
||||
`z-50 w-60 max-w-[85vw] duration-200 ${
|
||||
drawer.open
|
||||
? "visible translate-x-0 shadow-2xl transition-transform"
|
||||
: "invisible -translate-x-full transition-[transform,visibility]"
|
||||
}`
|
||||
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
|
||||
}`}
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
|
||||
{/* Wordmark under the logo: small caps + wide tracking so it reads
|
||||
as a caption to the logo rather than competing with it. The
|
||||
hairlines either side keep it centred on a narrow rail. */}
|
||||
<div className="mt-3 flex w-full items-center gap-2 px-1">
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
|
||||
{t("nav:brand")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav - only this section scrolls when its items overflow; the rest of
|
||||
the sidebar (brand, language, collapse toggle, footer) stays pinned.
|
||||
the sidebar (brand, and the Settings + collapse footer) stays pinned.
|
||||
Chevron buttons appear at the edges when content is clipped, so the
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
@@ -424,21 +460,28 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
title={collapsed ? label : undefined}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 rounded-lg text-sm font-medium transition-colors duration-150 ${
|
||||
collapsed ? "justify-center px-2 py-2.5" : "px-3 py-2.5"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
|
||||
}`
|
||||
}
|
||||
className={navClass}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{label}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* The workspace → sessions list scrolls with the nav. In the rail it
|
||||
is one icon that opens the sidebar instead. */}
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent px-2 py-2.5 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<FolderTree className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<WorkspaceTree />
|
||||
)}
|
||||
</nav>
|
||||
{!collapsed && navOverflow.up && (
|
||||
<button
|
||||
@@ -464,219 +507,42 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language + theme controls. Theme sits next to language: same icon
|
||||
row when collapsed, same row as the EN/VI buttons when expanded -
|
||||
both are 2-state click-to-flip toggles, so they read as one family
|
||||
of controls rather than two unrelated settings. */}
|
||||
<div className="px-2 pb-2 flex-shrink-0">
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
||||
title={switchLanguageTitle}
|
||||
aria-label={switchLanguageTitle}
|
||||
>
|
||||
<Languages className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{t(`nav:languageShort.${currentLanguage}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
|
||||
title={switchThemeTitle}
|
||||
aria-label={switchThemeTitle}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{t(`nav:themeNames.${theme}`).slice(0, 1)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:language")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-4 gap-1">
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const active = language === currentLanguage;
|
||||
return (
|
||||
<button
|
||||
key={language}
|
||||
onClick={() => changeLanguage(language)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:languageNames.${language}`)}
|
||||
title={t(`nav:languageNames.${language}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`nav:languageShort.${language}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t("nav:theme")}
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{(["dark", "light"] as const).map((option) => {
|
||||
const active = option === theme;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
onClick={() => setTheme(option)}
|
||||
aria-pressed={active}
|
||||
aria-label={t(`nav:themeNames.${option}`)}
|
||||
title={t(`nav:themeNames.${option}`)}
|
||||
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{option === "dark" ? (
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="px-2 py-2 flex-shrink-0">
|
||||
{/* Settings and the collapse toggle stick to the foot, out of the
|
||||
scrolling nav. On the rail they stack. */}
|
||||
<div
|
||||
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
|
||||
collapsed ? "flex-col" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
|
||||
collapsed
|
||||
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
data-testid="sidebar-toggle"
|
||||
title={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={drawer ? t("nav:closeMenu") : collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
className={`inline-flex items-center justify-center rounded-lg text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2.5" : "h-9 w-9 flex-shrink-0"
|
||||
}`}
|
||||
title={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen className="w-4 h-4 flex-shrink-0" />
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<>
|
||||
<PanelLeftClose className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wide">
|
||||
{t("nav:collapseShort")}
|
||||
</span>
|
||||
</>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
className={`px-3 pt-3 pb-4 border-t border-border space-y-2.5 flex-shrink-0 ${collapsed ? "px-2" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatusModalOpen(true)}
|
||||
aria-label={t("nav:connectionDetails")}
|
||||
title={t("nav:connectionDetails")}
|
||||
className={`rounded-lg border border-border bg-surface-2 hover:bg-surface-3 transition-colors text-left cursor-pointer ${
|
||||
collapsed
|
||||
? "w-8 h-8 mx-auto flex items-center justify-center p-0"
|
||||
: "block w-full px-2.5 py-2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center text-xs ${collapsed ? "justify-center" : "justify-between gap-2"}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 ${
|
||||
wsConnected ? "text-status-success" : "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{wsConnected ? (
|
||||
<Wifi className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<WifiOff className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{!collapsed && (
|
||||
<span className="font-medium">
|
||||
{wsConnected ? t("nav:live") : t("nav:disconnected")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{collapsed ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checking}
|
||||
title={checkTitle}
|
||||
aria-label={checkTitle}
|
||||
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
|
||||
updateAvailable
|
||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||
: checkError
|
||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
|
||||
{updateAvailable && !checking && (
|
||||
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCheckUpdates}
|
||||
disabled={checking}
|
||||
title={checkTitle}
|
||||
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
|
||||
updateAvailable
|
||||
? "border-status-success/40 text-status-success hover:bg-status-success/10"
|
||||
: checkError
|
||||
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
|
||||
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 truncate">
|
||||
<RefreshCw
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium truncate">{checkTitle}</span>
|
||||
</span>
|
||||
{updateAvailable && !checking && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConnectionStatusModal
|
||||
open={statusModalOpen}
|
||||
onClose={() => setStatusModalOpen(false)}
|
||||
open={connectionOpen}
|
||||
onClose={() => onConnectionOpenChange(false)}
|
||||
wsConnected={wsConnected}
|
||||
connectedSince={connectedSince}
|
||||
eventCountRef={eventCountRef}
|
||||
@@ -685,6 +551,13 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
eventTimestampsRef={eventTimestampsRef}
|
||||
typeCountRef={typeCountRef}
|
||||
recentEventsRef={recentEventsRef}
|
||||
update={{
|
||||
title: checkTitle,
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: onCheckUpdates,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
peakPerSecRef.current = 0;
|
||||
@@ -718,6 +591,15 @@ interface ConnectionStatusModalProps {
|
||||
eventTimestampsRef: React.MutableRefObject<number[]>;
|
||||
typeCountRef: React.MutableRefObject<Map<string, number>>;
|
||||
recentEventsRef: React.MutableRefObject<Array<{ type: string; at: number }>>;
|
||||
/** The release check: its one-line state ("Up to date", "Dev build · N
|
||||
* ahead", a release out…) and the button that re-runs it. */
|
||||
update: {
|
||||
title: string;
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
failed: boolean;
|
||||
onCheck: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
|
||||
@@ -732,6 +614,7 @@ function ConnectionStatusModal({
|
||||
eventTimestampsRef,
|
||||
typeCountRef,
|
||||
recentEventsRef,
|
||||
update,
|
||||
onResetStats,
|
||||
}: ConnectionStatusModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -747,15 +630,8 @@ function ConnectionStatusModal({
|
||||
}, [open]);
|
||||
|
||||
const close = useCallback(() => onClose(), [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, close]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panelRef, close);
|
||||
|
||||
const wsUrl = useMemo(() => {
|
||||
if (typeof window === "undefined") return "";
|
||||
@@ -784,14 +660,17 @@ function ConnectionStatusModal({
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) close();
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="connection-status-title"
|
||||
className="w-full max-w-md card shadow-2xl animate-slide-up overflow-hidden flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
@@ -833,13 +712,49 @@ function ConnectionStatusModal({
|
||||
type="button"
|
||||
onClick={close}
|
||||
aria-label={t("nav:close")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
className="btn-icon -m-1 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X className="w-4 h-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-5 overflow-y-auto">
|
||||
{/* Version + release check */}
|
||||
<Section title={t("nav:version")} icon={RefreshCw}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="font-mono text-sm text-fg-primary">{`v${__APP_VERSION__}`}</p>
|
||||
<p
|
||||
data-testid="update-status"
|
||||
className={`mt-0.5 inline-flex items-center gap-1.5 text-xs ${
|
||||
update.available
|
||||
? "text-status-success"
|
||||
: update.failed
|
||||
? "text-status-warning"
|
||||
: "text-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{update.available && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
{update.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* KPI row */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<KpiTile
|
||||
@@ -1026,6 +941,7 @@ function Sparkline({
|
||||
connected: boolean;
|
||||
avgLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const W = 320;
|
||||
const H = 56;
|
||||
const max = Math.max(1, ...buckets);
|
||||
@@ -1037,15 +953,15 @@ function Sparkline({
|
||||
});
|
||||
const linePath = points.length > 0 ? `M ${points.join(" L ")}` : "";
|
||||
const areaPath = points.length > 0 ? `M 0,${H} L ${points.join(" L ")} L ${W},${H} Z` : "";
|
||||
const stroke = connected ? "#34d399" : "#6b7280";
|
||||
const stroke = connected ? "rgb(var(--status-success))" : "rgb(var(--fg-muted))";
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-2.5">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="w-full h-14" aria-hidden>
|
||||
<defs>
|
||||
<linearGradient id="spark-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={stroke} stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor={stroke} stopOpacity="0" />
|
||||
<stop offset="0%" style={{ stopColor: stroke, stopOpacity: 0.35 }} />
|
||||
<stop offset="100%" style={{ stopColor: stroke, stopOpacity: 0 }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{areaPath && <path d={areaPath} fill="url(#spark-fill)" />}
|
||||
@@ -1053,7 +969,7 @@ function Sparkline({
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
style={{ stroke }}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
@@ -1064,7 +980,7 @@ function Sparkline({
|
||||
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
|
||||
<span>−60s</span>
|
||||
<span>{avgLabel}</span>
|
||||
<span>{"now"}</span>
|
||||
<span>{t("nav:now")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -65,13 +65,16 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SkeletonProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
/** Rounded shape variant. Defaults to "md". */
|
||||
rounded?: "sm" | "md" | "lg" | "full";
|
||||
/** Aria label for screen readers. Defaults to "Loading". */
|
||||
/** Aria label for screen readers. Defaults to the translated "Loading…";
|
||||
* `""` makes the block decorative (`aria-hidden`), for every block but one
|
||||
* in a group, so a reader hears "Loading" once, not once per shape. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
@@ -86,17 +89,15 @@ const ROUNDED_CLASS = {
|
||||
* Base shimmer block. Compose with width/height utilities for any shape:
|
||||
* <Skeleton className="h-6 w-24" />
|
||||
*/
|
||||
export function Skeleton({
|
||||
className = "",
|
||||
style,
|
||||
rounded = "md",
|
||||
label = "Loading",
|
||||
}: SkeletonProps) {
|
||||
export function Skeleton({ className = "", style, rounded = "md", label }: SkeletonProps) {
|
||||
const { t } = useTranslation();
|
||||
const hidden = label === "";
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={label}
|
||||
aria-busy="true"
|
||||
role={hidden ? undefined : "status"}
|
||||
aria-label={hidden ? undefined : (label ?? t("common:loading"))}
|
||||
aria-hidden={hidden || undefined}
|
||||
aria-busy={hidden ? undefined : "true"}
|
||||
className={`inline-block bg-surface-3/70 animate-pulse ${ROUNDED_CLASS[rounded]} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
@@ -133,7 +134,7 @@ export function TableRowSkeleton({ columns, widths }: { columns: number; widths?
|
||||
<tr className="border-b border-border/40">
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<td key={i} className="px-5 py-4">
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} />
|
||||
<Skeleton className={`h-4 ${widths?.[i] ?? "w-24"}`} label={i === 0 ? undefined : ""} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@@ -68,7 +68,10 @@ export function SplashScreen() {
|
||||
const { t } = useTranslation("splash");
|
||||
// Show at most once per tab session. Read synchronously so we never flash an
|
||||
// empty overlay on a repeat mount (StrictMode double-invoke, refresh, etc.).
|
||||
// Never on a deep link (a shared /sessions/<id>, a notification's
|
||||
// /workspace?…): the user came for that page, not the welcome.
|
||||
const [mounted, setMounted] = useState(() => {
|
||||
if (window.location.pathname !== "/") return false;
|
||||
try {
|
||||
return !sessionStorage.getItem(SESSION_KEY);
|
||||
} catch {
|
||||
@@ -109,7 +112,11 @@ export function SplashScreen() {
|
||||
/* sessionStorage may be unavailable (private mode) - show anyway */
|
||||
}
|
||||
exitTimer.current = window.setTimeout(beginExit, HOLD_MS);
|
||||
// Any key dismisses it (not only a click), so a keyboard user is never stuck.
|
||||
const onKey = () => beginExit();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
if (exitTimer.current) window.clearTimeout(exitTimer.current);
|
||||
if (doneTimer.current) window.clearTimeout(doneTimer.current);
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Tip } from "./Tip";
|
||||
import { StatValueSkeleton } from "./Skeleton";
|
||||
@@ -74,6 +75,8 @@ interface StatCardProps {
|
||||
raw?: string;
|
||||
/** When true, render skeletons in place of value/trend. */
|
||||
loading?: boolean;
|
||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||
to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,9 +91,10 @@ export function StatCard({
|
||||
accentColor = "text-accent",
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="card p-5">
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
{label}
|
||||
@@ -109,6 +113,16 @@ export function StatCard({
|
||||
<span className="text-xs text-fg-muted mb-1 flex-shrink-0">{trend}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return to ? (
|
||||
<Link
|
||||
to={to}
|
||||
className="card p-5 block hover:bg-surface-4/40 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="card p-5">{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,8 +79,8 @@ export const REASON_ICONS: Record<AwaitingReason, LucideIcon> = {
|
||||
/**
|
||||
* The "why" chip nested inside a Waiting badge: a small rounded pill with the
|
||||
* reason's icon and short label. Urgent reasons (permission prompts,
|
||||
* interruptions) get a hotter amber fill than the calm idle-between-turns
|
||||
* ones so a scan of a list surfaces the rows that actually block on the human.
|
||||
* interruptions) get the amber warning fill; the calm idle-between-turns
|
||||
* ones stay neutral, so a scan of a list surfaces the rows that actually block on the human.
|
||||
*/
|
||||
function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
|
||||
cfg.urgent
|
||||
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
|
||||
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
|
||||
: "bg-surface-3 border-border text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @file StatusBar — the one-line bar along the bottom of the app shell, on
|
||||
* every page: the live-connection state (click: the connection details modal,
|
||||
* which the sidebar owns and which also carries the update check), the version
|
||||
* — with a green dot while a newer release is out —, how many SESSIONS are
|
||||
* running and how many need the user (`GET /api/sessions?status=active` and
|
||||
* `?awaiting=1` — the Dashboard's Needs-you rule —, re-read on session
|
||||
* messages), and today's spend — `GET /api/pricing/cost?date=<local date>` — and, on the
|
||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* token total, and hovering it lists tokens and cost per model. No provider quota: reading Claude's quota rotates
|
||||
* the stored OAuth token (`cc-usage.js`), and the CLI shows it in the pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { CostResult } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const usd = (n: number) => `$${n.toFixed(2)}`;
|
||||
|
||||
/** Tokens and cost per model (the breakdown's speed/geo/tier buckets folded),
|
||||
* costliest first: the hover text of a cost figure. */
|
||||
function perModel(r: CostResult): { tokens: number; lines: string } {
|
||||
const byModel = new Map<string, { tokens: number; cost: number }>();
|
||||
for (const b of r.breakdown) {
|
||||
const m = byModel.get(b.model) ?? { tokens: 0, cost: 0 };
|
||||
m.tokens += b.input_tokens + b.output_tokens + b.cache_read_tokens + b.cache_write_tokens;
|
||||
m.cost += b.cost;
|
||||
byModel.set(b.model, m);
|
||||
}
|
||||
const rows = [...byModel].sort((a, b) => b[1].cost - a[1].cost);
|
||||
return {
|
||||
tokens: rows.reduce((n, [, m]) => n + m.tokens, 0),
|
||||
lines: rows
|
||||
.map(([model, m]) => `${formatModelName(model)}: ${fmt(m.tokens)} · ${fmtCostFull(m.cost)}`)
|
||||
.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function CostFigure({ testId, label, r }: { testId: string; label: string; r: CostResult }) {
|
||||
const { t } = useTranslation("nav");
|
||||
const { tokens, lines } = perModel(r);
|
||||
return (
|
||||
<span data-testid={testId} tabIndex={0} title={lines || undefined} className="tabular-nums">
|
||||
{label} {t("statusBar.tokens", { tokens: fmt(tokens) })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
wsConnected,
|
||||
left,
|
||||
onConnectionClick,
|
||||
updateAvailable = false,
|
||||
}: {
|
||||
wsConnected: boolean;
|
||||
/** Sidebar width, so the bar starts where the main column does. */
|
||||
left: string;
|
||||
onConnectionClick: () => void;
|
||||
/** A newer release is out: a dot beside the version. */
|
||||
updateAvailable?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [active, setActive] = useState(0);
|
||||
const [needYou, setNeedYou] = useState(0);
|
||||
const running = Math.max(active - needYou, 0);
|
||||
const [today, setToday] = useState<CostResult | null>(null);
|
||||
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
|
||||
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
|
||||
const [runs, setRuns] = useState<RunHandle[]>([]);
|
||||
const location = useLocation();
|
||||
const shown = selectedFrom(location.pathname, location.search);
|
||||
const lane = shown.lane;
|
||||
const sessionId =
|
||||
shown.session ?? (shown.run && runs.find((r) => r.id === shown.run)?.sessionId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const take = (e: Event) => setRuns((e as CustomEvent<RunHandle[]>).detail);
|
||||
window.addEventListener(LIVE_RUNS_EVENT, take);
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const loadCounts = useCallback(() => {
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 1 })
|
||||
.then((r) => setActive(r.total))
|
||||
.catch(() => undefined);
|
||||
api.sessions
|
||||
.list({ awaiting: true, limit: 1 })
|
||||
.then((r) => setNeedYou(r.total))
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(loadCounts, [loadCounts]);
|
||||
useLiveRefresh(loadCounts, {
|
||||
intervalMs: 15_000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
setLaneCost(null);
|
||||
setSessionCost(null);
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, COST_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
|
||||
return (
|
||||
<footer
|
||||
data-testid="status-bar"
|
||||
style={{ left }}
|
||||
className="fixed bottom-0 right-0 z-20 flex h-6 items-center gap-4 overflow-hidden whitespace-nowrap border-t border-border bg-surface-1 px-3 text-xs text-fg-muted transition-[left] duration-200"
|
||||
>
|
||||
{/* What a screen reader is told without looking: the connection dropping
|
||||
or coming back, and agents waiting on the user. Polite, and nothing
|
||||
that ticks on its own (costs, running counts) — those stay silent. */}
|
||||
<span role="status" className="sr-only" data-testid="status-bar-live">
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
{needYou > 0 ? ` · ${t("statusBar.needYou", { count: needYou })}` : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConnectionClick}
|
||||
title={t("connectionDetails")}
|
||||
className={`flex items-center gap-1.5 hover:text-fg-primary ${
|
||||
wsConnected ? "text-status-success" : ""
|
||||
}`}
|
||||
>
|
||||
{wsConnected ? (
|
||||
<Wifi className="h-3 w-3" aria-hidden />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
{wsConnected ? t("live") : t("disconnected")}
|
||||
<span className="text-fg-muted">{`v${__APP_VERSION__}`}</span>
|
||||
{updateAvailable && (
|
||||
<span
|
||||
data-testid="status-bar-update"
|
||||
title={t("updateAvailable")}
|
||||
className="h-1.5 w-1.5 rounded-full bg-status-success"
|
||||
>
|
||||
<span className="sr-only">{t("updateAvailable")}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span data-testid="status-bar-counts">
|
||||
{t("statusBar.running", { count: running })}
|
||||
{" · "}
|
||||
<span className={needYou ? "text-status-warning" : ""}>
|
||||
{t("statusBar.needYou", { count: needYou })}
|
||||
</span>
|
||||
</span>
|
||||
{today !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-cost"
|
||||
label={t("statusBar.today", { cost: usd(today.total_cost) })}
|
||||
r={today}
|
||||
/>
|
||||
)}
|
||||
{lane !== null && laneCost !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-workspace-cost"
|
||||
label={t("statusBar.workspace", { cost: usd(laneCost.total_cost) })}
|
||||
r={laneCost}
|
||||
/>
|
||||
)}
|
||||
{sessionId && sessionCost !== null && (
|
||||
<CostFigure
|
||||
testId="status-bar-session-cost"
|
||||
label={t("statusBar.session", { cost: usd(sessionCost.total_cost) })}
|
||||
r={sessionCost}
|
||||
/>
|
||||
)}
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @file Switch.tsx
|
||||
* @description The app's ONE on/off switch (`role="switch"`): an accent track
|
||||
* with a white knob, the same size everywhere. Settings rows, webhook targets
|
||||
* and the AI Tools list all draw this — do not hand-roll another one; a
|
||||
* yes/no choice inside a form or list is a `Checkbox` instead.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
title,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
/** Accessible name — a switch usually sits beside its visible label. */
|
||||
label?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
* by {@link StatCard} for raw metric values and anywhere a compact display needs
|
||||
* a full-precision expansion without cluttering the layout.
|
||||
*
|
||||
* ## Look
|
||||
* The bubble is {@link TOOLTIP_SURFACE} — the same surface the app-wide
|
||||
* {@link GlobalTooltip} draws — so a raw-value tooltip and a control's tooltip
|
||||
* are visibly one thing. It stays cursor-following (and not anchored like the
|
||||
* global one) because it expands a NUMBER inside a line of text, where the
|
||||
* anchor is a few characters wide and the cursor is the real subject.
|
||||
*
|
||||
* ## Portal rendering
|
||||
* Tooltip content is portaled to `document.body` with `position: fixed` so
|
||||
* parent `overflow: hidden` cannot clip it. Placement flips left/up when the
|
||||
@@ -56,6 +63,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { TOOLTIP_SURFACE } from "./Tooltip";
|
||||
|
||||
/** Props for {@link Tip}. */
|
||||
interface TipProps {
|
||||
@@ -135,11 +143,7 @@ export function Tip({ raw, children, maxWidth = 320, block = false }: TipProps)
|
||||
{children}
|
||||
{show &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
style={tipStyle}
|
||||
className="px-2.5 py-1.5 text-[11px] leading-relaxed font-mono text-fg-primary bg-[#12121f] border border-[#2a2a4a] rounded-lg shadow-xl pointer-events-none whitespace-pre-wrap break-words"
|
||||
>
|
||||
<div ref={tipRef} style={tipStyle} className={`${TOOLTIP_SURFACE} font-mono`}>
|
||||
{raw}
|
||||
</div>,
|
||||
document.body
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Toaster.tsx
|
||||
* @description Renders the app-wide toast queue from `lib/toast.ts`, mounted
|
||||
* once in `App.tsx`. Two live regions — `status` for success/info, `alert` for
|
||||
* errors — so a screen reader announces each message at the right urgency.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Check, Info, X } from "lucide-react";
|
||||
import { dismissToast, subscribeToasts, type ToastItem } from "../lib/toast";
|
||||
|
||||
const TONE: Record<ToastItem["kind"], string> = {
|
||||
success: "border-status-success/40 bg-status-success/10 text-status-success",
|
||||
info: "border-border bg-surface-2 text-fg-primary",
|
||||
error: "border-status-danger/50 bg-status-danger/10 text-status-danger",
|
||||
};
|
||||
|
||||
function Item({ item }: { item: ToastItem }) {
|
||||
const { t } = useTranslation("common");
|
||||
const Icon = item.kind === "error" ? AlertCircle : item.kind === "info" ? Info : Check;
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
className="opacity-70 hover:opacity-100 p-1 -m-0.5"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The single toast renderer. */
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
))}
|
||||
</div>
|
||||
<div role="alert" aria-live="assertive" className="flex flex-col gap-2">
|
||||
{errors.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* @file Tooltip.tsx
|
||||
* @description The dashboard's tooltip look, and the one renderer that gives it
|
||||
* to every control in the app.
|
||||
*
|
||||
* ## Why a global renderer instead of a wrapper component
|
||||
* The UI already labels its icon-only controls with the native `title`
|
||||
* attribute — over two hundred of them. A `<Tooltip>` wrapper would mean
|
||||
* touching every one of those call sites to get a designed bubble, and the
|
||||
* 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 — 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
|
||||
* it as a temporary `aria-label`, so focusing it never leaves it nameless; the
|
||||
* bubble goes away with its control if that is removed from the page.
|
||||
*
|
||||
* ## The look
|
||||
* Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is
|
||||
* hard to read and lands over the thing it describes. It flips above/below and
|
||||
* clamps to the viewport, carries an arrow pointing at its anchor, fades in
|
||||
* after a short delay so a cursor crossing a toolbar does not strobe, and is
|
||||
* built from the theme's own tokens so it is legible in light mode too (the
|
||||
* old bubble was a hardcoded near-black).
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
/** The shared bubble surface, so every tooltip in the app reads as one thing. */
|
||||
export const TOOLTIP_SURFACE =
|
||||
"pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " +
|
||||
"text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " +
|
||||
"whitespace-pre-wrap break-words";
|
||||
|
||||
/** How long the pointer must rest on a control before its tooltip appears. */
|
||||
const SHOW_DELAY_MS = 350;
|
||||
/** Gap between the control and the bubble, and the minimum viewport margin. */
|
||||
const GAP = 8;
|
||||
|
||||
/** Opt an element (or a subtree) out: `data-tip-off` keeps the native title.
|
||||
* `[data-tip]` matches the control whose title is parked, so moving between
|
||||
* its own children still resolves to it instead of re-opening the bubble. */
|
||||
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.
|
||||
*/
|
||||
export function GlobalTooltip() {
|
||||
const [tip, setTip] = useState<Anchored | null>(null);
|
||||
const [pos, setPos] = useState<Placed | null>(null);
|
||||
const bubble = useRef<HTMLDivElement>(null);
|
||||
// The element whose `title` is currently parked in `data-tip`, so it can be
|
||||
// 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);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
window.clearTimeout(timer.current);
|
||||
pending.current = null;
|
||||
silenced.current.forEach(unpark);
|
||||
silenced.current = [];
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
unpark(el);
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
setPos(null);
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(el: HTMLElement, delay: number) => {
|
||||
const text = (el.getAttribute("title") || "").trim();
|
||||
if (!text) return;
|
||||
restore();
|
||||
pending.current = el;
|
||||
timer.current = window.setTimeout(() => {
|
||||
pending.current = null;
|
||||
// Re-read at fire time: the control may have been re-rendered, removed
|
||||
// or had its title changed while the delay was running.
|
||||
if (!el.isConnected) return;
|
||||
const current = (el.getAttribute("title") || "").trim();
|
||||
if (!current) return;
|
||||
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") &&
|
||||
!el.hasAttribute("aria-labelledby") &&
|
||||
!el.textContent?.trim()
|
||||
) {
|
||||
el.setAttribute("aria-label", current);
|
||||
el.dataset.tipAria = "";
|
||||
}
|
||||
parked.current = el;
|
||||
setTip({ text: current, rect: el.getBoundingClientRect() });
|
||||
}, delay);
|
||||
},
|
||||
[restore]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const target = (e: Event) => {
|
||||
const node = e.target;
|
||||
return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null;
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
// Already showing or already counting down for this control: a pointer
|
||||
// crossing its children must neither hide nor restart it.
|
||||
if (!el || el === parked.current || el === pending.current) return;
|
||||
open(el, SHOW_DELAY_MS);
|
||||
};
|
||||
const onOut = (e: PointerEvent) => {
|
||||
// Moving between children of the same control must not flicker.
|
||||
const el = target(e);
|
||||
const to = e.relatedTarget;
|
||||
if (el && to instanceof Node && el.contains(to)) return;
|
||||
if (el || parked.current) restore();
|
||||
};
|
||||
// Keyboard users get the same tooltip, immediately — a focus ring that
|
||||
// reveals nothing is the whole reason icon-only buttons are hard to learn.
|
||||
// Only KEYBOARD focus, though: a click focuses too, and without the
|
||||
// `:focus-visible` check it reopened the bubble the pointerdown just
|
||||
// closed, with no delay. Hover keeps its own path.
|
||||
const onFocus = (e: FocusEvent) => {
|
||||
const el = target(e);
|
||||
if (!el || el === parked.current) return;
|
||||
if (!(e.target instanceof Element) || !e.target.matches(":focus-visible")) return;
|
||||
open(el, 0);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") restore();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerover", onOver);
|
||||
document.addEventListener("pointerout", onOut);
|
||||
document.addEventListener("pointerdown", restore);
|
||||
document.addEventListener("focusin", onFocus);
|
||||
document.addEventListener("focusout", restore);
|
||||
document.addEventListener("keydown", onKey);
|
||||
// A bubble anchored to a rect is wrong the moment its anchor moves — but
|
||||
// only a scroll of the document or of a box containing the anchor moves
|
||||
// it. A log tailing in another panel must not close the bubble.
|
||||
const onScroll = (e: Event) => {
|
||||
const anchor = parked.current || pending.current;
|
||||
const t = e.target;
|
||||
if (!anchor || !(t instanceof Node) || t === document || t.contains(anchor)) restore();
|
||||
};
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", restore);
|
||||
window.addEventListener("blur", restore);
|
||||
return () => {
|
||||
document.removeEventListener("pointerover", onOver);
|
||||
document.removeEventListener("pointerout", onOut);
|
||||
document.removeEventListener("pointerdown", restore);
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", restore);
|
||||
window.removeEventListener("blur", restore);
|
||||
restore();
|
||||
};
|
||||
}, [open, restore]);
|
||||
|
||||
// A control unmounted under a still cursor fires no pointerout: without this
|
||||
// its bubble stays up over whatever replaced it. The same goes for a title
|
||||
// re-rendered under it — the bubble would keep saying the old text, so it
|
||||
// hides and `restore` keeps the new attribute.
|
||||
useEffect(() => {
|
||||
if (!tip) return;
|
||||
const watch = new MutationObserver((records) => {
|
||||
const el = parked.current;
|
||||
if (!el) return;
|
||||
if (
|
||||
!el.isConnected ||
|
||||
records.some((r) => r.type === "attributes" && el.hasAttribute("title"))
|
||||
)
|
||||
restore();
|
||||
});
|
||||
watch.observe(document.body, { childList: true, subtree: true });
|
||||
if (parked.current) watch.observe(parked.current, { attributeFilter: ["title"] });
|
||||
return () => watch.disconnect();
|
||||
}, [tip, restore]);
|
||||
|
||||
// Placed after the first paint, when the bubble's real size is known —
|
||||
// measuring it is the only way to flip and clamp without guessing.
|
||||
useEffect(() => {
|
||||
if (!tip || !bubble.current) return;
|
||||
const { width, height } = bubble.current.getBoundingClientRect();
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const vh = window.innerHeight;
|
||||
const centre = tip.rect.left + tip.rect.width / 2;
|
||||
|
||||
let left = centre - width / 2;
|
||||
left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP));
|
||||
// Below by default; above only when below would run off the screen AND
|
||||
// above actually fits, so a control at the very bottom still gets a bubble.
|
||||
const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP;
|
||||
|
||||
setPos({
|
||||
left,
|
||||
top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP,
|
||||
// The arrow points at the control even after the bubble was clamped.
|
||||
arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)),
|
||||
above,
|
||||
});
|
||||
}, [tip]);
|
||||
|
||||
if (!tip) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={bubble}
|
||||
role="tooltip"
|
||||
data-testid="global-tooltip"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: pos?.left ?? -9999,
|
||||
top: pos?.top ?? -9999,
|
||||
opacity: pos ? 1 : 0,
|
||||
transition: "opacity 120ms ease-out",
|
||||
}}
|
||||
className={TOOLTIP_SURFACE}
|
||||
>
|
||||
{tip.text}
|
||||
{/* Two borders only: a fully bordered diamond draws its other two
|
||||
edges straight across the bubble it is attached to. */}
|
||||
<span
|
||||
aria-hidden
|
||||
style={{ left: pos?.arrow ?? 0, [pos?.above ? "bottom" : "top"]: -4.5 }}
|
||||
className={`absolute h-2 w-2 -translate-x-1/2 rotate-45 bg-surface-2 ${
|
||||
pos?.above ? "border-b border-r" : "border-l border-t"
|
||||
} border-border`}
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalTooltip;
|
||||
@@ -16,7 +16,9 @@
|
||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||
* keeps answering while the chain installs. New skills/slash commands still
|
||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||
* background process can do for it.
|
||||
* background process can do for it. Escape, the backdrop and "Not now" close it
|
||||
* for this page load only (never while applying); "Skip this version" is the
|
||||
* one persistent, per-version dismiss.
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -76,11 +78,13 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
@@ -125,6 +129,10 @@ export function UpdateNotifier() {
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
|
||||
// persistent per-version dismiss is the explicit "Skip this version".
|
||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
@@ -185,37 +193,44 @@ export function UpdateNotifier() {
|
||||
// also swallow v1.6.0 — and so the prompt survives the tag pointing at a
|
||||
// commit the user already has. Falls back to the upstream SHA for the
|
||||
// commit-count path (no release readable).
|
||||
const dismissKey = status?.latest_release || status?.remote_sha || null;
|
||||
const show = Boolean(status?.update_available && dismissKey && dismissedSha !== dismissKey);
|
||||
// A build installed from a terminal while this server kept running has no
|
||||
// tag or sha when the forge is unreachable — key it by its directory.
|
||||
const installed = status?.installed_not_running ?? null;
|
||||
const dismissKey =
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
const show = Boolean(
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
);
|
||||
const busy = applying || restarting;
|
||||
|
||||
const closeForNow = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
setClosedKey(dismissKey);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (restarting || !dismissKey) return;
|
||||
if (busy || !dismissKey) return;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, dismissKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setDismissedSha(dismissKey);
|
||||
}, [restarting, dismissKey]);
|
||||
}, [busy, dismissKey]);
|
||||
|
||||
// Escape to dismiss - standard modal affordance.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") dismiss();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [show, dismiss]);
|
||||
// Escape closes for now — never while an update is applying.
|
||||
useDialog(show, panelRef, busy ? undefined : closeForNow);
|
||||
|
||||
const copyCmd = async () => {
|
||||
if (!status?.manual_command) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(status.manual_command);
|
||||
if (await copyText(status.manual_command, true)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -298,17 +313,20 @@ export function UpdateNotifier() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-surface-0/70 backdrop-blur-sm animate-fade-in"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="update-notifier-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) dismiss();
|
||||
if (e.target === e.currentTarget) closeForNow();
|
||||
}}
|
||||
>
|
||||
{/* max-w-2xl, not lg: the footer carries up to four buttons and the
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<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">
|
||||
@@ -320,12 +338,16 @@ export function UpdateNotifier() {
|
||||
id="update-notifier-title"
|
||||
className="text-sm font-semibold text-fg-primary truncate"
|
||||
>
|
||||
{release ? t("releaseTitle", { version: release }) : t("title")}
|
||||
{installed
|
||||
? t("installedTitle", { version: installed.version ?? "?" })
|
||||
: release
|
||||
? t("releaseTitle", { version: release })
|
||||
: t("title")}
|
||||
</h2>
|
||||
<p className="text-[11px] text-fg-muted mt-0.5 font-mono truncate">
|
||||
{/* Never print a commit count on a shallow clone — the number
|
||||
is a graft artefact, not a distance. */}
|
||||
{release && !status.shallow_clone && status.local_version
|
||||
{(release || installed) && !status.shallow_clone && status.local_version
|
||||
? t("releaseSubtitle", { current: status.local_version })
|
||||
: t("commitsBehind", { count: behind, ref: refLabel })}
|
||||
</p>
|
||||
@@ -333,8 +355,9 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label={t("dismiss")}
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
aria-label={t("later")}
|
||||
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -450,10 +473,19 @@ export function UpdateNotifier() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
disabled={restarting}
|
||||
disabled={busy}
|
||||
title={t("skipVersionHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("dismiss")}
|
||||
{t("skipVersion")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("later")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
* fetched from the server (`/api/webhooks/providers`): each provider declares
|
||||
* whether it needs a URL and which credential fields to render, so adding a
|
||||
* provider server-side surfaces here with no UI change. Secrets are never
|
||||
* returned by the API - URLs are masked and re-entered to change.
|
||||
* returned by the API - URLs are masked and re-entered to change. A right-click
|
||||
* on a target opens its Test / Edit / Delete as a menu.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -58,6 +59,7 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Switch } from "./Switch";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -81,8 +83,10 @@ import { api } from "../lib/api";
|
||||
import { Select } from "./Select";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
|
||||
import { WEBHOOK_DOCS } from "./webhookGuides";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import type {
|
||||
AlertRule,
|
||||
WebhookProvider,
|
||||
@@ -91,20 +95,21 @@ import type {
|
||||
WebhookTestResult,
|
||||
} from "../lib/types";
|
||||
|
||||
// Brand-ish accent per provider type; anything unmapped falls back to neutral.
|
||||
// Brand-ish accent per provider type, on the BORDER only: brand hues fail
|
||||
// contrast as text on several themes, so the label stays text-fg-primary.
|
||||
const TYPE_STYLES: Partial<Record<WebhookType, string>> = {
|
||||
slack: "text-[#E01E5A] bg-[#E01E5A]/10 border-[#E01E5A]/20",
|
||||
discord: "text-[#5865F2] bg-[#5865F2]/10 border-[#5865F2]/20",
|
||||
teams: "text-[#6264A7] bg-[#6264A7]/10 border-[#6264A7]/20",
|
||||
google_chat: "text-[#1A73E8] bg-[#1A73E8]/10 border-[#1A73E8]/20",
|
||||
mattermost: "text-[#0058CC] bg-[#0058CC]/10 border-[#0058CC]/20",
|
||||
rocketchat: "text-[#F5455C] bg-[#F5455C]/10 border-[#F5455C]/20",
|
||||
telegram: "text-[#26A5E4] bg-[#26A5E4]/10 border-[#26A5E4]/20",
|
||||
pagerduty: "text-[#06AC38] bg-[#06AC38]/10 border-[#06AC38]/20",
|
||||
opsgenie: "text-[#2684FF] bg-[#2684FF]/10 border-[#2684FF]/20",
|
||||
splunk_oncall: "text-[#F99D1C] bg-[#F99D1C]/10 border-[#F99D1C]/20",
|
||||
slack: "border-[#E01E5A]/60",
|
||||
discord: "border-[#5865F2]/60",
|
||||
teams: "border-[#6264A7]/60",
|
||||
google_chat: "border-[#1A73E8]/60",
|
||||
mattermost: "border-[#0058CC]/60",
|
||||
rocketchat: "border-[#F5455C]/60",
|
||||
telegram: "border-[#26A5E4]/60",
|
||||
pagerduty: "border-[#06AC38]/60",
|
||||
opsgenie: "border-[#2684FF]/60",
|
||||
splunk_oncall: "border-[#F99D1C]/60",
|
||||
};
|
||||
const NEUTRAL_STYLE = "text-fg-secondary bg-surface-2 border-border";
|
||||
const NEUTRAL_STYLE = "border-border";
|
||||
|
||||
interface HeaderRow {
|
||||
key: string;
|
||||
@@ -132,34 +137,6 @@ function defaultsFor(provider: WebhookProvider | undefined): Record<string, stri
|
||||
return out;
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors ${
|
||||
checked ? "bg-blue-600" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform"
|
||||
style={{ transform: checked ? "translateX(18px)" : "translateX(3px)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [targets, setTargets] = useState<WebhookTarget[]>([]);
|
||||
@@ -171,6 +148,13 @@ export function WebhookSettings() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState<string | null>(null);
|
||||
const ctx = useContextMenu<WebhookTarget>();
|
||||
// A menu item: close the menu, then act on the target it was opened on.
|
||||
const pick = (fn: (target: WebhookTarget) => unknown) => () => {
|
||||
const target = ctx.menu?.item;
|
||||
ctx.close();
|
||||
if (target) fn(target);
|
||||
};
|
||||
const [testResult, setTestResult] = useState<Record<string, WebhookTestResult>>({});
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
@@ -186,11 +170,13 @@ export function WebhookSettings() {
|
||||
const res = await api.webhooks.list();
|
||||
setTargets(res.targets);
|
||||
} catch (err) {
|
||||
console.error("Failed to load webhook targets:", err);
|
||||
toast.error(
|
||||
t("webhooks.loadFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -270,6 +256,9 @@ export function WebhookSettings() {
|
||||
const canSubmit = useMemo(() => {
|
||||
if (!form || !provider) return false;
|
||||
if (!form.name.trim()) return false;
|
||||
// An empty rule list means ALL rules server-side — never save that from
|
||||
// "only selected rules".
|
||||
if (!form.scopeAll && form.ruleIds.length === 0) return false;
|
||||
if (isEdit) return true; // server merge - existing values fill the gaps
|
||||
if (provider.url_required && !form.url.trim()) return false;
|
||||
for (const f of provider.fields) {
|
||||
@@ -344,7 +333,9 @@ export function WebhookSettings() {
|
||||
await api.webhooks.update(target.id, { enabled: !target.enabled });
|
||||
load();
|
||||
} catch (err) {
|
||||
console.error("Failed to toggle webhook:", err);
|
||||
toast.error(
|
||||
t("webhooks.toggleFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -354,7 +345,10 @@ export function WebhookSettings() {
|
||||
setConfirmDelete(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete webhook:", err);
|
||||
setConfirmDelete(null);
|
||||
toast.error(
|
||||
t("webhooks.deleteFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -415,11 +409,12 @@ export function WebhookSettings() {
|
||||
return (
|
||||
<div
|
||||
key={target.id}
|
||||
onContextMenu={(e) => ctx.open(e, target)}
|
||||
className="bg-surface-2 border border-border rounded-lg px-3.5 py-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border ${
|
||||
className={`inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide px-2 py-0.5 rounded border bg-surface-2 text-fg-primary ${
|
||||
TYPE_STYLES[target.type] || NEUTRAL_STYLE
|
||||
}`}
|
||||
>
|
||||
@@ -452,7 +447,7 @@ export function WebhookSettings() {
|
||||
{timeAgo(target.last_delivery.created_at)}
|
||||
</span>
|
||||
)}
|
||||
<Toggle
|
||||
<Switch
|
||||
checked={target.enabled}
|
||||
onChange={() => onToggle(target)}
|
||||
label={t("webhooks.enabled")}
|
||||
@@ -509,6 +504,21 @@ export function WebhookSettings() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{ctx.menu && (
|
||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="webhook-menu">
|
||||
<MenuItem
|
||||
disabled={testing === ctx.menu.item.id}
|
||||
onSelect={pick((target) => void onTest(target.id))}
|
||||
>
|
||||
{t("webhooks.test")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(openEdit)}>{t("webhooks.edit")}</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem danger onSelect={pick((target) => setConfirmDelete(target.id))}>
|
||||
{t("webhooks.delete")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -767,12 +777,15 @@ export function WebhookSettings() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!form.scopeAll && form.ruleIds.length === 0 && (
|
||||
<p className="mt-1.5 text-[11px] text-status-warning">{t("webhooks.pickRule")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<label className="inline-flex items-center gap-2 text-[11px] text-fg-secondary cursor-pointer">
|
||||
<Toggle checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||
<Switch checked={form.enabled} onChange={(v) => set({ enabled: v })} />
|
||||
{t("webhooks.enabledOnSave")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* @file WorkspaceTree — the sidebar's Workspaces section, shaped like Claude
|
||||
* Desktop's project list: every workspace (lane) on one line in the stored drag
|
||||
* order (`laneOrderStorage.ts`), each expanding to ITS sessions
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves and handed over by the Workspace page's own
|
||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import {
|
||||
insertionIndex,
|
||||
moveBefore,
|
||||
moveLane,
|
||||
readLaneOrder,
|
||||
sortByStoredOrder,
|
||||
writeLaneOrder,
|
||||
} from "../lib/laneOrderStorage";
|
||||
import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePrefs";
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
const v = p.get("lane");
|
||||
return { lane: v === null ? null : Number(v), run: p.get("run"), session: p.get("session") };
|
||||
}
|
||||
|
||||
/** The Workspace page's polled run list, handed to the sidebar as it lands. */
|
||||
export const LIVE_RUNS_EVENT = "agenthub:live-runs";
|
||||
|
||||
export function WorkspaceTree() {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<{ lane: Lane; x: number; y: number } | null>(null);
|
||||
const [renamingId, setRenamingId] = useState<number | null>(null);
|
||||
const [destructive, setDestructive] = useState<{ lane: Lane; action: "reset" | "remove" } | null>(
|
||||
null
|
||||
);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addPrefill, setAddPrefill] = useState<{ sourceRepo?: string } | null>(null);
|
||||
const [cwdSuggestions, setCwdSuggestions] = useState<CwdSuggestion[]>([]);
|
||||
const [orderTick, setOrderTick] = useState(0);
|
||||
|
||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
// the Workspace page also hands over every run list it polls (a tab in
|
||||
// slots 1–3 moves no lane column).
|
||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => live && setLiveRuns(r.items))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [runKey]);
|
||||
useEffect(() => {
|
||||
const take = (e: Event) => setLiveRuns((e as CustomEvent<RunHandle[]>).detail);
|
||||
window.addEventListener(LIVE_RUNS_EVENT, take);
|
||||
return () => window.removeEventListener(LIVE_RUNS_EVENT, take);
|
||||
}, []);
|
||||
|
||||
const openAdd = (sourceRepo?: string) => {
|
||||
setAddPrefill(sourceRepo ? { sourceRepo } : null);
|
||||
setAddOpen(true);
|
||||
api.run
|
||||
.cwds()
|
||||
.then((r) => setCwdSuggestions(r.items))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
const select = useCallback((id: number) => navigate(`/workspace?lane=${id}`), [navigate]);
|
||||
const expanded = (id: number) => !!prefs.expanded[id] || !!query.trim();
|
||||
const setExpanded = (id: number, open: boolean) =>
|
||||
writeProjectTreePrefs({ expanded: { ...prefs.expanded, [id]: open } });
|
||||
|
||||
const order = useMemo(() => readLaneOrder(), [orderTick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// The tool filter, offered once workspaces of two tools exist; a stored
|
||||
// tool no workspace has any more filters nothing.
|
||||
const aiTools = useAiTools();
|
||||
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(
|
||||
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
[lanes, order, toolFilter]
|
||||
);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
// A search also finds a workspace by a session's name: every workspace's
|
||||
// list stays mounted (hidden) to load and count its matches.
|
||||
const [hits, setHits] = useState<Record<number, number>>({});
|
||||
const onMatches = useCallback(
|
||||
(id: number, n: number) => setHits((h) => (h[id] === n ? h : { ...h, [id]: n })),
|
||||
[]
|
||||
);
|
||||
// A cleared search unmounts the collapsed lists; their old counts must not
|
||||
// surface workspaces in the next search before its fetch answers.
|
||||
useEffect(() => {
|
||||
if (!q) setHits({});
|
||||
}, [q]);
|
||||
const visible = (l: Lane) => !q || nameHit(l) || (hits[l.id] ?? 0) > 0;
|
||||
|
||||
const reorder = (move: (ids: number[]) => number[]) => {
|
||||
const ids = sorted.map((l) => l.id);
|
||||
const moved = move(ids);
|
||||
if (moved === ids) return;
|
||||
// Under a tool filter the shown ids go back into the slots they held in
|
||||
// the full order, so the hidden workspaces keep their places.
|
||||
const shownIds = new Set(ids);
|
||||
let k = 0;
|
||||
const full = sortByStoredOrder(lanes, order).map((l) =>
|
||||
shownIds.has(l.id) ? (moved[k++] ?? l.id) : l.id
|
||||
);
|
||||
writeLaneOrder(full);
|
||||
setOrderTick((n) => n + 1);
|
||||
};
|
||||
|
||||
// A row only starts a drag; the whole section takes the drop, so a gap — a
|
||||
// workspace's session list, the space above or below a row — lands too.
|
||||
// `dropAt` is the insertion point in `sorted`, drawn as a line.
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||
const dropIndexAt = (y: number) => {
|
||||
const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-lane-index]") ?? [])];
|
||||
const mids = rows.map((r) => {
|
||||
const b = r.getBoundingClientRect();
|
||||
return b.top + b.height / 2;
|
||||
});
|
||||
const row = rows[insertionIndex(mids, y)];
|
||||
return row ? Number(row.dataset.laneIndex) : sorted.length;
|
||||
};
|
||||
const dragHandlers = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
const at = dropIndexAt(e.clientY);
|
||||
setDropAt((d) => (d === at ? d : at));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
setDropAt(null);
|
||||
if (!isLaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData(LANE_DRAG_TYPE);
|
||||
// Number("") is 0: an empty payload must not move the first row.
|
||||
const from = raw === "" ? NaN : Number(raw);
|
||||
const before = dropIndexAt(e.clientY);
|
||||
if (Number.isInteger(from)) reorder((ids) => moveBefore(ids, from, before));
|
||||
},
|
||||
};
|
||||
|
||||
const rename = async (lane: Lane, title: string | null) => {
|
||||
setRenamingId(null);
|
||||
if (title === null || title === (lane.title || "")) return;
|
||||
try {
|
||||
await api.lanes.update(lane.id, { title });
|
||||
await refreshLanes();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onMenuAction = (lane: Lane, action: WorkspaceMenuAction) => {
|
||||
if (action === "rename") setRenamingId(lane.id);
|
||||
else if (action === "new-here") openAdd(lane.source_repo || lane.cwd);
|
||||
else if (action === "reset" || action === "remove") setDestructive({ lane, action });
|
||||
};
|
||||
|
||||
const confirmDestructive = async (body?: { expect?: unknown; force?: boolean }) => {
|
||||
if (!destructive) return;
|
||||
const { lane, action } = destructive;
|
||||
setDestructive(null);
|
||||
try {
|
||||
await api.lanes.action(lane.id, action, {
|
||||
confirm: true,
|
||||
expect: body?.expect,
|
||||
...(body?.force === true ? { force: true } : {}),
|
||||
});
|
||||
await refreshLanes();
|
||||
if (action === "remove" && selected.lane === lane.id) navigate("/workspace");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// ≥24px targets (WCAG 2.5.8); each carries an aria-label.
|
||||
const headBtn = "btn-icon";
|
||||
return (
|
||||
<section
|
||||
data-testid="workspace-tree"
|
||||
// The bottom padding is a drop strip below the last workspace.
|
||||
className="mt-3 border-t border-border pb-6 pt-3"
|
||||
{...dragHandlers}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1 px-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
||||
{t("tree.title")}
|
||||
</span>
|
||||
{tools.length > 1 && (
|
||||
<select
|
||||
value={toolFilter ?? ""}
|
||||
onChange={(e) => writeProjectTreePrefs({ tool: e.target.value || null })}
|
||||
aria-label={t("tree.toolFilter")}
|
||||
title={t("tree.toolFilter")}
|
||||
className="select max-w-[6.5rem] !rounded !py-0.5 !pl-1 !pr-5 !text-[11px] text-fg-secondary"
|
||||
>
|
||||
<option value="">{t("tree.allTools")}</option>
|
||||
{tools.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{aiTools.label(id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearching((v) => !v);
|
||||
setQuery("");
|
||||
}}
|
||||
aria-label={t("tree.search")}
|
||||
aria-pressed={searching}
|
||||
title={t("tree.search")}
|
||||
className={headBtn}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-tree-add"
|
||||
onClick={() => openAdd()}
|
||||
aria-label={t("addLane")}
|
||||
title={t("addLane")}
|
||||
className={headBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{searching && (
|
||||
<input
|
||||
type="search"
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setQuery("");
|
||||
setSearching(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("tree.search")}
|
||||
aria-label={t("tree.search")}
|
||||
className="mx-2 mb-1 w-[calc(100%-1rem)] rounded border border-border bg-surface-1 px-2 py-1 text-xs text-fg-primary"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="space-y-px px-1">
|
||||
{sorted.map((lane, i) => (
|
||||
<div key={lane.id} className="relative">
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mx-2 mt-2 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{menu && (
|
||||
<WorkspaceRowMenu
|
||||
lane={menu.lane}
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
onClose={() => setMenu(null)}
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
<AddLaneModal
|
||||
open={addOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
data-testid="workspace-drop-line"
|
||||
className="pointer-events-none absolute inset-x-1 -top-px z-10 h-0.5 rounded-full bg-accent-solid"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||
// render is used inside renderCard helper
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
@@ -239,7 +240,7 @@ describe("AgentCard", () => {
|
||||
const { container } = renderCard(<AgentCard agent={makeAgent({ status: "waiting" })} />);
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-2");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
expect(card?.className).toContain("border-l-status-warning/60");
|
||||
});
|
||||
|
||||
it("should not apply active border for completed agents", () => {
|
||||
@@ -266,7 +267,7 @@ describe("AgentCard", () => {
|
||||
);
|
||||
expect(screen.getByText("Waiting")).toBeInTheDocument();
|
||||
const card = container.querySelector(".card-hover");
|
||||
expect(card?.className).toContain("border-l-yellow-500/60");
|
||||
expect(card?.className).toContain("border-l-status-warning/60");
|
||||
});
|
||||
|
||||
it("keeps the card badge compact: reason is tooltip-only, no inline chip", () => {
|
||||
@@ -325,4 +326,42 @@ describe("AgentCard", () => {
|
||||
);
|
||||
expect(screen.getByText(/ran 5m 30s/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("right-click opens the session menu; its items act without clicking the card", async () => {
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<AgentCard
|
||||
agent={makeAgent()}
|
||||
session={{ id: "sess-1", lane_id: 4, cwd: "/r/app" } as never}
|
||||
onClick={onClick}
|
||||
/>
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
const menu = screen.getByTestId("session-row-menu-sess-1");
|
||||
await user.click(within(menu).getByText("Copy folder path"));
|
||||
expect(await navigator.clipboard.readText()).toBe("/r/app");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
|
||||
await user.pointer({ keys: "[MouseRight]", target: screen.getByText("Main Agent") });
|
||||
await user.click(screen.getByText("Open in its workspace"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent("/workspace?lane=4&session=sess-1");
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @file AlertsNotifications.test.tsx
|
||||
* @description Alert rules are editable through the create form (PATCH, type
|
||||
* fixed), and an invalid cooldown blocks saving instead of silently becoming 300.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const update = vi.fn(async () => ({ rule: {} }));
|
||||
const create = vi.fn(async () => ({ rule: {} }));
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
rules: {
|
||||
list: vi.fn(async () => ({
|
||||
rules: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Idle",
|
||||
rule_type: "inactivity",
|
||||
config: { minutes: 30 },
|
||||
enabled: true,
|
||||
cooldown_seconds: 60,
|
||||
},
|
||||
],
|
||||
})),
|
||||
update: (...a: unknown[]) => update(...(a as [])),
|
||||
create: (...a: unknown[]) => create(...(a as [])),
|
||||
remove: vi.fn(),
|
||||
},
|
||||
list: vi.fn(async () => ({ alerts: [], total: 0, unacked: 0 })),
|
||||
ack: vi.fn(),
|
||||
ackAll: vi.fn(),
|
||||
},
|
||||
webhooks: {
|
||||
list: vi.fn(async () => ({ targets: [] })),
|
||||
providers: vi.fn(async () => ({ providers: [] })),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
const renderIt = () =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("AlertsNotifications rules", () => {
|
||||
it("edits a rule in place and validates the cooldown", async () => {
|
||||
renderIt();
|
||||
await userEvent.click(await screen.findByRole("button", { name: /edit rule/i }));
|
||||
const name = screen.getByDisplayValue("Idle");
|
||||
const cooldown = screen.getByDisplayValue("60");
|
||||
await userEvent.clear(cooldown);
|
||||
await userEvent.type(cooldown, "-5");
|
||||
const save = screen.getByRole("button", { name: /save rule/i });
|
||||
expect(save).toBeDisabled();
|
||||
await userEvent.clear(cooldown);
|
||||
await userEvent.type(cooldown, "120");
|
||||
await userEvent.clear(name);
|
||||
await userEvent.type(name, "Idle long");
|
||||
await userEvent.click(save);
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith("r1", {
|
||||
name: "Idle long",
|
||||
config: { minutes: 30 },
|
||||
cooldown_seconds: 120,
|
||||
})
|
||||
);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
}, 15_000); // many typed fields: slow under a parallel run
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file ContextMenu.test.tsx
|
||||
* @description Keyboard model of the shared context menu: focus lands on the
|
||||
* first item, arrows/Home/End move (wrapping, skipping disabled items), Escape
|
||||
* and Tab close and hand focus back to the trigger, and a keyboard-opened menu
|
||||
* (clientX/Y 0) anchors under its element.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../ContextMenu";
|
||||
|
||||
function Harness({ onPick = () => {} }: { onPick?: (s: string) => void }) {
|
||||
const { menu, open, close } = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<button onContextMenu={(e) => open(e, "row")}>trigger</button>
|
||||
{menu && (
|
||||
<ContextMenu x={menu.x} y={menu.y} onClose={close}>
|
||||
<MenuItem onSelect={() => (onPick("a"), close())}>Alpha</MenuItem>
|
||||
<MenuItem disabled onSelect={() => {}}>
|
||||
Beta
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={() => (onPick("c"), close())}>Gamma</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const item = (name: string) => screen.getByRole("menuitem", { name });
|
||||
|
||||
describe("ContextMenu keyboard", () => {
|
||||
it("focuses the first item and moves with arrows, Home and End", () => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
expect(item("Alpha")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Alpha"), { key: "ArrowDown" });
|
||||
expect(item("Gamma")).toHaveFocus(); // Beta is disabled
|
||||
fireEvent.keyDown(item("Gamma"), { key: "ArrowDown" });
|
||||
expect(item("Alpha")).toHaveFocus(); // wraps
|
||||
fireEvent.keyDown(item("Alpha"), { key: "ArrowUp" });
|
||||
expect(item("Gamma")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Gamma"), { key: "Home" });
|
||||
expect(item("Alpha")).toHaveFocus();
|
||||
fireEvent.keyDown(item("Alpha"), { key: "End" });
|
||||
expect(item("Gamma")).toHaveFocus();
|
||||
});
|
||||
|
||||
it.each(["Escape", "Tab"])("%s closes and returns focus to the trigger", (key) => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
fireEvent.keyDown(item("Alpha"), { key });
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("Escape in the menu does not reach a document listener (a dialog behind)", () => {
|
||||
const behind = vi.fn();
|
||||
document.addEventListener("keydown", behind);
|
||||
render(<Harness />);
|
||||
fireEvent.contextMenu(screen.getByText("trigger"), { clientX: 40, clientY: 50 });
|
||||
fireEvent.keyDown(item("Alpha"), { key: "Escape" });
|
||||
document.removeEventListener("keydown", behind);
|
||||
expect(behind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns focus to the trigger after an item runs", () => {
|
||||
const pick = vi.fn();
|
||||
render(<Harness onPick={pick} />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.focus();
|
||||
fireEvent.contextMenu(trigger, { clientX: 40, clientY: 50 });
|
||||
fireEvent.click(item("Gamma"));
|
||||
expect(pick).toHaveBeenCalledWith("c");
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
it("anchors a keyboard-opened menu (clientX/Y 0) under the element", () => {
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByText("trigger");
|
||||
trigger.getBoundingClientRect = () =>
|
||||
({ left: 120, bottom: 80, top: 60, right: 200, width: 80, height: 20 }) as DOMRect;
|
||||
fireEvent.contextMenu(trigger, { clientX: 0, clientY: 0 });
|
||||
const menu = screen.getByRole("menu");
|
||||
expect(menu.style.left).toBe("120px");
|
||||
expect(menu.style.top).toBe("80px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @file DateTimePicker.test.tsx
|
||||
* @description An end-bound picker ("To") picks the very end of a day, so an
|
||||
* event at 23:59:30 is still inside the range.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { DateTimePicker } from "../DateTimePicker";
|
||||
|
||||
describe("DateTimePicker", () => {
|
||||
it("endOfDay picks 23:59:59.999 of the chosen day", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateTimePicker value="" onChange={onChange} endOfDay aria-label="To" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "To" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
|
||||
expect(onChange.mock.calls[0]![0]).toMatch(/T23:59:59\.999$/);
|
||||
});
|
||||
|
||||
it("a start bound picks midnight", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<DateTimePicker value="" onChange={onChange} aria-label="From" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "From" }));
|
||||
fireEvent.click(screen.getAllByRole("button", { pressed: false })[0]!);
|
||||
expect(onChange.mock.calls[0]![0]).toMatch(/T00:00$/);
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,6 @@ describe("DocumentTitle", () => {
|
||||
it("titles the other top-level routes", () => {
|
||||
expect(titleFor("/")).toMatch(/^Dashboard · /);
|
||||
expect(titleFor("/sessions")).toMatch(/^Sessions · /);
|
||||
expect(titleFor("/workflows")).toMatch(/^Workflows · /);
|
||||
expect(titleFor("/settings")).toMatch(/^Settings · /);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { EventFilters, EMPTY_FILTERS, isEmptyFilters } from "../EventFilters";
|
||||
import {
|
||||
EventFilters,
|
||||
EMPTY_FILTERS,
|
||||
isEmptyFilters,
|
||||
filtersFromParams,
|
||||
filtersToParams,
|
||||
} from "../EventFilters";
|
||||
import type { EventFiltersValue } from "../EventFilters";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
@@ -82,4 +88,49 @@ describe("EventFilters", () => {
|
||||
fireEvent.click(clear);
|
||||
expect(onChange).toHaveBeenCalledWith(EMPTY_FILTERS);
|
||||
});
|
||||
|
||||
it("a debounced search keeps a facet toggled meanwhile (latest value, not the stale one)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onChange = vi.fn();
|
||||
const { rerender } = render(<EventFilters value={EMPTY_FILTERS} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/search summary/i), {
|
||||
target: { value: "curl" },
|
||||
});
|
||||
// The parent applies a toggle before the 300 ms elapse.
|
||||
const toggled = { ...EMPTY_FILTERS, status: ["error"] };
|
||||
rerender(<EventFilters value={toggled} onChange={onChange} />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ ...toggled, q: "curl" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("searches sessions on the server, 20 at a time", async () => {
|
||||
const list = vi
|
||||
.spyOn(api.sessions, "list")
|
||||
.mockResolvedValue({ sessions: [], total: 0, limit: 20, offset: 0 });
|
||||
render(<EventFilters value={EMPTY_FILTERS} onChange={() => {}} sessionOptions={[]} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /session/i }));
|
||||
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: undefined, limit: 20 }));
|
||||
fireEvent.change(screen.getByRole("searchbox"), { target: { value: "abc" } });
|
||||
await waitFor(() => expect(list).toHaveBeenCalledWith({ q: "abc", limit: 20 }));
|
||||
});
|
||||
|
||||
it("round-trips filters through URL params, leaving other keys alone", () => {
|
||||
const value: EventFiltersValue = {
|
||||
...EMPTY_FILTERS,
|
||||
event_type: ["Stop", "PreToolUse"],
|
||||
status: ["error"],
|
||||
q: "curl",
|
||||
to: "2026-01-02T23:59",
|
||||
};
|
||||
const params = filtersToParams(value, new URLSearchParams("tab=timeline"));
|
||||
expect(params.get("tab")).toBe("timeline");
|
||||
expect(filtersFromParams(params)).toEqual(value);
|
||||
expect(filtersToParams(EMPTY_FILTERS, params).toString()).toBe("tab=timeline");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @file Layout.test.tsx
|
||||
* @description Responsive shell: with no stored preference the sidebar starts
|
||||
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
|
||||
* from a menu button — a modal dialog that Escape and the backdrop close,
|
||||
* handing focus back to the button.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
vi.mock("../Sagi/Sagi", () => ({ Sagi: () => null }));
|
||||
vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
|
||||
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
|
||||
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
|
||||
|
||||
import { Layout, DRAWER_QUERY } from "../Layout";
|
||||
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
|
||||
|
||||
function viewport(width: number) {
|
||||
vi.stubGlobal("matchMedia", (q: string) => {
|
||||
const max = Number(/max-width:\s*([\d.]+)px/.exec(q)?.[1] ?? Infinity);
|
||||
return {
|
||||
matches: width <= max,
|
||||
media: q,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe("loadCollapsed", () => {
|
||||
it("starts collapsed below lg when nothing is stored", () => {
|
||||
viewport(800);
|
||||
expect(loadCollapsed()).toBe(true);
|
||||
viewport(1440);
|
||||
expect(loadCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it("a stored choice wins over the viewport", () => {
|
||||
viewport(800);
|
||||
localStorage.setItem(SIDEBAR_STORAGE_KEY, "false");
|
||||
expect(loadCollapsed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout drawer", () => {
|
||||
it("below md: the sidebar is off-canvas until the menu button opens it", async () => {
|
||||
viewport(600);
|
||||
expect(window.matchMedia(DRAWER_QUERY).matches).toBe(true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const aside = screen.getByTestId("sidebar");
|
||||
expect(aside.className).toContain("invisible");
|
||||
expect(aside).not.toHaveAttribute("role", "dialog");
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("0rem");
|
||||
|
||||
const open = screen.getByRole("button", { name: "Open menu" });
|
||||
open.focus();
|
||||
fireEvent.click(open);
|
||||
expect(aside).toHaveAttribute("role", "dialog");
|
||||
expect(aside).toHaveAttribute("aria-modal", "true");
|
||||
expect(aside.className).not.toContain("invisible");
|
||||
await waitFor(() => expect(aside.contains(document.activeElement)).toBe(true));
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(aside.className).toContain("invisible");
|
||||
expect(open).toHaveFocus();
|
||||
|
||||
fireEvent.click(open);
|
||||
fireEvent.click(screen.getByTestId("sidebar-backdrop"));
|
||||
expect(aside.className).toContain("invisible");
|
||||
});
|
||||
|
||||
it("at md and up: no menu button, the sidebar is a landmark beside the content", () => {
|
||||
viewport(800);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Layout wsConnected />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Open menu" })).toBeNull();
|
||||
expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument();
|
||||
// Collapsed rail by default below lg, so the content is not covered.
|
||||
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @file MarkdownPreview.test.tsx
|
||||
* @description The markdown preview: GFM renders (headings, tables, task
|
||||
* lists), raw HTML stays text, links open in a new tab, frontmatter is a
|
||||
* code block; and the config FileViewer offers Raw / Preview only for a
|
||||
* markdown file.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
import { FileViewer } from "../config/FileViewer";
|
||||
|
||||
describe("MarkdownPreview", () => {
|
||||
it("renders GFM: headings, tables and task lists", () => {
|
||||
render(
|
||||
<MarkdownPreview
|
||||
text={"# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n- [x] done\n- [ ] todo\n"}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Title" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("never runs raw HTML and opens links in a new tab", () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview
|
||||
text={'<script>alert(1)</script><img src=x onerror="alert(1)">\n\n[x](https://a.example)'}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector("script")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
const link = screen.getByRole("link", { name: "x" });
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noreferrer noopener");
|
||||
});
|
||||
|
||||
it("shows frontmatter as a block, not as a heading", () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview text={"---\nname: demo\ndescription: d\n---\n\nBody text\n"} />
|
||||
);
|
||||
expect(container.querySelector(".md-frontmatter")).toHaveTextContent("name: demo");
|
||||
expect(screen.queryByRole("heading")).toBeNull();
|
||||
expect(screen.getByText("Body text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("knows a markdown path", () => {
|
||||
expect(isMarkdown("/a/SKILL.md")).toBe(true);
|
||||
expect(isMarkdown("README.markdown")).toBe(true);
|
||||
expect(isMarkdown("config.toml")).toBe(false);
|
||||
expect(isMarkdown(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileViewer — markdown preview", () => {
|
||||
const data = { text: "# Hello\n", truncated: false } as never;
|
||||
|
||||
it("switches a markdown file between raw and preview", async () => {
|
||||
render(<FileViewer state={{ path: "/x/CLAUDE.md", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Preview" }));
|
||||
expect(screen.getByRole("heading", { name: "Hello" })).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Raw" }));
|
||||
expect(screen.queryByTestId("markdown-preview")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers no preview for another file type", () => {
|
||||
render(<FileViewer state={{ path: "/x/config.toml", data, error: null }} onClose={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: "Preview" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @file PathPickButton.test.tsx
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, a folder falls back to the
|
||||
* in-app browser on ANY native-picker failure, and a file falls back to a
|
||||
* "type the path" hint only when no picker exists for this browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
const fail = (code: string) => Object.assign(new Error(code), { code });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
vi.mocked(api.lanes.browse)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
|
||||
vi.mocked(toast.info).mockReset();
|
||||
vi.mocked(toast.error).mockReset();
|
||||
});
|
||||
|
||||
describe("PathPickButton", () => {
|
||||
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
|
||||
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
|
||||
render(<PathPickButton start=" /home/u " onPick={onPick} />);
|
||||
const button = screen.getByTitle("Browse for a folder");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
|
||||
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
||||
expect(onPick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
|
||||
render(<PathPickButton onPick={vi.fn()} />);
|
||||
await userEvent.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
|
||||
render(<PathPickButton kind="file" onPick={vi.fn()} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @file RouteErrorBoundary.test.tsx
|
||||
* @description The route error boundary: a failed lazy chunk (stale assets after
|
||||
* a self-update) reloads the page once; any other error — or a second chunk
|
||||
* failure — shows the themed Reload fallback instead of a blank app.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { RouteErrorBoundary, isChunkLoadError, reloadOnce } from "../RouteErrorBoundary";
|
||||
|
||||
function Boom({ error }: { error: Error }): never {
|
||||
throw error;
|
||||
}
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
reload = vi.fn();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("RouteErrorBoundary", () => {
|
||||
it("recognises dynamic-import failures", () => {
|
||||
expect(
|
||||
isChunkLoadError(new TypeError("Failed to fetch dynamically imported module: /a.js"))
|
||||
).toBe(true);
|
||||
expect(isChunkLoadError(Object.assign(new Error("x"), { name: "ChunkLoadError" }))).toBe(true);
|
||||
expect(isChunkLoadError(new Error("Importing a module script failed."))).toBe(true);
|
||||
expect(isChunkLoadError(new Error("boom"))).toBe(false);
|
||||
});
|
||||
|
||||
it("reloads once on a chunk error, never twice in a row", () => {
|
||||
expect(reloadOnce(reload)).toBe(true);
|
||||
expect(reloadOnce(reload)).toBe(false);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows a Reload fallback for an ordinary render error", () => {
|
||||
render(
|
||||
<RouteErrorBoundary reload={reload}>
|
||||
<Boom error={new Error("boom")} />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(/something went wrong/i);
|
||||
expect(screen.getByRole("button", { name: /reload/i })).toBeInTheDocument();
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the error when the route changes, so the next page renders", () => {
|
||||
const { rerender } = render(
|
||||
<RouteErrorBoundary reload={reload} resetKey="/workspace">
|
||||
<Boom error={new Error("boom")} />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
rerender(
|
||||
<RouteErrorBoundary reload={reload} resetKey="/workspace">
|
||||
<p>settings page</p>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
rerender(
|
||||
<RouteErrorBoundary reload={reload} resetKey="/settings/general">
|
||||
<p>settings page</p>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("settings page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reloads on a chunk error the first time", () => {
|
||||
render(
|
||||
<RouteErrorBoundary reload={reload}>
|
||||
<Boom error={new TypeError("Failed to fetch dynamically imported module: /x.js")} />
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user