Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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": [
|
||||
|
||||
@@ -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,27 @@
|
||||
# Set to 0 to disable the poll while leaving the watcher running (default: 30000).
|
||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
## Lane 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` lanes (like every non-Claude AI tool lane) 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.
|
||||
|
||||
+209
-93
File diff suppressed because one or more lines are too long
+65
-10
@@ -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 | **Required for the run console** — the browser terminal, `agenthub lanes shell`, and typing `/reload-plugins` into a live pane. See [Step 0](#step-0--system-packages) |
|
||||
| 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 lanes 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` |
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +85,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 run form.
|
||||
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
|
||||
@@ -160,7 +214,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 +293,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 +318,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/`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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
|
||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, lane runtimes, and Claude Code's own configuration.
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
@@ -31,11 +31,13 @@ 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.
|
||||
- **Lanes.** One lane per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a lane moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a lane's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
@@ -51,8 +53,6 @@ Internal build — all rights reserved.
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-lane app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per lane (`agenthub lanes up|down|logs`).
|
||||
- **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.
|
||||
|
||||
**Configure**
|
||||
|
||||
@@ -78,10 +78,10 @@ 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 |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub lanes shell`, typing `/reload-plugins` into a live pane |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` lanes |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` and the PR stages of the ship pipelines |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub lanes ci status` |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
@@ -104,6 +104,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
|
||||
@@ -116,34 +122,6 @@ terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub lanes
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
### 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
|
||||
@@ -187,19 +165,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
|
||||
@@ -217,7 +196,6 @@ 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 stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
@@ -244,22 +222,41 @@ agenthub lanes add --repo /path/to/repo --slug my-feature # managed worktre
|
||||
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. Two things help it get there (the run console
|
||||
no longer pre-fills anything into the prompt box — that box is the user's to
|
||||
type): `agenthub lanes agents install` writes the stage-declaration
|
||||
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.
|
||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||
are read from Codex's own rollout journals: every Codex session (a lane'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 lane turns running/idle as
|
||||
each turn starts and ends, and "Codex needs your permission" shows on the lane
|
||||
card and as a notification while Codex waits for an approval. A Codex lane 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** lane 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
|
||||
lane 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 lane 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 lane 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 lanes carry no pipeline either.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a lane has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||
Further development is planned.
|
||||
|
||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a lane can
|
||||
|
||||
+26
-31
@@ -3,7 +3,7 @@
|
||||
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
|
||||
công việc song song của agent, mỗi working directory một lane; 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.
|
||||
|
||||
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
|
||||
@@ -31,10 +31,12 @@ 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
|
||||
- **Lane.** Mỗi working directory một lane, 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), lane đ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
|
||||
@@ -44,8 +46,6 @@ Internal build — all rights reserved.
|
||||
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.
|
||||
|
||||
**Cấu hình**
|
||||
|
||||
@@ -67,10 +67,10 @@ 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 |
|
||||
| **tmux** | 3.x | Run console trên trình duyệt, `agenthub lanes 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 |
|
||||
| **GitHub CLI (`gh`)** | bất kỳ | Tùy chọn — chỉ cho `agenthub lanes 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
|
||||
@@ -92,6 +92,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
|
||||
@@ -104,23 +110,6 @@ trên trình duyệt và `tmux attach -t agenthub-lane-<id>` (hoặc `agenthub l
|
||||
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
|
||||
@@ -194,7 +183,6 @@ 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 stage <name> # khai báo stage hiện tại của lane
|
||||
agenthub tail # xem live event feed
|
||||
```
|
||||
@@ -222,10 +210,17 @@ chọn trong modal Add lane và cố định suốt vòng đời của lane. Lan
|
||||
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`.
|
||||
Lane 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 lane 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ó
|
||||
|
||||
@@ -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.
|
||||
+49
-180
@@ -1463,16 +1463,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 +1501,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 +1542,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 +1608,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 lanes add --repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -1604,6 +1620,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;
|
||||
@@ -1629,37 +1646,19 @@ async function cmdLanesAdd(args) {
|
||||
return;
|
||||
}
|
||||
if (!cwd || !title) {
|
||||
console.error("usage: agenthub lanes add --cwd <path> --title <text> [--pipeline <id>]");
|
||||
console.error(
|
||||
"usage: agenthub lanes 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
@@ -2402,25 +2401,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. */
|
||||
@@ -2499,8 +2479,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
|
||||
@@ -2663,88 +2642,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 +2671,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> [--lane <id>] [--cwd <path>] [--tool <id>] [--status <s>] [--evidence <text>] [--note <text>] [--result pass|fail]"
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
@@ -2897,10 +2794,14 @@ const COMMAND_GROUPS = [
|
||||
"Lanes",
|
||||
[
|
||||
["lanes", "", "List lanes with stage, liveness and progress"],
|
||||
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
|
||||
[
|
||||
"lanes add",
|
||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
|
||||
"--cwd <path> --title <text> [--tool <id>]",
|
||||
"Create a new lane bound to a directory (--tool: an AI Tools id, default claude)",
|
||||
],
|
||||
[
|
||||
"lanes add",
|
||||
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>] [--tool <id>]",
|
||||
"Provision a managed worktree lane",
|
||||
],
|
||||
[
|
||||
@@ -2923,16 +2824,6 @@ const COMMAND_GROUPS = [
|
||||
"[<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)",
|
||||
],
|
||||
[
|
||||
"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",
|
||||
"[<id>]",
|
||||
@@ -2961,11 +2852,6 @@ const COMMAND_GROUPS = [
|
||||
"[<id>]",
|
||||
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
|
||||
],
|
||||
[
|
||||
"lanes agents install",
|
||||
"[<id>]",
|
||||
"Write the ship-feature-lane agent templates (qc-local, senior-gate-reviewer) into <lane>/.claude/agents/, git-excluded",
|
||||
],
|
||||
[
|
||||
"lanes mcp sync",
|
||||
"[<id>]",
|
||||
@@ -2986,11 +2872,6 @@ const COMMAND_GROUPS = [
|
||||
"[--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",
|
||||
],
|
||||
[
|
||||
"lock status|acquire|release",
|
||||
"[<name>] [--holder X] [--timeout N]",
|
||||
@@ -2999,7 +2880,7 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
"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 lane (--tool <id> when one cwd has lanes for two tools)",
|
||||
],
|
||||
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
|
||||
[
|
||||
@@ -3817,9 +3698,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 +3720,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 +3733,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));
|
||||
|
||||
+24
-3
@@ -186,6 +186,15 @@ client/
|
||||
│ │ ├── Sidebar.tsx
|
||||
│ │ ├── Layout.tsx
|
||||
│ │ ├── RemoteSources.tsx # Remote Data Sources settings panel (SSH multi-machine collection)
|
||||
│ │ ├── SessionToolBadge.tsx # The CLI that produced a non-Claude session, by its AI Tools label (nothing for Claude)
|
||||
│ │ ├── settings/ # Settings panels, among them:
|
||||
│ │ │ ├── AiToolsModal.tsx # AI Tools dialog, opened from the Settings tab bar's right edge
|
||||
│ │ │ ├── AiToolsSettings.tsx # Its body: installed/available CLIs, enable/default, install/update
|
||||
│ │ │ ├── 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/ # Workflows page panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
@@ -195,6 +204,7 @@ client/
|
||||
│ │ ├── ActivityFeed.tsx # Real-time event log; row click expands payload; Session btn navigates
|
||||
│ │ ├── Workflows.tsx
|
||||
│ │ ├── 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
|
||||
@@ -210,7 +220,7 @@ client/
|
||||
│ │ ├── useNotifications.ts # Browser push notification triggers
|
||||
│ │ └── 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)
|
||||
@@ -239,7 +249,7 @@ graph TB
|
||||
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]
|
||||
Settings[SettingsHub<br/>Dashboard tab: appearance + notifications + data<br/>Claude Code / Codex → Overview, Antigravity:<br/>each tool's pricing + hooks]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Workflows & Settings
|
||||
@@ -285,7 +295,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
|
||||
|
||||
@@ -755,6 +765,17 @@ closes. Consequences worth knowing:
|
||||
expanding a truncated **number** inside a line of text, but draws the same
|
||||
surface so the two read as one thing.
|
||||
|
||||
#### MarkdownPreview (`components/MarkdownPreview.tsx`)
|
||||
|
||||
A `.md`/`.markdown`/`.mdx` file (`isMarkdown`) rendered for reading with
|
||||
`react-markdown` + `remark-gfm` (tables, task lists, strikethrough). It sits
|
||||
behind a **Preview** / **Raw** toggle in two places: the config explorer's
|
||||
`FileViewer` and the Changes panel's whole-file view (`lane-file-preview`,
|
||||
reset to Raw on every file switch; the editor still opens from Raw). Raw HTML
|
||||
in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||
|
||||
---
|
||||
|
||||
## Utilities
|
||||
|
||||
Generated
+1484
-8
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -23,7 +23,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",
|
||||
|
||||
@@ -182,7 +182,9 @@ 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-violet-500/15 text-violet-700 dark:text-violet-400"
|
||||
}`}
|
||||
>
|
||||
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @file AiToolIcon.tsx
|
||||
* @description An AI tool's icon: the server's cached favicon
|
||||
* (GET /api/ai-tools/:id/icon), or the label's first letter when there is
|
||||
* none. Shared by the AI Tools modal and the Add-lane tool picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { api, type AiTool } from "../lib/api";
|
||||
|
||||
/**
|
||||
* @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);
|
||||
if (failedId !== tool.id) {
|
||||
return (
|
||||
<img
|
||||
src={api.aiTools.iconUrl(tool.id)}
|
||||
alt=""
|
||||
onError={() => setFailedId(tool.id)}
|
||||
className={`${className} shrink-0 rounded-md bg-surface-4 object-contain p-1`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -625,7 +625,7 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
||||
value={result.backfilled ?? 0}
|
||||
color="text-blue-400"
|
||||
color="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<ResultStat
|
||||
label={t("import.result.skipped", { count: result.skipped })}
|
||||
@@ -672,12 +672,12 @@ export function ImportHistory() {
|
||||
<ResultStat
|
||||
label={t("import.backupResult.events", { count: backupResult.events })}
|
||||
value={backupResult.events}
|
||||
color="text-violet-300"
|
||||
color="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<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">
|
||||
@@ -737,7 +737,7 @@ function ModeButton({
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
||||
active ? "text-blue-400" : "text-fg-secondary"
|
||||
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,11 @@
|
||||
* @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.
|
||||
* the owning page keeps the active tab in its own query param. `actions` sits at
|
||||
* the bar's right edge, outside the tablist.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export interface PageTab {
|
||||
id: string;
|
||||
@@ -18,6 +19,8 @@ interface PageTabsProps {
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
/** Buttons at the right edge of the bar (not tabs). */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,12 +28,14 @@ interface PageTabsProps {
|
||||
* @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 }: PageTabsProps) {
|
||||
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"
|
||||
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;
|
||||
@@ -54,4 +59,11 @@ export function PageTabs({ tabs, activeId, onSelect, ariaLabel }: PageTabsProps)
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
if (!actions) return list;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,21 +95,21 @@ function StatTile({
|
||||
}) {
|
||||
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-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
|
||||
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-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
|
||||
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
|
||||
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
|
||||
}[tone];
|
||||
|
||||
const iconTone = {
|
||||
default: "text-fg-muted",
|
||||
violet: "text-violet-400",
|
||||
violet: "text-violet-700 dark:text-violet-400",
|
||||
emerald: "text-status-success",
|
||||
amber: "text-status-warning",
|
||||
rose: "text-rose-400",
|
||||
cyan: "text-cyan-400",
|
||||
rose: "text-rose-700 dark:text-rose-400",
|
||||
cyan: "text-cyan-700 dark:text-cyan-400",
|
||||
blue: "text-blue-500",
|
||||
}[tone];
|
||||
|
||||
@@ -359,7 +359,7 @@ 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" />
|
||||
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
Top tools
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">
|
||||
@@ -418,7 +418,7 @@ 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" />
|
||||
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
|
||||
Subagents
|
||||
</h3>
|
||||
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
|
||||
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Cache read",
|
||||
value: tokens.cache_read_tokens,
|
||||
cls: "bg-sky-500",
|
||||
text: "text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
},
|
||||
{
|
||||
key: "cache_write",
|
||||
label: "Cache write",
|
||||
value: tokens.cache_write_tokens,
|
||||
cls: "bg-violet-500",
|
||||
text: "text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
},
|
||||
{
|
||||
key: "input",
|
||||
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
|
||||
label: "Output",
|
||||
value: tokens.output_tokens,
|
||||
cls: "bg-orange-500",
|
||||
text: "text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -368,15 +368,25 @@ 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");
|
||||
: 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");
|
||||
const currentLanguage = normalizeLanguage(i18n.resolvedLanguage ?? i18n.language);
|
||||
const currentIndex = SUPPORTED_LANGUAGES.indexOf(currentLanguage);
|
||||
const nextLanguage = SUPPORTED_LANGUAGES[(currentIndex + 1) % SUPPORTED_LANGUAGES.length];
|
||||
@@ -520,7 +530,9 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
<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">
|
||||
{/* One column per language: a wider grid squeezed each button
|
||||
below the width of its own label once Theme grew a third. */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{SUPPORTED_LANGUAGES.map((language) => {
|
||||
const active = language === currentLanguage;
|
||||
return (
|
||||
@@ -530,7 +542,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
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 ${
|
||||
className={`rounded-md px-1 py-1.5 text-center text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? "bg-accent/20 text-accent border border-accent/30"
|
||||
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
|
||||
|
||||
@@ -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-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
* 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 it takes the element's `title`, suppresses the native tooltip
|
||||
* keyboard focus (`:focus-visible`, not the focus a click gives) it takes the element's `title`, suppresses the native tooltip
|
||||
* by removing the attribute for as long as the bubble is up, and restores it
|
||||
* 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
|
||||
@@ -39,8 +42,10 @@ 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. */
|
||||
const SELECTOR = "[title]:not([data-tip-off])";
|
||||
/** 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 };
|
||||
@@ -58,16 +63,29 @@ export function GlobalTooltip() {
|
||||
// that unmounts it, Escape).
|
||||
const parked = useRef<HTMLElement | null>(null);
|
||||
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;
|
||||
const el = parked.current;
|
||||
if (el) {
|
||||
// Drop the instance shim `open` installed; the prototype method is back.
|
||||
delete (el as { removeAttribute?: unknown }).removeAttribute;
|
||||
const text = el.dataset.tip;
|
||||
if (text !== undefined) {
|
||||
el.setAttribute("title", text);
|
||||
// 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;
|
||||
}
|
||||
if (el.dataset.tipAria !== undefined) {
|
||||
el.removeAttribute("aria-label");
|
||||
delete el.dataset.tipAria;
|
||||
}
|
||||
parked.current = null;
|
||||
}
|
||||
setTip(null);
|
||||
@@ -79,7 +97,9 @@ export function GlobalTooltip() {
|
||||
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;
|
||||
@@ -87,6 +107,29 @@ export function GlobalTooltip() {
|
||||
if (!current) return;
|
||||
el.dataset.tip = current;
|
||||
el.removeAttribute("title");
|
||||
// React removing `title` while it is parked calls removeAttribute on an
|
||||
// attribute that is already gone: no MutationRecord, nothing to see —
|
||||
// and `restore` would write the stale text back for good. So, on THIS
|
||||
// element and only while parked, that call is taken as React's intent:
|
||||
// the parked copy is dropped and the bubble hides. (React DOM calls
|
||||
// `node.removeAttribute`, the instance method; `restore` deletes the
|
||||
// shim.) No global prototype is touched.
|
||||
el.removeAttribute = (name: string) => {
|
||||
if (name.toLowerCase() === "title" && el.dataset.tip !== undefined) {
|
||||
delete el.dataset.tip;
|
||||
restore();
|
||||
}
|
||||
Element.prototype.removeAttribute.call(el, name);
|
||||
};
|
||||
// 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);
|
||||
@@ -101,7 +144,10 @@ export function GlobalTooltip() {
|
||||
};
|
||||
const onOver = (e: PointerEvent) => {
|
||||
const el = target(e);
|
||||
if (el) open(el, SHOW_DELAY_MS);
|
||||
// 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.
|
||||
@@ -112,9 +158,14 @@ export function GlobalTooltip() {
|
||||
};
|
||||
// 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) open(el, 0);
|
||||
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();
|
||||
@@ -126,9 +177,17 @@ export function GlobalTooltip() {
|
||||
document.addEventListener("focusin", onFocus);
|
||||
document.addEventListener("focusout", restore);
|
||||
document.addEventListener("keydown", onKey);
|
||||
// A bubble anchored to a rect is wrong the moment anything moves.
|
||||
window.addEventListener("scroll", restore, true);
|
||||
// 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);
|
||||
@@ -136,12 +195,33 @@ export function GlobalTooltip() {
|
||||
document.removeEventListener("focusin", onFocus);
|
||||
document.removeEventListener("focusout", restore);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("scroll", restore, true);
|
||||
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(() => {
|
||||
|
||||
@@ -185,7 +185,13 @@ 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;
|
||||
// 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);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@@ -320,12 +326,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>
|
||||
|
||||
@@ -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,29 @@
|
||||
/**
|
||||
* @file SessionToolBadge.test.tsx
|
||||
* @description The session tool chip: nothing for a Claude Code session (so
|
||||
* every hook row renders as before), the tool's name for a Codex one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SessionToolBadge } from "../SessionToolBadge";
|
||||
|
||||
vi.mock("../../lib/api", () => ({ api: { aiTools: { list: () => new Promise(() => {}) } } }));
|
||||
|
||||
describe("SessionToolBadge", () => {
|
||||
it("renders nothing for a Claude or unset tool", () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<SessionToolBadge tool="claude" />
|
||||
<SessionToolBadge tool={undefined} />
|
||||
</>
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("names a Codex session's tool", () => {
|
||||
render(<SessionToolBadge tool="codex" />);
|
||||
expect(screen.getByText("Codex")).toHaveAttribute("title", "Recorded by Codex");
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sidebar } from "../Sidebar";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
|
||||
function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
return render(
|
||||
@@ -103,4 +104,44 @@ describe("Sidebar", () => {
|
||||
expect(screen.getByText("VI")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("update status line", () => {
|
||||
const plugin = {
|
||||
git_repo: false,
|
||||
install_kind: "plugin",
|
||||
situation: "plugin_managed",
|
||||
local_version: "1.8.10",
|
||||
};
|
||||
const publish = (data: object) =>
|
||||
act(() => eventBus.publish({ type: "update_status", data } as never));
|
||||
|
||||
it("does not claim up to date when a plugin install could not read the release", () => {
|
||||
renderSidebar(true);
|
||||
publish({ ...plugin, update_available: false, release_check: "unreachable" });
|
||||
expect(screen.queryByText("Up to date")).toBeNull();
|
||||
expect(screen.getByText("Could not check for updates")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names a build installed beside the running one", () => {
|
||||
renderSidebar(true);
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: true,
|
||||
release_check: "unreachable",
|
||||
installed_not_running: { root: "/x", version: "1.8.12" },
|
||||
});
|
||||
expect(screen.getByText("1.8.12 installed, not running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still says up to date when the release was read and matches", () => {
|
||||
renderSidebar(true);
|
||||
publish({
|
||||
...plugin,
|
||||
update_available: false,
|
||||
release_check: "ok",
|
||||
latest_release: "v1.8.10",
|
||||
});
|
||||
expect(screen.getByText("Up to date")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GlobalTooltip } from "../Tooltip";
|
||||
|
||||
function setup(extra?: React.ReactNode) {
|
||||
@@ -24,6 +25,24 @@ function setup(extra?: React.ReactNode) {
|
||||
|
||||
const hover = (el: Element) => fireEvent.pointerOver(el, { bubbles: true });
|
||||
const unhover = (el: Element) => fireEvent.pointerOut(el, { bubbles: true });
|
||||
// jsdom's :focus-visible is a heuristic over the previous events and leaks
|
||||
// between tests, so the tests say which kind of focus they mean: a spy answers
|
||||
// `:focus-visible` from this flag and passes every other selector through.
|
||||
let keyboardFocus = false;
|
||||
const realMatches = Element.prototype.matches;
|
||||
beforeEach(() => {
|
||||
keyboardFocus = false;
|
||||
vi.spyOn(Element.prototype, "matches").mockImplementation(function (this: Element, sel) {
|
||||
return sel === ":focus-visible"
|
||||
? keyboardFocus && document.activeElement === this
|
||||
: realMatches.call(this, sel);
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
const tabTo = (el: HTMLElement) => {
|
||||
keyboardFocus = true;
|
||||
act(() => el.focus());
|
||||
};
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true }));
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -63,7 +82,7 @@ describe("GlobalTooltip", () => {
|
||||
setup();
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
fireEvent.focusIn(button);
|
||||
tabTo(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
@@ -105,4 +124,165 @@ describe("GlobalTooltip", () => {
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Refresh the working copy");
|
||||
});
|
||||
it("keeps an icon-only control's accessible name while its title is parked", async () => {
|
||||
setup(
|
||||
<button title="Stage this file">
|
||||
<svg aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
const icon = screen.getByRole("button", { name: "Stage this file" });
|
||||
tabTo(icon);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
expect(icon).not.toHaveAttribute("title");
|
||||
expect(screen.getByRole("button", { name: "Stage this file" })).toBe(icon);
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
// Put back exactly as found: no aria-label left behind.
|
||||
expect(icon).toHaveAttribute("title", "Stage this file");
|
||||
expect(icon).not.toHaveAttribute("aria-label");
|
||||
});
|
||||
|
||||
it("does not flicker while the pointer crosses the control's own children", async () => {
|
||||
setup(
|
||||
<button title="Two parts">
|
||||
<span data-testid="part-a">a</span>
|
||||
<span data-testid="part-b">b</span>
|
||||
</button>
|
||||
);
|
||||
const a = screen.getByTestId("part-a");
|
||||
const b = screen.getByTestId("part-b");
|
||||
hover(a);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
|
||||
fireEvent.pointerOut(a, { bubbles: true, relatedTarget: b });
|
||||
fireEvent.pointerOver(b, { bubbles: true, relatedTarget: a });
|
||||
// Still up, with no fresh delay.
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Two parts");
|
||||
});
|
||||
|
||||
it("hides when its control is removed from the page", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Going away">x</button>
|
||||
</>
|
||||
);
|
||||
hover(screen.getByRole("button", { name: "x" }));
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a title React changed while the bubble was up, instead of writing the stale one back", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// A poll flips the hint while the pointer rests on the control.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Up to date">pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
// The bubble must not keep advertising the old text.
|
||||
expect(screen.queryByText("Pull 2 commits")).toBeNull();
|
||||
|
||||
unhover(button);
|
||||
expect(button).toHaveAttribute("title", "Up to date");
|
||||
});
|
||||
|
||||
it("leaves no title behind when React removed it while the bubble was up", async () => {
|
||||
const { rerender } = render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button title="Pull 2 commits">pull</button>
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "pull" });
|
||||
hover(button);
|
||||
await settle();
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Pull 2 commits");
|
||||
|
||||
// React "removes" an attribute that is already parked: no MutationRecord.
|
||||
rerender(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<button>pull</button>
|
||||
</>
|
||||
);
|
||||
await settle();
|
||||
unhover(button);
|
||||
expect(button).not.toHaveAttribute("title");
|
||||
expect(button).not.toHaveAttribute("data-tip");
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not open on the focus a mouse click gives, only on keyboard focus", async () => {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
setup(<button title="Second control">second</button>);
|
||||
// A click is pointer focus: :focus-visible does not match.
|
||||
const button = screen.getByRole("button", { name: "refresh" });
|
||||
|
||||
await user.click(button);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
|
||||
tabTo(screen.getByRole("button", { name: "second" }));
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Second control");
|
||||
});
|
||||
|
||||
it("stays up when an unrelated panel scrolls, and hides when the anchor's own scroller moves", async () => {
|
||||
render(
|
||||
<>
|
||||
<GlobalTooltip />
|
||||
<div data-testid="scroller">
|
||||
<button title="Inside the scroller">inside</button>
|
||||
</div>
|
||||
<div data-testid="elsewhere" />
|
||||
</>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "inside" });
|
||||
hover(button);
|
||||
await settle();
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("elsewhere"));
|
||||
expect(screen.getByTestId("global-tooltip")).toHaveTextContent("Inside the scroller");
|
||||
|
||||
fireEvent.scroll(screen.getByTestId("scroller"));
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
expect(button).toHaveAttribute("title", "Inside the scroller");
|
||||
});
|
||||
|
||||
it("hides when the document itself scrolls", async () => {
|
||||
setup();
|
||||
hover(screen.getByRole("button", { name: "refresh" }));
|
||||
await settle();
|
||||
fireEvent.scroll(document);
|
||||
expect(screen.queryByTestId("global-tooltip")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,33 @@ describe("UpdateNotifier — Update now", () => {
|
||||
expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers Update now for a build installed from a terminal, even with no release read", async () => {
|
||||
// `claude plugin update` started nothing and the forge was unreachable:
|
||||
// no release tag and no sha to key the modal on, yet a newer build sits
|
||||
// on disk. That used to read as "up to date" with no way to switch.
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
git_repo: false,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
remote_sha: null,
|
||||
commits_behind: 0,
|
||||
local_version: "1.8.10",
|
||||
release_check: "unreachable",
|
||||
latest_release: null,
|
||||
installed_not_running: { root: "/cache/sg/agenthub/new", version: "1.8.12" },
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:installedTitle", { version: "1.8.12" }));
|
||||
expect(
|
||||
screen.getByText(i18n.t("updates:releaseSubtitle", { current: "1.8.10" }))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
* @description Read-only file-content modal for the config explorer. Takes
|
||||
* already-fetched data as props (path/data/error) rather than fetching
|
||||
* itself, so it stays tool-agnostic — the orchestrator (Claude or Codex
|
||||
* config page) owns the fetch.
|
||||
* config page) owns the fetch. A markdown file gets a Raw / Preview switch
|
||||
* (`MarkdownPreview`); Raw is the default.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, X, AlertCircle } from "lucide-react";
|
||||
import { FileText, X, AlertCircle, Eye, Code2 } from "lucide-react";
|
||||
import type { CcFileResponse } from "../../lib/api";
|
||||
import { CopyButton } from "./configPrimitives";
|
||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
||||
|
||||
export function FileViewer({
|
||||
state,
|
||||
@@ -21,6 +23,8 @@ export function FileViewer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [preview, setPreview] = useState(false);
|
||||
const canPreview = isMarkdown(state.path) && !!state.data;
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -42,6 +46,17 @@ export function FileViewer({
|
||||
<span className="font-mono text-[12px] text-fg-secondary truncate flex-1">
|
||||
{state.path}
|
||||
</span>
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={preview}
|
||||
onClick={() => setPreview((v) => !v)}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
{preview ? <Code2 className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
|
||||
{preview ? t("common.raw") : t("common.preview")}
|
||||
</button>
|
||||
)}
|
||||
<CopyButton value={state.path} />
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -59,6 +74,13 @@ export function FileViewer({
|
||||
</div>
|
||||
) : !state.data ? (
|
||||
<div className="text-sm text-fg-muted">…</div>
|
||||
) : preview && canPreview ? (
|
||||
<>
|
||||
<MarkdownPreview text={state.data.text} />
|
||||
{state.data.truncated && (
|
||||
<p className="text-[11px] text-fg-muted italic">{t("common.truncated")}</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<pre className="text-[11px] font-mono text-fg-secondary whitespace-pre-wrap break-words">
|
||||
{state.data.text}
|
||||
|
||||
@@ -12,11 +12,11 @@ export function ScopeBadge({ scope }: { scope: string }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const color =
|
||||
scope === "user"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: scope === "project"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: scope === "project-local"
|
||||
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
|
||||
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border";
|
||||
const label =
|
||||
scope === "project-local"
|
||||
|
||||
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={linkM[2]!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</a>
|
||||
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
|
||||
<ul
|
||||
key={idx}
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
|
||||
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
|
||||
label: "User",
|
||||
icon: User,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
|
||||
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
|
||||
accentBar: "before:bg-blue-600/40",
|
||||
headerText: "text-blue-300",
|
||||
headerText: "text-blue-700 dark:text-blue-300",
|
||||
},
|
||||
assistant: {
|
||||
label: "Assistant",
|
||||
icon: Bot,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
|
||||
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
|
||||
accentBar: "before:bg-violet-500/40",
|
||||
headerText: "text-violet-200",
|
||||
headerText: "text-violet-800 dark:text-violet-200",
|
||||
},
|
||||
orchestrator: {
|
||||
label: "Main agent",
|
||||
icon: Workflow,
|
||||
avatarRing:
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
|
||||
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
|
||||
accentBar: "before:bg-teal-500/40",
|
||||
headerText: "text-teal-200",
|
||||
headerText: "text-teal-800 dark:text-teal-200",
|
||||
},
|
||||
system: {
|
||||
label: "System",
|
||||
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
|
||||
return (
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
|
||||
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
|
||||
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
|
||||
<span className="text-fg-muted">Renamed session →</span>
|
||||
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
|
||||
{timestamp && (
|
||||
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
||||
<CollapsibleBlock
|
||||
key={key}
|
||||
text={seg.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Persisted output"
|
||||
borderClass="border-violet-500/20"
|
||||
bgClass="bg-violet-500/5"
|
||||
textClass="text-violet-300/80"
|
||||
textClass="text-violet-700/80 dark:text-violet-300/80"
|
||||
/>
|
||||
);
|
||||
case "text": {
|
||||
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
|
||||
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
|
||||
<span className="text-fg-muted">·</span>
|
||||
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
|
||||
<span className="text-orange-700/70 dark:text-orange-300/70">
|
||||
↑ {fmt(msg.usage.output_tokens)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
<CollapsibleBlock
|
||||
key={bIdx}
|
||||
text={block.text}
|
||||
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
|
||||
icon={
|
||||
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
|
||||
}
|
||||
title="Task Notification"
|
||||
borderClass="border-cyan-500/20"
|
||||
bgClass="bg-cyan-500/5"
|
||||
textClass="text-cyan-400/80"
|
||||
textClass="text-cyan-700/80 dark:text-cyan-400/80"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<FileText className="w-3.5 h-3.5 text-violet-400" />
|
||||
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
|
||||
<span className="font-mono">{obj.file_path}</span>
|
||||
{obj.replace_all === true && (
|
||||
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
|
||||
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
if (tool === "read" && typeof obj.file_path === "string") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
|
||||
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
|
||||
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
|
||||
<span className="font-mono break-all">{obj.file_path}</span>
|
||||
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
|
||||
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
|
||||
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
|
||||
Pattern
|
||||
</span>
|
||||
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
|
||||
{obj.pattern}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,8 @@ export interface ToolStyle {
|
||||
|
||||
const VIOLET: ToolStyle = {
|
||||
Icon: Wrench,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
};
|
||||
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
|
||||
const STYLES: Record<string, ToolStyle> = {
|
||||
bash: {
|
||||
Icon: Terminal,
|
||||
text: "text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-300",
|
||||
text: "text-emerald-700 dark:text-emerald-300",
|
||||
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
||||
bar: "bg-emerald-500/60",
|
||||
border: "border-emerald-500/20",
|
||||
},
|
||||
read: {
|
||||
Icon: FileText,
|
||||
text: "text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-300",
|
||||
text: "text-sky-700 dark:text-sky-300",
|
||||
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
|
||||
bar: "bg-sky-500/60",
|
||||
border: "border-sky-500/20",
|
||||
},
|
||||
write: {
|
||||
Icon: FilePlus2,
|
||||
text: "text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-300",
|
||||
text: "text-violet-700 dark:text-violet-300",
|
||||
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
|
||||
bar: "bg-violet-500/60",
|
||||
border: "border-violet-500/20",
|
||||
},
|
||||
edit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
multiedit: {
|
||||
Icon: FilePen,
|
||||
text: "text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-300",
|
||||
text: "text-amber-700 dark:text-amber-300",
|
||||
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
||||
bar: "bg-amber-500/60",
|
||||
border: "border-amber-500/20",
|
||||
},
|
||||
grep: {
|
||||
Icon: Search,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
glob: {
|
||||
Icon: FolderTree,
|
||||
text: "text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-300",
|
||||
text: "text-cyan-700 dark:text-cyan-300",
|
||||
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
|
||||
bar: "bg-cyan-500/60",
|
||||
border: "border-cyan-500/20",
|
||||
},
|
||||
webfetch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
websearch: {
|
||||
Icon: Globe,
|
||||
text: "text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-300",
|
||||
text: "text-blue-700 dark:text-blue-300",
|
||||
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
|
||||
bar: "bg-blue-500/60",
|
||||
border: "border-blue-500/20",
|
||||
},
|
||||
task: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
agent: {
|
||||
Icon: Bot,
|
||||
text: "text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-300",
|
||||
text: "text-pink-700 dark:text-pink-300",
|
||||
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
|
||||
bar: "bg-pink-500/60",
|
||||
border: "border-pink-500/20",
|
||||
},
|
||||
todowrite: {
|
||||
Icon: ListTodo,
|
||||
text: "text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-300",
|
||||
text: "text-rose-700 dark:text-rose-300",
|
||||
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
|
||||
bar: "bg-rose-500/60",
|
||||
border: "border-rose-500/20",
|
||||
},
|
||||
schedulewakeup: {
|
||||
Icon: Clock,
|
||||
text: "text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-300",
|
||||
text: "text-orange-700 dark:text-orange-300",
|
||||
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
|
||||
bar: "bg-orange-500/60",
|
||||
border: "border-orange-500/20",
|
||||
},
|
||||
skill: {
|
||||
Icon: Sparkles,
|
||||
text: "text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-300",
|
||||
text: "text-fuchsia-700 dark:text-fuchsia-300",
|
||||
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
|
||||
bar: "bg-fuchsia-500/60",
|
||||
border: "border-fuchsia-500/20",
|
||||
},
|
||||
|
||||
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
let newLine = hunk.newStart;
|
||||
return (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
|
||||
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
|
||||
</div>
|
||||
<table className="w-full border-collapse">
|
||||
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
|
||||
const showNew = kind !== "remove";
|
||||
const rowBg =
|
||||
kind === "add"
|
||||
? "bg-green-500/10 text-green-200"
|
||||
? "bg-green-500/10 text-green-800 dark:text-green-200"
|
||||
: kind === "remove"
|
||||
? "bg-status-danger/10 text-status-danger"
|
||||
: "text-fg-secondary";
|
||||
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
|
||||
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
|
||||
{matches.map((m, i) => (
|
||||
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
|
||||
{m.file && <span className="text-cyan-300">{m.file}</span>}
|
||||
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
|
||||
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
|
||||
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
|
||||
</li>
|
||||
|
||||
@@ -19,25 +19,15 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { ChevronDown, FolderOpen } from "lucide-react";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { FolderBrowseModal } from "./FolderBrowseModal";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AiRouter, CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneTool, LaneWorktree } from "../../lib/types";
|
||||
|
||||
/** Mirrors server/lib/worktree.js `slugify`, for the branch-name PREVIEW only.
|
||||
* Nothing is sent from it: an empty branch field means "let the server name
|
||||
* it", which is how `LANE_BRANCH_PREFIX` keeps working. */
|
||||
function slugPreview(title: string) {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -121,13 +111,30 @@ export function AddLaneModal({
|
||||
const [routers, setRouters] = useState<AiRouter[]>([]);
|
||||
const [routersLoaded, setRoutersLoaded] = useState(false);
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
const [tool, setTool] = useState<LaneTool>("claude");
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled)
|
||||
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
|
||||
const [toolListOpen, setToolListOpen] = useState(false);
|
||||
const pickTool = (id: string) => {
|
||||
setPickedTool(id === defaultTool ? null : id);
|
||||
// A tool with no endpoint to rewrite runs native only (see router-apply.js).
|
||||
if (!aiTools.byId(id)?.routerCapable) setRouter("native");
|
||||
setToolListOpen(false);
|
||||
};
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const toolRouterCapable =
|
||||
aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browseOpen, setBrowseOpen] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<{
|
||||
profile: "scaffolded" | "skipped" | "failed";
|
||||
agents: "ok" | "failed";
|
||||
mcp: "ok" | "failed";
|
||||
router: "ok" | "skipped" | "failed";
|
||||
} | null>(null);
|
||||
@@ -143,7 +150,7 @@ export function AddLaneModal({
|
||||
setBase("");
|
||||
setRouter("native");
|
||||
setRoutersLoaded(false);
|
||||
setTool("claude");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -306,7 +313,7 @@ export function AddLaneModal({
|
||||
// POST /worktree returns as soon as the DB row exists (202) — the
|
||||
// actual `git worktree add` runs afterward, in the background, on the
|
||||
// server. Firing setup against the lane's cwd before that finishes
|
||||
// means agents/mcp write into a directory that doesn't exist yet, so
|
||||
// means mcp writes into a directory that doesn't exist yet, so
|
||||
// wait for provisioning to leave the "provisioning" status first.
|
||||
const provisionedLane = await waitForProvisioned(result.lane.id);
|
||||
if (!provisionedLane || provisionedLane.status === "failed") {
|
||||
@@ -315,9 +322,8 @@ export function AddLaneModal({
|
||||
return;
|
||||
}
|
||||
|
||||
const [profileOutcome, agentsOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
const [profileOutcome, mcpOutcome, routerOutcome] = await Promise.allSettled([
|
||||
api.lanes.profileInit(result.lane.id),
|
||||
api.lanes.agentsInstall(result.lane.id),
|
||||
api.lanes.mcpSync(result.lane.id),
|
||||
applyRouter(result.lane.id),
|
||||
]);
|
||||
@@ -328,7 +334,6 @@ export function AddLaneModal({
|
||||
? "scaffolded"
|
||||
: "skipped"
|
||||
: "failed",
|
||||
agents: agentsOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
mcp: mcpOutcome.status === "fulfilled" ? "ok" : "failed",
|
||||
router:
|
||||
routerOutcome.status !== "fulfilled" ? "failed" : router === "native" ? "skipped" : "ok",
|
||||
@@ -340,7 +345,6 @@ export function AddLaneModal({
|
||||
console.info("[add-lane] auto-setup result:", {
|
||||
profile:
|
||||
profileOutcome.status === "fulfilled" ? profileOutcome.value : profileOutcome.reason,
|
||||
agents: agentsOutcome.status === "fulfilled" ? agentsOutcome.value : agentsOutcome.reason,
|
||||
mcp: mcpOutcome.status === "fulfilled" ? mcpOutcome.value : mcpOutcome.reason,
|
||||
// Never the profile itself: it would put an endpoint and a lane id in
|
||||
// the browser console. (The key is never sent here at all.)
|
||||
@@ -391,7 +395,7 @@ export function AddLaneModal({
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches || (branchMode === "existing" && !branch.trim()))));
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
/** branch name -> the worktree directory already holding it. git allows one
|
||||
* checkout per branch, so these are the branches this form cannot use. */
|
||||
@@ -430,18 +434,62 @@ export function AddLaneModal({
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
<div className="flex items-center rounded-md border border-border bg-surface-2 p-0.5">
|
||||
<Seg
|
||||
active={tool === "claude"}
|
||||
label={t("addLaneToolClaude")}
|
||||
onClick={() => setTool("claude")}
|
||||
/>
|
||||
<Seg
|
||||
active={tool === "codex"}
|
||||
label={t("addLaneToolCodex")}
|
||||
onClick={() => setTool("codex")}
|
||||
/>
|
||||
</div>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings#ai-tools" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -565,7 +613,18 @@ export function AddLaneModal({
|
||||
{t("addLaneBaseLabel")}
|
||||
</label>
|
||||
{branches.length === 0 ? (
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
// No commit, nothing to fork: the server refuses this with
|
||||
// EEMPTYREPO, so the one way forward is offered instead.
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] text-fg-muted">{t("addLaneNoBranches")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("repo")}
|
||||
className="rounded-md border border-border-light px-2 py-0.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("addLaneNoBranchesUseRepo")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="add-lane-base"
|
||||
@@ -589,12 +648,10 @@ export function AddLaneModal({
|
||||
id="add-lane-branch"
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
// The placeholder is what the SERVER will name the branch
|
||||
// if this is left empty, so the default is visible without
|
||||
// the form having to duplicate LANE_BRANCH_PREFIX.
|
||||
placeholder={
|
||||
title.trim() ? `feat/${slugPreview(title)}` : t("addLaneBranchPlaceholder")
|
||||
}
|
||||
// Neutral on purpose: an empty field lets the SERVER name
|
||||
// the branch, under a LANE_BRANCH_PREFIX this form cannot
|
||||
// see, so any `feat/…` preview here could be wrong.
|
||||
placeholder={t("addLaneBranchPlaceholder")}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("addLaneBranchHintAuto")}</p>
|
||||
@@ -645,7 +702,10 @@ export function AddLaneModal({
|
||||
<div className="flex gap-2">
|
||||
{(
|
||||
[
|
||||
{ value: "native", label: t("addLaneRouterNative") },
|
||||
{
|
||||
value: "native",
|
||||
label: t("addLaneRouterNativeTool", { tool: aiTools.label(tool) }),
|
||||
},
|
||||
{ value: "router", label: t("addLaneRouterCustom") },
|
||||
] as const
|
||||
).map((option) => {
|
||||
@@ -653,10 +713,16 @@ export function AddLaneModal({
|
||||
option.value === "native" ? router === "native" : router !== "native";
|
||||
// Clicking "AI Router" before the list lands would resolve back to
|
||||
// Native and read as a dead control.
|
||||
const disabled = option.value === "router" && !routersLoaded;
|
||||
const disabled =
|
||||
option.value === "router" && (!routersLoaded || !toolRouterCapable);
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
title={
|
||||
option.value === "router" && !toolRouterCapable
|
||||
? t("router.nativeOnly", { tool: aiTools.label(tool) })
|
||||
: undefined
|
||||
}
|
||||
// No radio dot: the card itself lights up when selected. That
|
||||
// leaves the DISABLED state with nothing to show it, since the
|
||||
// greyed-out dot was carrying it — hence the explicit branch.
|
||||
@@ -731,7 +797,6 @@ export function AddLaneModal({
|
||||
ok: setupResult.profile !== "failed",
|
||||
skipped: setupResult.profile === "skipped",
|
||||
},
|
||||
{ label: t("addLaneSetupAgents"), ok: setupResult.agents === "ok" },
|
||||
{ label: t("addLaneSetupMcp"), ok: setupResult.mcp === "ok" },
|
||||
{
|
||||
label: t("addLaneSetupRouter"),
|
||||
|
||||
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file One lane's card: branch/CI/PR facts, the "needs you"
|
||||
* @file One lane's card: CI/PR facts, the "needs you"
|
||||
* banner sourced from Claude Code's Notification hook, and (for managed
|
||||
* lanes only) a ⋯ menu holding the rare `reset worktree` action behind
|
||||
* DestructiveLaneModal. A dead lane (its driving session went silent while
|
||||
@@ -16,74 +16,34 @@
|
||||
* install / MCP sync / dev-sync check / create-update readme) and the
|
||||
* remove-lane button live in Workspace's page header / pipeline-picker row
|
||||
* instead of here, so they stay reachable and un-clipped regardless of this
|
||||
* card's own scroll area.
|
||||
* card's own scroll area. The working copy — branch, uncommitted counts, the
|
||||
* git action bar — is not here either: it heads the Changes panel
|
||||
* (`LaneGitHeader`), beside the diff it describes.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestructiveLaneModal } from "./DestructiveLaneModal";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { AiRouter } from "../../lib/api";
|
||||
import type { Lane, LaneGitFacts, LaneGitFile, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import type { Lane, LaneRuntime, NamedLock } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
/** How often a mounted card re-reads its working-copy facts. Slow on purpose:
|
||||
* each call is five git subprocesses server-side, and a branch name does not
|
||||
* change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/** How often a mounted card re-probes its stack. Faster than the git refresh
|
||||
* because a stack dying is exactly what the user needs to see, and slower than
|
||||
/** How often a mounted card re-probes its stack. Faster than the Changes panel's
|
||||
* git refresh because a stack dying is exactly what the user needs to see, and slower than
|
||||
* the lane poll because each call opens a socket per declared port. */
|
||||
const RUNTIME_REFRESH_MS = 10_000;
|
||||
|
||||
/** How often a mounted card re-reads locks held by this lane. Same refresh rate
|
||||
* as the git facts (slow, since lock state changes infrequently). */
|
||||
* as the Changes panel's git facts (slow, since lock state changes infrequently). */
|
||||
const LOCKS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched per card rather than folded into the
|
||||
* polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders as a card without a git row.
|
||||
*/
|
||||
function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a card that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The lane's own application stack. Same shape and same silence as the git
|
||||
* facts above: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* facts in LaneGitHeader: a lane without a `.agenthub/profile` simply has no runtime row, which
|
||||
* is the common case and not an error worth a banner.
|
||||
*
|
||||
* `bump` lets an up/down action re-read immediately instead of waiting out the
|
||||
@@ -161,6 +121,13 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
*/
|
||||
export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const toolLabel = aiTools.label(lane.tool || "claude");
|
||||
// Only Claude and Codex have an endpoint/model override to write; every
|
||||
// other tool is offered native alone (the server refuses the rest anyway).
|
||||
const routerCapable =
|
||||
aiTools.byId(lane.tool || "claude")?.routerCapable ??
|
||||
(lane.tool === "claude" || lane.tool === "codex" || !lane.tool);
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -191,7 +158,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
if (alive) setRouters(r.items);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent, same contract as the git/runtime pollers above: a card that
|
||||
// Silent, same contract as the runtime poller above: a card that
|
||||
// cannot list routers simply shows no router row.
|
||||
if (alive) setRouters([]);
|
||||
});
|
||||
@@ -266,8 +233,15 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
className="max-w-40 truncate rounded border border-border bg-surface-1 px-2 py-0.5 text-fg-secondary disabled:opacity-50"
|
||||
>
|
||||
{routers.map((r) => (
|
||||
<option key={r.name} value={r.name}>
|
||||
{r.label}
|
||||
<option
|
||||
key={r.name}
|
||||
value={r.name}
|
||||
disabled={!r.builtin && !routerCapable}
|
||||
title={
|
||||
!r.builtin && !routerCapable ? t("router.nativeOnly", { tool: toolLabel }) : undefined
|
||||
}
|
||||
>
|
||||
{r.builtin ? t("router.native", { tool: toolLabel }) : r.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -312,7 +286,7 @@ export function since(sec: number | null): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo tooling (agents install / MCP sync / dev-sync check, and the
|
||||
* Repo tooling (MCP sync / dev-sync check, and the
|
||||
* create/update-readme run, which moved here from the Add-lane modal) for one lane,
|
||||
* collapsed into a single dropdown. Lives outside LaneCard so it can be
|
||||
* placed in the Workspace page header, next to "Add lane" — it polls its
|
||||
@@ -322,16 +296,11 @@ export function since(sec: number | null): string {
|
||||
export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [toolsMenuOpen, setToolsMenuOpen] = useState(false);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<
|
||||
"agents" | "mcp" | "sync-check" | "docs" | null
|
||||
>(null);
|
||||
const [laneActionBusy, setLaneActionBusy] = useState<"mcp" | "sync-check" | "docs" | null>(null);
|
||||
const [laneActionResult, setLaneActionResult] = useState<string | null>(null);
|
||||
const runtime = useLaneRuntime(lane.id, 0);
|
||||
|
||||
const runLaneAction = async (
|
||||
which: "agents" | "mcp" | "sync-check" | "docs",
|
||||
fn: () => Promise<string>
|
||||
) => {
|
||||
const runLaneAction = async (which: "mcp" | "sync-check" | "docs", fn: () => Promise<string>) => {
|
||||
setLaneActionBusy(which);
|
||||
setLaneActionResult(null);
|
||||
try {
|
||||
@@ -343,12 +312,6 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentsInstall = () =>
|
||||
runLaneAction("agents", async () => {
|
||||
const result = await api.lanes.agentsInstall(lane.id);
|
||||
return t("actions.agentsInstallResult", { files: result.installed.join(", ") });
|
||||
});
|
||||
|
||||
// A skill only exists inside a CLI session, so there is nothing server-side
|
||||
// to call: this opens the lane's own terminal run with the slash command as
|
||||
// its first message. Claude-only — codex has no slash commands.
|
||||
@@ -404,20 +367,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
role="menu"
|
||||
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="lane-agents-install"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setToolsMenuOpen(false);
|
||||
void handleAgentsInstall();
|
||||
}}
|
||||
className="block w-full px-3 py-1.5 text-left text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("actions.agentsInstall")}
|
||||
</button>
|
||||
{lane.tool !== "codex" && (
|
||||
{lane.tool === "claude" && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@@ -473,147 +423,11 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`, for the same reason this
|
||||
* file's own header says the tools dropdown was moved out of the card: a lane
|
||||
* card renders inside the Workspace's `max-h-[45vh] overflow-y-auto` box, and
|
||||
* an absolutely positioned panel would be clipped by it for every card that is
|
||||
* not at the very top. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default function LaneCard({
|
||||
lane,
|
||||
onAction,
|
||||
childWorktrees,
|
||||
onSelectLane,
|
||||
onNewLaneFromRepo,
|
||||
}: {
|
||||
lane: Lane;
|
||||
onAction: (action: string, body?: Record<string, unknown>) => void;
|
||||
@@ -623,31 +437,12 @@ export default function LaneCard({
|
||||
childWorktrees?: Lane[];
|
||||
/** Jumps the Workspace page's selection to another lane's card. */
|
||||
onSelectLane?: (id: number) => void;
|
||||
/** Opens the Add-lane flow prefilled with this lane's repo, for a sibling
|
||||
* worktree on `branch`. Omitted where that flow is not reachable. */
|
||||
onNewLaneFromRepo?: (repo: string, branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [destructiveAction, setDestructiveAction] = useState<"reset" | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [runtimeBump, setRuntimeBump] = useState(0);
|
||||
const [bootLine, setBootLine] = useState<string | null>(null);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
const [git, refreshGit] = useLaneGitFacts(lane.id);
|
||||
const runtime = useLaneRuntime(lane.id, runtimeBump);
|
||||
const locks = useLaneLocks(lane.slot);
|
||||
|
||||
@@ -676,28 +471,20 @@ export default function LaneCard({
|
||||
<>
|
||||
<div
|
||||
data-testid={`lane-card-${lane.id}`}
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light"
|
||||
// Every row below is conditional: a lane with nothing to report
|
||||
// renders no frame at all rather than an empty box.
|
||||
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
|
||||
>
|
||||
{(lane.ci_status || lane.tool === "codex") && (
|
||||
{lane.ci_status && (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
|
||||
{lane.ci_status && (
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||
CI {lane.ci_status}
|
||||
</span>
|
||||
)}
|
||||
{lane.tool === "codex" && (
|
||||
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
||||
Codex
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
||||
CI {lane.ci_status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lane.needs_action && (
|
||||
<div className="mb-2 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1 text-xs text-status-warning">
|
||||
⚠ {lane.needs_action}
|
||||
</div>
|
||||
)}
|
||||
{/* No needs-you banner here: the strip card already flags it, and the
|
||||
console directly below is where the prompt actually waits. */}
|
||||
|
||||
{/* Runtime strip: the lane's own app stack, shown only for lanes that
|
||||
declare a profile. A port that drifted from `base + slot` is called
|
||||
@@ -773,73 +560,6 @@ export default function LaneCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One line, because every part of it is short and the panel is
|
||||
narrow: branch, head, subject, and the uncommitted counts read as a
|
||||
sentence rather than a four-row block. The cwd is NOT here — the
|
||||
lane-detail header above this card already spells it out. */}
|
||||
{git?.available && (
|
||||
<div className="mb-2">
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex items-baseline gap-2 truncate font-mono text-[11px] text-fg-secondary"
|
||||
title={git.subject}
|
||||
>
|
||||
<span className="shrink-0">⑂ {git.branch}</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="truncate text-fg-muted">{git.subject}</span>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help text-status-warning/80 underline decoration-dotted underline-offset-2"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{git?.available && (
|
||||
<LaneGitBar
|
||||
laneId={lane.id}
|
||||
git={git}
|
||||
onChanged={refreshGit}
|
||||
/* A sibling worktree forks from the lane's SOURCE repo when it
|
||||
has one (this lane is itself a worktree of it); an adopted lane
|
||||
is its own repo. */
|
||||
onNewLane={
|
||||
onNewLaneFromRepo
|
||||
? (branch) => onNewLaneFromRepo(lane.source_repo || lane.cwd, branch)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* The lane's own recorded branch, shown only when git could not be
|
||||
read — otherwise it duplicates the live branch above. */}
|
||||
{!git?.available && lane.branch && (
|
||||
<div className="mb-2 truncate font-mono text-[11px] text-fg-secondary">
|
||||
⑂ {lane.branch}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{childWorktrees && childWorktrees.length > 0 && (
|
||||
<div
|
||||
data-testid="lane-child-worktrees"
|
||||
@@ -854,7 +574,7 @@ export default function LaneCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectLane?.(w.id)}
|
||||
className="truncate text-left text-blue-400 hover:underline"
|
||||
className="truncate text-left text-blue-700 dark:text-blue-400 hover:underline"
|
||||
title={w.cwd}
|
||||
>
|
||||
#{w.id} {w.title || w.cwd} · {w.status}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @file LaneConflictView.tsx
|
||||
* @description The Changes panel's view of ONE conflicted file during a merge
|
||||
* (issue #45): the file with each conflict block drawn as its two sides —
|
||||
* "mine" and "theirs", labelled from git's own markers and tinted apart — and
|
||||
* a Keep mine / Take theirs / Keep both choice per block, undoable. Save and
|
||||
* mark resolved writes the chosen text over exactly the version it opened
|
||||
* (`gitSaveFile` with its `sha`, 409 ESTALE otherwise) and then runs
|
||||
* `resolve done`, which re-checks that no marker is left. Take all mine /
|
||||
* theirs is `resolve ours|theirs` for the whole file; Edit by hand hands the
|
||||
* file to the panel's plain editor.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
applyChoices,
|
||||
parseConflicts,
|
||||
type ConflictChoice,
|
||||
type ConflictPart,
|
||||
} from "../../lib/conflicts";
|
||||
|
||||
const BASE =
|
||||
"rounded border px-1.5 py-0.5 text-[11px] hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
const BTN = `${BASE} border-border-light text-fg-secondary hover:text-fg-primary`;
|
||||
|
||||
function Lines({ lines, className = "" }: { lines: string[]; className?: string }) {
|
||||
return (
|
||||
<pre className={`whitespace-pre px-2 font-mono text-[11px] leading-5 ${className}`}>
|
||||
{lines.length ? lines.join("\n") : " "}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
}: {
|
||||
laneId: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
/** Open the file in the panel's plain editor instead. */
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [file, setFile] = useState<{ lines: string[]; sha?: string; usable: boolean } | null>(null);
|
||||
const [choices, setChoices] = useState<(ConflictChoice | undefined)[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [at, setAt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setFile(null);
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(laneId, path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
r.available
|
||||
? { lines: r.lines, sha: r.sha, usable: !r.binary && !r.truncated && !!r.sha }
|
||||
: { lines: [], usable: false }
|
||||
);
|
||||
})
|
||||
.catch((e) => live && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
const chosen = choices.filter(Boolean).length;
|
||||
const done = total > 0 && chosen === total;
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
onResolved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () =>
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(laneId, path, applyChoices(parts, choices).join("\n"), file.sha);
|
||||
await api.lanes.gitAction(laneId, "resolve", { path, side: "done" });
|
||||
});
|
||||
const takeAll = (side: "ours" | "theirs") =>
|
||||
run(() => api.lanes.gitAction(laneId, "resolve", { path, side }));
|
||||
|
||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
const next = [...prev];
|
||||
next[i] = c;
|
||||
return next;
|
||||
});
|
||||
|
||||
const jump = (step: number) => {
|
||||
if (!total) return;
|
||||
const i = (at + step + total) % total;
|
||||
setAt(i);
|
||||
blockRefs.current[i]?.scrollIntoView({ block: "center" });
|
||||
};
|
||||
|
||||
let blockIndex = -1;
|
||||
return (
|
||||
<div data-testid="lane-conflict-view" className="flex min-h-0 flex-1 flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-primary">
|
||||
{path}
|
||||
</span>
|
||||
{total > 0 && (
|
||||
<span className="text-[11px] text-status-warning">
|
||||
{t("conflict.left", { left: total - chosen, total })}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.prev")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(-1)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
aria-label={t("conflict.next")}
|
||||
disabled={!total}
|
||||
onClick={() => jump(1)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void takeAll("ours")}>
|
||||
{t("conflict.allOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy}
|
||||
onClick={() => void takeAll("theirs")}
|
||||
>
|
||||
{t("conflict.allTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} disabled={busy} onClick={onEdit}>
|
||||
{t("conflict.editByHand")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-wrap text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!file ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !parts ? (
|
||||
<p className="text-[11px] text-status-warning">
|
||||
{file.usable ? t("conflict.unbalanced") : t("conflict.unusable")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto rounded border border-border-light bg-surface-0">
|
||||
{parts.map((p, n) => {
|
||||
if (p.kind === "text")
|
||||
return <Lines key={n} lines={p.lines} className="text-fg-primary" />;
|
||||
const i = ++blockIndex;
|
||||
const c = choices[i];
|
||||
return (
|
||||
<div
|
||||
key={n}
|
||||
ref={(el) => {
|
||||
blockRefs.current[i] = el;
|
||||
}}
|
||||
data-testid="conflict-block"
|
||||
className="my-1 border-y-2 border-status-warning/70"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1 bg-surface-2 px-2 py-0.5 text-[11px]">
|
||||
{c ? (
|
||||
<>
|
||||
<span className="text-fg-muted">{t(`conflict.chose.${c}`)}</span>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, undefined)}>
|
||||
{t("conflict.undo")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-status-success/70 font-medium text-status-success`}
|
||||
onClick={() => choose(i, "ours")}
|
||||
>
|
||||
{t("conflict.keepOurs")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${BASE} border-accent/70 font-medium text-accent`}
|
||||
onClick={() => choose(i, "theirs")}
|
||||
>
|
||||
{t("conflict.takeTheirs")}
|
||||
</button>
|
||||
<button type="button" className={BTN} onClick={() => choose(i, "both")}>
|
||||
{t("conflict.keepBoth")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto text-fg-muted">
|
||||
{i + 1}/{total}
|
||||
</span>
|
||||
</div>
|
||||
{c ? (
|
||||
<Lines
|
||||
lines={
|
||||
c === "ours" ? p.ours : c === "theirs" ? p.theirs : [...p.ours, ...p.theirs]
|
||||
}
|
||||
className="bg-surface-2 text-fg-primary"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="border-l-4 border-status-success bg-status-success/20">
|
||||
<div className="bg-status-success/30 px-2 py-0.5 text-[11px] font-semibold text-status-success">
|
||||
{t("conflict.ours", { label: p.oursLabel || "HEAD" })}
|
||||
</div>
|
||||
<Lines lines={p.ours} className="text-fg-primary" />
|
||||
</div>
|
||||
<div className="border-l-4 border-accent bg-accent/20">
|
||||
<div className="bg-accent/30 px-2 py-0.5 text-[11px] font-semibold text-accent">
|
||||
{t("conflict.theirs", { label: p.theirsLabel || "?" })}
|
||||
</div>
|
||||
<Lines lines={p.theirs} className="text-fg-primary" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts && total > 0 && (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
disabled={busy || !chosen}
|
||||
onClick={() => setChoices([])}
|
||||
>
|
||||
{t("conflict.reset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || !done}
|
||||
onClick={() => void save()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-[11px] text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("conflict.save")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,25 @@
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
* (`visibleRange`); the content's min-width comes from the longest line, so
|
||||
* the horizontal extent does not jump as rows come and go.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
|
||||
@@ -25,11 +39,125 @@ export type DiffLayout = "unified" | "split";
|
||||
|
||||
/**
|
||||
* How tall a diff box may be. `fill` (the single-file view) takes the height
|
||||
* its parent gives it; otherwise — the review list, which stacks many files —
|
||||
* each file is capped so its own horizontal scrollbar stays on screen.
|
||||
* its parent gives it. In the review list, which stacks many files, a diff
|
||||
* grows to its full height so the PANEL is the one vertical scroller — a
|
||||
* 60vh box per file nested inside it made hunk navigation scroll two boxes
|
||||
* at once and land nowhere predictable. Only a windowed diff (above
|
||||
* WINDOW_THRESHOLD rows) keeps the cap, because windowing needs its own
|
||||
* scroller.
|
||||
*/
|
||||
function boxHeight(fill: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : "max-h-[60vh]";
|
||||
function boxHeight(fill: boolean, windowed: boolean) {
|
||||
return fill ? "min-h-0 flex-1" : windowed ? "max-h-[60vh]" : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring `el` to the top of the scroller that actually scrolls it, below
|
||||
* `pad` px of sticky header. Not `scrollIntoView`: that moves EVERY scrolling
|
||||
* ancestor, the page included.
|
||||
*/
|
||||
function scrollToTop(el: HTMLElement, pad: number) {
|
||||
let s = el.parentElement;
|
||||
while (
|
||||
s &&
|
||||
!(s.scrollHeight > s.clientHeight && /auto|scroll/.test(getComputedStyle(s).overflowY))
|
||||
)
|
||||
s = s.parentElement;
|
||||
if (!s) return;
|
||||
s.scrollTop += el.getBoundingClientRect().top - s.getBoundingClientRect().top - pad;
|
||||
}
|
||||
|
||||
/** Rows above which a view windows; at or below it renders exactly as before. */
|
||||
export const WINDOW_THRESHOLD = 500;
|
||||
/** A windowed row's pinned height — the rows' own 11px × 1.45 line box, rounded. */
|
||||
export const ROW_PX = 16;
|
||||
const OVERSCAN = 20;
|
||||
const FIXED: CSSProperties = { height: ROW_PX };
|
||||
|
||||
/**
|
||||
* The `[start, end)` item range to render for a scroll position. `scrollTop`
|
||||
* is clamped to the content first, so a stale offset (content that shrank, a
|
||||
* layout switch) still lands on real rows instead of an empty window.
|
||||
*/
|
||||
export function visibleRange(
|
||||
scrollTop: number,
|
||||
viewport: number,
|
||||
rowHeight: number,
|
||||
count: number,
|
||||
overscan: number
|
||||
) {
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, count * rowHeight - viewport));
|
||||
const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
|
||||
const end = Math.min(count, Math.ceil((top + viewport) / rowHeight) + overscan);
|
||||
return { start: Math.min(start, end), end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowing state for one scroller of `count` items. Off (the whole range)
|
||||
* at or below the threshold.
|
||||
*/
|
||||
function useWindow(count: number) {
|
||||
const [top, setTop] = useState(0);
|
||||
const on = count > WINDOW_THRESHOLD;
|
||||
// The offset is tracked even while windowing is off, so a diff that GROWS
|
||||
// past the threshold (expanded context) windows where the reader is, not at
|
||||
// row 0. A ref, so an unwindowed diff does not re-render on every scroll.
|
||||
const lastTop = useRef(0);
|
||||
// count 0 = no scroller here (split layout): the browser drops its offset,
|
||||
// so coming back to unified must start from 0, not the pre-split row.
|
||||
if (count === 0) lastTop.current = 0;
|
||||
const [wasOn, setWasOn] = useState(on);
|
||||
if (wasOn !== on) {
|
||||
setWasOn(on);
|
||||
setTop(on ? lastTop.current : 0);
|
||||
}
|
||||
// ponytail: the viewport is taken as the window height — never smaller than
|
||||
// a box that lives inside the window, so it only over-renders a few dozen
|
||||
// rows, and it needs no ResizeObserver to follow a panel resize.
|
||||
const range = on
|
||||
? visibleRange(top, window.innerHeight, ROW_PX, count, OVERSCAN)
|
||||
: { start: 0, end: count };
|
||||
const onScroll = (el: HTMLElement) => {
|
||||
lastTop.current = el.scrollTop;
|
||||
if (on) setTop(el.scrollTop);
|
||||
};
|
||||
return { on, range, onScroll };
|
||||
}
|
||||
|
||||
/** Columns a line occupies under `whitespace-pre` (a tab counted at its widest). */
|
||||
function longest(texts: Iterable<string>) {
|
||||
let max = 0;
|
||||
for (const t of texts) {
|
||||
let n = t.length;
|
||||
for (let i = t.indexOf("\t"); i !== -1; i = t.indexOf("\t", i + 1)) n += 7;
|
||||
if (n > max) max = n;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Stands in for `rows` off-screen rows. */
|
||||
function Spacer({ rows }: { rows: number }) {
|
||||
return rows > 0 ? <div data-diff-spacer aria-hidden style={{ height: rows * ROW_PX }} /> : null;
|
||||
}
|
||||
|
||||
/** A windowed list: spacer, the rendered slice, spacer, at a stable width. */
|
||||
function Windowed({
|
||||
count,
|
||||
range,
|
||||
minWidth,
|
||||
children,
|
||||
}: {
|
||||
count: number;
|
||||
range: { start: number; end: number };
|
||||
minWidth: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div data-diff-content style={{ minWidth }}>
|
||||
<Spacer rows={range.start} />
|
||||
{children}
|
||||
<Spacer rows={count - range.end} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Language tag for a path, for the tokenizer. Unknown ones render plain. */
|
||||
@@ -86,26 +214,51 @@ function emphasis(
|
||||
};
|
||||
}
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it. */
|
||||
function pairs(
|
||||
lines: LaneDiffLine[]
|
||||
): Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> {
|
||||
const out: Array<{ left: LaneDiffLine | null; right: LaneDiffLine | null }> = [];
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
out.push({ left: line, right: line });
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const dels: LaneDiffLine[] = [];
|
||||
const adds: LaneDiffLine[] = [];
|
||||
while (i < lines.length && lines[i]!.type === "del") dels.push(lines[i++]!);
|
||||
while (i < lines.length && lines[i]!.type === "add") adds.push(lines[i++]!);
|
||||
for (let k = 0; k < Math.max(dels.length, adds.length); k += 1) {
|
||||
out.push({ left: dels[k] ?? null, right: adds[k] ?? null });
|
||||
const delStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "del") i += 1;
|
||||
const addStart = i;
|
||||
while (i < lines.length && lines[i]!.type === "add") i += 1;
|
||||
const dels = addStart - delStart;
|
||||
const adds = i - addStart;
|
||||
for (let k = 0; k < Math.max(dels, adds); k += 1) {
|
||||
const left = k < dels ? lines[delStart + k]! : null;
|
||||
const right = k < adds ? lines[addStart + k]! : null;
|
||||
const e = left && right ? emphasis(left.text, right.text) : null;
|
||||
if (e) {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -115,9 +268,30 @@ function Gutter({ n }: { n: number | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row. */
|
||||
function Row({ tint, children }: { tint: string; children: React.ReactNode }) {
|
||||
return <div className={`flex w-max min-w-full ${tint}`}>{children}</div>;
|
||||
/** A diff row. `w-max min-w-full` so the tint still spans a scrolled-open row.
|
||||
* `style` pins a windowed row's height; unwindowed rows carry none. */
|
||||
function Row({
|
||||
tint,
|
||||
style,
|
||||
anchor,
|
||||
children,
|
||||
}: {
|
||||
tint: string;
|
||||
style?: CSSProperties;
|
||||
/** This row starts change block `anchor` (file-wide): the bar's arrows land here. */
|
||||
anchor?: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex w-max min-w-full ${tint}`}
|
||||
style={style}
|
||||
data-diff-item={style ? "" : undefined}
|
||||
data-change-index={anchor}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A /20 fill plus a 3px status bar at the row's left edge, so +/- rows still
|
||||
@@ -135,44 +309,246 @@ function markerClass(type: LaneDiffLine["type"]) {
|
||||
return "text-fg-muted";
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk }: { hunk: LaneDiffHunk }) {
|
||||
const at = hunk.header.split("@@")[1];
|
||||
/** One change block: a run of consecutive +/- lines inside a hunk. git merges
|
||||
* edits a few lines apart into one `@@` hunk, so the review bar acts on these
|
||||
* instead (#50) — `block` is the index the server's `stage-hunk` /
|
||||
* `discard-hunk` take beside the hunk's header. */
|
||||
export type LaneDiffChange = {
|
||||
hunk: LaneDiffHunk;
|
||||
block: number;
|
||||
/** First/last index in `hunk.lines` (unified). */
|
||||
firstLine: number;
|
||||
lastLine: number;
|
||||
/** First/last split-view row. */
|
||||
firstRow: number;
|
||||
lastRow: number;
|
||||
};
|
||||
|
||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||
|
||||
/** A hunk's change blocks, cached per hunk object like `analyseHunk`. */
|
||||
export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
||||
const hit = blocksOf.get(hunk);
|
||||
if (hit) return hit;
|
||||
const out: LaneDiffChange[] = [];
|
||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (line.type === "ctx") return;
|
||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||
if (!c) {
|
||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
||||
out.push(c);
|
||||
}
|
||||
c.lastLine = i;
|
||||
owner.set(line, c);
|
||||
});
|
||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||||
if (!c) return;
|
||||
if (c.firstRow < 0) c.firstRow = i;
|
||||
c.lastRow = i;
|
||||
});
|
||||
blocksOf.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The block whose last line (unified) / last row (split) is `i`, if any. */
|
||||
const barAtLine = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.lastLine === i);
|
||||
const barAtRow = (hunk: LaneDiffHunk, i: number) => changeBlocks(hunk).find((c) => c.lastRow === i);
|
||||
const startAtLine = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.firstLine === i);
|
||||
const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
||||
changeBlocks(hunk).find((c) => c.firstRow === i);
|
||||
|
||||
/** Per-block review controls (Undo / Keep), drawn in a bar under each change
|
||||
* block's last line. A context rather than a prop because lines render from
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (change: LaneDiffChange) => ReactNode;
|
||||
changes: LaneDiffChange[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
|
||||
/** File-wide index of `change`, for a row's `anchor`. */
|
||||
function useAnchor(change?: LaneDiffChange) {
|
||||
const ctx = useContext(HunkActions);
|
||||
return change && ctx ? ctx.changes.indexOf(change) : undefined;
|
||||
}
|
||||
|
||||
/** The review bar: hangs just BELOW a change block's last line, pinned to the
|
||||
* visible right edge, so it sits on the change it acts on — the editor-style
|
||||
* "^ v 2 of 3 · Undo · Keep" instead of glyphs at the end of the `@@` line. */
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
const nav =
|
||||
"rounded px-0.5 text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-30";
|
||||
return (
|
||||
<div className="bg-surface-2 px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
<span className="sticky right-2 z-10 ml-auto h-0 w-0 self-start">
|
||||
<span
|
||||
data-testid="lane-hunk-bar"
|
||||
className="absolute right-0 top-[18px] flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.prevHunk")}
|
||||
title={t("changes.prevHunk")}
|
||||
disabled={index <= 0}
|
||||
onClick={() => ctx.go(index - 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("changes.nextHunk")}
|
||||
title={t("changes.nextHunk")}
|
||||
disabled={index >= total - 1}
|
||||
onClick={() => ctx.go(index + 1)}
|
||||
className={nav}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="px-0.5 tabular-nums text-fg-muted">
|
||||
{t("changes.hunkOf", { n: index + 1, total })}
|
||||
</span>
|
||||
{ctx.actions(change)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function HunkHeader({ hunk, fixed }: { hunk: LaneDiffHunk; fixed?: boolean }) {
|
||||
const at = hunk.header.split("@@")[1];
|
||||
// Windowed, the header is one ROW_PX row like the lines around it.
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 bg-surface-2 px-2 text-[10px] text-fg-muted ${
|
||||
fixed ? "overflow-hidden whitespace-nowrap leading-4" : "py-0.5"
|
||||
}`}
|
||||
style={fixed ? FIXED : undefined}
|
||||
data-diff-item={fixed ? "" : undefined}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{at ? `@@${at}@@` : hunk.header}
|
||||
{hunk.context ? ` ${hunk.context}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedLine({
|
||||
line,
|
||||
lang,
|
||||
emph,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
line: LaneDiffLine;
|
||||
lang: string;
|
||||
emph?: [number, number];
|
||||
style?: CSSProperties;
|
||||
/** This is a change block's last line: hang its review bar under it. */
|
||||
bar?: LaneDiffChange;
|
||||
/** This is a change block's first line: the bar's arrows scroll here. */
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line.type)} style={style} anchor={anchor}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph} />
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
function UnifiedHunk({ hunk, lang }: { hunk: LaneDiffHunk; lang: string }) {
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (const { left, right } of pairs(hunk.lines)) {
|
||||
if (!left || !right || left.type !== "del" || right.type !== "add") continue;
|
||||
const e = emphasis(left.text, right.text);
|
||||
if (!e) continue;
|
||||
emph.set(hunk.lines.indexOf(left), e.del);
|
||||
emph.set(hunk.lines.indexOf(right), e.add);
|
||||
}
|
||||
const { emph } = analyseHunk(hunk);
|
||||
return (
|
||||
<div>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{hunk.lines.map((line, i) => (
|
||||
<Row key={i} tint={rowTint(line.type)}>
|
||||
<Gutter n={line.oldLine} />
|
||||
<Gutter n={line.newLine} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<span className={`select-none ${markerClass(line.type)}`}>
|
||||
{line.type === "add" ? "+" : line.type === "del" ? "-" : " "}
|
||||
</span>
|
||||
<Code text={line.text} lang={lang} emph={emph.get(i)} />
|
||||
</span>
|
||||
</Row>
|
||||
<UnifiedLine
|
||||
key={i}
|
||||
line={line}
|
||||
lang={lang}
|
||||
emph={emph.get(i)}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One windowable item: a hunk's header (`i < 0`) or its i-th row — a line in
|
||||
* unified, a paired row in split. */
|
||||
type Item = { hunk: LaneDiffHunk; i: number };
|
||||
|
||||
function flatten(hunks: LaneDiffHunk[], layout: DiffLayout): Item[] {
|
||||
const out: Item[] = [];
|
||||
for (const hunk of hunks) {
|
||||
out.push({ hunk, i: -1 });
|
||||
const n = layout === "split" ? analyseHunk(hunk).rows.length : hunk.lines.length;
|
||||
for (let i = 0; i < n; i += 1) out.push({ hunk, i });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
return longest(
|
||||
(function* () {
|
||||
for (const h of hunks) for (const l of h.lines) yield l.text;
|
||||
})()
|
||||
);
|
||||
}
|
||||
|
||||
function SplitLine({
|
||||
row,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
row: PairRow;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffChange;
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null)} style={style} anchor={anchor}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** One side of the split view: a hunk header, then that side's rows. */
|
||||
function SplitColumn({
|
||||
hunks,
|
||||
@@ -188,52 +564,123 @@ function SplitColumn({
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{pairs(hunk.lines).map(({ left, right }, i) => {
|
||||
const e =
|
||||
left && right && left.type === "del" && right.type === "add"
|
||||
? emphasis(left.text, right.text)
|
||||
: null;
|
||||
const line = side === "left" ? left : right;
|
||||
return (
|
||||
<Row key={i} tint={rowTint(line?.type ?? null)}>
|
||||
<Gutter n={(side === "left" ? line?.oldLine : line?.newLine) ?? null} />
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code text={line.text} lang={lang} emph={side === "left" ? e?.del : e?.add} />
|
||||
) : null}
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per change block: on the right (new) column only.
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Both sides, each scrolling horizontally on its own but kept in step. */
|
||||
/** A windowed split column: the shared range of items, one side of each. */
|
||||
function SplitSlice({
|
||||
items,
|
||||
range,
|
||||
lang,
|
||||
side,
|
||||
}: {
|
||||
items: Item[];
|
||||
range: { start: number; end: number };
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items
|
||||
.slice(range.start, range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `v` held inside a scroller's own extent. */
|
||||
export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
return Math.min(v, Math.max(0, scrollSize - clientSize));
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
];
|
||||
|
||||
/** Mirror `src`'s offset onto `dst`, clamped to what `dst` can show. */
|
||||
function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
for (const [pos, size, client] of AXES) {
|
||||
// `src` pinned at its own end while `dst` sits past it: `src` is the
|
||||
// narrower column echoing a clamp we just caused. Pulling `dst` back to
|
||||
// it would snap the wide column home every time it scrolls past the
|
||||
// narrow one's longest line.
|
||||
if (src[pos] >= clampScroll(Infinity, src[size], src[client]) && dst[pos] > src[pos]) continue;
|
||||
const target = clampScroll(src[pos], dst[size], dst[client]);
|
||||
if (dst[pos] !== target) dst[pos] = target;
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
// Mirroring fires the other pane's own scroll handler, which mirrors back —
|
||||
// the equality check is what ends that, so no re-entry flag is needed.
|
||||
const sync = useCallback((from: "left" | "right") => {
|
||||
// the equality check (and `follow`'s edge rule) is what ends that, so no
|
||||
// re-entry flag is needed. Both axes: the columns hold paired rows, so a
|
||||
// vertical drift puts a deletion beside some unrelated line. Both columns
|
||||
// window off the same offset, so they always hold the same row range.
|
||||
const sync = (from: "left" | "right") => {
|
||||
const src = from === "left" ? left.current : right.current;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst || dst.scrollLeft === src.scrollLeft) return;
|
||||
dst.scrollLeft = src.scrollLeft;
|
||||
}, []);
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
win.onScroll(src);
|
||||
};
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
<SplitSlice items={items} range={win.range} lang={lang} side={side} />
|
||||
</Windowed>
|
||||
) : (
|
||||
<SplitColumn hunks={hunks} lang={lang} side={side} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex ${boxHeight(fill)}`}>
|
||||
<div className={`flex ${boxHeight(fill, win.on)}`}>
|
||||
<div
|
||||
ref={left}
|
||||
data-testid="lane-diff-split-left"
|
||||
onScroll={() => sync("left")}
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="left" />
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
@@ -241,7 +688,7 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
>
|
||||
<SplitColumn hunks={hunks} lang={lang} side="right" />
|
||||
{column("right")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -252,14 +699,55 @@ export function LaneDiffView({
|
||||
file,
|
||||
layout,
|
||||
fill = false,
|
||||
hunkActions,
|
||||
}: {
|
||||
file: LaneChangeFile;
|
||||
layout: DiffLayout;
|
||||
/** Take the parent's height (single-file view) instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Controls drawn in each change block's review bar. */
|
||||
hunkActions?: (change: LaneDiffChange) => ReactNode;
|
||||
}) {
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(file.path);
|
||||
const hunks = file.hunks;
|
||||
const items = useMemo(() => (hunks ? flatten(hunks, "unified") : []), [hunks]);
|
||||
const minWidth = useMemo(
|
||||
// two gutters + the row's px-2, plus the marker column
|
||||
() => `calc(${hunks ? longestInHunks(hunks) + 1 : 0}ch + 6rem)`,
|
||||
[hunks]
|
||||
);
|
||||
// Unified only; split windows inside its own two scrollers.
|
||||
const win = useWindow(layout === "unified" ? items.length : 0);
|
||||
const splitWindowed = useMemo(
|
||||
() => layout === "split" && !!hunks && flatten(hunks, "split").length > WINDOW_THRESHOLD,
|
||||
[layout, hunks]
|
||||
);
|
||||
|
||||
const changes = useMemo(() => (hunks ? hunks.flatMap(changeBlocks) : []), [hunks]);
|
||||
// The bar's arrows: bring change block `index`'s first line into view. A
|
||||
// windowed unified diff may not have it in the DOM, so there it is found by
|
||||
// offset.
|
||||
const go = (index: number) => {
|
||||
const el = root.current;
|
||||
const target = changes[index];
|
||||
if (!el || !target) return;
|
||||
const header = el.querySelector<HTMLElement>(`[data-change-index="${index}"]`);
|
||||
// The review list pins each file's header over its own diff.
|
||||
const sticky = el
|
||||
.closest("[data-review-file]")
|
||||
?.querySelector<HTMLElement>("[data-sticky-top]");
|
||||
const pad = sticky?.offsetHeight ?? 0;
|
||||
if (header) {
|
||||
scrollToTop(header, pad);
|
||||
return;
|
||||
}
|
||||
// Windowed: the header is not in the DOM, but its offset is known.
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
@@ -267,24 +755,50 @@ export function LaneDiffView({
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.noHunks")}</p>;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split" ? boxHeight(fill) : `overflow-auto ${boxHeight(fill)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
<HunkActions.Provider value={review}>
|
||||
<div
|
||||
ref={root}
|
||||
data-testid="lane-diff-view"
|
||||
data-layout={layout}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
className={`flex flex-col font-mono text-[11px] leading-[1.45] ${
|
||||
layout === "split"
|
||||
? boxHeight(fill, splitWindowed)
|
||||
: `overflow-auto ${boxHeight(fill, win.on)}`
|
||||
}`}
|
||||
>
|
||||
{layout === "split" ? (
|
||||
<SplitView hunks={file.hunks} lang={lang} fill={fill} />
|
||||
) : win.on ? (
|
||||
<Windowed count={items.length} range={win.range} minWidth={minWidth}>
|
||||
{items
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map(({ hunk, i }, k) =>
|
||||
i < 0 ? (
|
||||
<HunkHeader key={win.range.start + k} hunk={hunk} fixed />
|
||||
) : (
|
||||
<UnifiedLine
|
||||
key={win.range.start + k}
|
||||
line={hunk.lines[i]!}
|
||||
lang={lang}
|
||||
emph={analyseHunk(hunk).emph.get(i)}
|
||||
style={FIXED}
|
||||
bar={barAtLine(hunk, i)}
|
||||
start={startAtLine(hunk, i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Windowed>
|
||||
) : (
|
||||
file.hunks.map((hunk, i) => (
|
||||
<UnifiedHunk key={`${hunk.header}:${i}`} hunk={hunk} lang={lang} />
|
||||
))
|
||||
)}
|
||||
{file.truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
</div>
|
||||
</HunkActions.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,28 +808,68 @@ export function LaneFileView({
|
||||
lines,
|
||||
truncated,
|
||||
fill = false,
|
||||
onEditAt,
|
||||
}: {
|
||||
path: string;
|
||||
lines: string[];
|
||||
truncated?: boolean;
|
||||
/** Take the parent's height instead of the 60vh cap. */
|
||||
fill?: boolean;
|
||||
/** Set when the file is editable: a click on the text (0-based line, and
|
||||
* where the view was scrolled) asks to open the editor there. */
|
||||
onEditAt?: (line: number, scrollTop: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const lang = langFor(path);
|
||||
const win = useWindow(lines.length);
|
||||
// gutter + the row's px-2
|
||||
const minWidth = useMemo(() => `calc(${longest(lines)}ch + 3.5rem)`, [lines]);
|
||||
const row = (text: string, i: number, style?: CSSProperties) => (
|
||||
<Row key={i} tint="" style={style}>
|
||||
{/* `contents`: a line marker for the click handler, not a layout box. */}
|
||||
<span data-ln={i} className="contents">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</span>
|
||||
</Row>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-file-view"
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill)}`}
|
||||
onScroll={(e) => win.onScroll(e.currentTarget)}
|
||||
tabIndex={onEditAt ? 0 : undefined}
|
||||
onClick={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
// A click that ends a drag-selection is copying, not asking to edit.
|
||||
if (window.getSelection()?.toString()) return;
|
||||
const ln = (e.target as HTMLElement).closest("[data-ln]")?.getAttribute("data-ln");
|
||||
onEditAt(Number(ln ?? 0), e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
onKeyDown={
|
||||
onEditAt &&
|
||||
((e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
e.preventDefault();
|
||||
onEditAt(0, e.currentTarget.scrollTop);
|
||||
})
|
||||
}
|
||||
className={`overflow-auto font-mono text-[11px] leading-[1.45] ${boxHeight(fill, true)}${
|
||||
onEditAt ? " cursor-text" : ""
|
||||
}`}
|
||||
>
|
||||
{lines.map((text, i) => (
|
||||
<Row key={i} tint="">
|
||||
<Gutter n={i + 1} />
|
||||
<span className="whitespace-pre px-2">
|
||||
<Code text={text} lang={lang} />
|
||||
</span>
|
||||
</Row>
|
||||
))}
|
||||
{win.on ? (
|
||||
<Windowed count={lines.length} range={win.range} minWidth={minWidth}>
|
||||
{lines
|
||||
.slice(win.range.start, win.range.end)
|
||||
.map((text, k) => row(text, win.range.start + k, FIXED))}
|
||||
</Windowed>
|
||||
) : (
|
||||
lines.map((text, i) => row(text, i))
|
||||
)}
|
||||
{truncated && (
|
||||
<p className="px-2 py-1 text-[10px] text-status-warning">{t("changes.fileTruncated")}</p>
|
||||
)}
|
||||
|
||||
@@ -1,37 +1,55 @@
|
||||
/**
|
||||
* @file The lane's git action row: ahead/behind against the upstream, and the
|
||||
* writes `POST /api/lanes/:id/git/:action` offers that are not per-file —
|
||||
* fetch, pull, push, commit (everything, or only what the Changes panel has
|
||||
* staged), switch branch, and create/delete a branch. Per-file staging and
|
||||
* discard live in the Changes panel, beside the file they act on.
|
||||
* fetch, pull, push, switch branch, create/delete a branch, publish a repo
|
||||
* that has no remote yet (a URL, then `origin` + `push -u`), and start a merge
|
||||
* (the picked branch, or the upstream once diverged; resolving it is the
|
||||
* Changes panel's job). Committing is
|
||||
* NOT here: it is the Changes panel's staged-only commit box, beside the
|
||||
* per-file stage/unstage/discard it commits — one way to commit, and it only
|
||||
* ever takes what the user staged.
|
||||
*
|
||||
* It renders beside the read-only git line in LaneCard rather than inside the
|
||||
* ⋯ menu: that menu holds the rare and dangerous (`reset worktree`), and these
|
||||
* are the opposite — the ordinary moves a lane needs several times an hour.
|
||||
* It renders at the top of the Changes panel (`LaneGitHeader`), its actions as
|
||||
* icons on the same row as the branch line, rather than in the lane card's ⋯ menu: that menu holds the rare and dangerous (`reset
|
||||
* worktree`), and these are the ordinary moves a lane needs several times an hour.
|
||||
* Nothing here decides what an action may do; the server does (pull is always
|
||||
* --ff-only, switch refuses a dirty tree), and this only reports what it said.
|
||||
* Failures print git's own text, because "! [rejected] non-fast-forward" tells
|
||||
* the user more than any sentence this file could write.
|
||||
* the user more than any sentence this file could write. The one exception
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
CloudUpload,
|
||||
GitBranch,
|
||||
GitMerge,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, type GitAuthNeeded } from "../../lib/api";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action = "fetch" | "pull" | "push" | "commit" | "switch" | "branch-create" | "branch-delete";
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
|
||||
function Btn({
|
||||
label,
|
||||
@@ -39,6 +57,7 @@ function Btn({
|
||||
busy,
|
||||
disabled,
|
||||
title,
|
||||
compact,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
@@ -46,6 +65,8 @@ function Btn({
|
||||
busy?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Icon only; the label becomes the accessible name, the hint the tooltip. */
|
||||
compact?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -54,10 +75,13 @@ function Btn({
|
||||
onClick={onClick}
|
||||
disabled={disabled || busy}
|
||||
title={title}
|
||||
className="flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={compact ? label : undefined}
|
||||
className={`flex items-center gap-1 rounded text-[11px] text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
compact ? "h-6 w-6 justify-center" : "border border-border-light px-1.5 py-0.5"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{!compact && label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -67,9 +91,13 @@ export function LaneGitBar({
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
lead,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
lead?: React.ReactNode;
|
||||
/** Called after any action that could have moved the working copy. */
|
||||
onChanged: () => void;
|
||||
/** Open the Add-lane flow for a sibling worktree of this repo, optionally on
|
||||
@@ -79,32 +107,73 @@ export function LaneGitBar({
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [busy, setBusy] = useState<Action | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [switching, setSwitching] = useState(false);
|
||||
const [branches, setBranches] = useState<string[] | null>(null);
|
||||
const [worktrees, setWorktrees] = useState<LaneWorktree[]>([]);
|
||||
const [picked, setPicked] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
// Set when git needed a login for `action`; the form resends that action.
|
||||
const [login, setLogin] = useState<{ action: Action; auth: GitAuthNeeded } | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [url, setUrl] = useState("");
|
||||
// The Changes panel is not keyed by lane, so this bar outlives a lane switch.
|
||||
// All of the state above describes ONE lane — a branch picked for lane A,
|
||||
// applied after switching, would move B's HEAD — so it is reset during
|
||||
// render the moment the id changes, before anything can act on it.
|
||||
const [shownLane, setShownLane] = useState(laneId);
|
||||
// One action at a time, checked synchronously: `busy` is state, and a second
|
||||
// Enter can land before the render that disables anything.
|
||||
const inFlight = useRef(false);
|
||||
if (shownLane !== laneId) {
|
||||
setShownLane(laneId);
|
||||
setBusy(null);
|
||||
setError(null);
|
||||
setSwitching(false);
|
||||
setBranches(null);
|
||||
setWorktrees([]);
|
||||
setPicked("");
|
||||
setNewBranch("");
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
inFlight.current = false;
|
||||
}
|
||||
const laneNow = useRef(laneId);
|
||||
laneNow.current = laneId;
|
||||
|
||||
if (!git.available) return null;
|
||||
const dirty = git.dirty + git.untracked;
|
||||
const merging = !!git.merging;
|
||||
|
||||
const run = async (
|
||||
action: Action,
|
||||
body?: { message?: string; only?: "staged"; branch?: string }
|
||||
) => {
|
||||
const run = async (action: Action, body?: RunBody) => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const lane = laneId;
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
await api.lanes.gitAction(laneId, action, body || {});
|
||||
setCommitting(false);
|
||||
setMessage("");
|
||||
const res = await api.lanes.gitAction(lane, action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
// A merge that stopped on conflicts still succeeded in starting; the
|
||||
// Changes panel below lists the files to resolve.
|
||||
if (res?.conflicts?.length)
|
||||
setError(t("gitBar.mergeConflicts", { count: res.conflicts.length }));
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
// The branch list this bar is showing is stale the moment a branch is
|
||||
// created, deleted or switched to, so it is re-read rather than patched.
|
||||
if (action === "switch") setSwitching(false);
|
||||
else if (action.startsWith("branch-")) {
|
||||
setNewBranch("");
|
||||
if (action === "publish") {
|
||||
setPublishing(false);
|
||||
setUrl("");
|
||||
} else if (action === "switch" || action.startsWith("branch-")) {
|
||||
if (action === "switch") setSwitching(false);
|
||||
else setNewBranch("");
|
||||
setPicked("");
|
||||
setBranches(null);
|
||||
await refreshBranches();
|
||||
@@ -113,19 +182,37 @@ export function LaneGitBar({
|
||||
} catch (err) {
|
||||
// The server appends git's own stderr to the message, so this is the
|
||||
// rejection text verbatim — hence the <pre> below rather than a line.
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// ENOREMOTE is the one refusal with no git text to show: the server's
|
||||
// sentence is English and says less than a translated one.
|
||||
const auth = (err as { code?: string; auth?: GitAuthNeeded })?.auth;
|
||||
if (laneNow.current === lane && (err as { code?: string })?.code === "EGITAUTH" && auth) {
|
||||
setLogin({ action, auth });
|
||||
setUsername((u) => u || auth.username || "");
|
||||
setPassword("");
|
||||
} else if (laneNow.current === lane)
|
||||
setError(
|
||||
(err as { code?: string })?.code === "ENOREMOTE"
|
||||
? t("gitBar.errNoRemote")
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err)
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (laneNow.current === lane) {
|
||||
inFlight.current = false;
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(laneId);
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
} catch {
|
||||
setBranches([]);
|
||||
if (laneNow.current === laneId) setBranches([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,20 +223,33 @@ export function LaneGitBar({
|
||||
await refreshBranches();
|
||||
};
|
||||
|
||||
const merge = (branch: string) => {
|
||||
if (!window.confirm(t("gitBar.mergeConfirm", { branch, current: git.branch }))) return;
|
||||
void run("merge", { branch });
|
||||
};
|
||||
|
||||
const heldBy = new Map(
|
||||
worktrees.filter((w) => w.branch).map((w) => [w.branch as string, w.path])
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="lane-git-bar" className="mb-2 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div data-testid="lane-git-bar" className="space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{lead}
|
||||
{/* Ahead/behind first: it is the fact that decides which of the
|
||||
buttons beside it the user actually wants. */}
|
||||
{git.upstream ? (
|
||||
{/* Null counts with an upstream named means git could not compare
|
||||
the two (the upstream ref is gone): claiming "in sync" there
|
||||
would be a lie, so it reads as not pushed. */}
|
||||
{git.unborn ? (
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noCommits")}
|
||||
</span>
|
||||
) : git.upstream && git.ahead != null && git.behind != null ? (
|
||||
<span
|
||||
data-testid="lane-git-tracking"
|
||||
title={git.upstream}
|
||||
className="flex items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
className="flex shrink-0 items-center gap-1 font-mono text-[11px] text-fg-muted"
|
||||
>
|
||||
{(git.ahead ?? 0) > 0 && (
|
||||
<span className="flex items-center text-status-warning">
|
||||
@@ -167,95 +267,95 @@ export function LaneGitBar({
|
||||
</span>
|
||||
) : (
|
||||
git.hasRemote && (
|
||||
<span className="font-mono text-[11px] text-fg-muted">{t("gitBar.noUpstream")}</span>
|
||||
<span className="shrink-0 font-mono text-[11px] text-fg-muted">
|
||||
{t("gitBar.noUpstream")}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
<Btn
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3 w-3" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3 w-3" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.push")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.pushHint")}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commit")}
|
||||
icon={<Check className="h-3 w-3" />}
|
||||
disabled={dirty === 0 || !!busy}
|
||||
title={dirty === 0 ? t("gitBar.commitNothing") : t("gitBar.commitHint")}
|
||||
onClick={() => {
|
||||
setCommitting((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3 w-3" />}
|
||||
disabled={!!busy}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
{/* Diverged: a --ff-only pull can only refuse, so offer the merge. */}
|
||||
{git.upstream && (git.ahead ?? 0) > 0 && (git.behind ?? 0) > 0 && !merging && (
|
||||
<Btn
|
||||
label={t("gitBar.mergeUpstream", { upstream: git.upstream })}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!!busy || dirty > 0}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeUpstreamHint")}
|
||||
onClick={() => merge(git.upstream as string)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{committing && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && message.trim()) void run("commit", { message });
|
||||
if (e.key === "Escape") setCommitting(false);
|
||||
}}
|
||||
placeholder={t("gitBar.commitPlaceholder")}
|
||||
aria-label={t("gitBar.commitPlaceholder")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.commitAll", { count: dirty })}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message })}
|
||||
/>
|
||||
{/* The Changes panel's per-file staging only means something if a
|
||||
commit can take the index as it stands. The server answers
|
||||
ENOCHANGES when nothing is staged, and that text lands below. */}
|
||||
<Btn
|
||||
label={t("gitBar.commitStaged")}
|
||||
title={t("gitBar.commitStagedHint")}
|
||||
busy={busy === "commit"}
|
||||
disabled={!message.trim()}
|
||||
onClick={() => void run("commit", { message, only: "staged" })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("gitBar.cancel")}
|
||||
onClick={() => setCommitting(false)}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* A repo with no commit has nothing to push, fetch into or branch
|
||||
from — the one next step is the commit box below. */}
|
||||
{git.unborn ? (
|
||||
<span className="ml-auto text-[10px] text-fg-muted">{t("gitBar.unbornHint")}</span>
|
||||
) : (
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.fetch")}
|
||||
icon={<RefreshCw className="h-3.5 w-3.5" />}
|
||||
busy={busy === "fetch"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={t("gitBar.fetchHint")}
|
||||
onClick={() => run("fetch")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.pull")}
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
compact
|
||||
// Same `push -u <remote> HEAD` either way; a branch with no
|
||||
// upstream is being published, not updated, so it says that.
|
||||
label={git.hasRemote && !git.upstream ? t("gitBar.publishBranch") : t("gitBar.push")}
|
||||
icon={<Upload className="h-3.5 w-3.5" />}
|
||||
busy={busy === "push"}
|
||||
disabled={!git.hasRemote || !!busy}
|
||||
title={
|
||||
git.hasRemote && !git.upstream
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.publish")}
|
||||
icon={<CloudUpload className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => {
|
||||
setPublishing((v) => !v);
|
||||
setError(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Btn
|
||||
compact
|
||||
label={t("gitBar.switch")}
|
||||
icon={<GitBranch className="h-3.5 w-3.5" />}
|
||||
disabled={!!busy || merging}
|
||||
// A dirty tree is refused server-side; saying so before the click
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
}
|
||||
onClick={openSwitch}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{switching && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -286,11 +386,27 @@ export function LaneGitBar({
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.switchHint")}
|
||||
onClick={() => void run("switch", { branch: picked })}
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.merge")}
|
||||
icon={<GitMerge className="h-3 w-3" />}
|
||||
busy={busy === "merge"}
|
||||
disabled={!picked || picked === git.branch || dirty > 0 || !!busy}
|
||||
title={dirty > 0 ? t("gitBar.switchDirty") : t("gitBar.mergeHint")}
|
||||
onClick={() => merge(picked)}
|
||||
/>
|
||||
{onNewLane && (
|
||||
<Btn
|
||||
label={t("gitBar.newLane")}
|
||||
title={t("gitBar.newLaneHint")}
|
||||
disabled={!picked}
|
||||
// A branch any worktree holds — this lane's own included — is
|
||||
// one `addWorktree` refuses with EBRANCHBUSY; say so up front.
|
||||
title={
|
||||
picked && (heldBy.has(picked) || picked === git.branch)
|
||||
? t("gitBar.newLaneHeld", {
|
||||
path: heldBy.get(picked) ?? t("gitBar.switchCurrent"),
|
||||
})
|
||||
: t("gitBar.newLaneHint")
|
||||
}
|
||||
disabled={!picked || heldBy.has(picked) || picked === git.branch}
|
||||
// The sibling lane is created through the normal Add-lane flow,
|
||||
// prefilled — one provisioning path, not a second one here.
|
||||
onClick={() => onNewLane(picked)}
|
||||
@@ -336,6 +452,102 @@ export function LaneGitBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{publishing && !git.hasRemote && !git.unborn && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim()) void run("publish", { url: url.trim() });
|
||||
}}
|
||||
// The server decides which URLs are acceptable (https without a
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
placeholder={t("gitBar.publishUrl")}
|
||||
aria-label={t("gitBar.publishUrl")}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 font-mono text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<Btn
|
||||
label={t("gitBar.publishApply")}
|
||||
icon={<Upload className="h-3 w-3" />}
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{login && (
|
||||
<form
|
||||
data-testid="lane-git-login"
|
||||
className="space-y-1.5 rounded border border-border-light bg-surface-1 p-2 text-[11px]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const { protocol, host } = login.auth;
|
||||
void run(login.action, {
|
||||
credentials: { protocol, host, username: username.trim(), password, remember },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-fg-primary">
|
||||
{t("gitBar.loginTitle", { host: login.auth.host })}
|
||||
</p>
|
||||
{login.auth.rejected && (
|
||||
<p role="alert" className="text-status-danger">
|
||||
{t("gitBar.loginRejected")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
aria-label={t("gitBar.loginUser")}
|
||||
placeholder={t("gitBar.loginUser")}
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitBar.loginPassword")}
|
||||
placeholder={t("gitBar.loginPassword")}
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
/>
|
||||
{t("gitBar.loginRemember")}
|
||||
</label>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitBar.loginTokenHint")}</p>
|
||||
{/* The token crosses the network in the clear on a plain-http LAN address. */}
|
||||
{location.protocol === "http:" && !LOCAL_HOST.test(location.hostname) && (
|
||||
<p className="text-[10px] text-status-warning">{t("gitBar.loginInsecure")}</p>
|
||||
)}
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!username.trim() || !password || !!busy}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-2 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitBar.loginSubmit")}
|
||||
</button>
|
||||
<Btn
|
||||
label={t("gitBar.loginCancel")}
|
||||
onClick={() => {
|
||||
setLogin(null);
|
||||
setPassword("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<pre
|
||||
role="alert"
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @file The top of the Changes panel: the lane's working-copy line (branch,
|
||||
* head, subject, and the uncommitted counts that open a per-file hover panel)
|
||||
* and the git action bar under it (`LaneGitBar`).
|
||||
*
|
||||
* It used to sit in the lane card, a column away from the diff it describes —
|
||||
* a pull there changed files the Changes panel only noticed on its next poll.
|
||||
* Here the two share one read of `GET /api/lanes/:id/git` (`useLaneGitFacts`,
|
||||
* owned by the panel) and refresh each other after every write.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitBranch } from "lucide-react";
|
||||
import { LaneGitBar } from "./LaneGitBar";
|
||||
import { api } from "../../lib/api";
|
||||
import { keepIfSame } from "../../lib/keepIfSame";
|
||||
import type { LaneGitFacts, LaneGitFile } from "../../lib/types";
|
||||
|
||||
/** How often the mounted panel re-reads its working-copy facts. Slow on
|
||||
* purpose: each call is five git subprocesses server-side, and a branch name
|
||||
* does not change on the timescale the lane list is polled at. */
|
||||
const GIT_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* The lane's own working copy, fetched by the Changes panel rather than folded
|
||||
* into the polled lane list. Absent facts are not an error state: a lane may point at a
|
||||
* plain directory, and it renders with no git header.
|
||||
*/
|
||||
export function useLaneGitFacts(laneId: number): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
// this row, and waiting out the 30s poll makes the button look inert.
|
||||
const [nonce, setNonce] = useState(0);
|
||||
// A lane switch drops the previous lane's facts during render, so nothing
|
||||
// (the git bar's "current branch" included) shows them until the new read.
|
||||
const [factsLane, setFactsLane] = useState(laneId);
|
||||
if (factsLane !== laneId) {
|
||||
setFactsLane(laneId);
|
||||
setFacts(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(laneId)
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent: a panel that cannot read git shows the rest of itself. An
|
||||
// error banner here would fire on every lane on every server blip.
|
||||
if (alive) setFacts(keepIfSame<LaneGitFacts>({ available: false }));
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [laneId, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
|
||||
/** Porcelain status code -> the letter shown and the colour it is shown in.
|
||||
* Keyed on the code's first meaningful character, so an index-side change
|
||||
* (`M `), a worktree-side one (` M`) and both (`MM`) read the same. */
|
||||
const GIT_STATUS_STYLE: Record<string, string> = {
|
||||
M: "text-status-warning",
|
||||
A: "text-status-success",
|
||||
D: "text-status-danger",
|
||||
R: "text-accent",
|
||||
C: "text-accent",
|
||||
U: "text-status-danger",
|
||||
"?": "text-fg-muted",
|
||||
};
|
||||
|
||||
/**
|
||||
* The file list behind a lane's uncommitted counts: which files, what happened
|
||||
* to each, and how many lines it gained and lost.
|
||||
*
|
||||
* It replaced a native `title` tooltip, which could show the same paths but
|
||||
* neither colour them nor align the deltas — and a plain porcelain dump is not
|
||||
* something you read at a glance.
|
||||
*
|
||||
* PORTALLED to `document.body` and `position: fixed`: the Changes panel
|
||||
* scrolls in its own `overflow` box, and an absolutely positioned panel would
|
||||
* be clipped by it. It flips above its anchor rather than off the bottom of
|
||||
* the window, and clamps to the right edge.
|
||||
*/
|
||||
function LaneGitFilesPanel({
|
||||
files,
|
||||
hidden,
|
||||
anchor,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
files: LaneGitFile[];
|
||||
hidden: number;
|
||||
/** The counts button this panel hangs off. */
|
||||
anchor: HTMLElement | null;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
// After the first paint, when the panel's real height is known: place it
|
||||
// under the anchor, or above it when that would run off the bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !panelRef.current) return;
|
||||
const a = anchor.getBoundingClientRect();
|
||||
const p = panelRef.current.getBoundingClientRect();
|
||||
const gap = 4;
|
||||
const top =
|
||||
a.bottom + gap + p.height > window.innerHeight ? a.top - p.height - gap : a.bottom + gap;
|
||||
setPos({
|
||||
top: Math.max(8, top),
|
||||
left: Math.max(8, Math.min(a.left, document.documentElement.clientWidth - p.width - 8)),
|
||||
});
|
||||
}, [anchor, files]);
|
||||
// Totals are over the LISTED files only — the cap means the rest are unknown,
|
||||
// and a total that silently omits them would be a wrong number rather than a
|
||||
// partial one. `hidden` says how many are missing.
|
||||
const added = files.reduce((n, f) => n + (f.added ?? 0), 0);
|
||||
const deleted = files.reduce((n, f) => n + (f.deleted ?? 0), 0);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
data-testid="lane-git-files"
|
||||
role="group"
|
||||
aria-label={t("git.filesHeading", { count: files.length })}
|
||||
// The pointer has to be able to travel INTO the panel to scroll it, so
|
||||
// the panel answers the same enter/leave pair the trigger does.
|
||||
onMouseEnter={onPointerEnter}
|
||||
onMouseLeave={onPointerLeave}
|
||||
style={{
|
||||
position: "fixed",
|
||||
zIndex: 60,
|
||||
top: pos?.top ?? 0,
|
||||
left: pos?.left ?? 0,
|
||||
// Hidden for the one frame before it has been measured, so it never
|
||||
// flashes at the top-left corner.
|
||||
visibility: pos ? "visible" : "hidden",
|
||||
}}
|
||||
className="max-h-72 w-[22rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border border-border bg-surface-1 p-2 shadow-lg"
|
||||
>
|
||||
<div className="mb-1.5 flex items-baseline gap-2 border-b border-border pb-1.5 text-[11px]">
|
||||
<span className="text-fg-secondary">{t("git.filesHeading", { count: files.length })}</span>
|
||||
<span className="ml-auto shrink-0 font-mono tabular-nums">
|
||||
<span className="text-status-success">+{added}</span>{" "}
|
||||
<span className="text-status-danger">−{deleted}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 font-mono text-[11px]">
|
||||
{files.map((f) => {
|
||||
const code = f.status.trim()[0] || "?";
|
||||
return (
|
||||
<li key={f.path} className="flex items-baseline gap-2">
|
||||
<span
|
||||
className={`w-3 shrink-0 font-semibold ${GIT_STATUS_STYLE[code] || "text-fg-muted"}`}
|
||||
title={f.status}
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
{/* The DIRECTORY truncates, never the filename: the name is what
|
||||
you scan for, the path is only there to disambiguate it. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline" title={f.path}>
|
||||
<span className="truncate text-fg-muted">{f.path.replace(/[^/]+$/, "")}</span>
|
||||
<span className="shrink-0 text-fg-primary">{f.path.split("/").pop()}</span>
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{f.added === null && f.deleted === null ? (
|
||||
<span className="text-fg-muted">{t("git.noDelta")}</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-status-success">+{f.added ?? 0}</span>{" "}
|
||||
<span className="text-status-danger">−{f.deleted ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hidden > 0 && (
|
||||
<p className="mt-1.5 border-t border-border pt-1.5 text-[10px] text-fg-muted">
|
||||
{t("git.filesMore", { count: hidden })}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
}: {
|
||||
laneId: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
/** See `LaneGitBar`'s prop of the same name. */
|
||||
onNewLane?: (branch?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
const [countsAnchor, setCountsAnchor] = useState<HTMLButtonElement | null>(null);
|
||||
// Closing is DELAYED: the panel is portalled away from the trigger, so the
|
||||
// pointer leaves the trigger before it can land on the panel it is heading
|
||||
// for. Any enter — trigger or panel — cancels the pending close.
|
||||
const filesCloseTimer = useRef<number | undefined>(undefined);
|
||||
const openFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
setFilesOpen(true);
|
||||
}, []);
|
||||
const closeFiles = useCallback(() => {
|
||||
window.clearTimeout(filesCloseTimer.current);
|
||||
filesCloseTimer.current = window.setTimeout(() => setFilesOpen(false), 150);
|
||||
}, []);
|
||||
useEffect(() => () => window.clearTimeout(filesCloseTimer.current), []);
|
||||
|
||||
if (!git?.available) return null;
|
||||
const lead = (
|
||||
<>
|
||||
{/* One row with the actions: branch first and strongest, then the head
|
||||
and its subject, which is what truncates when the panel is narrow
|
||||
(the whole of it stays in the tooltip). */}
|
||||
<div
|
||||
data-testid="lane-git"
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
{(git.dirty > 0 || git.untracked > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
ref={setCountsAnchor}
|
||||
data-testid="lane-git-counts"
|
||||
aria-expanded={filesOpen}
|
||||
onMouseEnter={openFiles}
|
||||
onMouseLeave={closeFiles}
|
||||
// Focus and click open it too: hover is not a gesture a
|
||||
// keyboard or a touchscreen has.
|
||||
onFocus={openFiles}
|
||||
onBlur={closeFiles}
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className="shrink-0 cursor-help rounded-full border border-status-warning/40 bg-status-warning/10 px-2 py-px text-[10.5px] text-status-warning"
|
||||
>
|
||||
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-testid="lane-git-header"
|
||||
className="mb-2 rounded-md border border-border bg-surface-2 px-2 py-1"
|
||||
>
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
lead={lead}
|
||||
/>
|
||||
{filesOpen && git.files?.length > 0 && (
|
||||
<LaneGitFilesPanel
|
||||
files={git.files}
|
||||
hidden={git.dirty + git.untracked - git.files.length}
|
||||
anchor={countsAnchor}
|
||||
onPointerEnter={openFiles}
|
||||
onPointerLeave={closeFiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
|
||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
@@ -41,7 +42,7 @@ const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
* recordDetection refuses any detection that isn't strictly ahead.
|
||||
*/
|
||||
function detectionLeads(lane: Lane): boolean {
|
||||
if (!lane.detected_stage) return false;
|
||||
if (!lane.detected_stage || !lane.pipeline_nodes.length) return false;
|
||||
const nodes = lane.pipeline_nodes;
|
||||
return (
|
||||
nodes.findIndex((n) => n.id === lane.detected_stage) >=
|
||||
@@ -64,6 +65,7 @@ export default function LaneStripCard({
|
||||
onReorder: (from: number, to: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [dropTarget, setDropTarget] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -115,15 +117,18 @@ export default function LaneStripCard({
|
||||
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||
#{lane.id}
|
||||
</span>
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
{lane.tool === "codex" && (
|
||||
{/* Only claude lanes carry a pipeline, and only while a template is
|
||||
installed; the others show their CLI's tag in place of the stage chip. */}
|
||||
{lane.tool !== "claude" || lane.pipeline_nodes.length === 0 ? (
|
||||
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
|
||||
Codex
|
||||
{toolLabel(lane.tool)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||
{lane.stage}
|
||||
</span>
|
||||
)}
|
||||
{detectionLeads(lane) && (
|
||||
{lane.tool === "claude" && detectionLeads(lane) && (
|
||||
<span
|
||||
data-testid={`lane-tile-auto-${lane.id}`}
|
||||
title={lane.detected_signal || undefined}
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import type { AiTool, AiToolsView, CwdSuggestion } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
@@ -27,15 +28,48 @@ vi.mock("../../../lib/api", () => ({
|
||||
browse: vi.fn(),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
|
||||
id,
|
||||
label,
|
||||
command: id,
|
||||
homepageUrl: null,
|
||||
installed: true,
|
||||
path: `/usr/bin/${id}`,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
launcher: null,
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
defaultModel: null,
|
||||
modelVendor: null,
|
||||
configFiles: 0,
|
||||
...over,
|
||||
});
|
||||
const TOOLS: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
],
|
||||
};
|
||||
|
||||
function laneFixture(over: Partial<Lane> = {}): Lane {
|
||||
// `as Lane`: spreading a Partial<Lane> widens every field it may carry to
|
||||
// `T | undefined`, which no longer satisfies Lane's required fields. The
|
||||
@@ -108,6 +142,8 @@ async function fillWorktreeForm(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(TOOLS);
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
@@ -120,7 +156,6 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.profileInit)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.agentsInstall).mockReset().mockResolvedValue({ installed: [] });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply).mockReset().mockResolvedValue({
|
||||
tool: "claude",
|
||||
@@ -140,7 +175,7 @@ beforeEach(() => {
|
||||
function nativeRouter() {
|
||||
return {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
@@ -245,10 +280,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
await screen.findByLabelText("Branch to fork from");
|
||||
await user.type(screen.getByLabelText("Title"), "Auto named");
|
||||
// The placeholder previews the name the server will pick.
|
||||
// No `feat/…` guess: the prefix is the server's LANE_BRANCH_PREFIX, which
|
||||
// the form cannot see, so the placeholder only says the field is optional.
|
||||
expect(screen.getByLabelText("New branch name")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"feat/auto-named"
|
||||
"Automatic — named from the title"
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add lane" }));
|
||||
@@ -304,6 +340,28 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("refuses a worktree of a repo with no commits and steers to Repo mode", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: [],
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/empty");
|
||||
expect(await screen.findByText(/commit first, then publish/i)).toBeInTheDocument();
|
||||
await user.type(screen.getByLabelText("Title"), "demo");
|
||||
// There is nothing to branch from, so the worktree cannot be created.
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Use as a Repo lane" }));
|
||||
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: "Add lane" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("submits through the worktree provisioning endpoint, with the typed branch name", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
@@ -360,7 +418,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires profileInit, agentsInstall, and mcpSync after a successful worktree call", async () => {
|
||||
it("fires profileInit and mcpSync after a successful worktree call", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue({
|
||||
branches: ["main"],
|
||||
current: "main",
|
||||
@@ -378,7 +436,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
|
||||
await waitFor(() => expect(api.lanes.worktree).toHaveBeenCalled());
|
||||
await waitFor(() => expect(api.lanes.profileInit).toHaveBeenCalledWith(42));
|
||||
expect(api.lanes.agentsInstall).toHaveBeenCalledWith(42);
|
||||
expect(api.lanes.mcpSync).toHaveBeenCalledWith(42);
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
@@ -393,7 +450,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
lane: { id: 43, title: "demo2", cwd: "/lanes/demo2", status: "provisioning" } as Lane,
|
||||
});
|
||||
vi.mocked(api.lanes.profileInit).mockRejectedValue(new Error("boom"));
|
||||
vi.mocked(api.lanes.agentsInstall).mockRejectedValue(new Error("boom"));
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -459,7 +515,6 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
expect(await screen.findByText(/auto-setup was skipped/)).toBeInTheDocument();
|
||||
expect(api.lanes.profileInit).not.toHaveBeenCalled();
|
||||
expect(api.lanes.agentsInstall).not.toHaveBeenCalled();
|
||||
expect(api.lanes.mcpSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -631,7 +686,7 @@ describe("AddLaneModal — AI Router", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("radio", { name: "AI Router" }));
|
||||
|
||||
expect(screen.getByRole("radio", { name: "Native" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked();
|
||||
expect(screen.getByText(/No router profile saved yet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -698,7 +753,7 @@ describe("AddLaneModal — a router profile that disappeared", () => {
|
||||
rerender(<AddLaneModal open={false} onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
rerender(<AddLaneModal open onClose={vi.fn()} onAdded={vi.fn()} cwdSuggestions={[]} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: "Native" })).toBeChecked());
|
||||
await waitFor(() => expect(screen.getByRole("radio", { name: /^Native/ })).toBeChecked());
|
||||
});
|
||||
|
||||
it("does not offer AI Router until the profile list has arrived", async () => {
|
||||
@@ -733,7 +788,20 @@ describe("AddLaneModal — tool selection", () => {
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
await user.click(screen.getByRole("button", { name: /codex/i }));
|
||||
// One button showing the default; it opens every installed + enabled tool.
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: /Which CLI drives this lane/ });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add lane|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -741,6 +809,25 @@ describe("AddLaneModal — tool selection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers only Native for a tool that cannot use an AI Router", async () => {
|
||||
renderModal({});
|
||||
const user = userEvent.setup();
|
||||
await user.click(await screen.findByRole("button", { name: /Claude Code.*Default/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Gemini" }));
|
||||
expect(screen.getByRole("radio", { name: "Native (Gemini)" })).toBeChecked();
|
||||
expect(screen.getByRole("radio", { name: "AI Router" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({ default: null, tools: [] });
|
||||
renderModal({});
|
||||
expect(await screen.findByText(/No AI tool is installed and enabled/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /AI Tools/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings#ai-tools"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to claude code", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 24, kind: "adopted", status: "idle" }),
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent, act } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import LaneCard, { LaneToolsMenu, LaneRouterRow } from "../LaneCard";
|
||||
import type { Lane, LaneRuntime } from "../../../lib/types";
|
||||
@@ -23,7 +23,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
up: vi.fn(),
|
||||
down: vi.fn(),
|
||||
preflight: vi.fn().mockResolvedValue({ blocked: [], warnings: [] }),
|
||||
agentsInstall: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
syncBaseCheck: vi.fn(),
|
||||
routerApply: vi.fn(),
|
||||
@@ -96,13 +95,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
||||
}
|
||||
|
||||
describe("LaneCard lane tool badge", () => {
|
||||
it("badges a codex lane and leaves a claude lane unbadged", () => {
|
||||
const { rerender } = render(
|
||||
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
|
||||
);
|
||||
expect(screen.getByText("Codex")).toBeInTheDocument();
|
||||
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
|
||||
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
|
||||
// The strip tile already names the CLI; a card holding only that tag was an
|
||||
// empty frame in the lane detail.
|
||||
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
|
||||
for (const tool of ["codex", "antigravity"] as const) {
|
||||
const { container, unmount } = render(
|
||||
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,9 +169,9 @@ describe("LaneCard rebuilt layout", () => {
|
||||
expect(screen.queryByText("48%")).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a needs-you message", () => {
|
||||
it("leaves the needs-you message to the strip card", () => {
|
||||
render(<LaneCard lane={full({ needs_action: "waiting on approval" })} onAction={vi.fn()} />);
|
||||
expect(screen.getByText(/waiting on approval/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/waiting on approval/)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reset behind the ⋯ menu rather than showing it directly", async () => {
|
||||
@@ -204,126 +206,11 @@ describe("LaneCard rebuilt layout", () => {
|
||||
});
|
||||
|
||||
describe("LaneCard git block", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane({ branch: "stale/recorded" })} onAction={vi.fn()} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(block.textContent).toContain("2");
|
||||
// The live branch replaces the lane's recorded one rather than doubling it.
|
||||
expect(screen.queryByText(/stale\/recorded/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders the card with no git block and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<LaneCard lane={makeLane({ id: 2 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-2")).toBeInTheDocument();
|
||||
it("carries no working-copy row — that heads the Changes panel now", async () => {
|
||||
render(<LaneCard lane={makeLane({ id: 4 })} onAction={vi.fn()} />);
|
||||
expect(await screen.findByTestId("lane-card-4")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<LaneCard lane={makeLane({ id: 3 })} onAction={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByTestId("lane-card-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the card unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<LaneCard lane={makeLane()} onAction={vi.fn()} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(api.lanes.git).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -421,17 +308,6 @@ describe("LaneToolsMenu", () => {
|
||||
} as unknown as LaneRuntime);
|
||||
});
|
||||
|
||||
it("clicking Install agents calls the API and shows the result", async () => {
|
||||
vi.mocked(api.lanes.agentsInstall).mockResolvedValue({
|
||||
installed: ["qc-local.md", "senior-gate-reviewer.md"],
|
||||
});
|
||||
render(<LaneToolsMenu lane={makeLane()} />);
|
||||
await userEvent.click(await screen.findByTestId("lane-tools-menu"));
|
||||
await userEvent.click(await screen.findByTestId("lane-agents-install"));
|
||||
await waitFor(() => expect(api.lanes.agentsInstall).toHaveBeenCalledWith(1));
|
||||
expect(await screen.findByTestId("lane-action-result")).toHaveTextContent("qc-local.md");
|
||||
});
|
||||
|
||||
it("clicking Sync MCP calls the API and shows the result", async () => {
|
||||
vi.mocked(api.lanes.mcpSync).mockResolvedValue({
|
||||
servers: ["playwright"],
|
||||
@@ -502,7 +378,7 @@ describe("LaneToolsMenu", () => {
|
||||
describe("LaneRouterRow — the Model picker", () => {
|
||||
const native = {
|
||||
name: "native",
|
||||
label: "Native (Claude / Codex)",
|
||||
label: "Native",
|
||||
builtin: true,
|
||||
baseUrl: "",
|
||||
hasKey: false,
|
||||
@@ -530,6 +406,16 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers only native to a tool that cannot use an AI Router", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
render(<LaneRouterRow lane={makeLane({ tool: "gemini" })} />);
|
||||
const select = await screen.findByDisplayValue("Native (gemini)");
|
||||
const router = Array.from((select as HTMLSelectElement).options).find(
|
||||
(o) => o.value === "9router"
|
||||
);
|
||||
expect(router?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("applies a switch through the router route, not a lane PATCH", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
@@ -541,9 +427,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(1, "9router"));
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
@@ -564,9 +448,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
|
||||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||||
await userEvent.click(restart);
|
||||
@@ -588,9 +470,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
});
|
||||
@@ -611,14 +491,14 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
const { rerender } = render(<LaneRouterRow lane={laneA} />);
|
||||
await screen.findByDisplayValue("Native (Claude / Codex)");
|
||||
await screen.findByDisplayValue("Native (Claude)");
|
||||
|
||||
rerender(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude / Codex)"), ["9router"]);
|
||||
await userEvent.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not roll a failed apply back onto a DIFFERENT lane the user has since switched to", async () => {
|
||||
@@ -634,9 +514,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
const { rerender } = render(<LaneRouterRow lane={laneB} />);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalledWith(2, "9router"));
|
||||
|
||||
rerender(<LaneRouterRow lane={laneA} />);
|
||||
@@ -645,7 +523,7 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
expect(screen.queryByTitle("EBADJSON")).toBeNull();
|
||||
// And the picker must not be left locked by a request for another lane.
|
||||
expect(screen.getByRole("combobox")).not.toBeDisabled();
|
||||
@@ -656,10 +534,8 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
vi.mocked(api.lanes.routerApply).mockRejectedValue(new Error("EBADJSON"));
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude)"), ["9router"]);
|
||||
await waitFor(() => expect(screen.getByTitle("EBADJSON")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("Native (Claude / Codex)")).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file Tests for the merge-conflict view: both sides of each block shown
|
||||
* under their own labels, one choice per block with undo, Save only once
|
||||
* every block is chosen — and Save writes the chosen text over the version
|
||||
* opened, then marks the file resolved. Whole-file ours/theirs reuse
|
||||
* `resolve`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneConflictView } from "../LaneConflictView";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { gitFile: vi.fn(), gitSaveFile: vi.fn(), gitAction: vi.fn() } },
|
||||
}));
|
||||
|
||||
const LINES = [
|
||||
"top",
|
||||
"<<<<<<< HEAD",
|
||||
"mine",
|
||||
"=======",
|
||||
"theirs",
|
||||
">>>>>>> origin/main",
|
||||
"middle",
|
||||
"<<<<<<< HEAD",
|
||||
"a",
|
||||
"=======",
|
||||
"b",
|
||||
">>>>>>> origin/main",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.gitFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: LINES,
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
vi.mocked(api.lanes.gitSaveFile)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ available: true } as never);
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ action: "resolve", ok: true, output: "" });
|
||||
});
|
||||
|
||||
const blocks = () => screen.getAllByTestId("conflict-block");
|
||||
|
||||
describe("LaneConflictView", () => {
|
||||
it("shows both sides under their labels and saves once every block is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
|
||||
const [first, second] = (await screen.findAllByTestId("conflict-block")) as [
|
||||
HTMLElement,
|
||||
HTMLElement,
|
||||
];
|
||||
expect(first).toHaveTextContent("Mine (HEAD)");
|
||||
expect(first).toHaveTextContent("Theirs (origin/main)");
|
||||
const save = screen.getByRole("button", { name: "Save and mark resolved" });
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
await user.click(within(first).getByRole("button", { name: "Keep mine" }));
|
||||
// Chosen: the block is plain text now, with an undo.
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Undo" }));
|
||||
await user.click(within(blocks()[0]!).getByRole("button", { name: "Take theirs" }));
|
||||
await user.click(within(second).getByRole("button", { name: "Keep both" }));
|
||||
expect(save).toBeEnabled();
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitSaveFile).toHaveBeenCalledWith(
|
||||
3,
|
||||
"a.txt",
|
||||
"top\ntheirs\nmiddle\na\nb",
|
||||
"s1"
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "done",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("takes a whole side for the file through resolve", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onResolved = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={onResolved} onEdit={vi.fn()} />);
|
||||
await screen.findAllByTestId("conflict-block");
|
||||
await user.click(screen.getByRole("button", { name: "Take all theirs" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "resolve", {
|
||||
path: "a.txt",
|
||||
side: "theirs",
|
||||
})
|
||||
);
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(api.lanes.gitSaveFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers the plain editor when the markers do not balance", async () => {
|
||||
vi.mocked(api.lanes.gitFile).mockResolvedValue({
|
||||
available: true,
|
||||
path: "a.txt",
|
||||
rev: "work",
|
||||
lines: ["<<<<<<< HEAD", "x"],
|
||||
truncated: false,
|
||||
binary: false,
|
||||
sha: "s1",
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
const onEdit = vi.fn();
|
||||
render(<LaneConflictView laneId={3} path="a.txt" onResolved={vi.fn()} onEdit={onEdit} />);
|
||||
await screen.findByText(/markers do not line up/);
|
||||
await user.click(screen.getByRole("button", { name: "Edit by hand" }));
|
||||
expect(onEdit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,21 @@
|
||||
* @description Pins the side-by-side renderer: two independently scrolling
|
||||
* columns (a long line must be reachable, not painted over its neighbour)
|
||||
* whose horizontal scroll is mirrored, so the two halves of a line stay
|
||||
* opposite each other.
|
||||
* opposite each other. Also pins the clamped mirroring (a narrow column may
|
||||
* not drag the wide one back) and the row windowing of large diffs.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { LaneDiffView } from "../LaneDiffView";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
ROW_PX,
|
||||
WINDOW_THRESHOLD,
|
||||
analyseHunk,
|
||||
visibleRange,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
@@ -33,6 +41,17 @@ const FILE: LaneChangeFile = {
|
||||
],
|
||||
};
|
||||
|
||||
/** jsdom has no layout: give a scroller the extents a browser would. */
|
||||
function layout(
|
||||
el: HTMLElement,
|
||||
g: { scrollWidth?: number; clientWidth?: number; scrollHeight?: number; clientHeight?: number }
|
||||
) {
|
||||
for (const [k, v] of Object.entries(g))
|
||||
Object.defineProperty(el, k, { value: v, configurable: true });
|
||||
}
|
||||
|
||||
const ROOMY = { scrollWidth: 2000, clientWidth: 200, scrollHeight: 2000, clientHeight: 200 };
|
||||
|
||||
describe("LaneDiffView — side by side", () => {
|
||||
it("gives each column its own scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
@@ -40,11 +59,28 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||
});
|
||||
|
||||
it("bounds its own height so the horizontal bar stays on screen", () => {
|
||||
// A stacked review list caps each file; the single-file view takes the
|
||||
// height its parent gives it. Either way the diff scrolls itself rather
|
||||
// than growing a 2000-line container whose scrollbar is off the bottom.
|
||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||
// Unwindowed, a review-list diff grows so the PANEL is the one vertical
|
||||
// scroller (nested 60vh boxes made hunk navigation land nowhere); a
|
||||
// windowed one keeps the cap because windowing needs its own scroller.
|
||||
const { rerender } = render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
|
||||
const big: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
...FILE.hunks![0]!,
|
||||
lines: Array.from({ length: WINDOW_THRESHOLD + 1 }, (_, i) => ({
|
||||
type: "ctx" as const,
|
||||
oldLine: i + 1,
|
||||
newLine: i + 1,
|
||||
text: `line ${i}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(<LaneDiffView file={big} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).toContain("max-h-[60vh]");
|
||||
|
||||
rerender(<LaneDiffView file={FILE} layout="unified" fill />);
|
||||
@@ -56,6 +92,8 @@ describe("LaneDiffView — side by side", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollLeft = 320;
|
||||
fireEvent.scroll(left);
|
||||
@@ -66,6 +104,55 @@ describe("LaneDiffView — side by side", () => {
|
||||
expect(left.scrollLeft).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the split columns on the same row vertically too", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, ROOMY);
|
||||
layout(right, ROOMY);
|
||||
|
||||
left.scrollTop = 480;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollTop).toBe(480);
|
||||
|
||||
right.scrollTop = 40;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(40);
|
||||
});
|
||||
|
||||
it("clamps the mirrored offset to the other column's own extent", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200, scrollHeight: 300, clientHeight: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
right.scrollTop = 250;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollLeft).toBe(300);
|
||||
expect(left.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("does not let the narrow column's clamped echo drag the wide one back", () => {
|
||||
render(<LaneDiffView file={FILE} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollWidth: 500, clientWidth: 200 });
|
||||
layout(right, { scrollWidth: 1000, clientWidth: 200 });
|
||||
|
||||
right.scrollLeft = 600;
|
||||
fireEvent.scroll(right);
|
||||
// A browser fires the narrow column's scroll for the clamped write.
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(600);
|
||||
|
||||
// A real move of the narrow column still carries the wide one.
|
||||
left.scrollLeft = 100;
|
||||
fireEvent.scroll(left);
|
||||
expect(right.scrollLeft).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps the unified layout on one scroller", () => {
|
||||
render(<LaneDiffView file={FILE} layout="unified" />);
|
||||
expect(screen.queryByTestId("lane-diff-split-left")).toBeNull();
|
||||
@@ -158,3 +245,260 @@ describe("LaneDiffView — row styling", () => {
|
||||
expect(left!.className).not.toMatch(/status-(success|danger)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("analyseHunk", () => {
|
||||
const hunk = {
|
||||
header: "@@ -1,4 +1,4 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "ctx" as const, oldLine: 1, newLine: 1, text: "keep" },
|
||||
{ type: "del" as const, oldLine: 2, newLine: null, text: "foo = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 2, text: "foo = 2" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "bar = 1" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 3, text: "bar = 2" },
|
||||
],
|
||||
};
|
||||
|
||||
it("pairs each del/add with its emphasis, keyed by line index", () => {
|
||||
const a = analyseHunk(hunk);
|
||||
expect(a.rows).toHaveLength(3);
|
||||
expect(a.emph.get(1)).toEqual([6, 7]);
|
||||
expect(a.emph.get(2)).toEqual([6, 7]);
|
||||
expect(a.emph.get(3)).toEqual([6, 7]);
|
||||
expect(a.emph.get(4)).toEqual([6, 7]);
|
||||
expect(a.emph.has(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("computes once per hunk, so both split columns and re-renders share it", () => {
|
||||
expect(analyseHunk(hunk)).toBe(analyseHunk(hunk));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visibleRange", () => {
|
||||
it("covers the viewport plus overscan on both sides", () => {
|
||||
expect(visibleRange(0, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
expect(visibleRange(1600, 160, 16, 1000, 5)).toEqual({ start: 95, end: 115 });
|
||||
});
|
||||
|
||||
it("clamps a scrollTop past the content to the last full viewport", () => {
|
||||
expect(visibleRange(99999, 160, 16, 1000, 5)).toEqual({ start: 985, end: 1000 });
|
||||
expect(visibleRange(-50, 160, 16, 1000, 5)).toEqual({ start: 0, end: 15 });
|
||||
});
|
||||
|
||||
it("never runs past a short list", () => {
|
||||
expect(visibleRange(0, 800, 16, 3, 5)).toEqual({ start: 0, end: 3 });
|
||||
expect(visibleRange(0, 800, 16, 0, 5)).toEqual({ start: 0, end: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
function bigFile(n: number): LaneChangeFile {
|
||||
const lines = Array.from({ length: n }, (_, i) =>
|
||||
i % 2
|
||||
? { type: "add" as const, oldLine: null, newLine: i + 1, text: `line ${i};` }
|
||||
: { type: "ctx" as const, oldLine: i + 1, newLine: i + 1, text: `line ${i};` }
|
||||
);
|
||||
lines[7] = { type: "ctx", oldLine: 8, newLine: 8, text: `line 7; ${"w".repeat(300)}` };
|
||||
return fileWith(lines);
|
||||
}
|
||||
|
||||
const rowsIn = (el: HTMLElement) => [...el.querySelectorAll<HTMLElement>("div.flex.w-max")];
|
||||
|
||||
describe("LaneDiffView — windowing", () => {
|
||||
it("renders every row below the threshold, with no spacers", () => {
|
||||
const { container } = render(
|
||||
<LaneDiffView file={bigFile(WINDOW_THRESHOLD - 10)} layout="unified" />
|
||||
);
|
||||
expect(rowsIn(container)).toHaveLength(WINDOW_THRESHOLD - 10);
|
||||
expect(container.querySelector("[data-diff-spacer]")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders far fewer rows than a 5000-line diff holds, keeping the full scroll height", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const rows = rowsIn(container);
|
||||
expect(rows.length).toBeGreaterThan(0);
|
||||
expect(rows.length).toBeLessThan(300);
|
||||
const spacers = [...container.querySelectorAll<HTMLElement>("[data-diff-spacer]")];
|
||||
const spaced = spacers.reduce((sum, s) => sum + parseFloat(s.style.height), 0);
|
||||
// header + 5000 lines, every item one fixed row tall
|
||||
const rendered = container.querySelectorAll("[data-diff-item]").length;
|
||||
expect(spaced + rendered * ROW_PX).toBe(5001 * ROW_PX);
|
||||
});
|
||||
|
||||
it("moves the window with the scroll", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
expect(container.textContent).not.toContain("line 3000;");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("line 3000;");
|
||||
expect(container.textContent).not.toContain("line 10;");
|
||||
});
|
||||
|
||||
it("starts windowing where the reader is when a diff grows past the threshold", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(400)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 350 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
// Expanding context replaces the file in place, same mounted view.
|
||||
rerender(<LaneDiffView file={bigFile(600)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 350;");
|
||||
});
|
||||
|
||||
it("starts from the top again after a round trip through split", () => {
|
||||
const { container, rerender } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
// In split the outer box is no scroller; the browser drops its offset.
|
||||
screen.getByTestId("lane-diff-view").scrollTop = 0;
|
||||
rerender(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
expect(container.textContent).toContain("line 5;");
|
||||
});
|
||||
|
||||
it("holds the horizontal extent at the longest line, rendered or not", () => {
|
||||
const { container } = render(<LaneDiffView file={bigFile(5000)} layout="unified" />);
|
||||
const view = screen.getByTestId("lane-diff-view");
|
||||
view.scrollTop = 3000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
const content = container.querySelector<HTMLElement>("[data-diff-content]")!;
|
||||
// the 300-w line 7 is off screen now, and still sets the width
|
||||
expect(content.style.minWidth).toContain(`${"line 7; ".length + 300 + 1}ch`);
|
||||
});
|
||||
|
||||
it("windows both split columns over the same row range", () => {
|
||||
render(<LaneDiffView file={bigFile(5000)} layout="split" />);
|
||||
const left = screen.getByTestId("lane-diff-split-left");
|
||||
const right = screen.getByTestId("lane-diff-split-right");
|
||||
layout(left, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
layout(right, { scrollHeight: 5001 * ROW_PX, clientHeight: 400 });
|
||||
right.scrollTop = 2000 * ROW_PX;
|
||||
fireEvent.scroll(right);
|
||||
expect(left.scrollTop).toBe(2000 * ROW_PX);
|
||||
const l = rowsIn(left);
|
||||
const r = rowsIn(right);
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
});
|
||||
|
||||
it("windows a whole-file read too", () => {
|
||||
const lines = Array.from({ length: 5000 }, (_, i) => `row ${i}`);
|
||||
const { container } = render(<LaneFileView path="a.ts" lines={lines} />);
|
||||
expect(rowsIn(container).length).toBeLessThan(300);
|
||||
const view = screen.getByTestId("lane-file-view");
|
||||
view.scrollTop = 4000 * ROW_PX;
|
||||
fireEvent.scroll(view);
|
||||
expect(container.textContent).toContain("row 4000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LaneDiffView — hunk review bar navigation", () => {
|
||||
const hunk = (n: number) => ({
|
||||
header: `@@ -${n},1 +${n},1 @@`,
|
||||
context: "",
|
||||
oldStart: n,
|
||||
newStart: n,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: n, newLine: null, text: `old ${n}` },
|
||||
{ type: "add" as const, oldLine: null, newLine: n, text: `new ${n}` },
|
||||
],
|
||||
});
|
||||
const three: LaneChangeFile = { ...FILE, hunks: [hunk(1), hunk(20), hunk(40)] };
|
||||
|
||||
// jsdom lays nothing out: give the ONE real scroller a scrollable box and
|
||||
// put hunk k's `@@` row k×100px below its top, wherever it has scrolled to.
|
||||
function renderInScroller() {
|
||||
const view = render(
|
||||
<div data-testid="scroller" style={{ overflowY: "auto" }}>
|
||||
<LaneDiffView file={three} layout="unified" hunkActions={() => <span />} />
|
||||
</div>
|
||||
);
|
||||
const scroller = screen.getByTestId("scroller");
|
||||
Object.defineProperty(scroller, "scrollHeight", { value: 1000 });
|
||||
Object.defineProperty(scroller, "clientHeight", { value: 100 });
|
||||
scroller.getBoundingClientRect = () => ({ top: 0 }) as DOMRect;
|
||||
for (const el of view.container.querySelectorAll<HTMLElement>("[data-change-index]")) {
|
||||
const k = Number(el.dataset.changeIndex);
|
||||
el.getBoundingClientRect = () => ({ top: k * 100 - scroller.scrollTop }) as DOMRect;
|
||||
}
|
||||
return scroller;
|
||||
}
|
||||
|
||||
it("steps to the NEXT hunk and back to the PREVIOUS one, not the first", () => {
|
||||
const scroller = renderInScroller();
|
||||
const next = screen.getAllByRole("button", { name: "Next change" });
|
||||
const prev = screen.getAllByRole("button", { name: "Previous change" });
|
||||
|
||||
fireEvent.click(next[0]!); // bar 1 → hunk 2
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
fireEvent.click(next[1]!); // bar 2 → hunk 3
|
||||
expect(scroller.scrollTop).toBe(200);
|
||||
fireEvent.click(prev[2]!); // bar 3 → hunk 2, not hunk 1
|
||||
expect(scroller.scrollTop).toBe(100);
|
||||
});
|
||||
|
||||
it("counts the hunks and disables the arrows at either end", () => {
|
||||
renderInScroller();
|
||||
const bars = screen.getAllByTestId("lane-hunk-bar");
|
||||
expect(bars.map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
expect(screen.getAllByRole("button", { name: "Previous change" })[0]).toBeDisabled();
|
||||
expect(screen.getAllByRole("button", { name: "Next change" })[2]).toBeDisabled();
|
||||
});
|
||||
|
||||
it("hangs one bar under EACH +/- block of a single hunk, in both layouts (#50)", () => {
|
||||
const merged: LaneChangeFile = {
|
||||
...FILE,
|
||||
hunks: [
|
||||
{
|
||||
header: "@@ -1,6 +1,6 @@",
|
||||
context: "",
|
||||
oldStart: 1,
|
||||
newStart: 1,
|
||||
lines: [
|
||||
{ type: "del" as const, oldLine: 1, newLine: null, text: "a" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 1, text: "A" },
|
||||
{ type: "ctx" as const, oldLine: 2, newLine: 2, text: "b" },
|
||||
{ type: "del" as const, oldLine: 3, newLine: null, text: "c" },
|
||||
{ type: "ctx" as const, oldLine: 4, newLine: 3, text: "d" },
|
||||
{ type: "add" as const, oldLine: null, newLine: 4, text: "E" },
|
||||
{ type: "ctx" as const, oldLine: 5, newLine: 5, text: "f" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const seen: number[] = [];
|
||||
for (const layout of ["unified", "split"] as const) {
|
||||
const { unmount } = render(
|
||||
<LaneDiffView
|
||||
file={merged}
|
||||
layout={layout}
|
||||
hunkActions={(c) => {
|
||||
seen.push(c.block);
|
||||
return <span />;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId("lane-hunk-bar").map((b) => b.textContent)).toEqual([
|
||||
expect.stringContaining("1 of 3"),
|
||||
expect.stringContaining("2 of 3"),
|
||||
expect.stringContaining("3 of 3"),
|
||||
]);
|
||||
unmount();
|
||||
}
|
||||
expect(seen).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it("leaves an unwindowed review diff uncapped, so the panel is its only scroller", () => {
|
||||
render(<LaneDiffView file={three} layout="unified" />);
|
||||
expect(screen.getByTestId("lane-diff-view").className).not.toContain("max-h-[60vh]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* @file LaneGitBar.test.tsx
|
||||
* @description Pins the lane git action row: the ahead/behind readout, which
|
||||
* buttons a repo with no remote may press, that commit sends the typed
|
||||
* message, that a dirty tree blocks the branch switch client-side (the server
|
||||
* buttons a repo with no remote may press (and that it may publish to a typed URL),
|
||||
* what an unborn repo and a branch with no upstream show, that it offers no commit (the
|
||||
* Changes panel's staged-only box owns that), that a dirty tree blocks the branch switch client-side (the server
|
||||
* refuses it too), and that a failed action shows git's own words rather than
|
||||
* a generic sentence.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -24,6 +25,7 @@ function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}):
|
||||
available: true,
|
||||
branch: "feat/thing",
|
||||
head: "9b3e74a",
|
||||
unborn: false,
|
||||
subject: "wip",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
@@ -70,28 +72,72 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Fetch/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Push/ })).toBeDisabled();
|
||||
// Local actions stay available — a repo with no remote still commits.
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeEnabled();
|
||||
// Local actions stay available — a repo with no remote still branches.
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("commits with the typed message and reports the change upward", async () => {
|
||||
it("offers no commit at all — that is the Changes panel's staged-only box", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByRole("button", { name: /Commit/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an unborn repo's branch and asks for a first commit instead of a push", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({
|
||||
branch: "main",
|
||||
head: null,
|
||||
unborn: true,
|
||||
subject: "",
|
||||
hasRemote: false,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
})}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("lane-git-bar")).toHaveTextContent("no commits yet");
|
||||
expect(screen.getByText(/Commit first, then publish/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Fetch|Pull|Push|Publish/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes a repo with no remote to a typed URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: the thing");
|
||||
await user.click(screen.getByRole("button", { name: /Commit 3 files/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||
await user.type(screen.getByLabelText(/Remote URL/), "git@host:owner/repo.git");
|
||||
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", { message: "fix: the thing" })
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "publish", {
|
||||
url: "git@host:owner/repo.git",
|
||||
})
|
||||
);
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no commit when nothing is uncommitted", () => {
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: /^Commit$/ })).toBeDisabled();
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: null, ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /^Push$/ })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Publish branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||
});
|
||||
|
||||
it("loads the branch list on demand and refuses to switch while dirty", async () => {
|
||||
@@ -120,6 +166,15 @@ describe("LaneGitBar", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the branch list after a switch, so the branch just left is not held", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Switch branch" }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("creates a branch without switching, then offers a sibling lane on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onNewLane = vi.fn();
|
||||
@@ -146,20 +201,32 @@ describe("LaneGitBar", () => {
|
||||
expect(onNewLane).toHaveBeenCalledWith("development");
|
||||
});
|
||||
|
||||
it("commits only the index when asked", async () => {
|
||||
it("offers a sibling lane only on a branch no worktree holds — the server refuses the rest", async () => {
|
||||
vi.mocked(api.lanes.gitBranches).mockResolvedValue({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing", "feat/free"],
|
||||
current: "feat/thing",
|
||||
worktrees: [
|
||||
{ path: "/repo/lane3", branch: "feat/thing" },
|
||||
{ path: "/repo/dev", branch: "development" },
|
||||
],
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
const newLane = () => screen.getByRole("button", { name: "New lane here" });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Commit$/ }));
|
||||
await user.type(screen.getByLabelText(/Commit message/), "fix: staged only");
|
||||
await user.click(screen.getByRole("button", { name: "Commit staged" }));
|
||||
// This lane's own branch: its worktree is this lane.
|
||||
await user.selectOptions(select, "feat/thing");
|
||||
expect(newLane()).toBeDisabled();
|
||||
// Held by another worktree: git allows one worktree per branch (EBRANCHBUSY).
|
||||
await user.selectOptions(select, "development");
|
||||
expect(newLane()).toBeDisabled();
|
||||
expect(newLane()).toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "commit", {
|
||||
message: "fix: staged only",
|
||||
only: "staged",
|
||||
})
|
||||
);
|
||||
await user.selectOptions(select, "feat/free");
|
||||
expect(newLane()).toBeEnabled();
|
||||
});
|
||||
|
||||
it("prints git's own rejection text when an action fails", async () => {
|
||||
@@ -172,4 +239,168 @@ describe("LaneGitBar", () => {
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent("non-fast-forward");
|
||||
});
|
||||
|
||||
it("re-reads the branch list for the new lane instead of showing the old one's", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} />);
|
||||
expect(screen.queryByLabelText("Switch branch…")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
expect(await screen.findByLabelText("Switch branch…")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("sends one branch-create for a double Enter", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockReturnValue(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.type(await screen.findByLabelText(/New branch/), "feat/once{Enter}{Enter}");
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets which branches the previous lane's worktrees held once the lane changes", async () => {
|
||||
let resolveOld: (v: Awaited<ReturnType<typeof api.lanes.gitBranches>>) => void = () => {};
|
||||
vi.mocked(api.lanes.gitBranches).mockReturnValueOnce(
|
||||
new Promise((r) => {
|
||||
resolveOld = r;
|
||||
})
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
|
||||
rerender(<LaneGitBar laneId={4} git={facts()} onChanged={vi.fn()} onNewLane={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
const select = await screen.findByLabelText("Switch branch…");
|
||||
await waitFor(() => expect(api.lanes.gitBranches).toHaveBeenCalledWith(4));
|
||||
// Lane 3's answer lands late: its worktree held `development`.
|
||||
resolveOld({
|
||||
available: true,
|
||||
branches: ["development", "feat/thing"],
|
||||
current: "feat/thing",
|
||||
worktrees: [{ path: "/repo/dev", branch: "development" }],
|
||||
});
|
||||
await user.selectOptions(select, "development");
|
||||
const newLane = screen.getByRole("button", { name: "New lane here" });
|
||||
expect(newLane).toBeEnabled();
|
||||
expect(newLane).not.toHaveAttribute("title", expect.stringContaining("/repo/dev"));
|
||||
});
|
||||
|
||||
it("claims no sync state when the upstream comparison could not be counted", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={facts({ upstream: "origin/gone", ahead: null, behind: null })}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText("in sync")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-git-tracking")).toBeNull();
|
||||
});
|
||||
|
||||
it("explains a push with nowhere to go instead of the server's generic text", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValue(
|
||||
Object.assign(new Error("this repository has no remote to push to"), { code: "ENOREMOTE" })
|
||||
);
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
expect(await screen.findByTestId("lane-git-bar-error")).toHaveTextContent(
|
||||
"No remote to push to or pull from"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("git login (issue #45)", () => {
|
||||
const authErr = (rejected = false) =>
|
||||
Object.assign(new Error("git.smartgift.io.vn needs a username and password or access token"), {
|
||||
code: "EGITAUTH",
|
||||
auth: { protocol: "https", host: "git.smartgift.io.vn", username: "vi", rejected },
|
||||
});
|
||||
|
||||
it("asks for a login when git needs one, then resends the same action with it", async () => {
|
||||
vi.mocked(api.lanes.gitAction).mockRejectedValueOnce(authErr());
|
||||
const user = userEvent.setup();
|
||||
const onChanged = vi.fn();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={onChanged} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||
const form = await screen.findByTestId("lane-git-login");
|
||||
expect(form).toHaveTextContent("git.smartgift.io.vn");
|
||||
expect(screen.getByLabelText("Username")).toHaveValue("vi");
|
||||
expect(screen.getByLabelText(/Remember for everyone/)).toBeChecked();
|
||||
await user.type(screen.getByLabelText("Password or access token"), "s3cret");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenLastCalledWith(3, "push", {
|
||||
credentials: {
|
||||
protocol: "https",
|
||||
host: "git.smartgift.io.vn",
|
||||
username: "vi",
|
||||
password: "s3cret",
|
||||
remember: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-login")).toBeNull());
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("s3cret");
|
||||
});
|
||||
|
||||
it("says the login was wrong when the host rejected it, and keeps the form", async () => {
|
||||
vi.mocked(api.lanes.gitAction)
|
||||
.mockRejectedValueOnce(authErr())
|
||||
.mockRejectedValueOnce(authErr(true));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts()} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Fetch/ }));
|
||||
await user.type(await screen.findByLabelText("Password or access token"), "bad");
|
||||
await user.click(screen.getByRole("button", { name: "Log in and retry" }));
|
||||
expect(await screen.findByTestId("lane-git-login")).toHaveTextContent(/rejected/i);
|
||||
expect(screen.getByLabelText("Password or access token")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("merge (issue #45)", () => {
|
||||
it("merges the picked branch into the current one after a confirm", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /Branch/ }));
|
||||
await user.selectOptions(await screen.findByLabelText("Switch branch…"), "development");
|
||||
await user.click(screen.getByRole("button", { name: "Merge into current" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "development" })
|
||||
);
|
||||
confirm.mockRestore();
|
||||
});
|
||||
|
||||
it("offers to merge the upstream when the branch has diverged", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
render(<LaneGitBar laneId={3} git={facts({ dirty: 0, untracked: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: "Merge origin/feat/thing" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "merge", { branch: "origin/feat/thing" })
|
||||
);
|
||||
});
|
||||
|
||||
it("disables pull and switch while a merge is in progress", () => {
|
||||
render(
|
||||
<LaneGitBar
|
||||
laneId={3}
|
||||
git={{ ...facts(), merging: true, conflicts: 1 } as LaneGitFacts}
|
||||
onChanged={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /Pull/ })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: /Branch/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @file LaneGitHeader.test.tsx
|
||||
* @description Pins the Changes panel's working-copy line: the live branch,
|
||||
* head, subject and uncommitted counts, the per-file hover panel behind the
|
||||
* counts, that a lane switch drops the previous lane's facts at once, and that
|
||||
* an unreadable repo renders nothing rather than an error.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
import { LaneGitHeader, useLaneGitFacts } from "../LaneGitHeader";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { git: vi.fn(), gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
});
|
||||
|
||||
/** The panel's own wiring: one facts read feeding the header. */
|
||||
function Harness({ laneId }: { laneId: number }) {
|
||||
const [git, refresh] = useLaneGitFacts(laneId);
|
||||
return <LaneGitHeader laneId={laneId} git={git} onChanged={refresh} />;
|
||||
}
|
||||
|
||||
describe("LaneGitHeader", () => {
|
||||
const facts = {
|
||||
available: true as const,
|
||||
branch: "feat/rename-metric",
|
||||
head: "9b3e74a",
|
||||
subject: "free-text rule mode in the form",
|
||||
dirty: 2,
|
||||
untracked: 1,
|
||||
files: [
|
||||
{ path: "server/lib/alerts.js", status: " M", added: 12, deleted: 3 },
|
||||
{ path: "client/src/lib/api.ts", status: "M ", added: 1, deleted: 0 },
|
||||
{ path: "scratch.txt", status: "??", added: null, deleted: null },
|
||||
],
|
||||
// Tracking facts ride along on the same endpoint; a branch that has never
|
||||
// been pushed reports nulls rather than zeros.
|
||||
hasRemote: true,
|
||||
upstream: null,
|
||||
ahead: null,
|
||||
behind: null,
|
||||
};
|
||||
|
||||
it("renders the live branch, head, subject and uncommitted counts", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).toContain("feat/rename-metric");
|
||||
expect(block.textContent).toContain("9b3e74a");
|
||||
expect(block.textContent).toContain("free-text rule mode in the form");
|
||||
expect(screen.getByTestId("lane-git-counts").textContent).toContain("2");
|
||||
});
|
||||
|
||||
it("drops the previous lane's facts the moment the lane changes", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
const { rerender } = render(<Harness laneId={1} />);
|
||||
await screen.findByText(/feat\/rename-metric/);
|
||||
|
||||
// The next lane's read never lands: nothing of lane 1's git may linger.
|
||||
vi.mocked(api.lanes.git).mockReturnValueOnce(new Promise(() => {}));
|
||||
rerender(<Harness laneId={2} />);
|
||||
expect(screen.queryByText(/feat\/rename-metric/)).toBeNull();
|
||||
});
|
||||
|
||||
it("names the files behind the counts on hover, with each one's line delta", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
// Hidden until asked for: the row is a one-line summary until you hover.
|
||||
expect(screen.queryByTestId("lane-git-files")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(counts);
|
||||
const panel = screen.getByTestId("lane-git-files");
|
||||
expect(panel.textContent).toContain("alerts.js");
|
||||
expect(panel.textContent).toContain("+12");
|
||||
expect(panel.textContent).toContain("−3");
|
||||
// Totals across the listed files.
|
||||
expect(panel.textContent).toContain("+13");
|
||||
// An untracked file has no delta to report and says so rather than "+0".
|
||||
expect(panel.textContent).toContain("new");
|
||||
expect(panel.textContent).toContain("3 files changed");
|
||||
|
||||
// Closing is delayed so the pointer can travel into the panel and scroll
|
||||
// it — the panel is portalled to the body, away from its trigger.
|
||||
fireEvent.mouseLeave(counts);
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("stays open while the pointer is inside the panel", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts);
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
const counts = await screen.findByTestId("lane-git-counts");
|
||||
fireEvent.mouseEnter(counts);
|
||||
fireEvent.mouseLeave(counts);
|
||||
// The pointer landed on the panel before the pending close fired.
|
||||
fireEvent.mouseEnter(screen.getByTestId("lane-git-files"));
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
expect(screen.queryByTestId("lane-git-files")).not.toBeNull();
|
||||
|
||||
fireEvent.mouseLeave(screen.getByTestId("lane-git-files"));
|
||||
await waitFor(() => expect(screen.queryByTestId("lane-git-files")).toBeNull());
|
||||
});
|
||||
|
||||
it("says how many files the server-side cap left out", async () => {
|
||||
// 40 uncommitted files, 3 listed: the counts are exact, the listing is not.
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 39, untracked: 1 });
|
||||
render(<Harness laneId={1} />);
|
||||
|
||||
fireEvent.mouseEnter(await screen.findByTestId("lane-git-counts"));
|
||||
expect(screen.getByTestId("lane-git-files").textContent).toContain("37 more");
|
||||
});
|
||||
|
||||
it("omits the uncommitted line when the tree is clean", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ ...facts, dirty: 0, untracked: 0 });
|
||||
render(<Harness laneId={1} />);
|
||||
const block = await screen.findByTestId("lane-git");
|
||||
expect(block.textContent).not.toContain("modified");
|
||||
});
|
||||
|
||||
it("renders nothing and no error when git is unavailable", async () => {
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce({ available: false });
|
||||
render(<Harness laneId={2} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(2));
|
||||
expect(screen.queryByTestId("lane-git-header")).toBeNull();
|
||||
});
|
||||
|
||||
it("swallows a rejected request instead of surfacing an error", async () => {
|
||||
vi.mocked(api.lanes.git).mockRejectedValueOnce(new Error("network down"));
|
||||
render(<Harness laneId={3} />);
|
||||
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(screen.queryByTestId("lane-git")).toBeNull();
|
||||
expect(screen.queryByText(/network down/)).toBeNull();
|
||||
});
|
||||
|
||||
it("stops polling once the panel unmounts", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.mocked(api.lanes.git).mockResolvedValue({ available: false });
|
||||
const { unmount } = render(<Harness laneId={1} />);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
unmount();
|
||||
vi.advanceTimersByTime(120_000);
|
||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,9 @@
|
||||
* and its live tmux session — instead of leaving the previous lane's terminal
|
||||
* on screen under a new lane's header.
|
||||
*
|
||||
* A lane's pane is one tmux session, so starting or resuming into a lane
|
||||
* already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* Each pane is one tmux session on its lane — split panes all show the same
|
||||
* lane, pane `slot` N owning `agenthub-lane-<id>[-N]` — so starting or resuming
|
||||
* into a pane already holding a live run answers 409 ERUNLIVE rather than overwriting a
|
||||
* prompt in flight — `onStartFromSetup` and `onResumeFromHistory` both catch
|
||||
* that and attach to the still-live session instead of failing outright
|
||||
* (with a notice explaining what happened), since that session is still
|
||||
@@ -27,7 +28,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, AlertCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
@@ -42,18 +43,33 @@ import type {
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
} from "../../lib/api";
|
||||
import type { Session, Lane, LaneTool } from "../../lib/types";
|
||||
import type { Session, Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { RunSetup } from "./RunSetup";
|
||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||
|
||||
/** The claude lane a session's cwd belongs to: the longest path-boundary
|
||||
* prefix, the same rule the server binds hook sessions with. External
|
||||
* sessions are all claude — Codex emits no hooks. */
|
||||
function owningLaneId(cwd: string, lanes: Lane[]): number | null {
|
||||
let best: Lane | null = null;
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== "claude") continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
lanes: Lane[];
|
||||
laneId: number | null;
|
||||
showLaneSelector: boolean;
|
||||
onLaneIdChange: (id: number) => void;
|
||||
onLaneCreated: (lane: Lane) => void;
|
||||
binaryStatus: Record<LaneTool, { found: boolean; path: string | null }> | null;
|
||||
binaryStatus: Record<string, { found: boolean; path: string | null }> | null;
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
activeRuns: RunListResponse | null;
|
||||
/** Active Claude Code sessions started outside the dashboard, listed in the
|
||||
@@ -62,6 +78,19 @@ export interface LaneConsolePaneProps {
|
||||
wsConnected: boolean;
|
||||
defaultCwd?: string;
|
||||
onHasActiveRunChange?: (active: boolean) => void;
|
||||
/** Which split pane this is. Every pane shows the SAME lane, each in its own
|
||||
* tmux session: slot 0 is the lane's own, 1–3 are `agenthub-lane-<id>-<slot>`. */
|
||||
slot?: number;
|
||||
/** Runs the OTHER panes are showing — left out of this pane's Active runs so
|
||||
* one terminal is never attached twice side by side. */
|
||||
hiddenRunIds?: string[];
|
||||
/** Reports the run this pane shows (null on the setup form). */
|
||||
onHandleChange?: (runId: string | null) => void;
|
||||
}
|
||||
|
||||
/** The tmux session a lane's split pane owns (see server/lib/pty-run.js). */
|
||||
function paneRunId(laneId: number, slot: number): string {
|
||||
return slot ? `agenthub-lane-${laneId}-${slot}` : `agenthub-lane-${laneId}`;
|
||||
}
|
||||
|
||||
export function LaneConsolePane({
|
||||
@@ -77,6 +106,9 @@ export function LaneConsolePane({
|
||||
wsConnected,
|
||||
defaultCwd,
|
||||
onHasActiveRunChange,
|
||||
slot = 0,
|
||||
hiddenRunIds,
|
||||
onHandleChange,
|
||||
}: LaneConsolePaneProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const { t: tLanes } = useTranslation("lanes");
|
||||
@@ -97,12 +129,44 @@ export function LaneConsolePane({
|
||||
|
||||
const currentLane = laneId !== null ? lanes.find((l) => l.id === laneId) : null;
|
||||
const laneTool = currentLane?.tool ?? "claude";
|
||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
||||
// The page polls runs and sessions for EVERY lane; this pane's Active runs
|
||||
// list is this lane's only. No lane selected keeps the full lists.
|
||||
const laneRuns = useMemo(
|
||||
() =>
|
||||
laneId === null || !activeRuns
|
||||
? activeRuns
|
||||
: {
|
||||
...activeRuns,
|
||||
items: activeRuns.items.filter(
|
||||
(r) => r.laneId === laneId && !hiddenRunIds?.includes(r.id)
|
||||
),
|
||||
},
|
||||
[activeRuns, laneId, hiddenRunIds]
|
||||
);
|
||||
const laneSessions = useMemo(
|
||||
() =>
|
||||
laneId === null
|
||||
? externalSessions
|
||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||
[externalSessions, laneId, lanes]
|
||||
);
|
||||
const aiTools = useAiTools();
|
||||
const toolLabel = aiTools.label(laneTool);
|
||||
// A new run starts on the tool's default model (AI Tools settings), and a
|
||||
// lane switch to another tool drops the previous tool's pick.
|
||||
const defaultModel = aiTools.byId(laneTool)?.defaultModel ?? "";
|
||||
useEffect(() => {
|
||||
setModel(defaultModel);
|
||||
}, [laneTool, defaultModel]);
|
||||
|
||||
useEffect(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
}, [handle, onHasActiveRunChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onHandleChange?.(handle?.id ?? null);
|
||||
}, [handle, onHandleChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLane && defaultCwd && cwd === "") {
|
||||
setCwd(defaultCwd);
|
||||
@@ -141,10 +205,11 @@ export function LaneConsolePane({
|
||||
setResumeSession(null);
|
||||
setError(null);
|
||||
setBusy(null);
|
||||
setHandle(runs?.items.find((r) => r.laneId === laneId && r.status === "running") ?? null);
|
||||
const own = laneId === null ? null : paneRunId(laneId, slot);
|
||||
setHandle(runs?.items.find((r) => r.id === own && r.status === "running") ?? null);
|
||||
autoAttachedForLane.current = null;
|
||||
refreshList();
|
||||
}, [laneId, refreshList]);
|
||||
}, [laneId, slot, refreshList]);
|
||||
|
||||
// Picking a pipeline template writes NOTHING into the prompt box: a prefilled
|
||||
// slash command (or the codex stage-checklist prose) is sent as soon as the
|
||||
@@ -161,12 +226,13 @@ export function LaneConsolePane({
|
||||
useEffect(() => {
|
||||
if (handle || laneId === null) return;
|
||||
if (autoAttachedForLane.current === laneId) return;
|
||||
const running = activeRuns?.items.find((r) => r.laneId === laneId && r.status === "running");
|
||||
const own = paneRunId(laneId, slot);
|
||||
const running = activeRuns?.items.find((r) => r.id === own && r.status === "running");
|
||||
if (running) {
|
||||
autoAttachedForLane.current = laneId;
|
||||
setHandle(running);
|
||||
}
|
||||
}, [activeRuns, laneId, handle]);
|
||||
}, [activeRuns, laneId, slot, handle]);
|
||||
|
||||
// The cwd tracks the lane's own folder separately, keyed on the resolved
|
||||
// path rather than on `laneId` alone: the pane can mount before the lane
|
||||
@@ -245,6 +311,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: args.initialPrompt || "",
|
||||
model: args.model || undefined,
|
||||
permissionMode: args.permissionMode,
|
||||
@@ -253,35 +320,34 @@ export function LaneConsolePane({
|
||||
sandbox: args.sandbox,
|
||||
approval: args.approval,
|
||||
resumeLast: args.resumeLast,
|
||||
bypass: args.bypass,
|
||||
});
|
||||
} catch (laneErr: unknown) {
|
||||
// A code-only check: the server's message is human prose ("lane
|
||||
// already has a live run"), never the literal strings "409" or
|
||||
// "ERUNLIVE" — matching against `.message` here is dead code that
|
||||
// always falls through to the raw error below.
|
||||
// This pane's own session is the live one — land on it.
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error(t("errors.noRunIdReturned"));
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
const fetched = await api.run.get(startedId);
|
||||
setHandle(fetched);
|
||||
refreshList();
|
||||
} catch {
|
||||
try {
|
||||
await attachToRun(laneStartResult.lane.run_id);
|
||||
await attachToRun(startedId);
|
||||
refreshList();
|
||||
} catch (fallbackErr: unknown) {
|
||||
const attachMsg = fallbackErr instanceof Error ? fallbackErr.message : "unknown";
|
||||
@@ -295,7 +361,18 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, t, lanes, laneId, laneTool, onLaneIdChange, onLaneCreated, attachToRun, refreshList]
|
||||
[
|
||||
busy,
|
||||
t,
|
||||
lanes,
|
||||
laneId,
|
||||
laneTool,
|
||||
slot,
|
||||
onLaneIdChange,
|
||||
onLaneCreated,
|
||||
attachToRun,
|
||||
refreshList,
|
||||
]
|
||||
);
|
||||
|
||||
const onResumeFromHistory = useCallback(
|
||||
@@ -323,6 +400,7 @@ export function LaneConsolePane({
|
||||
let laneStartResult;
|
||||
try {
|
||||
laneStartResult = await api.lanes.action(targetLaneId, "start", {
|
||||
slot,
|
||||
prompt: "",
|
||||
model: item.model || undefined,
|
||||
permissionMode: item.permission_mode || undefined,
|
||||
@@ -337,23 +415,20 @@ export function LaneConsolePane({
|
||||
// — the server's `.message` is prose, never the literal string
|
||||
// "ERUNLIVE" or "409".
|
||||
if ((laneErr as { code?: string })?.code === "ERUNLIVE") {
|
||||
const fresh = await api.lanes.list().catch(() => null);
|
||||
const updatedLane = fresh?.lanes.find((l) => l.id === targetLaneId);
|
||||
if (updatedLane?.run_id) {
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(updatedLane.run_id);
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
onLaneIdChange(targetLaneId);
|
||||
await attachToRun(paneRunId(targetLaneId, slot));
|
||||
setError(t("errors.attachedToLiveInstead"));
|
||||
return;
|
||||
}
|
||||
throw laneErr;
|
||||
}
|
||||
|
||||
if (!laneStartResult.lane?.run_id) {
|
||||
const startedId = laneStartResult.run_id ?? laneStartResult.lane?.run_id;
|
||||
if (!startedId) {
|
||||
throw new Error("No run_id returned from lane start");
|
||||
}
|
||||
|
||||
fetched = await api.run.get(laneStartResult.lane.run_id);
|
||||
fetched = await api.run.get(startedId);
|
||||
onLaneIdChange(targetLaneId);
|
||||
} else {
|
||||
fetched = await api.run.start({
|
||||
@@ -377,7 +452,7 @@ export function LaneConsolePane({
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[busy, refreshList, t, lanes, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
[busy, refreshList, t, lanes, slot, onLaneCreated, onLaneIdChange, attachToRun]
|
||||
);
|
||||
|
||||
const onViewFromHistory = useCallback(
|
||||
@@ -487,11 +562,11 @@ export function LaneConsolePane({
|
||||
</p>
|
||||
</div>
|
||||
<ActiveRunsSwitcher
|
||||
activeRuns={activeRuns}
|
||||
activeRuns={laneRuns}
|
||||
currentHandleId={handle?.id || null}
|
||||
onAttach={attachToRun}
|
||||
runHistory={runHistory}
|
||||
externalSessions={externalSessions}
|
||||
externalSessions={laneSessions}
|
||||
onResumeFromHistory={onResumeFromHistory}
|
||||
onViewFromHistory={onViewFromHistory}
|
||||
onKill={killRun}
|
||||
@@ -499,10 +574,16 @@ export function LaneConsolePane({
|
||||
/>
|
||||
</header>
|
||||
|
||||
{binaryStatus && !binaryStatus[laneTool]?.found && (
|
||||
{/* Not probed yet (a lane on a tool the page has not probed) is not "missing". */}
|
||||
{binaryStatus?.[laneTool] && !binaryStatus[laneTool].found && (
|
||||
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{t("binary.missing", { cli: laneTool, tool: toolLabel })}</span>
|
||||
<span>
|
||||
{t("binary.missing", {
|
||||
cli: aiTools.byId(laneTool)?.command ?? laneTool,
|
||||
tool: toolLabel,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -541,7 +622,9 @@ export function LaneConsolePane({
|
||||
onSandboxChange={setSandbox}
|
||||
approval={approval}
|
||||
onApprovalChange={setApproval}
|
||||
onResumeLast={() => onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true })}
|
||||
onResumeLast={(bypass) =>
|
||||
onStartFromSetup({ laneId: laneId ?? 0, cwd, resumeLast: true, bypass })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
*
|
||||
* `externalSessions` (active Claude Code sessions this dashboard did NOT spawn —
|
||||
* e.g. `claude` started by hand in a terminal tab) are merged in as live rows so
|
||||
* "Active runs" counts everything actually running. They carry no tmux session
|
||||
* "Active runs" counts everything actually running in scope. They carry no tmux session
|
||||
* the dashboard can attach to, so their only action is Resume, which spawns a
|
||||
* fresh tmux-backed `claude --resume <session>` in that cwd — Kill is withheld
|
||||
* from them for the same reason.
|
||||
@@ -58,7 +58,7 @@ function StatusPill({
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-status-success/10 text-status-success border-status-success/30",
|
||||
gone: "bg-surface-3 text-fg-secondary border-border",
|
||||
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
|
||||
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
|
||||
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
|
||||
killed: "bg-surface-3 text-fg-secondary border-border",
|
||||
abandoned: "bg-surface-3 text-fg-secondary border-border",
|
||||
@@ -574,7 +574,7 @@ function UnifiedRunRowView({
|
||||
)}
|
||||
{row.external && (
|
||||
<span
|
||||
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
|
||||
title={t("runs.externalHint")}
|
||||
>
|
||||
{t("runs.externalBadge")}
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
RotateCcw,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { api, RUN_MODEL_CHOICES, RUN_MODEL_CHOICES_CODEX, RUN_EFFORT_CHOICES } from "../../lib/api";
|
||||
import { api, RUN_MODEL_CHOICES, RUN_EFFORT_CHOICES, type ModelChoice } from "../../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
DashboardRunHistoryItem,
|
||||
@@ -53,30 +53,215 @@ import type {
|
||||
RunStartArgs,
|
||||
CodexSandbox,
|
||||
CodexApproval,
|
||||
CcMdItem,
|
||||
} from "../../lib/api";
|
||||
import type { Session, LaneTool } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
// Minimal SlashCommand type (from deleted RunConsole)
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
source: "project" | "user" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Minimal PromptEditor component (from deleted RunConsole)
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
"compact",
|
||||
"config",
|
||||
"model",
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
"status",
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"output-style",
|
||||
"exit",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
/** Tiered match: exact > prefix > word prefix > substring > subsequence >
|
||||
* description (3+ chars only, so one keystroke drags nothing tangential in). */
|
||||
function scoreSlashMatch(name: string, description: string | undefined, q: string): number {
|
||||
if (!q) return 1;
|
||||
const n = name.toLowerCase();
|
||||
if (n === q) return 1000;
|
||||
if (n.startsWith(q)) return 800 - Math.min(n.length, 100);
|
||||
if (n.split(/[-_.:\s]/).some((p) => p.startsWith(q))) return 600 - Math.min(n.length, 100);
|
||||
const idx = n.indexOf(q);
|
||||
if (idx >= 0) return 400 - Math.min(idx, 100);
|
||||
let i = 0;
|
||||
for (let k = 0; k < n.length && i < q.length; k++) if (n[k] === q[i]) i++;
|
||||
if (i === q.length) return 200;
|
||||
if (q.length >= 3 && (description || "").toLowerCase().includes(q)) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
interface Trigger {
|
||||
kind: "slash" | "file";
|
||||
query: string;
|
||||
/** Where the `/` or `@` token starts, so a pick replaces it up to the cursor. */
|
||||
start: number;
|
||||
cursor: number;
|
||||
}
|
||||
|
||||
/** The `/…` or `@…` token the cursor is in, if any. A token starts at the
|
||||
* line start or after whitespace, so `a/b` and `me@x.io` never trigger. */
|
||||
function detectTrigger(value: string, cursor: number): Trigger | null {
|
||||
let start = cursor;
|
||||
while (start > 0 && !/\s/.test(value[start - 1] ?? " ")) start--;
|
||||
const tok = value.slice(start, cursor);
|
||||
if (tok.startsWith("/")) return { kind: "slash", query: tok.slice(1), start, cursor };
|
||||
if (tok.startsWith("@")) return { kind: "file", query: tok.slice(1), start, cursor };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface PromptEditorProps {
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder: string;
|
||||
rows?: number;
|
||||
slashCommands: SlashCommand[];
|
||||
/** Offer slash commands. Off for codex: these are Claude Code's. */
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
}
|
||||
|
||||
function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: PromptEditorProps) {
|
||||
function PromptEditor({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
rows = 5,
|
||||
slash,
|
||||
fileCwd,
|
||||
}: PromptEditorProps) {
|
||||
const { t } = useTranslation("run");
|
||||
const taRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [trigger, setTrigger] = useState<Trigger | null>(null);
|
||||
const [active, setActive] = useState(0);
|
||||
const [files, setFiles] = useState<string[]>([]);
|
||||
const [commands, setCommands] = useState<SlashCommand[] | null>(null);
|
||||
const [commandsCwd, setCommandsCwd] = useState(fileCwd);
|
||||
if (commandsCwd !== fileCwd) {
|
||||
setCommandsCwd(fileCwd);
|
||||
setCommands(null);
|
||||
}
|
||||
const kind = trigger?.kind === "slash" && !slash ? null : trigger?.kind;
|
||||
|
||||
// Commands and skills are read the first time `/` is typed for this cwd —
|
||||
// two directory walks nobody should pay for on every render of the form.
|
||||
useEffect(() => {
|
||||
if (kind !== "slash" || commands) return;
|
||||
let live = true;
|
||||
const toCmd = (c: CcMdItem): SlashCommand => ({
|
||||
name: c.name,
|
||||
source: c.scope,
|
||||
description: c.frontmatter?.description || c.preview.slice(0, 80),
|
||||
});
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [kind, commands, fileCwd]);
|
||||
|
||||
// Files: debounced, and only the newest query may land.
|
||||
const query = trigger?.query ?? "";
|
||||
useEffect(() => {
|
||||
if (kind !== "file") return;
|
||||
let live = true;
|
||||
const tid = window.setTimeout(() => {
|
||||
api.run
|
||||
.files(fileCwd, query)
|
||||
.then((r) => live && setFiles(r.items))
|
||||
.catch(() => live && setFiles([]));
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearTimeout(tid);
|
||||
};
|
||||
}, [kind, query, fileCwd]);
|
||||
|
||||
const slashItems = useMemo(() => {
|
||||
if (kind !== "slash" || !commands) return [];
|
||||
const q = query.toLowerCase();
|
||||
return commands
|
||||
.map((cmd) => ({ cmd, score: scoreSlashMatch(cmd.name, cmd.description, q) }))
|
||||
.filter((x) => x.score > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.score - a.score ||
|
||||
SOURCE_ORDER[a.cmd.source] - SOURCE_ORDER[b.cmd.source] ||
|
||||
a.cmd.name.localeCompare(b.cmd.name)
|
||||
)
|
||||
.map((x) => x.cmd);
|
||||
}, [kind, commands, query]);
|
||||
const count = kind === "file" ? files.length : slashItems.length;
|
||||
const current = Math.min(active, Math.max(0, count - 1));
|
||||
|
||||
const pick = (token: string) => {
|
||||
if (!trigger) return;
|
||||
const before = value.slice(0, trigger.start);
|
||||
const after = value.slice(trigger.cursor);
|
||||
const sep = after.startsWith(" ") ? "" : " ";
|
||||
onChange(before + token + sep + after);
|
||||
setTrigger(null);
|
||||
setActive(0);
|
||||
const pos = before.length + token.length + 1;
|
||||
requestAnimationFrame(() => {
|
||||
taRef.current?.focus();
|
||||
taRef.current?.setSelectionRange(pos, pos);
|
||||
});
|
||||
};
|
||||
const pickAt = (i: number) => {
|
||||
if (kind === "file") {
|
||||
const f = files[i];
|
||||
if (f) pick(`@${f}`);
|
||||
} else {
|
||||
const c = slashItems[i];
|
||||
if (c) pick(`/${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (kind && count > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const step = e.key === "ArrowDown" ? 1 : -1;
|
||||
setActive(Math.min(count - 1, Math.max(0, current + step)));
|
||||
return;
|
||||
}
|
||||
if ((e.key === "Enter" || e.key === "Tab") && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
pickAt(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTrigger(null);
|
||||
return;
|
||||
}
|
||||
// Enter sends (Ctrl/Cmd+Enter still does); Shift+Enter inserts a newline.
|
||||
// isComposing guards IME input (Vietnamese/CJK), where Enter commits the candidate.
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
@@ -85,15 +270,89 @@ function PromptEditor({ value, onChange, onSubmit, placeholder, rows = 5 }: Prom
|
||||
}
|
||||
};
|
||||
|
||||
const retrack = (ta: HTMLTextAreaElement) => {
|
||||
setTrigger(detectTrigger(ta.value, ta.selectionStart ?? 0));
|
||||
};
|
||||
|
||||
const rowClass = (i: number) =>
|
||||
`w-full text-left px-3 py-1.5 transition-colors ${i === current ? "bg-accent/15" : "hover:bg-surface-3"}`;
|
||||
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
retrack(e.target);
|
||||
setActive(0);
|
||||
}}
|
||||
onSelect={(e) => retrack(e.currentTarget)}
|
||||
onBlur={() => setTrigger(null)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-2 border border-border rounded-md px-3 py-2 text-[11px] font-mono text-fg-primary placeholder:text-fg-muted focus:outline-none focus:border-accent/50 resize-none"
|
||||
/>
|
||||
{kind && (
|
||||
<div
|
||||
role="listbox"
|
||||
data-testid="prompt-autocomplete"
|
||||
className="absolute z-30 left-0 right-0 top-full mt-1 max-h-72 overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{kind === "slash" ? t("autocomplete.slashHint") : t("autocomplete.fileHint")}
|
||||
</div>
|
||||
{count === 0 ? (
|
||||
<div className="px-3 py-2 text-[11px] text-fg-muted">
|
||||
{kind === "slash" && !commands ? "…" : t("autocomplete.noMatches")}
|
||||
</div>
|
||||
) : kind === "slash" ? (
|
||||
slashItems.map((c, i) => (
|
||||
<button
|
||||
key={`${c.source}:${c.name}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
// Keep focus in the textarea, or its blur closes the list first.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={rowClass(i)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-[12px] text-fg-primary">/{c.name}</span>
|
||||
<span
|
||||
className={`rounded border px-1.5 py-0.5 font-mono text-[9px] ${SOURCE_TONE[c.source]}`}
|
||||
>
|
||||
{c.source}
|
||||
</span>
|
||||
</div>
|
||||
{c.description && (
|
||||
<div className="mt-0.5 truncate text-[10.5px] text-fg-muted">{c.description}</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
files.map((f, i) => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === current}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => pickAt(i)}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
className={`${rowClass(i)} truncate font-mono text-[11px] text-fg-secondary`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +381,6 @@ interface RunSetupProps {
|
||||
* sessions. Undefined when no lane is selected (the picker then lists
|
||||
* everything, same as before lanes existed). */
|
||||
laneCwd?: string;
|
||||
slashCommands?: SlashCommand[];
|
||||
runHistory: DashboardRunHistoryItem[];
|
||||
onResumeFromHistory: (item: DashboardRunHistoryItem) => void;
|
||||
/** The selected lane's CLI. Decides which advanced fields render. */
|
||||
@@ -131,9 +389,10 @@ interface RunSetupProps {
|
||||
onSandboxChange: (s: CodexSandbox) => void;
|
||||
approval: CodexApproval;
|
||||
onApprovalChange: (a: CodexApproval) => void;
|
||||
/** Codex lanes resume with `--last`: the sessions table holds Claude
|
||||
/** Every non-Claude lane resumes its tool's LAST session (`codex resume
|
||||
* --last`, a catalog tool's `resume` args): the sessions table holds Claude
|
||||
* sessions only, so there is nothing for the picker to list. */
|
||||
onResumeLast: () => void;
|
||||
onResumeLast: (bypass?: boolean) => void;
|
||||
}
|
||||
|
||||
export function RunSetup(props: RunSetupProps) {
|
||||
@@ -141,6 +400,17 @@ export function RunSetup(props: RunSetupProps) {
|
||||
const atCap = false; // TODO: re-add when concurrency info is available
|
||||
const isResume = !!props.resumeSession;
|
||||
const [resumePicked, setResumePicked] = useState(isResume);
|
||||
const aiTools = useAiTools();
|
||||
const entry = aiTools.byId(props.tool);
|
||||
// Claude and Codex keep their hand-written fields; every other catalog tool
|
||||
// gets the plain form — cwd, prompt, resume and bypass when it has them.
|
||||
const catalogTool = props.tool !== "claude" && props.tool !== "codex";
|
||||
const canResume = !catalogTool || !!entry?.resume;
|
||||
// Per run, never remembered: the form starts unticked every time it mounts.
|
||||
const [bypass, setBypass] = useState(false);
|
||||
const canBypass = props.tool !== "claude" && !!entry?.bypass;
|
||||
// Claude and Codex always take a model; a catalog tool only when its file names the flag.
|
||||
const hasModel = !catalogTool || !!entry?.modelFlag;
|
||||
// Keep "resume picked" in sync with the parent. Parent set a resume session
|
||||
// (e.g. user clicked Resume in the runs modal) - flip the radio so the picker
|
||||
// is shown and the selection is visible.
|
||||
@@ -173,10 +443,13 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
{props.tool === "codex" ? (
|
||||
{/* A catalog tool takes no permission or effort flag, and a model only when it declares one. */}
|
||||
{hasModel && (
|
||||
<Field label={t("fields.model")}>
|
||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||
</Field>
|
||||
)}
|
||||
{catalogTool ? null : props.tool === "codex" ? (
|
||||
<>
|
||||
<Field label={t("fields.sandbox")}>
|
||||
<Select<CodexSandbox>
|
||||
@@ -215,35 +488,39 @@ export function RunSetup(props: RunSetupProps) {
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
{!catalogTool && (
|
||||
<Field label={t("fields.effort")}>
|
||||
<Select<EffortLevel>
|
||||
value={props.effort}
|
||||
onChange={props.onEffortChange}
|
||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.label,
|
||||
hint: c.hint,
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
{canResume && (
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||
<Seg
|
||||
active={!resumePicked}
|
||||
label={t("resume.freshOption")}
|
||||
title={t("resume.freshHint")}
|
||||
onClick={() => {
|
||||
setResumePicked(false);
|
||||
props.onResumeSessionChange(null);
|
||||
}}
|
||||
/>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
<Seg
|
||||
active={resumePicked}
|
||||
label={t("resume.resumeOption")}
|
||||
title={t("resume.resumeHint")}
|
||||
onClick={() => setResumePicked(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{resumePicked && (
|
||||
@@ -257,7 +534,8 @@ export function RunSetup(props: RunSetupProps) {
|
||||
// Pass the picked session explicitly: the parent's state
|
||||
// update has not landed yet on this tick, so reading
|
||||
// props.resumeSession here would resume nothing.
|
||||
if (s && !props.busy) handleStart(props, s);
|
||||
if (s && !props.busy)
|
||||
handleStart(props, s, bypass, hasModel, entry?.defaultModel);
|
||||
}}
|
||||
// Fall back to the typed cwd when no lane is selected — otherwise
|
||||
// an unfiltered picker lists every session from every repo.
|
||||
@@ -265,7 +543,7 @@ export function RunSetup(props: RunSetupProps) {
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => props.onResumeLast()}
|
||||
onClick={() => props.onResumeLast(canBypass && bypass ? true : undefined)}
|
||||
disabled={props.busy}
|
||||
className="px-3 py-1.5 text-[11px] rounded-md bg-surface-2 border border-border hover:border-accent/50 text-fg-primary hover:text-accent transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
@@ -284,10 +562,12 @@ export function RunSetup(props: RunSetupProps) {
|
||||
<PromptEditor
|
||||
value={props.prompt}
|
||||
onChange={props.onPromptChange}
|
||||
onSubmit={() => handleStart(props)}
|
||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
||||
onSubmit={() =>
|
||||
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
|
||||
}
|
||||
placeholder={t("fields.promptPlaceholderTerminal", { tool: aiTools.label(props.tool) })}
|
||||
rows={5}
|
||||
slashCommands={props.slashCommands ?? []}
|
||||
slash={props.tool === "claude"}
|
||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||
/>
|
||||
<div className="mt-1 text-[10px] text-fg-muted">
|
||||
@@ -295,7 +575,27 @@ export function RunSetup(props: RunSetupProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.tool === "codex" && props.sandbox === "danger-full-access" && (
|
||||
{canBypass && (
|
||||
<div className="px-4 pb-3">
|
||||
<label className="inline-flex items-center gap-2 text-[11.5px] text-status-danger cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bypass}
|
||||
onChange={(e) => setBypass(e.target.checked)}
|
||||
className="accent-[rgb(var(--status-danger))]"
|
||||
/>
|
||||
{t("fields.bypass")}
|
||||
</label>
|
||||
{bypass && (
|
||||
<div className="mt-2 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.bypassWarning", { tool: aiTools.label(props.tool) })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{props.tool === "codex" && !bypass && props.sandbox === "danger-full-access" && (
|
||||
<div className="mx-4 mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-[11px] text-status-danger flex items-start gap-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("hint.sandboxWarning")}</span>
|
||||
@@ -320,7 +620,9 @@ export function RunSetup(props: RunSetupProps) {
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleStart(props)}
|
||||
onClick={() =>
|
||||
handleStart(props, undefined, canBypass && bypass, hasModel, entry?.defaultModel)
|
||||
}
|
||||
disabled={
|
||||
!props.binaryFound ||
|
||||
// A resume needs no prompt — the session it continues is the input.
|
||||
@@ -349,25 +651,38 @@ export function RunSetup(props: RunSetupProps) {
|
||||
|
||||
/** `session` overrides `props.resumeSession` for the auto-start fired straight
|
||||
* out of the picker, before the parent's state has caught up. */
|
||||
function handleStart(props: RunSetupProps, session?: Session) {
|
||||
function handleStart(
|
||||
props: RunSetupProps,
|
||||
session?: Session,
|
||||
bypass?: boolean,
|
||||
hasModel?: boolean,
|
||||
defaultModel?: string | null
|
||||
) {
|
||||
const resume = session ?? props.resumeSession;
|
||||
const codex = props.tool === "codex";
|
||||
// Any catalog tool: none of Claude's or Codex's own flags apply.
|
||||
const other = props.tool !== "claude" && !codex;
|
||||
props.onStart({
|
||||
laneId: props.laneId,
|
||||
// A resume is pinned to its own session's folder — that's what the locked
|
||||
// cwd field shows, so it's what gets sent.
|
||||
cwd: resume?.cwd || props.cwd || undefined,
|
||||
model: props.model || undefined,
|
||||
permissionMode: codex ? undefined : props.permissionMode || undefined,
|
||||
// A resume keeps the session's own model: the tool's default the form
|
||||
// starts on is not a pick, so only a model the user changed is sent.
|
||||
model:
|
||||
!(other ? hasModel : true) || (resume && props.model === (defaultModel ?? ""))
|
||||
? undefined
|
||||
: props.model || undefined,
|
||||
permissionMode: codex || other ? undefined : props.permissionMode || undefined,
|
||||
sandbox: codex ? props.sandbox : undefined,
|
||||
approval: codex ? props.approval : undefined,
|
||||
effort: props.effort || undefined,
|
||||
resumeSessionId: codex ? undefined : resume?.id || undefined,
|
||||
resumeLast: codex && !resume ? true : undefined,
|
||||
effort: other ? undefined : props.effort || undefined,
|
||||
resumeSessionId: codex || other ? undefined : resume?.id || undefined,
|
||||
// A resume continues a session that already has its own history — sending
|
||||
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||
// asked for, so a resume carries no prompt at all.
|
||||
initialPrompt: resume ? undefined : props.prompt || undefined,
|
||||
bypass: bypass || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -596,7 +911,10 @@ function SessionPicker({
|
||||
if (sessions !== null && fetchedForCwd.current === cwd) return;
|
||||
fetchedForCwd.current = cwd;
|
||||
api.sessions
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd })
|
||||
// `claude --resume` can only resume a Claude session; a Codex lane at the
|
||||
// same directory records its own sessions there too. Filtered by the
|
||||
// server, before its limit.
|
||||
.list({ sort_by: "started_at", sort_desc: true, limit: 100, cwd, tool: "claude" })
|
||||
.then((r) => setSessions(r.sessions))
|
||||
.catch(() => setSessions([]));
|
||||
}, [open, cwd, sessions]);
|
||||
@@ -691,7 +1009,7 @@ function SessionPicker({
|
||||
s.status === "active"
|
||||
? "bg-status-success/10 text-status-success border-status-success/30"
|
||||
: s.status === "completed"
|
||||
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
|
||||
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
|
||||
: s.status === "error"
|
||||
? "bg-status-danger/10 text-status-danger border-status-danger/30"
|
||||
: "bg-surface-3 text-fg-secondary border-border"
|
||||
@@ -729,7 +1047,7 @@ function SessionPicker({
|
||||
// the "inherit from settings" choice, so use a non-empty marker.
|
||||
const MODEL_CUSTOM = "__custom__";
|
||||
|
||||
function ModelPicker({
|
||||
export function ModelPicker({
|
||||
value,
|
||||
onChange,
|
||||
tool,
|
||||
@@ -739,11 +1057,31 @@ function ModelPicker({
|
||||
tool: LaneTool;
|
||||
}) {
|
||||
const { t } = useTranslation("run");
|
||||
const choices = tool === "codex" ? RUN_MODEL_CHOICES_CODEX : RUN_MODEL_CHOICES;
|
||||
// "Custom" is selected when the value isn't one of our curated IDs.
|
||||
// The tool's own list (Codex's models cache, Claude's aliases named after
|
||||
// the newest version, a catalog tool's vendor rows); Claude's static
|
||||
// aliases only when the server cannot answer.
|
||||
const [fetched, setFetched] = useState<ModelChoice[] | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setFetched(null);
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.models(tool))
|
||||
.then((r) => live && setFetched(r.models))
|
||||
.catch(() => live && setFetched([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [tool]);
|
||||
const choices = useMemo<ModelChoice[]>(() => {
|
||||
const inherit = { id: "", label: "" };
|
||||
if (fetched?.length) return [inherit, ...fetched];
|
||||
return tool === "claude" ? RUN_MODEL_CHOICES : [inherit];
|
||||
}, [fetched, tool]);
|
||||
// "Custom" is selected when the value isn't one of the listed IDs — judged
|
||||
// only once the list has arrived, so a listed default never flashes as custom.
|
||||
const knownIds = useMemo(() => choices.map((c) => c.id), [choices]);
|
||||
const isCustom = value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(isCustom);
|
||||
const isCustom = fetched !== null && value !== "" && !knownIds.includes(value);
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
// Reuse the shared Select so the Model dropdown renders identically to the
|
||||
// Permission Mode and Effort dropdowns (Tailwind + lucide popover) instead of
|
||||
|
||||
@@ -9,13 +9,25 @@
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed" | "imagesOnly";
|
||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||
|
||||
interface TerminalViewProps {
|
||||
runId: string;
|
||||
@@ -33,6 +45,50 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
// re-attach on each one.
|
||||
const onExitRef = useRef(onExit);
|
||||
onExitRef.current = onExit;
|
||||
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
|
||||
// assumes a dark background. Read through a ref at construction so a theme
|
||||
// change never re-runs the attach effect; the effect below recolours live.
|
||||
const darkTheme = useTheme().themeFor.dark;
|
||||
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
|
||||
const paletteRef = useRef(palette);
|
||||
paletteRef.current = palette;
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const { t } = useTranslation("run");
|
||||
const [drop, setDrop] = useState<DropState | null>(null);
|
||||
// dragenter/dragleave fire for every child crossed, so count them: the hint
|
||||
// only goes away when the pointer leaves the terminal itself.
|
||||
const dragDepth = useRef(0);
|
||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flash = (state: DropState) => {
|
||||
setDrop(state);
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
||||
};
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onDrop = async (e: DragEvent) => {
|
||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
setDrop("uploading");
|
||||
try {
|
||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||
// sees one pasted path rather than typed keystrokes.
|
||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||
termRef.current?.focus();
|
||||
setDrop(null);
|
||||
} catch {
|
||||
flash("failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||
@@ -56,7 +112,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
theme: paletteRef.current,
|
||||
});
|
||||
termRef.current = term;
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
if (containerRef.current) term.open(containerRef.current);
|
||||
@@ -225,8 +283,54 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
|
||||
useEffect(() => {
|
||||
if (termRef.current) termRef.current.options.theme = palette;
|
||||
}, [palette]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full"
|
||||
onDragEnter={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault();
|
||||
dragDepth.current += 1;
|
||||
setDrop("hint");
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
e.preventDefault(); // without it the browser refuses the drop
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!hasFiles(e)) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) setDrop((d) => (d === "hint" ? null : d));
|
||||
}}
|
||||
onDrop={(e) => void onDrop(e)}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full w-full"
|
||||
style={{ backgroundColor: palette.background }}
|
||||
data-testid="terminal-view"
|
||||
/>
|
||||
{drop && (
|
||||
// pointer-events-none: the drop must land on the terminal, not on this.
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MemoryRouter } from "react-router-dom";
|
||||
import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -28,6 +29,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
action: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockResolvedValue({ items: [] }),
|
||||
history: vi.fn().mockResolvedValue({ items: [] }),
|
||||
@@ -36,7 +41,6 @@ vi.mock("../../../lib/api", () => ({
|
||||
},
|
||||
},
|
||||
RUN_MODEL_CHOICES: [],
|
||||
RUN_MODEL_CHOICES_CODEX: [],
|
||||
RUN_EFFORT_CHOICES: [],
|
||||
}));
|
||||
|
||||
@@ -84,6 +88,7 @@ function baseProps() {
|
||||
binaryStatus: {
|
||||
claude: { found: true, path: "/usr/local/bin/claude" },
|
||||
codex: { found: true, path: "/usr/local/bin/codex" },
|
||||
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
||||
},
|
||||
cwdSuggestions: [],
|
||||
activeRuns: { items: [] },
|
||||
@@ -135,6 +140,31 @@ describe("LaneConsolePane", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("starts a run on the tool's default model from AI Tools settings", async () => {
|
||||
__resetAiTools();
|
||||
(api.aiTools.list as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
default: "claude",
|
||||
tools: [{ id: "claude", label: "Claude Code", defaultModel: "sonnet", modelFlag: true }],
|
||||
});
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({ lane: LANE });
|
||||
render(<LaneConsolePane {...baseProps()} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/type to search/i), {
|
||||
target: { value: "/workspace/a" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ model: "sonnet" })
|
||||
)
|
||||
);
|
||||
__resetAiTools();
|
||||
});
|
||||
|
||||
it("returns to the setup form when the pane exits, without a New run click", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "run-1" },
|
||||
@@ -331,4 +361,126 @@ describe("LaneConsolePane", () => {
|
||||
expect(screen.getByTestId("pane-lane-select")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("console-body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists only this lane's runs and sessions in Active runs, not every lane's", () => {
|
||||
const run = (id: string, laneId: number, promptPreview: string) => ({
|
||||
id,
|
||||
laneId,
|
||||
status: "running",
|
||||
cwd: "/workspace/x",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
});
|
||||
const session = (id: string, cwd: string, name: string) =>
|
||||
({
|
||||
id,
|
||||
cwd,
|
||||
name,
|
||||
model: null,
|
||||
source: "local",
|
||||
started_at: "2026-09-24T01:00:00Z",
|
||||
}) as never;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
lanes={[
|
||||
LANE,
|
||||
{ ...LANE, id: 2, cwd: "/workspace/b" },
|
||||
// Nested inside lane 1: its sessions are ITS own, by longest prefix.
|
||||
{ ...LANE, id: 3, cwd: "/workspace/a/nested" },
|
||||
]}
|
||||
activeRuns={{ items: [run("r1", 1, "mine"), run("r2", 2, "other lane's")] as never }}
|
||||
externalSessions={[
|
||||
session("s1", "/workspace/a/src", "my session"),
|
||||
session("s2", "/workspace/b", "b session"),
|
||||
session("s3", "/workspace/a/nested/x", "nested session"),
|
||||
session("s4", "/workspace/ab", "sibling prefix"),
|
||||
]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /2 active runs/ }));
|
||||
expect(screen.getByText("mine")).toBeTruthy();
|
||||
expect(screen.getByText("my session")).toBeTruthy();
|
||||
expect(screen.queryByText("other lane's")).toBeNull();
|
||||
expect(screen.queryByText("b session")).toBeNull();
|
||||
expect(screen.queryByText("nested session")).toBeNull();
|
||||
expect(screen.queryByText("sibling prefix")).toBeNull();
|
||||
});
|
||||
|
||||
describe("split panes on one lane", () => {
|
||||
const run = (id: string, promptPreview = id) => ({
|
||||
id,
|
||||
laneId: 1,
|
||||
status: "running" as const,
|
||||
cwd: "/workspace/a",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: "2026-09-24T01:00:00Z",
|
||||
promptPreview,
|
||||
tool: "claude" as const,
|
||||
});
|
||||
|
||||
it("attaches a pane only to its own slot's session", async () => {
|
||||
const activeRuns = { items: [run("agenthub-lane-1"), run("agenthub-lane-1-2")] };
|
||||
render(<LaneConsolePane {...baseProps()} activeRuns={activeRuns} slot={2} />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-2"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("starts into its own slot and shows the session the server opened", async () => {
|
||||
(api.lanes.action as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
lane: { ...LANE, run_id: "agenthub-lane-1" },
|
||||
run_id: "agenthub-lane-1-1",
|
||||
});
|
||||
(api.run.get as ReturnType<typeof vi.fn>).mockResolvedValue(run("agenthub-lane-1-1"));
|
||||
render(<LaneConsolePane {...baseProps()} slot={1} />);
|
||||
fireEvent.change(screen.getByPlaceholderText(/ask claude/i), { target: { value: "go" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^run$/i }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ slot: 1 })
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toHaveAttribute(
|
||||
"data-run-id",
|
||||
"agenthub-lane-1-1"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves runs other panes show out of Active runs", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LaneConsolePane
|
||||
{...baseProps()}
|
||||
slot={1}
|
||||
activeRuns={{
|
||||
items: [run("agenthub-lane-1", "shown"), run("agenthub-lane-1-3", "free")],
|
||||
}}
|
||||
hiddenRunIds={["agenthub-lane-1"]}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /1 active run/ }));
|
||||
expect(screen.getByText("free")).toBeTruthy();
|
||||
expect(screen.queryByText("shown")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { RunSetup } from "../RunSetup";
|
||||
import type { CwdSuggestion } from "../../../lib/api";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -24,9 +25,60 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiTools: {
|
||||
list: r({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
resume: false,
|
||||
bypass: false,
|
||||
modelFlag: true,
|
||||
defaultModel: "sonnet",
|
||||
},
|
||||
{ id: "codex", label: "Codex", resume: false, bypass: true },
|
||||
{
|
||||
id: "antigravity",
|
||||
label: "Antigravity",
|
||||
resume: true,
|
||||
bypass: true,
|
||||
modelFlag: true,
|
||||
},
|
||||
{ id: "gemini", label: "Gemini", resume: false, bypass: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true, command: x.id })),
|
||||
}),
|
||||
// Each tool's own model list, as GET /api/ai-tools/:id/models answers it.
|
||||
models: vi.fn(async (id: string) => {
|
||||
const lists: Record<string, { id: string; label: string }[]> = {
|
||||
claude: [
|
||||
{ id: "sonnet", label: "Sonnet (latest · 5.5)" },
|
||||
{ id: "claude-sonnet-5-5", label: "Claude Sonnet 5.5" },
|
||||
],
|
||||
codex: [{ id: "gpt-6-sol", label: "GPT-6-Sol" }],
|
||||
antigravity: [{ id: "gemini-3.1-pro", label: "gemini-3.1-pro" }],
|
||||
};
|
||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||
}),
|
||||
},
|
||||
run: { files: r({ items: [] }) },
|
||||
sessions: { list: r({ sessions: [], total: 0, limit: 100, offset: 0 }) },
|
||||
ccConfig: { file: r({ text: "" }) },
|
||||
ccConfig: {
|
||||
file: r({ text: "" }),
|
||||
commands: r({
|
||||
items: [
|
||||
{
|
||||
scope: "project",
|
||||
name: "ship-it",
|
||||
frontmatter: { description: "Ship the change" },
|
||||
preview: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -66,7 +118,6 @@ function renderSetup(overrides: Partial<React.ComponentProps<typeof RunSetup>> =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
tool="claude"
|
||||
sandbox="workspace-write"
|
||||
@@ -119,11 +170,12 @@ describe("RunSetup — selections report through callbacks", () => {
|
||||
onlyCalled(spies, "onCwdChange");
|
||||
});
|
||||
|
||||
it("reports the model from the picker, including a custom id", () => {
|
||||
it("reports the model from the picker, including a custom id", async () => {
|
||||
const { spies } = renderSetup();
|
||||
// The picker offers the CLI's "latest" aliases, not pinned ids — that is
|
||||
// what keeps the list from going stale on every model release.
|
||||
pickFromSelect(i18n.t("run:fields.modelInheritLabel"), "Sonnet (latest)");
|
||||
// The list is the tool's own, fetched from the server — aliases named
|
||||
// after the newest version, then pinned versions — never a hardcoded copy.
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
fireEvent.click(await screen.findByText("Sonnet (latest · 5.5)"));
|
||||
expect(spies.onModelChange).toHaveBeenCalledWith("sonnet");
|
||||
// "Custom…" is a sentinel, not a model id — it must not be reported as one;
|
||||
// the free-text box it reveals is what reports.
|
||||
@@ -210,6 +262,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers a Codex session, which `claude --resume` cannot resume", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
sessions: [
|
||||
{ id: "sess-claude", cwd: "/Users/tester/lane-a", started_at: "", status: "completed" },
|
||||
],
|
||||
total: 1,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
} as never);
|
||||
|
||||
renderSetup({ laneCwd: "/Users/tester/lane-a" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.resumeOption")));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:resume.pickSession")));
|
||||
|
||||
await screen.findByText("/Users/tester/lane-a");
|
||||
// Filtered by the server, before its limit: a busy Codex lane at the same
|
||||
// directory cannot push Claude sessions off the page.
|
||||
expect(api.sessions.list).toHaveBeenCalledWith(expect.objectContaining({ tool: "claude" }));
|
||||
});
|
||||
|
||||
it("starts the resume immediately when a session is picked", async () => {
|
||||
const { api } = await import("../../../lib/api");
|
||||
vi.mocked(api.sessions.list).mockResolvedValue({
|
||||
@@ -300,7 +373,6 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
busy={false}
|
||||
activeRuns={null}
|
||||
resumeSession={null}
|
||||
slashCommands={[]}
|
||||
runHistory={[]}
|
||||
laneCwd="/Users/tester/lane-b"
|
||||
tool="claude"
|
||||
@@ -328,6 +400,27 @@ describe("RunSetup — resume picker scopes sessions to the selected lane", () =
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — resume keeps the session's model", () => {
|
||||
const session = { id: "sess-1", cwd: "/Users/tester" } as never;
|
||||
|
||||
it("sends no model when it is still the tool's default", async () => {
|
||||
const { spies } = renderSetup({ resumeSession: session, model: "sonnet", prompt: "" });
|
||||
await screen.findByText(i18n.t("run:fields.model"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ resumeSessionId: "sess-1", model: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a model the user picked", async () => {
|
||||
const { spies } = renderSetup({ resumeSession: session, model: "opus", prompt: "" });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: "opus" }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — per-tool fields", () => {
|
||||
it("shows sandbox and approval instead of permission mode for a codex lane", () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
@@ -364,3 +457,124 @@ describe("RunSetup — per-tool fields", () => {
|
||||
expect(onResumeLast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
||||
const box = () => screen.getByPlaceholderText(/ anything…$/);
|
||||
|
||||
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||
const { spies } = renderSetup({ prompt: "" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
|
||||
// Both prefix matches; the shorter name ranks first.
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the run will start in, not the server's own.
|
||||
expect(api.ccConfig.commands).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
expect(api.ccConfig.skills).toHaveBeenCalledWith(undefined, "/Users/tester");
|
||||
|
||||
fireEvent.keyDown(box(), { key: "Enter" });
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("/review-pr ");
|
||||
// Enter picked the suggestion; it did not send the prompt.
|
||||
expect(spies.onStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suggests files under the cwd on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the page: put the cursor at the end of it.
|
||||
const { spies } = renderSetup({ prompt: "look at @ma" });
|
||||
(box() as HTMLTextAreaElement).setSelectionRange(11, 11);
|
||||
fireEvent.select(box());
|
||||
|
||||
fireEvent.click(await screen.findByText("src/main.ts"));
|
||||
expect(api.run.files).toHaveBeenCalledWith("/Users/tester", "ma");
|
||||
expect(spies.onPromptChange).toHaveBeenLastCalledWith("look at @src/main.ts ");
|
||||
});
|
||||
|
||||
it("offers no slash commands on a codex lane", async () => {
|
||||
renderSetup({ prompt: "", tool: "codex" });
|
||||
fireEvent.change(box(), { target: { value: "/rev" } });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(screen.queryByTestId("prompt-autocomplete")).toBeNull();
|
||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — antigravity lanes", () => {
|
||||
it("names the lane's CLI, offers its model flag and drops the flags `agy` lacks", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", model: "gemini-3.1-pro", effort: "high" });
|
||||
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
|
||||
expect(await screen.findByText(i18n.t("run:fields.model"))).toBeTruthy();
|
||||
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: "gemini-3.1-pro",
|
||||
effort: undefined,
|
||||
permissionMode: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no model for a catalog tool whose CLI has no model flag", async () => {
|
||||
const { spies } = renderSetup({ tool: "gemini", model: "stale", prompt: "go" });
|
||||
await screen.findByPlaceholderText("Ask Gemini anything…");
|
||||
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledWith(expect.objectContaining({ model: undefined }));
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — codex lanes", () => {
|
||||
it("offers the models Codex itself lists", async () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:fields.modelInheritLabel")));
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a fresh codex run instead of resuming the last one", () => {
|
||||
const { spies } = renderSetup({ tool: "codex" });
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart).toHaveBeenCalledOnce();
|
||||
expect(spies.onStart.mock.calls[0]?.[0].resumeLast).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RunSetup — catalog tools", () => {
|
||||
it("offers a per-run bypass switch with a warning, and sends it", async () => {
|
||||
const { spies } = renderSetup({ tool: "antigravity", prompt: "go" });
|
||||
const box = await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") });
|
||||
expect(box).not.toBeChecked();
|
||||
expect(screen.queryByText(/without asking/)).toBeNull();
|
||||
fireEvent.click(box);
|
||||
expect(screen.getByText(/Antigravity will run every command/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBe(true);
|
||||
// Resume is offered because the catalog gives the tool a resume flag.
|
||||
expect(screen.getByText(i18n.t("run:resume.resumeOption"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves out resume and bypass for a tool with neither", async () => {
|
||||
const { spies } = renderSetup({ tool: "gemini", prompt: "go" });
|
||||
expect(await screen.findByPlaceholderText("Ask Gemini anything…")).toBeTruthy();
|
||||
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
|
||||
expect(screen.queryByText(i18n.t("run:resume.resumeOption"))).toBeNull();
|
||||
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
|
||||
expect(spies.onStart.mock.calls[0]?.[0].bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives Codex the switch but not Claude", async () => {
|
||||
renderSetup({ tool: "codex" });
|
||||
expect(await screen.findByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("gives Claude no bypass switch (it has the bypassPermissions mode)", async () => {
|
||||
renderSetup({ tool: "claude" });
|
||||
await screen.findByPlaceholderText("Ask Claude Code anything…");
|
||||
expect(screen.queryByRole("checkbox", { name: i18n.t("run:fields.bypass") })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`.
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -17,6 +18,11 @@ const disposeMock = vi.fn();
|
||||
const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
const termState = {
|
||||
@@ -26,9 +32,13 @@ const termState = {
|
||||
rows: 28,
|
||||
};
|
||||
const onResizeHandlers: Array<() => void> = [];
|
||||
const termOptions: { theme?: unknown } = {};
|
||||
const ctorArgs: Array<{ theme?: unknown }> = [];
|
||||
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: vi.fn().mockImplementation(() => ({
|
||||
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
|
||||
...(ctorArgs.push(opts), {}),
|
||||
options: termOptions,
|
||||
get modes() {
|
||||
return termState.modes;
|
||||
},
|
||||
@@ -56,8 +66,13 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomWheelEventHandler: wheelHandlerMock,
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
vi.mock("../../../lib/terminalTheme", () => ({
|
||||
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
|
||||
}));
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
@@ -103,6 +118,10 @@ describe("TerminalView", () => {
|
||||
loadAddonMock.mockClear();
|
||||
openMock.mockClear();
|
||||
focusMock.mockClear();
|
||||
disposeMock.mockClear();
|
||||
ctorArgs.length = 0;
|
||||
delete termOptions.theme;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
@@ -126,6 +145,21 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
});
|
||||
|
||||
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
|
||||
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
|
||||
localStorage.setItem("agenthub.theme.dark", "apple-dark");
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
|
||||
|
||||
act(() => {
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
});
|
||||
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(disposeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes incoming WS data to the terminal", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
@@ -286,4 +320,31 @@ describe("TerminalView", () => {
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @file AiToolsModal.tsx
|
||||
* @description The AI Tools dialog opened from the Settings tab bar's right
|
||||
* edge: a header with the title, description and a close button around
|
||||
* `AiToolsSettings`. Escape, the backdrop and the X close it; an Escape a
|
||||
* nested ConfirmModal already handled (defaultPrevented) is left to it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bot, X } from "lucide-react";
|
||||
import { AiToolsSettings } from "./AiToolsSettings";
|
||||
|
||||
/**
|
||||
* @param props.onClose - Called on Escape, a backdrop click or the X.
|
||||
* @returns The modal.
|
||||
*/
|
||||
export function AiToolsModal({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="ai-tools-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2
|
||||
id="ai-tools-title"
|
||||
className="flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<Bot className="h-4 w-4 text-fg-muted" />
|
||||
{t("aiTools.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("aiTools.description")}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("common:close")}
|
||||
className="rounded-md p-1 text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<AiToolsSettings />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @file AiToolsSettings.tsx
|
||||
* @description The body of the AI Tools modal (Settings tab bar → AI Tools,
|
||||
* `AiToolsModal.tsx`): every AI coding CLI in the server's catalog
|
||||
* (GET /api/ai-tools). Installed ones are rows with an enable switch, a
|
||||
* Default badge or Set default, and Update whenever the catalog names an
|
||||
* updater — no version lookup, the updater itself knows whether it has work.
|
||||
* The ones available to install fold behind a disclosure, with Install where
|
||||
* the catalog knows how. Install/Update/Update all confirm the exact command
|
||||
* first, run as ONE server job at a time and show its log under the row
|
||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
|
||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||
|
||||
const GHOST_BTN =
|
||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-50";
|
||||
|
||||
function Homepage({ tool }: { tool: AiTool }) {
|
||||
const { t } = useTranslation("settings");
|
||||
if (!tool.homepageUrl) return null;
|
||||
return (
|
||||
<a
|
||||
href={tool.homepageUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
title={t("aiTools.homepage")}
|
||||
aria-label={t("aiTools.homepageOf", { tool: tool.label })}
|
||||
className="text-fg-muted hover:text-accent"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Switch({
|
||||
tool,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: {
|
||||
tool: AiTool;
|
||||
disabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={tool.enabled}
|
||||
aria-label={t("aiTools.enabledOf", { tool: tool.label })}
|
||||
title={tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 ${
|
||||
tool.enabled ? "bg-accent" : "bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-all ${
|
||||
tool.enabled ? "left-[18px]" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
<span className="sr-only">{tool.enabled ? t("aiTools.enabled") : t("aiTools.disabled")}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function JobLog({ job }: { job: AiToolJob }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const ref = useRef<HTMLPreElement | null>(null);
|
||||
// A finished job folds to its status line; running or failed stays open.
|
||||
const [open, setOpen] = useState(job.status !== "ok");
|
||||
useEffect(() => {
|
||||
if (job.status === "ok") setOpen(false);
|
||||
}, [job.status]);
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight;
|
||||
}, [job.log, open]);
|
||||
const state =
|
||||
job.status === "running"
|
||||
? t("aiTools.jobRunning")
|
||||
: job.status === "ok"
|
||||
? t("aiTools.jobOk")
|
||||
: t("aiTools.jobFailed", { code: job.exitCode ?? "?" });
|
||||
return (
|
||||
<div className="mt-2 w-full">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className={job.status === "failed" ? "text-status-danger" : "text-fg-muted"}>
|
||||
{state}
|
||||
</span>
|
||||
{job.status !== "running" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||
>
|
||||
{open ? t("aiTools.hideLog") : t("aiTools.showLog")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<pre
|
||||
ref={ref}
|
||||
data-testid="ai-tool-log"
|
||||
className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-surface-0 p-2 font-mono text-[11px] text-fg-secondary"
|
||||
>
|
||||
{job.log}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiToolsSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [showAvailable, setShowAvailable] = useState(false);
|
||||
|
||||
// A modal opened mid-job picks it up. Deferred like useAiTools' load: an
|
||||
// unreachable server (or a mocked api without it) means "no job".
|
||||
useEffect(() => {
|
||||
Promise.resolve()
|
||||
.then(() => api.aiTools.job())
|
||||
.then(setJob)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const running = job?.status === "running";
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const h = window.setInterval(async () => {
|
||||
try {
|
||||
const j = await api.aiTools.job();
|
||||
setJob(j);
|
||||
if (j?.status !== "running") void reload();
|
||||
} catch {
|
||||
/* next tick */
|
||||
}
|
||||
}, 1000);
|
||||
return () => window.clearInterval(h);
|
||||
}, [running, reload]);
|
||||
|
||||
const run = async (key: string, fn: () => Promise<void>) => {
|
||||
setBusy(key);
|
||||
setError(null);
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const npmCmd = (pkg: string) => `npm install -g ${pkg}@latest`;
|
||||
const commandFor = (tool: AiTool, action: "install" | "update"): string => {
|
||||
const inst = tool.install;
|
||||
if (action === "update" && tool.update?.kind === "self") {
|
||||
return [tool.command ?? tool.id, ...tool.update.args].join(" ");
|
||||
}
|
||||
if (inst?.kind === "npm") return npmCmd(inst.package);
|
||||
if (inst?.kind === "script") return t("aiTools.runScript", { url: inst.url });
|
||||
return "";
|
||||
};
|
||||
const installed = tools.filter((x) => x.installed);
|
||||
const available = tools.filter((x) => !x.installed);
|
||||
// Mirrors the server's update-all: every installed tool with an updater.
|
||||
const updateAllTools = installed.filter((x) => x.update);
|
||||
|
||||
const startConfirmed = async () => {
|
||||
const c = confirm;
|
||||
setConfirm(null);
|
||||
if (!c) return;
|
||||
await run("job", async () => {
|
||||
const r =
|
||||
c.kind === "install"
|
||||
? await api.aiTools.install(c.tool.id)
|
||||
: c.kind === "update"
|
||||
? await api.aiTools.updateTool(c.tool.id)
|
||||
: await api.aiTools.updateAll();
|
||||
setJob(r.job);
|
||||
});
|
||||
};
|
||||
|
||||
const jobBusy = running || busy !== null;
|
||||
const logFor = (id: string | null) => (job && job.tool === id ? <JobLog job={job} /> : null);
|
||||
|
||||
const confirmLines =
|
||||
confirm?.kind === "update-all"
|
||||
? updateAllTools.map((x) => commandFor(x, "update"))
|
||||
: confirm
|
||||
? [commandFor(confirm.tool, confirm.kind)]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<p className="text-xs font-medium text-fg-secondary">
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
disabled={jobBusy}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
{logFor(null)}
|
||||
{loaded && installed.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("aiTools.noneInstalled")}</p>
|
||||
)}
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
return (
|
||||
<li key={tool.id} data-testid={`ai-tool-${tool.id}`} className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 items-center gap-3 ${tool.enabled ? "" : "opacity-50"}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
<Homepage tool={tool} />
|
||||
{isDefault && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("aiTools.default")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="truncate font-mono text-[11px] text-fg-muted"
|
||||
title={tool.path ?? ""}
|
||||
>
|
||||
{tool.command}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => void run(tool.id, () => update(tool.id, { default: true }))}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() =>
|
||||
void run(tool.id, () => update(tool.id, { enabled: !tool.enabled }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{available.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showAvailable}
|
||||
onClick={() => setShowAvailable((s) => !s)}
|
||||
className="mb-2 inline-flex items-center gap-1.5 text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${showAvailable ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
{t("aiTools.available", { count: available.length })}
|
||||
</button>
|
||||
{showAvailable && (
|
||||
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{available.map((tool) => (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
className={`flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface-2 px-2.5 py-2 ${
|
||||
job?.tool === tool.id ? "sm:col-span-2 lg:col-span-3" : ""
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={tool} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs text-fg-primary">{tool.label}</span>
|
||||
<span className="block truncate font-mono text-[10px] text-fg-muted">
|
||||
{tool.command}
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "install", tool })}
|
||||
className="rounded-md border border-border px-2 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 disabled:opacity-50"
|
||||
>
|
||||
{t("aiTools.install")}
|
||||
</button>
|
||||
)}
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={confirm !== null}
|
||||
destructive={false}
|
||||
title={
|
||||
confirm?.kind === "update-all"
|
||||
? t("aiTools.confirmUpdateAll")
|
||||
: confirm
|
||||
? t(confirm.kind === "install" ? "aiTools.confirmInstall" : "aiTools.confirmUpdate", {
|
||||
tool: confirm.tool.label,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
message={t("aiTools.confirmBody")}
|
||||
confirmLabel={t("aiTools.run")}
|
||||
cancelLabel={t("aiTools.cancel")}
|
||||
onConfirm={() => void startConfirmed()}
|
||||
onCancel={() => setConfirm(null)}
|
||||
>
|
||||
<pre
|
||||
data-testid="ai-tool-confirm-command"
|
||||
className="whitespace-pre-wrap rounded-md border border-border bg-surface-1 p-2 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{confirmLines.join("\n")}
|
||||
</pre>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.tsx
|
||||
* @description Settings → Antigravity → Antigravity hooks: whether
|
||||
* AgentHub's named hook is in `~/.gemini/config/hooks.json` (and not switched
|
||||
* off there), per event, with Install/Reinstall, Remove and Refresh. Nothing is
|
||||
* written until the user clicks; agy asks for no trust, and has no permission
|
||||
* hook — the note says both (#40 part D).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Antigravity hooks card.
|
||||
* @returns A section with id `antigravity-hooks` for deep links.
|
||||
*/
|
||||
export function AntigravityHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<AntigravityHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.antigravityHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.antigravityHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("antigravityHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.enabled ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("antigravityHooks.installed")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("antigravityHooks.disabled")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="antigravity-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("antigravityHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("antigravityHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("antigravityHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("antigravityHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed
|
||||
? t("antigravityHooks.reinstall")
|
||||
: t("antigravityHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("antigravityHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("antigravityHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-fg-secondary">{t("antigravityHooks.note")}</p>
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => {
|
||||
const label = e.installed
|
||||
? t("antigravityHooks.eventInstalled")
|
||||
: t("antigravityHooks.eventMissing");
|
||||
return (
|
||||
<div
|
||||
key={event}
|
||||
title={label}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{e.installed ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.tsx
|
||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import { HomeDirSetting } from "./HomeDirSetting";
|
||||
import { PricingSettings } from "./PricingSettings";
|
||||
|
||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
|
||||
/**
|
||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||
* the config it lists, which lives under that home).
|
||||
* @returns The three sections, each with its own id.
|
||||
*/
|
||||
export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () => void } = {}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hooks, setHooks] = useState<HooksInfo | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const infoRes = await api.settings.info();
|
||||
setHooks(infoRes.hooks);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// Hooks can be (re)installed from a terminal while this tab is open; keep the
|
||||
// badge current the way the old Settings page did, every 10 s.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
api.settings
|
||||
.info()
|
||||
.then((r) => setHooks(r.hooks))
|
||||
.catch(() => {});
|
||||
}, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionResult) return;
|
||||
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [actionResult]);
|
||||
|
||||
const runAction = async (key: string, fn: () => Promise<string>) => {
|
||||
setActionLoading(key);
|
||||
setActionResult(null);
|
||||
try {
|
||||
const message = await fn();
|
||||
setActionResult({ key, message, isError: false });
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key,
|
||||
message: t("messages.actionFailed", {
|
||||
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstallHooks = () =>
|
||||
runAction("hooks", async () => {
|
||||
const res = await api.settings.reinstallHooks();
|
||||
return res.ok ? t("hooks.success") : t("hooks.failed");
|
||||
});
|
||||
|
||||
const actionBanner = (keys: string[]) => {
|
||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg text-xs ${
|
||||
match.isError
|
||||
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
|
||||
: "bg-status-success/10 border border-status-success/20 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{match.message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<HomeDirSetting
|
||||
id="claude-home"
|
||||
title={t("claudeHome.title")}
|
||||
description={t("claudeHome.description")}
|
||||
note={t("cursorPathsNote")}
|
||||
placeholder={t("claudeHome.placeholder")}
|
||||
load={() =>
|
||||
api.settings.claudeHome
|
||||
.get()
|
||||
.then((r) => ({ value: r.claude_home, envOwned: !!r.env_owned }))
|
||||
}
|
||||
save={(p) =>
|
||||
api.settings.claudeHome.set(p).then((r) => {
|
||||
void load();
|
||||
onHomeChanged?.();
|
||||
const missing = r.routers?.missing ?? [];
|
||||
return {
|
||||
value: r.claude_home,
|
||||
envOwned: !!r.env_owned,
|
||||
warning: missing.length
|
||||
? t("claudeHome.routersMissing", { lanes: missing.join(", ") })
|
||||
: null,
|
||||
};
|
||||
})
|
||||
}
|
||||
/>
|
||||
{error && (
|
||||
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── HOOK CONFIGURATION ─── */}
|
||||
<section id="hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("hooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{hooks?.installed ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReinstallHooks}
|
||||
disabled={actionLoading !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{actionLoading === "hooks" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("hooks.reinstall")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionBanner(["hooks"])}
|
||||
|
||||
{hooks && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{Object.entries(hooks.hooks).map(([hook, active]) => (
|
||||
<div
|
||||
key={hook}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{active ? (
|
||||
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{hook}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.tsx
|
||||
* @description Settings → Codex → Overview → Codex hooks: whether AgentHub's
|
||||
* hooks are in `$CODEX_HOME/hooks.json` and trusted by Codex, per event, with
|
||||
* Install/Reinstall and Remove. Nothing is written until the user clicks;
|
||||
* Codex itself asks the user to trust the hooks before it runs them, and this
|
||||
* card says so while they wait, with a Refresh to re-read once they have
|
||||
* (#40 part C2).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Plug,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../lib/types";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
|
||||
const CHIP = "inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full border";
|
||||
|
||||
/**
|
||||
* The Codex hooks card.
|
||||
* @returns A section with id `codex-hooks` for deep links.
|
||||
*/
|
||||
export function CodexHooksSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<CodexHooksStatus | null>(null);
|
||||
const [busy, setBusy] = useState<"install" | "remove" | "get" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.settings.codexHooks
|
||||
.get()
|
||||
.then(setStatus)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, []);
|
||||
|
||||
const run = async (kind: "install" | "remove" | "get") => {
|
||||
setBusy(kind);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(await api.settings.codexHooks[kind]());
|
||||
} catch (err) {
|
||||
setError(
|
||||
t("codexHooks.failed", { message: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Some of ours present but not all (an event added since, or one taken out
|
||||
// by hand): still show them, and still offer Remove.
|
||||
const anyInstalled = !!status && Object.values(status.events).some((e) => e.installed);
|
||||
const eventLabel = (e: { installed: boolean; trusted: boolean }) =>
|
||||
!e.installed
|
||||
? t("codexHooks.eventMissing")
|
||||
: e.trusted
|
||||
? t("codexHooks.eventTrusted")
|
||||
: t("codexHooks.eventUntrusted");
|
||||
|
||||
const chip = !status?.installed ? (
|
||||
<span className={`${CHIP} text-fg-secondary bg-surface-2 border-border`}>
|
||||
<XCircle className="w-3.5 h-3.5" /> {t("codexHooks.notInstalled")}
|
||||
</span>
|
||||
) : status.trusted ? (
|
||||
<span className={`${CHIP} text-status-success bg-status-success/10 border-status-success/20`}>
|
||||
<CheckCircle className="w-3.5 h-3.5" /> {t("codexHooks.trusted")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${CHIP} text-status-warning bg-status-warning/10 border-status-warning/20`}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" /> {t("codexHooks.untrusted")}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<section id="codex-hooks" className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<Plug className="w-4 h-4 text-fg-muted" />
|
||||
{t("codexHooks.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("codexHooks.description")}</p>
|
||||
|
||||
<div className="card p-5 space-y-4">
|
||||
{!status && !error ? (
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && !status.supported ? (
|
||||
<p className="text-xs text-fg-muted">{t("codexHooks.unsupported")}</p>
|
||||
) : null}
|
||||
|
||||
{status && status.supported ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
{chip}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => run("get")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "get" ? "animate-spin" : ""}`} />
|
||||
{t("codexHooks.refresh")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run("install")}
|
||||
disabled={busy !== null || !status.readable}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "install" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{status.installed ? t("codexHooks.reinstall") : t("codexHooks.install")}
|
||||
</button>
|
||||
{anyInstalled ? (
|
||||
<button
|
||||
onClick={() => run("remove")}
|
||||
disabled={busy !== null}
|
||||
className="btn-ghost text-xs disabled:opacity-50"
|
||||
>
|
||||
{busy === "remove" ? (
|
||||
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{t("codexHooks.remove")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.readable ? (
|
||||
<p className="text-xs text-status-danger">
|
||||
{t("codexHooks.unreadable", { path: status.path, error: status.error ?? "" })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{status.installed && !status.trusted ? (
|
||||
<p className="text-xs text-fg-secondary">{t("codexHooks.trustNote")}</p>
|
||||
) : null}
|
||||
|
||||
{anyInstalled ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{Object.entries(status.events).map(([event, e]) => (
|
||||
<div
|
||||
key={event}
|
||||
title={eventLabel(e)}
|
||||
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
||||
>
|
||||
{!e.installed ? (
|
||||
<XCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-danger flex-shrink-0"
|
||||
/>
|
||||
) : e.trusted ? (
|
||||
<CheckCircle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-success flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="w-3 h-3 text-status-warning flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-fg-secondary truncate">{event}</span>
|
||||
<span className="sr-only">{eventLabel(e)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-[11px] text-fg-muted font-mono truncate">{status.path}</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="px-3 py-2 rounded-lg text-xs bg-status-danger/10 border border-status-danger/20 text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file HomeDirSetting.tsx
|
||||
* @description One editable home directory (CLAUDE_HOME in Claude Code →
|
||||
* Overview, CODEX_HOME in Codex → Overview): a path field with Save, the
|
||||
* server's refusal inline, the saved value while the field differs from it,
|
||||
* and a warning when the real
|
||||
* environment sets the variable (the environment wins at the next start, so a
|
||||
* saved value would not survive a restart). The server validates and persists;
|
||||
* this component only loads, submits and reports.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertTriangle, FolderOpen } from "lucide-react";
|
||||
|
||||
export interface HomeDirValue {
|
||||
value: string;
|
||||
envOwned: boolean;
|
||||
/** Shown after a save that succeeded only in part (e.g. a lane not updated). */
|
||||
warning?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** `<section id>` — a deep-link target. */
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Optional second line under the description. */
|
||||
note?: string;
|
||||
placeholder: string;
|
||||
load: () => Promise<HomeDirValue>;
|
||||
save: (path: string) => Promise<HomeDirValue>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An editable home directory with its own load/save cycle.
|
||||
* @returns The section, or its skeleton-free shell while loading.
|
||||
*/
|
||||
export function HomeDirSetting({ id, title, description, note, placeholder, load, save }: Props) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [current, setCurrent] = useState<HomeDirValue | null>(null);
|
||||
const [input, setInput] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((v) => {
|
||||
if (!live) return;
|
||||
setCurrent(v);
|
||||
setInput(v.value);
|
||||
})
|
||||
.catch((err) => live && setError(err instanceof Error ? err.message : String(err)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `load` is a stable api call per mount; re-running on identity would loop.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!current || input === current.value) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = await save(input);
|
||||
setCurrent(next);
|
||||
setInput(next.value);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("homeDir.saveFailed"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
||||
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mb-1">{description}</p>
|
||||
{note && <p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{note}</p>}
|
||||
|
||||
<div className={`card p-5 space-y-4 ${note ? "" : "mt-3"}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
aria-label={title}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !current || input === current.value}
|
||||
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||
{/* The saved value, only while the field says something else. */}
|
||||
{current && input !== current.value && (
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("homeDir.current")} <code className="text-fg-secondary">{current.value}</code>
|
||||
</p>
|
||||
)}
|
||||
{current?.envOwned && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{t("homeDir.envOwned")}
|
||||
</p>
|
||||
)}
|
||||
{current?.warning && (
|
||||
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||
{current.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,778 @@
|
||||
/**
|
||||
* @file PricingSettings.tsx
|
||||
* @description Model Pricing — the per-model rates every session's cost is
|
||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||
* and Codex, the Antigravity tab itself): Claude
|
||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
* automatic sync owns (OpenAI's, Gemini's) straight after.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { getCurrentLocale } from "../../lib/format";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
import type { ModelPricing, WSMessage } from "../../lib/types";
|
||||
|
||||
interface EditRow {
|
||||
model_pattern: string;
|
||||
display_name: string;
|
||||
input_per_mtok: string;
|
||||
output_per_mtok: string;
|
||||
cache_read_per_mtok: string;
|
||||
cache_write_per_mtok: string;
|
||||
cache_write_1h_per_mtok: string;
|
||||
fast_input_per_mtok: string;
|
||||
fast_output_per_mtok: string;
|
||||
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
||||
// values are ignored). Generic: any model can carry a promo window.
|
||||
intro_until: string;
|
||||
intro_input_per_mtok: string;
|
||||
intro_output_per_mtok: string;
|
||||
intro_cache_read_per_mtok: string;
|
||||
intro_cache_write_per_mtok: string;
|
||||
intro_cache_write_1h_per_mtok: string;
|
||||
}
|
||||
|
||||
const emptyRow: EditRow = {
|
||||
model_pattern: "",
|
||||
display_name: "",
|
||||
input_per_mtok: "0",
|
||||
output_per_mtok: "0",
|
||||
cache_read_per_mtok: "0",
|
||||
cache_write_per_mtok: "0",
|
||||
cache_write_1h_per_mtok: "0",
|
||||
fast_input_per_mtok: "0",
|
||||
fast_output_per_mtok: "0",
|
||||
intro_until: "",
|
||||
intro_input_per_mtok: "0",
|
||||
intro_output_per_mtok: "0",
|
||||
intro_cache_read_per_mtok: "0",
|
||||
intro_cache_write_per_mtok: "0",
|
||||
intro_cache_write_1h_per_mtok: "0",
|
||||
};
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const normalized =
|
||||
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
||||
const d = new Date(normalized);
|
||||
return d.toLocaleString(getCurrentLocale(), {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
||||
* three-section explanation: how prices are applied, how pattern matching
|
||||
* works, and where the rates come from (LiteLLM's price list, kept up to date
|
||||
* daily for the rows marked Auto). All copy is i18n-driven
|
||||
* (settings.pricing.tooltip.*).
|
||||
*
|
||||
* The popover is fixed-positioned and clamped to the viewport so it never
|
||||
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
||||
* the Workflows stat tooltips.
|
||||
*/
|
||||
function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
const claude = vendor === "claude";
|
||||
const { t } = useTranslation("settings");
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
||||
|
||||
const positionPopover = useCallback(() => {
|
||||
const btn = buttonRef.current;
|
||||
const pop = popoverRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
const w = pop?.offsetWidth ?? 320;
|
||||
const h = pop?.offsetHeight ?? 240;
|
||||
const margin = 8;
|
||||
|
||||
let left = r.right - w; // right-align with the icon
|
||||
if (left < margin) left = margin;
|
||||
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
||||
let top = r.bottom + 8;
|
||||
if (top + h > window.innerHeight - margin) {
|
||||
top = Math.max(margin, r.top - h - 8);
|
||||
}
|
||||
setPos({ left, top });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
positionPopover();
|
||||
const onScroll = () => positionPopover();
|
||||
const onResize = () => positionPopover();
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
const raf = requestAnimationFrame(positionPopover);
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, positionPopover]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={t("pricing.tooltip.title")}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="tooltip"
|
||||
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
||||
style={{ left: pos.left, top: pos.top, width: 320 }}
|
||||
>
|
||||
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.howItWorks")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.howItWorksBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.patternsTitle")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">
|
||||
{t("pricing.tooltip.patternsBody")}
|
||||
</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.synced")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug mb-2.5">{t("pricing.tooltip.syncedBody")}</p>
|
||||
|
||||
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
||||
{t("pricing.tooltip.apiPricing")}
|
||||
</p>
|
||||
<p className="text-fg-secondary leading-snug">
|
||||
{t(claude ? "pricing.tooltip.apiPricingBody" : "pricing.tooltip.apiPricingOtherBody")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type PricingVendor = "claude" | "openai" | "google";
|
||||
|
||||
const VENDOR_PATTERN: Record<PricingVendor, RegExp> = {
|
||||
claude: /^claude-/,
|
||||
openai: /^(gpt-|o\d|codex-)/,
|
||||
google: /^gemini-/,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vendor: PricingVendor;
|
||||
/** Offer Reset Defaults (it reseeds Claude's rates and resets only `claude-…` rows). */
|
||||
allowReset?: boolean;
|
||||
/** `<section id>` — a deep-link target. */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pricing table filtered to one vendor's rows.
|
||||
* @returns The section, or a skeleton while the rules load.
|
||||
*/
|
||||
export function PricingSettings({ vendor, allowReset = false, id = "pricing" }: Props) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
||||
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [resetting, setResetting] = useState(false);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
const [actionResult, setActionResult] = useState<{
|
||||
key: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.pricing.list();
|
||||
setPricing(res.pricing);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// A remote source can bring its own pricing rules; reload when one syncs.
|
||||
useEffect(() => {
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
if (isRemoteDataRefreshMessage(msg)) load();
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionResult) return;
|
||||
const timeout = setTimeout(() => setActionResult(null), 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [actionResult]);
|
||||
|
||||
const ours = (pattern: string) => VENDOR_PATTERN[vendor].test(pattern);
|
||||
const rows = showAll ? pricing : pricing.filter((p) => ours(p.model_pattern));
|
||||
const hidden = pricing.length - pricing.filter((p) => ours(p.model_pattern)).length;
|
||||
|
||||
const startEdit = (rule: ModelPricing) => {
|
||||
setAdding(false);
|
||||
setEditingPattern(rule.model_pattern);
|
||||
setEditRow({
|
||||
model_pattern: rule.model_pattern,
|
||||
display_name: rule.display_name,
|
||||
input_per_mtok: String(rule.input_per_mtok),
|
||||
output_per_mtok: String(rule.output_per_mtok),
|
||||
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
||||
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
||||
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
||||
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
||||
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
||||
intro_until: rule.intro_until ?? "",
|
||||
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
||||
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
||||
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
||||
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
||||
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
||||
});
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(true);
|
||||
setEditRow({ ...emptyRow });
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
||||
setError(t("pricing.validationRequired"));
|
||||
return;
|
||||
}
|
||||
const introUntil = editRow.intro_until.trim();
|
||||
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
||||
setError(t("pricing.introUntilInvalid"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.pricing.upsert({
|
||||
model_pattern: editRow.model_pattern.trim(),
|
||||
display_name: editRow.display_name.trim(),
|
||||
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
||||
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
||||
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
||||
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
||||
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
||||
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
||||
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
||||
// Always send the intro block so the UI is authoritative for it: an
|
||||
// empty date clears the promo, a valid date persists the intro rates.
|
||||
intro_until: introUntil || null,
|
||||
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
||||
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
||||
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
||||
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
||||
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
||||
});
|
||||
if (!ours(editRow.model_pattern.trim())) setShowAll(true);
|
||||
setEditingPattern(null);
|
||||
setAdding(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRule = async (pattern: string) => {
|
||||
try {
|
||||
await api.pricing.delete(pattern);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPricing = async () => {
|
||||
setResetting(true);
|
||||
setActionResult(null);
|
||||
setConfirmReset(false);
|
||||
try {
|
||||
// Only the Claude tab offers the button, and it resets only Claude's rows.
|
||||
const res = await api.settings.resetPricing("claude");
|
||||
setActionResult({
|
||||
key: "reset-pricing",
|
||||
message: t("pricing.resetResult", {
|
||||
count: res.pricing.filter((p) => ours(p.model_pattern)).length,
|
||||
}),
|
||||
isError: false,
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionResult({
|
||||
key: "reset-pricing",
|
||||
message: t("messages.actionFailed", {
|
||||
message: err instanceof Error ? err.message : t("messages.unknownError"),
|
||||
}),
|
||||
isError: true,
|
||||
});
|
||||
} finally {
|
||||
setResetting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const lastUpdated =
|
||||
rows.length > 0
|
||||
? rows.reduce(
|
||||
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
||||
rows[0]!.updated_at
|
||||
)
|
||||
: null;
|
||||
|
||||
const isEditing = editingPattern !== null || adding;
|
||||
|
||||
const renderEditCells = () => (
|
||||
<>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.model_pattern}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
||||
placeholder={t(`pricing.placeholders.${vendor}.pattern`)}
|
||||
disabled={editingPattern !== null}
|
||||
className="input w-full text-sm font-mono disabled:opacity-50"
|
||||
autoFocus={adding}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
value={editRow.display_name}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
||||
placeholder={t(`pricing.placeholders.${vendor}.name`)}
|
||||
className="input w-full text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_read_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.cache_write_1h_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_input_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={editRow.fast_output_per_mtok}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
||||
className="input w-full text-sm text-right font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={saveEdit}
|
||||
disabled={saving}
|
||||
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
||||
title={t("common:save")}
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEdit}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
||||
title={t("common:cancel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
|
||||
// Second edit row: the time-limited introductory-rate block. Rendered under
|
||||
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
||||
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
||||
// ONLY place intro rates are entered, and it works for any model pattern (not
|
||||
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
||||
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<input
|
||||
type={opts.date ? "text" : "number"}
|
||||
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
||||
value={editRow[key]}
|
||||
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
||||
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
const renderIntroEditRow = () => (
|
||||
<tr className="bg-surface-3">
|
||||
<td colSpan={10} className="px-4 pb-3 pt-2">
|
||||
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
|
||||
{t("pricing.introRatesTitle")}
|
||||
</span>
|
||||
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{introField("intro_until", "pricing.introUntil", { date: true })}
|
||||
{introField("intro_input_per_mtok", "common:token.input")}
|
||||
{introField("intro_output_per_mtok", "common:token.output")}
|
||||
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
||||
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
||||
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
const actionBanner = (keys: string[]) => {
|
||||
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
|
||||
if (!match) return null;
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg text-xs ${
|
||||
match.isError
|
||||
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
|
||||
: "bg-status-success/10 border border-status-success/20 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{match.message}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="scroll-mt-24">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-fg-muted" />
|
||||
{t("pricing.title")}
|
||||
<PricingInfoTooltip vendor={vendor} />
|
||||
</h3>
|
||||
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hidden > 0 && (
|
||||
<label className="inline-flex items-center gap-1.5 text-xs text-fg-secondary cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAll}
|
||||
onChange={(e) => setShowAll(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
{t("pricing.showAll", { count: hidden })}
|
||||
</label>
|
||||
)}
|
||||
{allowReset && (
|
||||
<button
|
||||
onClick={() => (confirmReset ? handleResetPricing() : setConfirmReset(true))}
|
||||
disabled={isEditing || resetting}
|
||||
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
||||
confirmReset
|
||||
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
||||
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{confirmReset ? t("pricing.resetConfirm") : t("pricing.resetDefaults")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={startAdd}
|
||||
disabled={isEditing}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionBanner(["reset-pricing"])}
|
||||
|
||||
<div className="card overflow-x-auto mt-4">
|
||||
<table className="w-full min-w-[1000px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("pricing.pattern")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:cost.model")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.input")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.output")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("common:token.cacheRead")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite5m")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.cacheWrite1h")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastInput")}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
||||
{t("pricing.fastOutput")}
|
||||
</th>
|
||||
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("common:actions")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.length === 0 && !adding && (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-4 py-6 text-center text-xs text-fg-muted">
|
||||
{t(`pricing.empty.${vendor}`)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((rule) =>
|
||||
editingPattern === rule.model_pattern ? (
|
||||
<Fragment key={rule.model_pattern}>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</Fragment>
|
||||
) : (
|
||||
<tr key={rule.model_pattern} className="hover:bg-surface-4 transition-colors group">
|
||||
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
||||
{rule.model_pattern}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary">
|
||||
{rule.display_name}
|
||||
{rule.source === "sync" && (
|
||||
<span
|
||||
title={t("pricing.autoBadgeTitle")}
|
||||
className="ml-2 rounded bg-accent/10 px-1.5 py-0.5 text-[10px] font-medium text-accent"
|
||||
>
|
||||
{t("pricing.autoBadge")}
|
||||
</span>
|
||||
)}
|
||||
{rule.intro_until && (
|
||||
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
|
||||
{t("pricing.introNote", {
|
||||
input: rule.intro_input_per_mtok,
|
||||
output: rule.intro_output_per_mtok,
|
||||
until: rule.intro_until,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.input_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_input_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.output_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_output_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_read_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_read_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
${rule.cache_write_1h_per_mtok}
|
||||
{rule.intro_until && (
|
||||
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
|
||||
${rule.intro_cache_write_1h_per_mtok}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
||||
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEdit(rule)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:edit")}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteRule(rule.model_pattern)}
|
||||
disabled={isEditing}
|
||||
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
||||
title={t("common:delete")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
{adding && (
|
||||
<>
|
||||
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
||||
{renderIntroEditRow()}
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{lastUpdated && (
|
||||
<p className="text-xs text-fg-muted mt-3">
|
||||
{t("pricing.lastUpdated")}
|
||||
{formatTimestamp(lastUpdated)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* @file AiToolsSettings.test.tsx
|
||||
* @description The AI Tools modal: the Settings tab bar's button and the
|
||||
* `#ai-tools` deep link open it, Escape closes it; installed rows carry an
|
||||
* enable switch and Set default, available rows fold behind a disclosure with
|
||||
* a homepage link, Refresh re-detects, a disabled tool's Settings tab
|
||||
* disappears from the hub, every updater gets Update without a version
|
||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||
* job log, folded once it is done.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
update: vi.fn(),
|
||||
install: vi.fn(),
|
||||
updateTool: vi.fn(),
|
||||
updateAll: vi.fn(),
|
||||
job: vi.fn(),
|
||||
iconUrl: (id: string) => `/api/ai-tools/${id}/icon`,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool => ({
|
||||
id,
|
||||
label,
|
||||
command: id,
|
||||
homepageUrl: `https://${id}.example/`,
|
||||
installed: true,
|
||||
path: `/usr/bin/${id}`,
|
||||
enabled: true,
|
||||
source: "builtin",
|
||||
launcher: null,
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
defaultModel: null,
|
||||
modelVendor: null,
|
||||
configFiles: 0,
|
||||
...over,
|
||||
});
|
||||
const VIEW: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code"),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
__resetAiTools();
|
||||
vi.mocked(api.aiTools.list).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.update).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.refresh).mockReset().mockResolvedValue(VIEW);
|
||||
vi.mocked(api.aiTools.job).mockReset().mockResolvedValue(null);
|
||||
vi.mocked(api.aiTools.install).mockReset();
|
||||
vi.mocked(api.aiTools.updateTool).mockReset();
|
||||
vi.mocked(api.aiTools.updateAll).mockReset();
|
||||
});
|
||||
|
||||
const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
|
||||
id: 1,
|
||||
tool: "qwen",
|
||||
action: "install",
|
||||
status: "running",
|
||||
startedAt: "2026-09-28T00:00:00Z",
|
||||
endedAt: null,
|
||||
exitCode: null,
|
||||
log: "$ npm install -g @qwen-code/qwen-code@latest\n",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("AiToolsSettings", () => {
|
||||
it("splits installed tools from the ones available to install", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
expect(within(claude).getByText(/Default/)).toBeInTheDocument();
|
||||
const aider = screen.getByTestId("ai-tool-aider");
|
||||
expect(within(aider).queryByRole("switch")).toBeNull();
|
||||
expect(within(aider).queryByText("Set default")).toBeNull();
|
||||
expect(within(aider).getByRole("link")).toHaveAttribute("href", "https://aider.example/");
|
||||
});
|
||||
|
||||
it("toggles a tool and sets the default through the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
await user.click(within(gemini).getByText("Set default"));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { default: true });
|
||||
// A disabled tool cannot become the default.
|
||||
const grok = screen.getByTestId("ai-tool-grok");
|
||||
expect(within(grok).getByText("Set default")).toBeDisabled();
|
||||
expect(within(grok).getByRole("switch")).toHaveTextContent("Disabled");
|
||||
});
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolsSettings — install and update", () => {
|
||||
const A2: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { update: { kind: "self", args: ["update"] } }),
|
||||
tool("codex", "Codex", {
|
||||
install: { kind: "npm", package: "@openai/codex" },
|
||||
update: { kind: "npm" },
|
||||
}),
|
||||
tool("gemini", "Gemini", {
|
||||
install: { kind: "npm", package: "@google/gemini-cli" },
|
||||
update: { kind: "npm" },
|
||||
}),
|
||||
tool("qwen", "Qwen", {
|
||||
installed: false,
|
||||
path: null,
|
||||
install: { kind: "npm", package: "@qwen-code/qwen-code" },
|
||||
}),
|
||||
tool("cursor", "Cursor", {
|
||||
installed: false,
|
||||
path: null,
|
||||
install: { kind: "script", url: "https://cursor.com/install" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(A2);
|
||||
});
|
||||
const openAvailable = async () =>
|
||||
userEvent.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||
|
||||
it("confirms the exact npm command, then polls and shows the log", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||
vi.mocked(api.aiTools.install).mockResolvedValue({ job: job() });
|
||||
vi.mocked(api.aiTools.job)
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
"npm install -g @qwen-code/qwen-code@latest"
|
||||
);
|
||||
expect(api.aiTools.install).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Run" }));
|
||||
expect(api.aiTools.install).toHaveBeenCalledWith("qwen");
|
||||
expect(await within(qwen).findByTestId("ai-tool-log")).toHaveTextContent("npm install -g");
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
expect(await within(qwen).findByText("Done")).toBeInTheDocument();
|
||||
expect(within(qwen).queryByTestId("ai-tool-log")).toBeNull(); // folded once done
|
||||
await user.click(within(qwen).getByRole("button", { name: "Show log" }));
|
||||
expect(within(qwen).getByTestId("ai-tool-log")).toHaveTextContent("added 1 package");
|
||||
const calls = vi.mocked(api.aiTools.job).mock.calls.length;
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(vi.mocked(api.aiTools.job).mock.calls.length).toBe(calls); // polling stopped
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("names the vendor script for a script install", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await openAvailable();
|
||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||
expect(screen.getByTestId("ai-tool-confirm-command")).toHaveTextContent(
|
||||
"Download https://cursor.com/install and run it with bash"
|
||||
);
|
||||
});
|
||||
|
||||
it("offers Update on every installed tool with an updater", async () => {
|
||||
render(<AiToolsSettings />);
|
||||
for (const id of ["claude", "codex", "gemini"]) {
|
||||
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("Update all lists every installed tool's updater", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||
expect(cmd).toHaveTextContent("claude update");
|
||||
expect(cmd).toHaveTextContent("npm install -g @google/gemini-cli@latest");
|
||||
expect(cmd).toHaveTextContent("npm install -g @openai/codex@latest");
|
||||
});
|
||||
|
||||
it("hides Update all when no tool has an updater", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||
render(<AiToolsSettings />);
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the letter when the icon fails", async () => {
|
||||
const { container } = render(<AiToolsSettings />);
|
||||
await openAvailable();
|
||||
await screen.findByTestId("ai-tool-qwen");
|
||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||
expect(img).toBeTruthy();
|
||||
fireEvent.error(img);
|
||||
expect(within(screen.getByTestId("ai-tool-qwen")).getByText("Q")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsHub — AI tool tabs", () => {
|
||||
it("hides the Codex tab while Codex is disabled", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [tool("claude", "Claude Code"), tool("codex", "Codex", { enabled: false })],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Claude Code/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /^Codex/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives every installed, enabled catalog tool its own tab, and none to the rest", async () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude" }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("qwen-code", "Qwen Code", { enabled: false }),
|
||||
tool("aider", "Aider", { installed: false }),
|
||||
],
|
||||
});
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("tab", { name: /Gemini/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /Qwen Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: /Aider/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens AI Tools as a modal from the tab bar, and closes it on Escape", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
await userEvent.click(await screen.findByRole("button", { name: "AI Tools" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "AI Tools" });
|
||||
expect(await within(dialog).findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the modal for the /settings#ai-tools deep link", async () => {
|
||||
vi.doMock("../../../pages/Settings", () => ({ Settings: () => null }));
|
||||
const { SettingsHub } = await import("../../../pages/SettingsHub");
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings#ai-tools"]}>
|
||||
<SettingsHub />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByRole("dialog", { name: "AI Tools" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AiToolIcon", () => {
|
||||
it("retries the icon when the same instance shows another tool", async () => {
|
||||
const { AiToolIcon } = await import("../../AiToolIcon");
|
||||
const { container, rerender } = render(<AiToolIcon tool={{ id: "claude", label: "Claude" }} />);
|
||||
fireEvent.error(container.querySelector("img")!);
|
||||
expect(screen.getByText("C")).toBeInTheDocument();
|
||||
rerender(<AiToolIcon tool={{ id: "codex", label: "Codex" }} />);
|
||||
expect(container.querySelector('img[src="/api/ai-tools/codex/icon"]')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* @file AntigravityHooksSettings.test.tsx
|
||||
* @description Settings → Antigravity → Antigravity hooks: not installed until
|
||||
* the user clicks Install, then "Installed" with the note that agy asks no
|
||||
* trust and has no permission hook; a hook switched off in hooks.json is
|
||||
* reported; Refresh re-reads; Remove; a server refusal shown inline.
|
||||
* @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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { AntigravityHooksSettings } from "../AntigravityHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { AntigravityHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { antigravityHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
function status(installed: boolean, enabled = installed): AntigravityHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.gemini/config/hooks.json",
|
||||
script: "/home/u/.gemini/config/agenthub-hook.sh",
|
||||
installed,
|
||||
enabled,
|
||||
events: Object.fromEntries(
|
||||
["PreInvocation", "PostToolUse", "Stop"].map((e) => [e, { installed }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const hooks = api.settings.antigravityHooks;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(hooks.get).mockReset().mockResolvedValue(status(false));
|
||||
vi.mocked(hooks.install).mockReset().mockResolvedValue(status(true));
|
||||
vi.mocked(hooks.remove).mockReset().mockResolvedValue(status(false));
|
||||
});
|
||||
|
||||
describe("AntigravityHooksSettings", () => {
|
||||
it("installs on a click, and says agy has no permission hook", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
expect(screen.getByText(/no hook for a permission prompt/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(hooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.getByText("PreInvocation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PreToolUse")).toBeNull();
|
||||
});
|
||||
|
||||
it("reports a hook switched off, re-reads on Refresh, and removes", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<AntigravityHooksSettings />);
|
||||
expect(await screen.findByText(/switched off/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/switched off/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(hooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(hooks.install).mockRejectedValue(new Error("hooks.json is not a JSON object"));
|
||||
render(<AntigravityHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* @file ClaudeAgentHubSettings.test.tsx
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: {
|
||||
info: vi.fn(),
|
||||
reinstallHooks: vi.fn(),
|
||||
resetPricing: vi.fn(),
|
||||
claudeHome: { get: vi.fn(), set: vi.fn() },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const RULE = {
|
||||
model_pattern: "claude-opus-5-5%",
|
||||
display_name: "Claude Opus 5.5",
|
||||
input_per_mtok: 4,
|
||||
output_per_mtok: 20,
|
||||
cache_read_per_mtok: 0.2,
|
||||
cache_write_per_mtok: 5,
|
||||
cache_write_1h_per_mtok: 8,
|
||||
fast_input_per_mtok: 8,
|
||||
fast_output_per_mtok: 40,
|
||||
updated_at: "2026-09-28 10:00:00",
|
||||
} as ModelPricing;
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
hooks: {
|
||||
installed: true,
|
||||
path: "/home/u/.claude/settings.json",
|
||||
hooks: { SessionStart: true, Stop: true },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||
vi.mocked(api.settings.claudeHome.get)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ claude_home: "/home/u/.claude" });
|
||||
vi.mocked(api.settings.claudeHome.set)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, claude_home: "/srv/claude", routers: { missing: [] } });
|
||||
vi.mocked(api.settings.resetPricing).mockReset();
|
||||
vi.mocked(api.settings.reinstallHooks)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ ok: true, hooks: { installed: true, hooks: {} } });
|
||||
});
|
||||
|
||||
describe("ClaudeAgentHubSettings", () => {
|
||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).getByText("SessionStart")).toBeInTheDocument();
|
||||
expect(within(hooks).getByText("/home/u/.claude/settings.json")).toBeInTheDocument();
|
||||
expect(await screen.findByDisplayValue("/home/u/.claude")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves a new CLAUDE_HOME and reinstalls hooks", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(api.settings.claudeHome.set).toHaveBeenCalledWith("/srv/claude");
|
||||
|
||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||
await user.click(within(hooks).getByRole("button"));
|
||||
expect(api.settings.reinstallHooks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-reads hook status and tells the page after a CLAUDE_HOME save, naming lanes left without their router", async () => {
|
||||
vi.mocked(api.settings.claudeHome.set).mockResolvedValue({
|
||||
ok: true,
|
||||
claude_home: "/srv/claude",
|
||||
routers: { missing: ["7 (api): my-router"] },
|
||||
});
|
||||
const onHomeChanged = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ClaudeAgentHubSettings onHomeChanged={onHomeChanged} />);
|
||||
const input = await screen.findByDisplayValue("/home/u/.claude");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/srv/claude");
|
||||
const home = container.querySelector("section#claude-home") as HTMLElement;
|
||||
const reads = vi.mocked(api.settings.info).mock.calls.length;
|
||||
await user.click(within(home).getByRole("button"));
|
||||
expect(await within(home).findByText(/7 \(api\): my-router/)).toBeInTheDocument();
|
||||
expect(onHomeChanged).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
render(<ClaudeAgentHubSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file CodexHooksSettings.test.tsx
|
||||
* @description Settings → Codex → AgentHub → Codex hooks: not installed until
|
||||
* the user clicks Install, then "waiting for your trust" with the note on how
|
||||
* Codex asks for it, "trusted" once Codex's config says so, Remove, and a
|
||||
* server refusal shown inline.
|
||||
* @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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { CodexHooksSettings } from "../CodexHooksSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { CodexHooksStatus } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { settings: { codexHooks: { get: vi.fn(), install: vi.fn(), remove: vi.fn() } } },
|
||||
};
|
||||
});
|
||||
|
||||
const EVENTS = ["SessionStart", "PreToolUse", "PermissionRequest", "Stop"];
|
||||
function status(installed: boolean, trusted: boolean): CodexHooksStatus {
|
||||
return {
|
||||
supported: true,
|
||||
readable: true,
|
||||
path: "/home/u/.codex/hooks.json",
|
||||
script: "/home/u/.codex/agenthub-hook.sh",
|
||||
installed,
|
||||
trusted: installed && trusted,
|
||||
events: Object.fromEntries(
|
||||
EVENTS.map((e) => [e, { installed, trusted: installed && trusted }])
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.settings.codexHooks.get).mockReset().mockResolvedValue(status(false, false));
|
||||
vi.mocked(api.settings.codexHooks.install).mockReset().mockResolvedValue(status(true, false));
|
||||
vi.mocked(api.settings.codexHooks.remove).mockReset().mockResolvedValue(status(false, false));
|
||||
});
|
||||
|
||||
describe("CodexHooksSettings", () => {
|
||||
it("installs on a click, then says Codex still has to trust them", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Remove" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Install" }));
|
||||
expect(api.settings.codexHooks.install).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Trust all and continue/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Reinstall" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows trusted hooks, and removes them", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Trust all and continue/)).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: "Remove" }));
|
||||
expect(api.settings.codexHooks.remove).toHaveBeenCalled();
|
||||
expect(await screen.findByText("Not installed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("re-reads the status on Refresh, e.g. once the user trusted the hooks in Codex", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.get)
|
||||
.mockResolvedValueOnce(status(true, false))
|
||||
.mockResolvedValueOnce(status(true, true));
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByText(/waiting for your trust/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
expect(await screen.findByText("Installed and trusted")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still offers Remove when only some of the hooks are there", async () => {
|
||||
const partial = status(true, false);
|
||||
partial.installed = false;
|
||||
partial.events.Stop = { installed: false, trusted: false };
|
||||
vi.mocked(api.settings.codexHooks.get).mockResolvedValue(partial);
|
||||
render(<CodexHooksSettings />);
|
||||
expect(await screen.findByRole("button", { name: "Remove" })).toBeInTheDocument();
|
||||
// The chip, plus the missing event's screen-reader label.
|
||||
expect(screen.getAllByText("Not installed")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows a server refusal inline", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.codexHooks.install).mockRejectedValue(
|
||||
new Error("/home/u/.codex/hooks.json is not a JSON object; fix or move it first")
|
||||
);
|
||||
render(<CodexHooksSettings />);
|
||||
await user.click(await screen.findByRole("button", { name: "Install" }));
|
||||
expect(await screen.findByText(/is not a JSON object/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file HomeDirSetting.test.tsx
|
||||
* @description The shared editable home directory: loads the value in effect,
|
||||
* Save is disabled until the path changes, a server refusal shows inline and
|
||||
* changes nothing, an env-owned variable warns that a save will not survive a
|
||||
* restart, and a partial-success warning from `save` is shown.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { HomeDirSetting, type HomeDirValue } from "../HomeDirSetting";
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
function setup(load: HomeDirValue, save: (p: string) => Promise<HomeDirValue>) {
|
||||
const saveFn = vi.fn(save);
|
||||
const view = render(
|
||||
<HomeDirSetting
|
||||
id="x-home"
|
||||
title="X_HOME"
|
||||
description="desc"
|
||||
placeholder="/path"
|
||||
load={() => Promise.resolve(load)}
|
||||
save={saveFn}
|
||||
/>
|
||||
);
|
||||
return { saveFn, ...view };
|
||||
}
|
||||
|
||||
describe("HomeDirSetting", () => {
|
||||
it("saves a changed path and shows the value now in effect", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { saveFn, container } = setup({ value: "/a", envOwned: false }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: false })
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).toBeDisabled();
|
||||
expect(container.querySelector("section#x-home")).not.toBeNull();
|
||||
|
||||
// The saved value shows only while the field says something else.
|
||||
expect(screen.queryByText("/a", { selector: "code" })).toBeNull();
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
await user.click(button);
|
||||
expect(saveFn).toHaveBeenCalledWith("/b");
|
||||
await waitFor(() => expect(screen.queryByText("/a", { selector: "code" })).toBeNull());
|
||||
expect(screen.getByDisplayValue("/b")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/set by the environment/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a refusal inline and keeps the old value", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: false }, () =>
|
||||
Promise.reject(new Error("Too broad for X_HOME: / contains your home directory"))
|
||||
);
|
||||
const input = await screen.findByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText(/Too broad for X_HOME/)).toBeInTheDocument();
|
||||
expect(screen.getByText("/a", { selector: "code" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the environment owns the variable, and shows a save's warning", async () => {
|
||||
const user = userEvent.setup();
|
||||
setup({ value: "/a", envOwned: true }, (p) =>
|
||||
Promise.resolve({ value: p, envOwned: true, warning: "lane 3 not updated" })
|
||||
);
|
||||
expect(await screen.findByText(/set by the environment/)).toBeInTheDocument();
|
||||
const input = screen.getByDisplayValue("/a");
|
||||
await user.clear(input);
|
||||
await user.type(input, "/b");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByText("lane 3 not updated")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @file PricingSettings.test.tsx
|
||||
* @description The shared Model Pricing table: each tool's tab shows its own
|
||||
* vendor's rows, **Show all models** lists the rest, a row added outside the
|
||||
* filter turns that switch on instead of vanishing, and Reset Defaults is
|
||||
* offered only where asked for (the Claude tab).
|
||||
* @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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { PricingSettings } from "../PricingSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { list: vi.fn(), upsert: vi.fn(), delete: vi.fn() },
|
||||
settings: { resetPricing: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const rule = (model_pattern: string, display_name: string) =>
|
||||
({
|
||||
model_pattern,
|
||||
display_name,
|
||||
input_per_mtok: 1,
|
||||
output_per_mtok: 2,
|
||||
cache_read_per_mtok: 0.1,
|
||||
cache_write_per_mtok: 0,
|
||||
cache_write_1h_per_mtok: 0,
|
||||
fast_input_per_mtok: 0,
|
||||
fast_output_per_mtok: 0,
|
||||
updated_at: "2026-09-29 10:00:00",
|
||||
}) as ModelPricing;
|
||||
|
||||
const ROWS = [rule("claude-opus-5-5%", "Claude Opus 5.5"), rule("gpt-5.6-sol%", "GPT-5.6 Sol")];
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: ROWS });
|
||||
vi.mocked(api.pricing.upsert)
|
||||
.mockReset()
|
||||
.mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
describe("PricingSettings", () => {
|
||||
it("shows the vendor's rows, and the rest behind Show all models", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText("gpt-5.6-sol%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("claude-opus-5-5%")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Reset Defaults/i })).toBeNull();
|
||||
await user.click(screen.getByRole("checkbox", { name: /Show all models \(1 more\)/ }));
|
||||
expect(screen.getByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a row the automatic sync keeps up to date", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [
|
||||
{ ...rule("gpt-6-sol%", "GPT-6 Sol"), source: "sync" },
|
||||
{ ...rule("gpt-5.6-sol%", "GPT-5.6 Sol"), source: "user" },
|
||||
],
|
||||
});
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-6-sol%");
|
||||
expect(screen.getAllByText("Auto")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("says where OpenAI prices come from when there are none", async () => {
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({ pricing: [ROWS[0]!] });
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
expect(await screen.findByText(/added automatically from LiteLLM/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("turns Show all on when a row outside the filter is added", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PricingSettings vendor="openai" />);
|
||||
await screen.findByText("gpt-5.6-sol%");
|
||||
await user.click(screen.getByRole("button", { name: /Add Model/i }));
|
||||
const [pattern, name] = screen.getAllByRole("textbox");
|
||||
await user.type(pattern!, "deepseek-v4%");
|
||||
await user.type(name!, "DeepSeek V4");
|
||||
vi.mocked(api.pricing.list).mockResolvedValue({
|
||||
pricing: [...ROWS, rule("deepseek-v4%", "DeepSeek V4")],
|
||||
});
|
||||
await user.click(screen.getByTitle(/save/i));
|
||||
expect(await screen.findByText("deepseek-v4%")).toBeInTheDocument();
|
||||
expect(screen.getByRole("checkbox", { name: /Show all models/ })).toBeChecked();
|
||||
});
|
||||
|
||||
it("offers Reset Defaults on the Claude tab, confirmed by a second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockReset().mockResolvedValue({ ok: true, pricing: ROWS });
|
||||
render(<PricingSettings vendor="claude" allowReset />);
|
||||
expect(await screen.findByText("claude-opus-5-5%")).toBeInTheDocument();
|
||||
expect(screen.queryByText("gpt-5.6-sol%")).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /Reset Defaults/i }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: /confirm/i }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledWith("claude");
|
||||
});
|
||||
});
|
||||
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x2", innerW)
|
||||
.attr("y1", (d) => yScale(d))
|
||||
.attr("y2", (d) => yScale(d))
|
||||
.attr("stroke", "#2a2a3d")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1);
|
||||
|
||||
// Y axis (sessions)
|
||||
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
)
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
|
||||
.call((ax) => ax.select(".domain").remove())
|
||||
.selectAll("text")
|
||||
.attr("fill", "#9ca3af")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-family", "Inter, sans-serif");
|
||||
|
||||
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -38)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", 500)
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("x", bx + bw / 2)
|
||||
.attr("y", by - 5)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#a5b4fc")
|
||||
.attr("fill", "rgb(var(--fg-secondary))")
|
||||
.attr("font-size", 10)
|
||||
.attr("font-weight", "600")
|
||||
.attr("font-family", "Inter, sans-serif")
|
||||
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
|
||||
.attr("y", innerH - 1)
|
||||
.attr("width", bw)
|
||||
.attr("height", 1)
|
||||
.attr("fill", "#2a2a3d");
|
||||
.attr("fill", "rgb(var(--border))");
|
||||
}
|
||||
|
||||
// Transparent, full-height hover target on top of the bar.
|
||||
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
|
||||
label={t("compaction.sessionsAffected")}
|
||||
value={affected.toLocaleString()}
|
||||
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
|
||||
accent="text-violet-300"
|
||||
accent="text-violet-700 dark:text-violet-300"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.avgPerSession")}
|
||||
value={avgPerSession.toFixed(1)}
|
||||
accent="text-blue-400"
|
||||
accent="text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<StatBox
|
||||
label={t("compaction.peakSession")}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#10b981"
|
||||
stroke="rgb(var(--status-success))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
|
||||
</div>
|
||||
<span
|
||||
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
|
||||
style={{ color: isHovered ? color : "#9ca3af" }}
|
||||
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
|
||||
>
|
||||
{d.count}
|
||||
</span>
|
||||
|
||||
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("x", (_, i) => layerXPositions[i] ?? 0)
|
||||
.attr("y", 22)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "10px")
|
||||
.attr("font-weight", "500")
|
||||
.attr("letter-spacing", "0.08em")
|
||||
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.attr("y1", 32)
|
||||
.attr("x2", sepX)
|
||||
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
|
||||
.attr("stroke", "#1f1f30")
|
||||
.attr("stroke", "rgb(var(--border))")
|
||||
.attr("stroke-width", 1)
|
||||
.attr("stroke-dasharray", "4 4");
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
|
||||
.append("path")
|
||||
.attr("d", path)
|
||||
.attr("fill", "none")
|
||||
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
|
||||
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
|
||||
.attr("stroke-width", isZero ? 1 : stroke)
|
||||
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
|
||||
.attr("stroke-linecap", "round");
|
||||
|
||||
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
|
||||
|
||||
// Grid lines
|
||||
const gridColor = "#2a2a3d";
|
||||
const gridColor = "rgb(var(--border))";
|
||||
|
||||
g.append("g")
|
||||
.attr("class", "grid-x")
|
||||
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.tickFormat((d) => fmtXTick(d as number))
|
||||
)
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// X axis label
|
||||
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", innerW / 2)
|
||||
.attr("y", innerH + 44)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.duration"));
|
||||
|
||||
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
g.append("g")
|
||||
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
|
||||
.call((sel) => {
|
||||
sel.select(".domain").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick line").attr("stroke", "#363650");
|
||||
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
|
||||
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
|
||||
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
|
||||
});
|
||||
|
||||
// Y axis label
|
||||
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
|
||||
.attr("x", -innerH / 2)
|
||||
.attr("y", -40)
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "#6b7280")
|
||||
.attr("fill", "rgb(var(--fg-muted))")
|
||||
.attr("font-size", "11")
|
||||
.text(t("complexity.agentCount"));
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
case "working":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "error":
|
||||
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
|
||||
case "active":
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "waiting":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
default:
|
||||
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
|
||||
}
|
||||
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
@@ -255,7 +255,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
|
||||
>
|
||||
{/* Tool pill */}
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
|
||||
{ev.tool_name ?? ev.event_type}
|
||||
</span>
|
||||
|
||||
@@ -286,8 +286,8 @@ interface EventSequenceProps {
|
||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||
tool_use: "text-blue-500",
|
||||
tool_result: "text-status-success",
|
||||
agent_start: "text-indigo-400",
|
||||
agent_stop: "text-violet-400",
|
||||
agent_start: "text-indigo-700 dark:text-indigo-400",
|
||||
agent_stop: "text-violet-700 dark:text-violet-400",
|
||||
compaction: "text-status-warning",
|
||||
error: "text-status-danger",
|
||||
};
|
||||
|
||||
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
|
||||
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
|
||||
// as its own color in both the filter row and the result label chips.
|
||||
const PHASE_PALETTE = [
|
||||
"bg-violet-500/15 text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-300 border-sky-500/40",
|
||||
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
|
||||
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
|
||||
"bg-status-warning/15 text-status-warning border-status-warning/40",
|
||||
"bg-status-success/15 text-status-success border-status-success/40",
|
||||
"bg-rose-500/15 text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
|
||||
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
|
||||
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
|
||||
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
|
||||
];
|
||||
|
||||
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
|
||||
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
|
||||
if (!controlled && loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
|
||||
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
|
||||
<span className="animate-pulse">{t("runs.loading")}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
|
||||
{running ? (
|
||||
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
|
||||
) : (
|
||||
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
||||
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
|
||||
<Link
|
||||
to={`/sessions/${encodeURIComponent(run.session_id)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
|
||||
title={t("runs.openSession")}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
|
||||
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
|
||||
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
|
||||
{ts?.loading && (
|
||||
<span className="flex items-center gap-1 text-violet-400">
|
||||
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{t("runs.loadingFull")}
|
||||
</span>
|
||||
|
||||
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
|
||||
|
||||
function successRateColor(rate: number): string {
|
||||
if (rate > 90) return "text-status-success";
|
||||
if (rate > 70) return "text-yellow-400";
|
||||
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
|
||||
return "text-status-danger";
|
||||
}
|
||||
|
||||
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgAgentDepth")}
|
||||
value={stats.avgDepth.toFixed(1)}
|
||||
icon={GitFork}
|
||||
accentClass="text-indigo-400"
|
||||
accentClass="text-indigo-700 dark:text-indigo-400"
|
||||
calculationKey="stats.tooltip.calc.depth"
|
||||
interp={interpAvgDepth(stats.avgDepth)}
|
||||
metricPhraseKey="stats.tooltip.phrase.depth"
|
||||
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.mostCommonFlow")}
|
||||
value={topFlowLabel}
|
||||
icon={ArrowRightLeft}
|
||||
accentClass="text-violet-400"
|
||||
accentClass="text-violet-700 dark:text-violet-400"
|
||||
calculationKey="stats.tooltip.calc.topFlow"
|
||||
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
|
||||
metricPhraseKey="stats.tooltip.phrase.topFlow"
|
||||
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
|
||||
label={t("stats.avgCompactions")}
|
||||
value={stats.avgCompactions.toFixed(1)}
|
||||
icon={Layers}
|
||||
accentClass="text-cyan-400"
|
||||
accentClass="text-cyan-700 dark:text-cyan-400"
|
||||
calculationKey="stats.tooltip.calc.compactions"
|
||||
interp={interpAvgCompactions(stats.avgCompactions)}
|
||||
metricPhraseKey="stats.tooltip.phrase.compactions"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* @file useAiTools — the AI Tools catalog (GET /api/ai-tools) shared by every
|
||||
* component that names a lane's tool or offers one: the Add-lane modal, the
|
||||
* run form, the lane strip and Settings → AI Tools. One module-level copy, so
|
||||
* N components mean one request; refetched on window focus and after every
|
||||
* change made through `update`/`refresh`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { api, type AiTool, type AiToolUpdateBody, type AiToolsView } from "../lib/api";
|
||||
|
||||
let view: AiToolsView | null = null;
|
||||
let inflight: Promise<void> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function publish(next: AiToolsView) {
|
||||
view = next;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function load(): Promise<void> {
|
||||
if (!inflight) {
|
||||
inflight = Promise.resolve()
|
||||
.then(() => api.aiTools.list())
|
||||
.then(publish)
|
||||
// Unreachable server: keep what we had; every caller has a fallback.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function subscribe(l: () => void) {
|
||||
listeners.add(l);
|
||||
if (listeners.size === 1) window.addEventListener("focus", onFocus);
|
||||
return () => {
|
||||
listeners.delete(l);
|
||||
if (listeners.size === 0) window.removeEventListener("focus", onFocus);
|
||||
};
|
||||
}
|
||||
function onFocus() {
|
||||
void load();
|
||||
}
|
||||
|
||||
/** Built-in names for the three tools a lane may carry before the catalog loads. */
|
||||
const FALLBACK: Record<string, string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
};
|
||||
|
||||
/** Test seam: forget the cached catalog. */
|
||||
export function __resetAiTools() {
|
||||
view = null;
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
export function useAiTools() {
|
||||
const v = useSyncExternalStore(subscribe, () => view);
|
||||
useEffect(() => {
|
||||
if (!view) void load();
|
||||
}, []);
|
||||
const tools: AiTool[] = v?.tools ?? [];
|
||||
const byId = (id: string) => tools.find((t) => t.id === id) ?? null;
|
||||
return {
|
||||
tools,
|
||||
loaded: v !== null,
|
||||
defaultId: v?.default ?? null,
|
||||
byId,
|
||||
/** A tool's display name; the id itself for a tool the catalog no longer knows. */
|
||||
label: (id: string) => byId(id)?.label ?? FALLBACK[id] ?? id,
|
||||
reload: load,
|
||||
refresh: async () => publish(await api.aiTools.refresh()),
|
||||
update: async (id: string, body: AiToolUpdateBody) =>
|
||||
publish(await api.aiTools.update(id, body)),
|
||||
};
|
||||
}
|
||||
@@ -50,7 +50,9 @@
|
||||
"modified": "Modified",
|
||||
"scopeLabel": "Scope",
|
||||
"noFrontmatter": "No frontmatter",
|
||||
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side."
|
||||
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"skills": {
|
||||
"name": "Name",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"action.stop": "stop",
|
||||
"actionError": "Lane action failed: {{message}}",
|
||||
"actionErrorUnknown": "Unknown error",
|
||||
"actions.agentsInstall": "Install agents",
|
||||
"actions.agentsInstallResult": "Installed: {{files}}",
|
||||
"actions.busy": "…",
|
||||
"actions.mcpSync": "Sync MCP",
|
||||
"actions.mcpSyncResult": "Synced: {{servers}}",
|
||||
@@ -25,14 +23,15 @@
|
||||
"addLaneBranchBusy": "{{branch}} — already checked out at {{path}}",
|
||||
"addLaneBranchExistingHint": "The worktree checks this branch out as it is — no new branch, no base.",
|
||||
"addLaneBranchExistingLabel": "Branch to check out",
|
||||
"addLaneBranchHintAuto": "Leave empty to use the default name shown above.",
|
||||
"addLaneBranchHintAuto": "Leave empty to let the server name the branch from the title.",
|
||||
"addLaneBranchLabel": "New branch name",
|
||||
"addLaneBranchModeCurrent": "Current branch",
|
||||
"addLaneBranchModeCurrentHint": "Work straight on this repo's checked-out branch (main) — no worktree, no fork. Switches to Repo mode.",
|
||||
"addLaneBranchModeExisting": "Existing branch",
|
||||
"addLaneBranchModeNew": "New branch",
|
||||
"addLaneBranchPlaceholder": "feat/my-feature",
|
||||
"addLaneNoBranches": "This repo has no commits yet — the worktree will start empty.",
|
||||
"addLaneBranchPlaceholder": "Automatic — named from the title",
|
||||
"addLaneNoBranches": "This repo has no commits yet, so a worktree has nothing to branch from. Add it as a Repo lane: commit first, then publish it from the Changes panel.",
|
||||
"addLaneNoBranchesUseRepo": "Use as a Repo lane",
|
||||
"addLaneNoFreeBranches": "Every branch in this repo is already checked out in a worktree. Create a new branch instead.",
|
||||
"addLaneExists": "This folder already has a lane: #{{id}} {{title}}. It is selected; pick another folder to add a new one.",
|
||||
"addLaneNotARepo": "Not a git repository (or no read access) yet.",
|
||||
@@ -46,9 +45,7 @@
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -56,8 +53,10 @@
|
||||
"addLaneSetupTitle": "Setup",
|
||||
"addLaneTitleLabel": "Title",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolDefaultBadge": "Default",
|
||||
"addLaneNoTools": "No AI tool is installed and enabled.",
|
||||
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
||||
"addLaneRouterNativeTool": "Native ({{tool}})",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
@@ -108,8 +107,13 @@
|
||||
"changes.refresh": "Refresh",
|
||||
"changes.resize": "Resize the changes panel (next: {{size}})",
|
||||
"changes.size.compact": "Narrow",
|
||||
"changes.size.half": "50 / 50",
|
||||
"changes.size.wide": "70 / 30",
|
||||
"changes.size.half": "Medium",
|
||||
"changes.size.full": "Full screen",
|
||||
"changes.stageAll": "Stage all ({{count}})",
|
||||
"changes.unstageAll": "Unstage all ({{count}})",
|
||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||
"changes.stagedGroup": "Staged Changes",
|
||||
"changes.unstagedGroup": "Changes",
|
||||
"changes.stage": "Stage this file",
|
||||
"changes.title": "Changes",
|
||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||
@@ -169,21 +173,21 @@
|
||||
"gitBar.branchDelete": "Delete",
|
||||
"gitBar.branchDeleteHint": "Delete the selected branch (refused if it has unmerged commits)",
|
||||
"gitBar.branchNewPlaceholder": "New branch name…",
|
||||
"gitBar.cancel": "Cancel",
|
||||
"gitBar.commit": "Commit",
|
||||
"gitBar.commitAll_one": "Commit {{count}} file",
|
||||
"gitBar.commitAll_other": "Commit {{count}} files",
|
||||
"gitBar.commitHint": "Stage everything and commit",
|
||||
"gitBar.commitNothing": "Nothing to commit",
|
||||
"gitBar.commitPlaceholder": "Commit message",
|
||||
"gitBar.commitStaged": "Commit staged",
|
||||
"gitBar.commitStagedHint": "Commit only what is staged in the Changes panel",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "No remote to push to or pull from. Add one, or set this branch's upstream, in a terminal.",
|
||||
"gitBar.inSync": "in sync",
|
||||
"gitBar.newLane": "New lane here",
|
||||
"gitBar.newLaneHeld": "Already checked out at {{path}} — git allows one worktree per branch",
|
||||
"gitBar.newLaneHint": "Open Add lane for a sibling worktree on the selected branch",
|
||||
"gitBar.noCommits": "no commits yet",
|
||||
"gitBar.noUpstream": "not pushed",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish branch",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — this branch has no upstream yet",
|
||||
"gitBar.publishHint": "Add a remote named origin and push this branch to it (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "Remote URL: https://host/owner/repo.git or git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
@@ -194,6 +198,7 @@
|
||||
"gitBar.switchDirty": "Commit or discard the uncommitted changes first",
|
||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||
"gitBar.switchLabel": "Switch branch…",
|
||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||
"kind.adopted": "adopted",
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
@@ -213,6 +218,8 @@
|
||||
"renameLabel": "Rename this lane",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"router.applied": "Applied.",
|
||||
"router.native": "Native ({{tool}})",
|
||||
"router.nativeOnly": "{{tool}} runs on its own settings — AI Router profiles work with Claude Code and Codex only.",
|
||||
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Restart",
|
||||
@@ -239,5 +246,70 @@
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees"
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"changes.stageHunk": "Stage this hunk",
|
||||
"changes.undoHunk": "Undo this hunk",
|
||||
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
||||
"changes.stageHunkShort": "Keep",
|
||||
"changes.undoHunkShort": "Undo",
|
||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.clickToEdit": "Click the text to edit",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
"changes.editorLabel": "Edit {{path}}",
|
||||
"changes.prevHunk": "Previous change",
|
||||
"changes.nextHunk": "Next change",
|
||||
"changes.hunkOf": "{{n}} of {{total}}",
|
||||
"changes.noEditBinary": "Binary file — not editable here",
|
||||
"changes.noEditTooBig": "Too large to edit here (over 1 MB)",
|
||||
"changes.noEdit": "Not editable here",
|
||||
"gitBar.loginTitle": "Git needs a login for {{host}}",
|
||||
"gitBar.loginRejected": "The server rejected that username or token.",
|
||||
"gitBar.loginUser": "Username",
|
||||
"gitBar.loginPassword": "Password or access token",
|
||||
"gitBar.loginRemember": "Remember for everyone using this AgentHub",
|
||||
"gitBar.loginTokenHint": "On Gitea: Settings → Applications → Generate token, with repository read/write.",
|
||||
"gitBar.loginInsecure": "This dashboard is on plain http — the token crosses the network unencrypted.",
|
||||
"gitBar.loginSubmit": "Log in and retry",
|
||||
"gitBar.loginCancel": "Cancel",
|
||||
"gitBar.merge": "Merge into current",
|
||||
"gitBar.mergeHint": "Merge the selected branch into the branch this lane is on",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this lane change now — if an agent is working in this lane, let it finish first.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
"conflict.left": "{{left}} of {{total}} conflicts left",
|
||||
"conflict.prev": "Previous conflict",
|
||||
"conflict.next": "Next conflict",
|
||||
"conflict.allOurs": "Keep all mine",
|
||||
"conflict.allTheirs": "Take all theirs",
|
||||
"conflict.editByHand": "Edit by hand",
|
||||
"conflict.unbalanced": "The conflict markers do not line up (edited by hand?) — use Edit by hand.",
|
||||
"conflict.unusable": "This file cannot be shown here (binary or too large) — use Keep all mine / Take all theirs, or a terminal.",
|
||||
"conflict.keepOurs": "Keep mine",
|
||||
"conflict.takeTheirs": "Take theirs",
|
||||
"conflict.keepBoth": "Keep both",
|
||||
"conflict.chose.ours": "Kept mine",
|
||||
"conflict.chose.theirs": "Took theirs",
|
||||
"conflict.chose.both": "Kept both",
|
||||
"conflict.undo": "Undo",
|
||||
"conflict.ours": "Mine ({{label}})",
|
||||
"conflict.theirs": "Theirs ({{label}})",
|
||||
"conflict.reset": "Cancel",
|
||||
"conflict.save": "Save and mark resolved",
|
||||
"changes.takeOursHint": "Keep your version of the whole file",
|
||||
"changes.takeTheirsHint": "Take the incoming version of the whole file"
|
||||
}
|
||||
|
||||
@@ -68,5 +68,7 @@
|
||||
"workspace": "Workspace",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "{{version}} available",
|
||||
"devBuild": "Dev build · {{count}} ahead"
|
||||
"devBuild": "Dev build · {{count}} ahead",
|
||||
"installedNotRunning": "{{version}} installed, not running",
|
||||
"releaseUnchecked": "Could not check for updates"
|
||||
}
|
||||
|
||||
@@ -76,12 +76,13 @@
|
||||
"permissionAcceptEdits": "acceptEdits (file edits without asking)",
|
||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||
"bypass": "Bypass permissions for this run",
|
||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (read-only planning)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask Claude anything…",
|
||||
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Full access (dangerous)",
|
||||
"sandboxReadOnly": "Read only",
|
||||
@@ -97,6 +98,7 @@
|
||||
"hint": {
|
||||
"headlessExplain": "Headless mode is best for scripted tasks where you know exactly what you want. The session can't ask follow-up questions and will hang on permission prompts unless you stay in acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions allows ALL tool invocations without confirming. Only use for trusted prompts.",
|
||||
"bypassWarning": "{{tool}} will run every command and edit every file without asking. Only for a prompt and a folder you trust.",
|
||||
"sandboxWarning": "Codex will run commands with no sandbox.",
|
||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
||||
},
|
||||
@@ -164,5 +166,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,5 +39,6 @@
|
||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
||||
"transcriptNotFound": "Conversation transcript not found for this agent. The transcript file may be missing or not yet linked."
|
||||
},
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine"
|
||||
"remoteSourceBadgeTitle": "Collected from a remote machine",
|
||||
"toolBadgeTitle": "Recorded by {{tool}}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage pricing, notifications, data, and hooks",
|
||||
"subtitle": "Manage appearance, AI tools, notifications and data",
|
||||
"exportData": "Export Data",
|
||||
"acrossSessions": "Across all tracked sessions",
|
||||
"basedOnUsage": "Based on per-model token usage",
|
||||
@@ -23,8 +23,6 @@
|
||||
"resetResult": "Reset to {{count}} default rules",
|
||||
"addModel": "Add Model",
|
||||
"pattern": "Pattern",
|
||||
"patternPlaceholder": "claude-opus-4%",
|
||||
"namePlaceholder": "Claude Opus 4",
|
||||
"validationRequired": "Pattern and display name are required",
|
||||
"lastUpdated": "Last updated: ",
|
||||
"tooltip": {
|
||||
@@ -33,24 +31,44 @@
|
||||
"howItWorksBody": "When a session reports usage, the dashboard finds the first pricing rule whose pattern matches the model id and multiplies (input tokens / 1M) and (output tokens / 1M) by the rates below to compute cost. Rules are evaluated top-to-bottom - the first match wins.",
|
||||
"patternsTitle": "Pattern syntax",
|
||||
"patternsBody": "Patterns are SQL-style globs. % is a wildcard that matches any characters. For example: claude-opus-4-7% matches every model id that starts with claude-opus-4-7 (e.g. claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku matches any haiku variant. An exact id like claude-sonnet-4-6 only matches itself.",
|
||||
"manualUpdates": "You must update prices manually",
|
||||
"manualUpdatesBody": "Anthropic publishes pricing changes on their website - there is no live feed. When a new model launches or rates change, edit the matching rule (or add a new one above the catch-all) so newly recorded sessions are costed correctly. Already-stored sessions keep the price that was applied at ingest time.",
|
||||
"apiPricing": "Reference only - API rates",
|
||||
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill."
|
||||
"apiPricingBody": "These are pay-as-you-go API rates. If you use Claude Code on a subscription plan (Pro, Max, Team, or Enterprise), your actual cost is a flat monthly fee, not per-token - so these figures will usually be higher than what you really pay. Treat the costs here as a usage estimate for reference, not a bill.",
|
||||
"synced": "Prices are filled in automatically",
|
||||
"syncedBody": "Every current Claude, OpenAI and Gemini model is priced from LiteLLM's public price list, checked once a day, as is any other model the moment a session uses it. Rows marked Auto follow that list when a rate changes; a row you edit becomes yours and is never touched again, and a row you delete stays deleted. Already-stored sessions keep the price that was applied at ingest time.",
|
||||
"apiPricingOtherBody": "These are pay-as-you-go API rates. If you use this CLI on a subscription plan, your actual cost is a flat fee, not per-token - so these figures will usually be higher than what you really pay. Treat them as a usage estimate, not a bill."
|
||||
},
|
||||
"cacheWrite5m": "Cache Write (5m)",
|
||||
"cacheWrite1h": "Cache Write (1h)",
|
||||
"fastInput": "Fast Input",
|
||||
"fastOutput": "Fast Output"
|
||||
"fastOutput": "Fast Output",
|
||||
"showAll": "Show all models ({{count}} more)",
|
||||
"empty": {
|
||||
"claude": "No Claude model prices. Reset Defaults restores them.",
|
||||
"openai": "No OpenAI model prices yet. They are added automatically from LiteLLM's price list once a Codex session uses a model, or add one yourself.",
|
||||
"google": "No Gemini model prices yet. They are added automatically from LiteLLM's price list once an Antigravity session uses a model, or add one yourself."
|
||||
},
|
||||
"placeholders": {
|
||||
"claude": {
|
||||
"pattern": "claude-opus-4%",
|
||||
"name": "Claude Opus 4"
|
||||
},
|
||||
"openai": {
|
||||
"pattern": "gpt-5%",
|
||||
"name": "GPT 5"
|
||||
},
|
||||
"google": {
|
||||
"pattern": "gemini-3.8-flash%",
|
||||
"name": "Gemini 3.8 Flash"
|
||||
}
|
||||
},
|
||||
"autoBadge": "Auto",
|
||||
"autoBadgeTitle": "Kept at the published rate automatically (LiteLLM's price list, checked daily). Edit it to keep your own rate."
|
||||
},
|
||||
"claudeHome": {
|
||||
"title": "CLAUDE_HOME",
|
||||
"description": "Root directory for Claude Code session data. Changing this updates the scan path for transcripts and imports immediately.",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"current": "Current:",
|
||||
"placeholder": "/path/to/claude-home",
|
||||
"saveFailed": "Failed to update CLAUDE_HOME"
|
||||
"routersMissing": "Saved. These lanes name an AI Router profile the new home does not have, and start on Native until it is added there or they are re-pointed: {{lanes}}"
|
||||
},
|
||||
"hooks": {
|
||||
"title": "Hook Configuration",
|
||||
@@ -63,9 +81,7 @@
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Lanes",
|
||||
"description": "Machine-wide housekeeping and one-time setup for the ship-feature-lane pipeline.",
|
||||
"installSkill": "Install ship-feature-lane skill",
|
||||
"installSkillSuccess": "Installed to {{path}}",
|
||||
"description": "Machine-wide lane housekeeping.",
|
||||
"runHousekeeping": "Run housekeeping",
|
||||
"dryRun": "Dry run",
|
||||
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
|
||||
@@ -452,7 +468,9 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"antigravity": "Antigravity",
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -496,6 +514,36 @@
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
"description": "AI coding CLIs AgentHub can run in a lane. Detected on this machine's PATH; a disabled tool is hidden from Add lane.",
|
||||
"installed": "Installed ({{count}} detected)",
|
||||
"available": "Available to install ({{count}})",
|
||||
"refresh": "Refresh",
|
||||
"noneInstalled": "No AI tool found on PATH. Install one, then press Refresh.",
|
||||
"default": "Default",
|
||||
"setDefault": "Set default",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"enabledOf": "{{tool}} enabled",
|
||||
"homepage": "Homepage",
|
||||
"homepageOf": "{{tool}} homepage",
|
||||
"install": "Install",
|
||||
"update": "Update",
|
||||
"updateAll": "Update all ({{count}})",
|
||||
"runScript": "Download {{url}} and run it with bash",
|
||||
"confirmInstall": "Install {{tool}}?",
|
||||
"confirmUpdate": "Update {{tool}}?",
|
||||
"confirmUpdateAll": "Update AI tools?",
|
||||
"confirmBody": "AgentHub will run on this machine:",
|
||||
"run": "Run",
|
||||
"cancel": "Cancel",
|
||||
"jobRunning": "Running…",
|
||||
"jobOk": "Done",
|
||||
"jobFailed": "Failed (exit {{code}})",
|
||||
"showLog": "Show log",
|
||||
"hideLog": "Hide log"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"description": "Colour mode and theme. Saved in this browser only.",
|
||||
@@ -509,5 +557,87 @@
|
||||
"lightThemes": "Light themes",
|
||||
"active": "· in use now",
|
||||
"default": "Default"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push — one per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
"add": "Add login",
|
||||
"replace": "Replace token",
|
||||
"remove": "Delete",
|
||||
"removeConfirm": "Delete this git login? Dashboard fetch/pull/push to that host will ask for a login again.",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"fieldHost": "Server URL",
|
||||
"fieldUser": "Username",
|
||||
"fieldToken": "Password or access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Username",
|
||||
"colSaved": "Saved",
|
||||
"empty": "No git login saved. The git bar asks for one the first time a host needs it.",
|
||||
"badUrl": "Enter the server as a URL, e.g. https://git.example.com"
|
||||
},
|
||||
"homeDir": {
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"current": "Current:",
|
||||
"saveFailed": "Failed to save the directory",
|
||||
"envOwned": "This variable is set by the environment the dashboard started in, or by the checkout's .env — either wins at every start: a value saved here lasts until the next restart."
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex lane with it.",
|
||||
"restartNote": "A codex lane already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||
"placeholder": "/path/to/codex-home",
|
||||
"routersFailed": "Saved, but the AI Router layer could not be re-written for: {{lanes}}"
|
||||
},
|
||||
"codexHooks": {
|
||||
"title": "Codex hooks",
|
||||
"description": "Let Codex report to AgentHub as it works: each tool call in the session's timeline, \"Codex needs your permission\" on the lane card and as a notification, and running/idle the moment a turn starts or ends. Tokens and cost come from Codex's journals either way.",
|
||||
"notInstalled": "Not installed",
|
||||
"untrusted": "Installed — waiting for your trust in Codex",
|
||||
"trusted": "Installed and trusted",
|
||||
"trustNote": "Codex runs a hook only after you trust it. The next time Codex starts it asks you to review new hooks: choose Trust all and continue (or run /hooks). Until then Codex skips them.",
|
||||
"install": "Install",
|
||||
"reinstall": "Reinstall",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"eventTrusted": "Trusted in Codex",
|
||||
"eventUntrusted": "Not trusted in Codex yet",
|
||||
"eventMissing": "Not installed",
|
||||
"unsupported": "Codex hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||
"failed": "Could not change Codex hooks: {{message}}"
|
||||
},
|
||||
"antigravityHooks": {
|
||||
"title": "Antigravity hooks",
|
||||
"description": "Let Antigravity's CLI (agy) report to AgentHub as it works: each tool call in the session's timeline, and running/idle the moment a run starts or ends. Tokens and cost come from agy's conversation files either way.",
|
||||
"notInstalled": "Not installed",
|
||||
"installed": "Installed",
|
||||
"disabled": "Installed but switched off in hooks.json",
|
||||
"note": "agy loads hooks at its next start and asks for no trust. The file is global, so the Antigravity IDE runs the hook too; AgentHub ignores what the IDE sends. agy has no hook for a permission prompt, so an Antigravity lane cannot show \"needs your permission\".",
|
||||
"install": "Install",
|
||||
"reinstall": "Reinstall",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"eventInstalled": "Installed",
|
||||
"eventMissing": "Not installed",
|
||||
"unsupported": "Antigravity hooks from AgentHub need a POSIX shell, so they are not available on Windows.",
|
||||
"unreadable": "AgentHub cannot read {{path}}: {{error}}",
|
||||
"failed": "Could not change Antigravity hooks: {{message}}"
|
||||
},
|
||||
"toolTab": {
|
||||
"update": "Update…",
|
||||
"usage_one": "{{count}} session · ${{cost}}",
|
||||
"usage_other": "{{count}} sessions · ${{cost}}",
|
||||
"lanes": "Lanes:",
|
||||
"defaultModel": "Default model",
|
||||
"defaultModelHint": "The model a new run in this tool's lanes starts on. The run form can still pick another one.",
|
||||
"saveDefaultModel": "Save default model",
|
||||
"config": "Config files",
|
||||
"configHint": "This tool's own settings files. A save is checked for valid syntax, the old text is backed up in AgentHub's data folder, and nothing is saved if the file changed since you opened it.",
|
||||
"missing": "This file does not exist yet",
|
||||
"save": "Save",
|
||||
"close": "Close",
|
||||
"saved": "Saved. Backup: {{backup}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…"
|
||||
"restartingShort": "Restarting…",
|
||||
"installedTitle": "Version {{version}} is installed but not running"
|
||||
}
|
||||
|
||||
@@ -50,7 +50,9 @@
|
||||
"modified": "Đã sửa",
|
||||
"scopeLabel": "Phạm vi",
|
||||
"noFrontmatter": "Không có frontmatter",
|
||||
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ."
|
||||
"redactedNotice": "Các giá trị khớp /token|secret|password|key|auth/i đã được ẩn ở phía máy chủ.",
|
||||
"preview": "Xem trước",
|
||||
"raw": "Mã nguồn"
|
||||
},
|
||||
"skills": {
|
||||
"name": "Tên",
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
"action.stop": "Dừng",
|
||||
"actionError": "Thao tác làn đường thất bại: {{message}}",
|
||||
"actionErrorUnknown": "Lỗi không xác định",
|
||||
"actions.agentsInstall": "Cài agent",
|
||||
"actions.agentsInstallResult": "Đã cài: {{files}}",
|
||||
"actions.busy": "…",
|
||||
"actions.mcpSync": "Đồng bộ MCP",
|
||||
"actions.mcpSyncResult": "Đã đồng bộ: {{servers}}",
|
||||
@@ -25,14 +23,15 @@
|
||||
"addLaneBranchBusy": "{{branch}} — đang được checkout tại {{path}}",
|
||||
"addLaneBranchExistingHint": "Worktree checkout đúng nhánh này — không tạo nhánh mới, không cần nhánh gốc.",
|
||||
"addLaneBranchExistingLabel": "Nhánh cần checkout",
|
||||
"addLaneBranchHintAuto": "Để trống để dùng tên mặc định hiển thị ở trên.",
|
||||
"addLaneBranchHintAuto": "Để trống để server tự đặt tên nhánh theo tiêu đề.",
|
||||
"addLaneBranchLabel": "Tên nhánh mới",
|
||||
"addLaneBranchModeCurrent": "Nhánh hiện tại",
|
||||
"addLaneBranchModeCurrentHint": "Làm thẳng trên nhánh repo đang mở (main) — không tạo worktree, không fork nhánh. Chuyển sang chế độ Repo.",
|
||||
"addLaneBranchModeExisting": "Nhánh có sẵn",
|
||||
"addLaneBranchModeNew": "Nhánh mới",
|
||||
"addLaneBranchPlaceholder": "feat/tinh-nang-cua-toi",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào — worktree sẽ bắt đầu trống.",
|
||||
"addLaneBranchPlaceholder": "Tự động — đặt theo tiêu đề",
|
||||
"addLaneNoBranches": "Repo này chưa có commit nào nên worktree không có gì để rẽ nhánh. Hãy thêm nó thành lane Repo: commit trước, rồi publish từ bảng Changes.",
|
||||
"addLaneNoBranchesUseRepo": "Dùng làm lane Repo",
|
||||
"addLaneNoFreeBranches": "Mọi nhánh của repo này đều đang nằm trong một worktree. Hãy tạo nhánh mới.",
|
||||
"addLaneExists": "Thư mục này đã có lane: #{{id}} {{title}}. Lane đó đã được chọn; hãy chọn thư mục khác để thêm lane mới.",
|
||||
"addLaneNotARepo": "Chưa phải repo git (hoặc không có quyền đọc).",
|
||||
@@ -46,9 +45,7 @@
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
@@ -56,8 +53,10 @@
|
||||
"addLaneSetupTitle": "Thiết lập",
|
||||
"addLaneTitleLabel": "Tiêu đề",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolDefaultBadge": "Mặc định",
|
||||
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
||||
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
||||
"addLaneRouterNativeTool": "Native ({{tool}})",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
@@ -108,8 +107,13 @@
|
||||
"changes.refresh": "Tải lại",
|
||||
"changes.resize": "Đổi độ rộng khung changes (kế tiếp: {{size}})",
|
||||
"changes.size.compact": "Hẹp",
|
||||
"changes.size.half": "50 / 50",
|
||||
"changes.size.wide": "70 / 30",
|
||||
"changes.size.half": "Vừa",
|
||||
"changes.size.full": "Toàn màn hình",
|
||||
"changes.stageAll": "Stage tất cả ({{count}})",
|
||||
"changes.unstageAll": "Bỏ stage tất cả ({{count}})",
|
||||
"changes.dropEdits": "Bỏ các chỉnh sửa chưa lưu của file này?",
|
||||
"changes.stagedGroup": "Đã stage",
|
||||
"changes.unstagedGroup": "Thay đổi",
|
||||
"changes.stage": "Đưa file này vào index",
|
||||
"changes.title": "Thay đổi",
|
||||
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||
@@ -169,21 +173,21 @@
|
||||
"gitBar.branchDelete": "Xoá",
|
||||
"gitBar.branchDeleteHint": "Xoá nhánh đang chọn (git từ chối nếu còn commit chưa merge)",
|
||||
"gitBar.branchNewPlaceholder": "Tên nhánh mới…",
|
||||
"gitBar.cancel": "Huỷ",
|
||||
"gitBar.commit": "Commit",
|
||||
"gitBar.commitAll_one": "Commit {{count}} file",
|
||||
"gitBar.commitAll_other": "Commit {{count}} file",
|
||||
"gitBar.commitHint": "Stage tất cả rồi commit",
|
||||
"gitBar.commitNothing": "Không có gì để commit",
|
||||
"gitBar.commitPlaceholder": "Nội dung commit",
|
||||
"gitBar.commitStaged": "Commit phần đã stage",
|
||||
"gitBar.commitStagedHint": "Chỉ commit những file đã stage ở thẻ Changes",
|
||||
"gitBar.fetch": "Fetch",
|
||||
"gitBar.fetchHint": "git fetch --all --prune",
|
||||
"gitBar.errNoRemote": "Không có remote để push hoặc pull. Hãy thêm remote, hoặc đặt upstream cho nhánh này, trong terminal.",
|
||||
"gitBar.inSync": "đã đồng bộ",
|
||||
"gitBar.newLane": "Tạo lane mới",
|
||||
"gitBar.newLaneHeld": "Nhánh đã được checkout tại {{path}} — git chỉ cho một worktree trên mỗi nhánh",
|
||||
"gitBar.newLaneHint": "Mở Add lane để tạo worktree anh em trên nhánh đang chọn",
|
||||
"gitBar.noCommits": "chưa có commit",
|
||||
"gitBar.noUpstream": "chưa push",
|
||||
"gitBar.publish": "Publish…",
|
||||
"gitBar.publishApply": "Publish",
|
||||
"gitBar.publishBranch": "Publish nhánh",
|
||||
"gitBar.publishBranchHint": "git push -u origin HEAD — nhánh này chưa có upstream",
|
||||
"gitBar.publishHint": "Thêm remote tên origin rồi push nhánh này lên đó (git remote add origin + git push -u origin HEAD)",
|
||||
"gitBar.publishUrl": "URL remote: https://host/owner/repo.git hoặc git@host:owner/repo.git",
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
@@ -194,6 +198,7 @@
|
||||
"gitBar.switchDirty": "Hãy commit hoặc bỏ các thay đổi chưa commit trước",
|
||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||
"gitBar.switchLabel": "Đổi nhánh…",
|
||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
||||
"kind.adopted": "đã nhận",
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
@@ -213,6 +218,8 @@
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"router.applied": "Đã áp dụng.",
|
||||
"router.native": "Native ({{tool}})",
|
||||
"router.nativeOnly": "{{tool}} chạy bằng cấu hình riêng — hồ sơ AI Router chỉ dùng được với Claude Code và Codex.",
|
||||
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Khởi động lại",
|
||||
@@ -239,5 +246,70 @@
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree"
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"changes.stageHunk": "Stage đoạn này",
|
||||
"changes.undoHunk": "Hoàn tác đoạn này",
|
||||
"changes.undoHunkConfirm": "Bấm lần nữa để hoàn tác đoạn này — không thể khôi phục",
|
||||
"changes.stageHunkShort": "Giữ",
|
||||
"changes.undoHunkShort": "Hoàn tác",
|
||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.clickToEdit": "Bấm vào nội dung để sửa",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
"changes.editorLabel": "Sửa {{path}}",
|
||||
"changes.prevHunk": "Thay đổi trước",
|
||||
"changes.nextHunk": "Thay đổi sau",
|
||||
"changes.hunkOf": "{{n}} / {{total}}",
|
||||
"changes.noEditBinary": "File nhị phân — không sửa được ở đây",
|
||||
"changes.noEditTooBig": "File quá lớn để sửa ở đây (trên 1 MB)",
|
||||
"changes.noEdit": "Không sửa được ở đây",
|
||||
"gitBar.loginTitle": "Git cần đăng nhập {{host}}",
|
||||
"gitBar.loginRejected": "Máy chủ từ chối (rejected) tài khoản hoặc token này.",
|
||||
"gitBar.loginUser": "Tên đăng nhập",
|
||||
"gitBar.loginPassword": "Mật khẩu hoặc access token",
|
||||
"gitBar.loginRemember": "Ghi nhớ cho mọi người dùng AgentHub",
|
||||
"gitBar.loginTokenHint": "Trên Gitea: Settings → Applications → Generate token, cấp quyền đọc/ghi repository.",
|
||||
"gitBar.loginInsecure": "Dashboard đang chạy http thường - token sẽ đi qua mạng không mã hoá.",
|
||||
"gitBar.loginSubmit": "Đăng nhập và thử lại",
|
||||
"gitBar.loginCancel": "Huỷ",
|
||||
"gitBar.merge": "Merge vào nhánh hiện tại",
|
||||
"gitBar.mergeHint": "Merge nhánh đang chọn vào nhánh lane này đang đứng",
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong lane sẽ thay đổi ngay - nếu có agent đang làm việc trong lane này, hãy để nó xong trước.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
"conflict.left": "Còn {{left}}/{{total}} khối conflict",
|
||||
"conflict.prev": "Khối trước",
|
||||
"conflict.next": "Khối sau",
|
||||
"conflict.allOurs": "Giữ hết của mình",
|
||||
"conflict.allTheirs": "Lấy hết bản kia",
|
||||
"conflict.editByHand": "Sửa tay",
|
||||
"conflict.unbalanced": "Dấu conflict không khớp nhau (đã sửa tay?) - hãy dùng Sửa tay.",
|
||||
"conflict.unusable": "Không hiển thị được file này ở đây (nhị phân hoặc quá lớn) - hãy dùng Giữ hết của mình / Lấy hết bản kia, hoặc terminal.",
|
||||
"conflict.keepOurs": "Giữ của mình",
|
||||
"conflict.takeTheirs": "Lấy bản kia",
|
||||
"conflict.keepBoth": "Giữ cả hai",
|
||||
"conflict.chose.ours": "Đã giữ của mình",
|
||||
"conflict.chose.theirs": "Đã lấy bản kia",
|
||||
"conflict.chose.both": "Đã giữ cả hai",
|
||||
"conflict.undo": "Hoàn tác",
|
||||
"conflict.ours": "Của mình ({{label}})",
|
||||
"conflict.theirs": "Bản kia ({{label}})",
|
||||
"conflict.reset": "Huỷ",
|
||||
"conflict.save": "Lưu và đánh dấu đã xong",
|
||||
"changes.takeOursHint": "Giữ bản của mình cho cả file",
|
||||
"changes.takeTheirsHint": "Lấy bản kia cho cả file"
|
||||
}
|
||||
|
||||
@@ -68,5 +68,7 @@
|
||||
"workspace": "Không gian làm việc",
|
||||
"wsEndpoint": "WebSocket",
|
||||
"releaseAvailable": "Có {{version}}",
|
||||
"devBuild": "Bản dev · trước {{count}} commit"
|
||||
"devBuild": "Bản dev · trước {{count}} commit",
|
||||
"installedNotRunning": "Đã cài {{version}}, chưa chạy",
|
||||
"releaseUnchecked": "Không kiểm tra được bản mới"
|
||||
}
|
||||
|
||||
@@ -75,12 +75,13 @@
|
||||
"permissionAcceptEdits": "acceptEdits (sửa file không hỏi)",
|
||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||
"permissionMode": "Permission mode",
|
||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
||||
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…",
|
||||
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
|
||||
"sandbox": "Sandbox",
|
||||
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
||||
"sandboxReadOnly": "Chỉ đọc",
|
||||
@@ -96,6 +97,7 @@
|
||||
"hint": {
|
||||
"headlessExplain": "Headless phù hợp cho tác vụ tự động. Phiên không thể hỏi lại, sẽ treo ở prompt xin quyền nếu bạn không giữ acceptEdits.",
|
||||
"permissionWarning": "bypassPermissions cho phép gọi mọi tool không cần xác nhận. Chỉ dùng cho prompt bạn tin tưởng.",
|
||||
"bypassWarning": "{{tool}} sẽ chạy mọi lệnh và sửa mọi file mà không hỏi. Chỉ dùng với prompt và thư mục bạn tin tưởng.",
|
||||
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox.",
|
||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
||||
},
|
||||
@@ -163,5 +165,11 @@
|
||||
"input": "Input",
|
||||
"label": "Context",
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,5 +38,6 @@
|
||||
"conversation": "Hội thoại",
|
||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||
"transcriptNotFound": "Không tìm thấy bản ghi hội thoại cho agent này. Tệp transcript có thể bị thiếu hoặc chưa được liên kết."
|
||||
}
|
||||
},
|
||||
"toolBadgeTitle": "Ghi lại bởi {{tool}}"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Cài đặt",
|
||||
"subtitle": "Quản lý định giá, thông báo, dữ liệu và hooks",
|
||||
"subtitle": "Quản lý giao diện, công cụ AI, thông báo và dữ liệu",
|
||||
"exportData": "Xuất dữ liệu",
|
||||
"acrossSessions": "Trên toàn bộ phiên đang được theo dõi",
|
||||
"basedOnUsage": "Dựa trên mức dùng token theo mô hình",
|
||||
@@ -23,8 +23,6 @@
|
||||
"resetResult": "Đã đặt lại về {{count}} quy tắc mặc định",
|
||||
"addModel": "Thêm mô hình",
|
||||
"pattern": "Mẫu",
|
||||
"patternPlaceholder": "claude-opus-4%",
|
||||
"namePlaceholder": "Claude Opus 4",
|
||||
"validationRequired": "Bắt buộc nhập mẫu và tên hiển thị",
|
||||
"lastUpdated": "Cập nhật lần cuối: ",
|
||||
"tooltip": {
|
||||
@@ -33,24 +31,44 @@
|
||||
"howItWorksBody": "Khi một phiên báo cáo mức dùng, dashboard tìm quy tắc định giá đầu tiên có mẫu khớp với model id, rồi nhân (token đầu vào / 1 triệu) và (token đầu ra / 1 triệu) với các đơn giá bên dưới để tính chi phí. Quy tắc được duyệt từ trên xuống - quy tắc khớp đầu tiên sẽ được dùng.",
|
||||
"patternsTitle": "Cú pháp mẫu",
|
||||
"patternsBody": "Mẫu là dạng glob kiểu SQL. % là ký tự đại diện khớp với bất kỳ chuỗi nào. Ví dụ: claude-opus-4-7% khớp mọi model id bắt đầu bằng claude-opus-4-7 (ví dụ claude-opus-4-7, claude-opus-4-7-20260101). claude-%-haiku khớp mọi biến thể haiku. Một id chính xác như claude-sonnet-4-6 chỉ khớp với chính nó.",
|
||||
"manualUpdates": "Bạn phải cập nhật giá thủ công",
|
||||
"manualUpdatesBody": "Anthropic công bố thay đổi giá trên website của họ - không có nguồn dữ liệu trực tiếp. Khi có model mới ra mắt hoặc đơn giá thay đổi, hãy chỉnh sửa quy tắc tương ứng (hoặc thêm quy tắc mới ở phía trên quy tắc bao quát) để những phiên ghi nhận sau này được tính phí chính xác. Phiên đã lưu vẫn giữ nguyên giá đã áp dụng tại thời điểm nạp.",
|
||||
"apiPricing": "Chỉ để tham khảo - đơn giá API",
|
||||
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn."
|
||||
"apiPricingBody": "Đây là đơn giá API trả theo lượng dùng. Nếu bạn dùng Claude Code theo gói thuê bao (Pro, Max, Team hoặc Enterprise), chi phí thực tế là phí cố định hàng tháng chứ không tính theo token - nên những con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chi phí ở đây là ước tính mức dùng để tham khảo, không phải hóa đơn.",
|
||||
"synced": "Giá được điền tự động",
|
||||
"syncedBody": "Mọi model Claude, OpenAI và Gemini hiện hành được lấy giá từ bảng giá công khai của LiteLLM, kiểm tra mỗi ngày một lần; model khác cũng có giá ngay khi một phiên dùng tới. Dòng có nhãn Tự động sẽ theo bảng giá đó khi giá thay đổi; dòng bạn sửa trở thành của bạn và không bao giờ bị ghi đè, dòng bạn xoá sẽ không quay lại. Phiên đã lưu giữ nguyên giá đã áp dụng lúc ghi nhận.",
|
||||
"apiPricingOtherBody": "Đây là giá API trả theo mức dùng. Nếu bạn dùng CLI này theo gói thuê bao, chi phí thực là một khoản cố định chứ không tính theo token, nên các con số ở đây thường cao hơn số tiền bạn thực trả. Hãy xem chúng là ước tính mức dùng, không phải hoá đơn."
|
||||
},
|
||||
"cacheWrite5m": "Ghi bộ nhớ đệm (5m)",
|
||||
"cacheWrite1h": "Ghi bộ nhớ đệm (1h)",
|
||||
"fastInput": "Fast Input",
|
||||
"fastOutput": "Fast Output"
|
||||
"fastOutput": "Fast Output",
|
||||
"showAll": "Hiện mọi model (thêm {{count}})",
|
||||
"empty": {
|
||||
"claude": "Chưa có giá model Claude. Nút Khôi phục mặc định sẽ đưa chúng trở lại.",
|
||||
"openai": "Chưa có giá model OpenAI. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Codex dùng model đó, hoặc bạn tự thêm.",
|
||||
"google": "Chưa có giá model Gemini. Giá được thêm tự động từ bảng giá LiteLLM khi một phiên Antigravity dùng model đó, hoặc bạn tự thêm."
|
||||
},
|
||||
"placeholders": {
|
||||
"claude": {
|
||||
"pattern": "claude-opus-4%",
|
||||
"name": "Claude Opus 4"
|
||||
},
|
||||
"openai": {
|
||||
"pattern": "gpt-5%",
|
||||
"name": "GPT 5"
|
||||
},
|
||||
"google": {
|
||||
"pattern": "gemini-3.8-flash%",
|
||||
"name": "Gemini 3.8 Flash"
|
||||
}
|
||||
},
|
||||
"autoBadge": "Tự động",
|
||||
"autoBadgeTitle": "Tự cập nhật theo giá công bố (bảng giá LiteLLM, kiểm tra mỗi ngày). Sửa dòng này để giữ giá của riêng bạn."
|
||||
},
|
||||
"claudeHome": {
|
||||
"title": "CLAUDE_HOME",
|
||||
"description": "Thư mục gốc chứa dữ liệu phiên Claude Code. Thay đổi sẽ cập nhật ngay đường dẫn quét cho transcript và import.",
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"current": "Hiện tại:",
|
||||
"placeholder": "/đường-dẫn/tới/claude-home",
|
||||
"saveFailed": "Cập nhật CLAUDE_HOME thất bại"
|
||||
"routersMissing": "Đã lưu. Các lane này dùng một hồ sơ AI Router mà thư mục mới không có, nên sẽ chạy Native cho đến khi bạn thêm hồ sơ đó ở đây hoặc chọn lại router cho lane: {{lanes}}"
|
||||
},
|
||||
"hooks": {
|
||||
"title": "Cấu hình hook",
|
||||
@@ -63,9 +81,7 @@
|
||||
},
|
||||
"lanesSection": {
|
||||
"title": "Lanes",
|
||||
"description": "Bảo trì toàn máy và cài đặt một lần cho pipeline ship-feature-lane.",
|
||||
"installSkill": "Cài skill ship-feature-lane",
|
||||
"installSkillSuccess": "Đã cài vào {{path}}",
|
||||
"description": "Dọn dẹp lane trên toàn máy.",
|
||||
"runHousekeeping": "Chạy dọn dẹp",
|
||||
"dryRun": "Chạy thử (dry run)",
|
||||
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
|
||||
@@ -452,7 +468,9 @@
|
||||
"dashboard": "Dashboard",
|
||||
"claudeCode": "Claude Code",
|
||||
"codex": "Codex",
|
||||
"aiRouter": "AI Router"
|
||||
"antigravity": "Antigravity",
|
||||
"aiRouter": "AI Router",
|
||||
"gitCredentials": "Git credentials"
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
@@ -496,6 +514,36 @@
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
"description": "Các CLI lập trình AI mà AgentHub chạy được trong lane. Nhận diện qua PATH của máy; tool bị tắt sẽ ẩn khỏi Add lane.",
|
||||
"installed": "Đã cài ({{count}} tool)",
|
||||
"available": "Có thể cài ({{count}})",
|
||||
"refresh": "Làm mới",
|
||||
"noneInstalled": "Chưa tìm thấy AI tool nào trong PATH. Cài một tool rồi bấm Làm mới.",
|
||||
"default": "Mặc định",
|
||||
"setDefault": "Đặt mặc định",
|
||||
"enabled": "Đang bật",
|
||||
"disabled": "Đang tắt",
|
||||
"enabledOf": "Bật {{tool}}",
|
||||
"homepage": "Trang chủ",
|
||||
"homepageOf": "Trang chủ {{tool}}",
|
||||
"install": "Cài đặt",
|
||||
"update": "Cập nhật",
|
||||
"updateAll": "Cập nhật tất cả ({{count}})",
|
||||
"runScript": "Tải {{url}} và chạy bằng bash",
|
||||
"confirmInstall": "Cài {{tool}}?",
|
||||
"confirmUpdate": "Cập nhật {{tool}}?",
|
||||
"confirmUpdateAll": "Cập nhật các AI tool?",
|
||||
"confirmBody": "AgentHub sẽ chạy trên máy này:",
|
||||
"run": "Chạy",
|
||||
"cancel": "Huỷ",
|
||||
"jobRunning": "Đang chạy…",
|
||||
"jobOk": "Xong",
|
||||
"jobFailed": "Lỗi (exit {{code}})",
|
||||
"showLog": "Xem log",
|
||||
"hideLog": "Ẩn log"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Giao diện",
|
||||
"description": "Chế độ màu và theme. Chỉ lưu trong trình duyệt này.",
|
||||
@@ -509,5 +557,87 @@
|
||||
"lightThemes": "Theme sáng",
|
||||
"active": "· đang dùng",
|
||||
"default": "Mặc định"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Tài khoản AgentHub dùng cho git khi fetch, pull, push từ dashboard - mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
"add": "Thêm tài khoản",
|
||||
"replace": "Thay token",
|
||||
"remove": "Xoá",
|
||||
"removeConfirm": "Xoá tài khoản git này? Fetch/pull/push tới host đó từ dashboard sẽ hỏi đăng nhập lại.",
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"fieldHost": "URL máy chủ",
|
||||
"fieldUser": "Tên đăng nhập",
|
||||
"fieldToken": "Mật khẩu hoặc access token",
|
||||
"colHost": "Host",
|
||||
"colUser": "Tên đăng nhập",
|
||||
"colSaved": "Đã lưu",
|
||||
"empty": "Chưa lưu tài khoản git nào. Thanh git sẽ hỏi khi một host cần đăng nhập.",
|
||||
"badUrl": "Nhập máy chủ dạng URL, ví dụ https://git.example.com"
|
||||
},
|
||||
"homeDir": {
|
||||
"save": "Lưu",
|
||||
"saving": "Đang lưu...",
|
||||
"current": "Hiện tại:",
|
||||
"saveFailed": "Không lưu được thư mục",
|
||||
"envOwned": "Biến này được đặt bởi môi trường lúc khởi động dashboard, hoặc bởi file .env của bản checkout, và nguồn đó luôn được ưu tiên mỗi lần khởi động: giá trị lưu ở đây chỉ giữ đến lần khởi động lại tiếp theo."
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi lane codex với thư mục này.",
|
||||
"restartNote": "Lane codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
|
||||
"placeholder": "/đường-dẫn/tới/codex-home",
|
||||
"routersFailed": "Đã lưu, nhưng không ghi lại được layer AI Router cho: {{lanes}}"
|
||||
},
|
||||
"codexHooks": {
|
||||
"title": "Hook của Codex",
|
||||
"description": "Cho Codex báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, \"Codex cần bạn cho phép\" trên thẻ lane và qua thông báo, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ nhật ký của Codex dù có hook hay không.",
|
||||
"notInstalled": "Chưa cài",
|
||||
"untrusted": "Đã cài — đang chờ bạn tin cậy trong Codex",
|
||||
"trusted": "Đã cài và được tin cậy",
|
||||
"trustNote": "Codex chỉ chạy một hook sau khi bạn tin cậy nó. Lần tới Codex khởi động, nó sẽ hỏi bạn xem lại các hook mới: chọn Trust all and continue (hoặc gõ /hooks). Trước đó Codex bỏ qua chúng.",
|
||||
"install": "Cài đặt",
|
||||
"reinstall": "Cài lại",
|
||||
"refresh": "Làm mới",
|
||||
"remove": "Gỡ bỏ",
|
||||
"eventTrusted": "Đã được tin cậy trong Codex",
|
||||
"eventUntrusted": "Chưa được tin cậy trong Codex",
|
||||
"eventMissing": "Chưa cài",
|
||||
"unsupported": "Hook của Codex từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||
"failed": "Không đổi được hook của Codex: {{message}}"
|
||||
},
|
||||
"antigravityHooks": {
|
||||
"title": "Hook của Antigravity",
|
||||
"description": "Cho CLI của Antigravity (agy) báo về AgentHub trong lúc làm việc: từng lần gọi công cụ trong dòng thời gian của phiên, và trạng thái chạy/nghỉ ngay khi một lượt bắt đầu hay kết thúc. Token và chi phí vẫn lấy từ file hội thoại của agy dù có hook hay không.",
|
||||
"notInstalled": "Chưa cài",
|
||||
"installed": "Đã cài",
|
||||
"disabled": "Đã cài nhưng đang tắt trong hooks.json",
|
||||
"note": "agy nạp hook ở lần khởi động tiếp theo và không hỏi tin cậy. File này dùng chung, nên Antigravity IDE cũng chạy hook; AgentHub bỏ qua những gì IDE gửi. agy không có hook cho lời hỏi quyền, nên lane Antigravity không thể hiện \"cần bạn cấp quyền\".",
|
||||
"install": "Cài đặt",
|
||||
"reinstall": "Cài lại",
|
||||
"refresh": "Làm mới",
|
||||
"remove": "Gỡ bỏ",
|
||||
"eventInstalled": "Đã cài",
|
||||
"eventMissing": "Chưa cài",
|
||||
"unsupported": "Hook của Antigravity từ AgentHub cần một shell POSIX nên không dùng được trên Windows.",
|
||||
"unreadable": "AgentHub không đọc được {{path}}: {{error}}",
|
||||
"failed": "Không đổi được hook của Antigravity: {{message}}"
|
||||
},
|
||||
"toolTab": {
|
||||
"update": "Cập nhật…",
|
||||
"usage_one": "{{count}} phiên · ${{cost}}",
|
||||
"usage_other": "{{count}} phiên · ${{cost}}",
|
||||
"lanes": "Lane:",
|
||||
"defaultModel": "Model mặc định",
|
||||
"defaultModelHint": "Model mà một lần chạy mới trong lane của công cụ này dùng. Ô chọn khi chạy vẫn đổi được.",
|
||||
"saveDefaultModel": "Lưu model mặc định",
|
||||
"config": "File cấu hình",
|
||||
"configHint": "Các file cấu hình của chính công cụ này. Khi lưu, cú pháp được kiểm tra, bản cũ được sao lưu vào thư mục dữ liệu của AgentHub, và sẽ không lưu nếu file đã đổi từ lúc mở.",
|
||||
"missing": "File này chưa có",
|
||||
"save": "Lưu",
|
||||
"close": "Đóng",
|
||||
"saved": "Đã lưu. Bản sao lưu: {{backup}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,5 +26,6 @@
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…"
|
||||
"restartingShort": "Đang khởi động lại…",
|
||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user