Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f3735b29 | |||
| a9c71cd905 | |||
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 |
@@ -91,6 +91,11 @@
|
|||||||
# (default: enabled).
|
# (default: enabled).
|
||||||
# AGENTHUB_PRICING_SYNC=0
|
# AGENTHUB_PRICING_SYNC=0
|
||||||
|
|
||||||
|
# The status bar's Usage panel reads Antigravity's quota by running
|
||||||
|
# `agy --print /usage --output-format json` (no model call), which may let agy
|
||||||
|
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
|
||||||
|
# AGENTHUB_AGY_USAGE=0
|
||||||
|
|
||||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||||
# their own connection so they never stall other requests. 0 runs them inline
|
# their own connection so they never stall other requests. 0 runs them inline
|
||||||
# on the event loop instead (default: worker). A job past the timeout answers
|
# on the event loop instead (default: worker). A job past the timeout answers
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# commit-msg hook – runs Commitlint
|
|
||||||
npx --no-install commitlint --edit "$1"
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
- Correctness over cleverness.
|
- Correctness over cleverness.
|
||||||
- Small, scoped, reversible diffs.
|
- Small, scoped, reversible diffs.
|
||||||
- Preserve existing behavior unless change is requested.
|
- Preserve existing behavior unless change is requested.
|
||||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
- 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`.
|
||||||
|
|
||||||
## Workspace stage
|
## Workspace stage
|
||||||
|
|||||||
+2
-2
@@ -386,7 +386,7 @@ graph TD
|
|||||||
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
| `lib/lane-services.js` | (A2) `ensureDatabase`/`dropDatabase` call the profile's `db-create`/`db-drop` hooks — AgentHub stays stack-agnostic on purpose. A state-dir marker file tracks whether a slot's database was already created, since a plain `createdb` can't be re-run safely and AgentHub can't assume the hook is idempotent; this is also how `upLane` knows to seed only a freshly-created database. `dropDatabase` asserts the lane is `managed` and that the name being dropped is one this lane's own slot actually derives (itself or its `_test` sibling) before spawning anything |
|
||||||
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
| `lib/lane-features.js` | (B) Per-feature state and archive. `activateFeature` archives the lane's current active feature (if different) and restores the target's saved stage onto the live `lanes` row — the row stays the one live view every other reader already uses. `canonicalizeSlug` is a DELIBERATELY separate rule from `worktree.js:slugify` (drops a leading `feat/`, keeps `[A-Za-z0-9._-]`, does not lowercase) — the two must never be conflated. `clearLane` (`lib/lanes.js`) archives the active feature (if any) before resetting; a lane that never activated one is unaffected |
|
||||||
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
| `lib/git-diff.js` | (#34/#35) Reads a lane's code changes as structured hunks for the Workspace Changes panel — `work` (HEAD vs the working tree, untracked files included), `branch` (`<base>...HEAD`) or `commit` (one sha), at a caller-chosen `context` (default 3, clamped to 5000, which is how the viewer expands a diff towards the whole file). Also the four reads behind History, the whole-file view, quick-open and the file explorer: `listCommits`, `readFileAt` (the `work` revision is realpath-confined to the lane; anything else goes through `git show`), `listPaths` and `listTree` (both `git ls-files`, so `.gitignore` is honoured without a skip-list). Read-only, argv-only through `worktree.js:git()`. An untracked file's patch is SYNTHESIZED from the file itself rather than fetched with `git diff --no-index`, which exits non-zero on a difference and would read as a failure. Three caps (300 files, 200 KB per file, 1 MB per full read) keep a lane mid-build from shipping megabytes of patch into a browser; a cap that bit sets `truncated` |
|
||||||
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (`reset` stays behind worktree.js's three-check destroy guard). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
| `lib/git-ops.js` | (#34) The git WRITES a lane offers: `fetch`, `pull`, `push`, `stage`, `unstage`, `discard`, `stage-hunk`/`discard-hunk` (one hunk, matched by its `@@` line against a fresh diff — or one `block`, a run of +/- lines inside it, the others folded into context by `onlyBlock` in `git-diff.js` (#50); `discard-hunk` needs `confirm:true`), `commit` (everything, or `only:"staged"`), `switch`, `branch-create` (never switches) and `branch-delete` (`-d`, refusing `main`/`master`/`development`/`develop`, the current branch and one another worktree holds), `publish` (`remote add origin -- <url>` + `push -u origin HEAD`, only when the repo has NO remote and a commit; the one write whose destination comes from the request, so the URL is allowlisted by `validateRemoteUrl` — `https://` without userinfo, `ssh://`, scp-like `user@host:path` — and `origin` is removed again when the push fails), and `merge`/`resolve`/`merge-commit`/`merge-abort` (issue #45: a named branch into a clean repo-root lane, conflicts resolved per file, abort behind `confirm:true`), `cherry-pick`/`revert` (a non-merge commit by sha, same guards and conflict flow — `merge-commit`/`merge-abort` finish whichever of the three is open, `operationInProgress` in worktree.js), and `undo-commit` (`reset --soft HEAD~1`, `confirm:true`) / `amend`, both refused once a remote-tracking branch holds HEAD (`EPUSHED`). A caller-supplied path is validated against `git status` before it reaches an argv, so only a path git already reports as changed is reachable; `discard` — the one irreversible action — additionally requires `confirm: true`. No rebase, hard reset or stash, because a dashboard button is the wrong place to start an operation whose recovery needs a terminal (a hard `reset` stays behind worktree.js's three-check destroy guard; `undo-commit`'s soft reset moves only an unpushed branch tip and keeps every change staged). `pull` is ALWAYS `--ff-only` and `switch` refuses a dirty tree, so neither can invent a merge commit or a conflict underneath a running agent; `push` pushes `HEAD`, never a branch name from the request. Network actions carry a 60s timeout |
|
||||||
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
| `lib/proof.js` | (C) Proof gallery: lists/serves/deletes QC screenshots grouped by feature slug (Task B's `lane_features`) and phase, under `<lane.cwd>/.playwright-mcp/proof/<slug>/<group>/`. Every path is resolved, realpath'd, and containment-checked before touching `fs` — the entire security surface. `ensureProofLink` ports Shipyard's `ensure_proof_link` (converge a stray clone-root `proof/` onto the canonical dir); never auto-invoked, exposed only as `agenthub lanes proof-link` |
|
||||||
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
| `lib/named-lock.js` | (D) Cross-lane named locks — the OTHER axis from `lib/lane-lock.js`'s per-lane, in-process serialization, deliberately a separate module. `mkdir` is the atomicity primitive (EEXIST decides "already held" in one syscall, never check-then-create). `LOCK_MAX_HOLD` (default 2700s) breaks a stale holder on the next acquire, floored at 300s so the floor — not the configurable default — is the actual safety property: nothing can force-break a live holder by setting the env var low. Single-shot only; the CLI's `agenthub lock acquire` owns the polling loop, keeping the server side non-orchestrating like every other lane primitive |
|
||||||
|
|
||||||
@@ -2224,7 +2224,7 @@ sequenceDiagram
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
||||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
||||||
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production, then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
| **`server/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||||
| **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
| **`Sidebar.tsx`** | The update state and its **Check for updates** button live in the connection details modal the sidebar owns (opened from the status bar; the sidebar has no footer); the status bar shows a green dot beside the version while a release is out. Subscribes to `update_status` (no own fetch). On click: clears dismissed SHA in localStorage, dispatches `dashboard:reset-update-dismissal`, then calls `api.updates.check()`. Visual state: emerald badge when `update_available`, amber when `fetch_error`, neutral otherwise. Label has four resting states, not two — `"<tag> available"`, `"Dev build · N ahead"` (when `dev_build`), `"Up to date"`, or `"Check for updates"` before the first result. |
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ result and `/agenthub-open` to build the UI and get the URL.
|
|||||||
|
|
||||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||||
the server stores data through `node:sqlite`). Full behavior, including the
|
the server stores data through `node:sqlite`). Full behavior, including the
|
||||||
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||||
|
|
||||||
Follow the steps below instead when you want a checkout — to develop the
|
Follow the steps below instead when you want a checkout — to develop the
|
||||||
dashboard, run it from source, or stay on Node 20.
|
dashboard, run it from source, or stay on Node 20.
|
||||||
@@ -323,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
|
|||||||
agenthub service uninstall # disable and remove it
|
agenthub service uninstall # disable and remove it
|
||||||
```
|
```
|
||||||
|
|
||||||
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
|
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
|
||||||
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# AgentHub
|
||||||
|
|
||||||
|
[Tiếng Việt](README.md) · **English**
|
||||||
|
|
||||||
|
A dashboard that runs on your machine to watch and drive your Claude Code (and
|
||||||
|
Codex) sessions from the browser.
|
||||||
|
|
||||||
|
| Section | Tiếng Việt |
|
||||||
|
| --- | --- |
|
||||||
|
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
|
||||||
|
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
|
||||||
|
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
|
||||||
|
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
|
||||||
|
|
||||||
|
<a id="requirements"></a>
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- **Node.js ≥ 22.5**
|
||||||
|
- **git**
|
||||||
|
- **tmux**
|
||||||
|
|
||||||
|
To install them:
|
||||||
|
|
||||||
|
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
|
||||||
|
- macOS: `brew install node git tmux`
|
||||||
|
|
||||||
|
<a id="install-claude-code"></a>
|
||||||
|
|
||||||
|
## Option 1 — Install through Claude Code (recommended)
|
||||||
|
|
||||||
|
Send **each line on its own** straight into the Claude Code chat:
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin install agenthub@agenthub
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
/agenthub:agenthub-open
|
||||||
|
```
|
||||||
|
|
||||||
|
The last one prints the dashboard's address (by default <http://localhost:4820>) —
|
||||||
|
open it in the browser and you are done.
|
||||||
|
|
||||||
|
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||||
|
To update to a new version: `/agenthub:agenthub-update`.
|
||||||
|
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
|
||||||
|
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
|
||||||
|
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
|
||||||
|
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
|
||||||
|
|
||||||
|
<a id="install-terminal"></a>
|
||||||
|
|
||||||
|
## Option 2 — Install from a terminal (without Claude Code)
|
||||||
|
|
||||||
|
Run these in a terminal, one after another:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
cd AgentHub
|
||||||
|
npm run setup # install the dependencies + the agenthub command
|
||||||
|
npm run build # build the UI
|
||||||
|
npm start # run the dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:4820> in the browser.
|
||||||
|
|
||||||
|
`npm start` stops when the terminal closes. To have the dashboard start every time
|
||||||
|
the machine boots:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agenthub service install
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Pick **only one** of the two options. With both installed, every event is
|
||||||
|
> counted twice.
|
||||||
|
|
||||||
|
<a id="more-docs"></a>
|
||||||
|
|
||||||
|
## More docs
|
||||||
|
|
||||||
|
- Vietnamese README: [README.md](README.md)
|
||||||
|
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
|
||||||
|
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
|
||||||
|
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
|
||||||
|
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||||
|
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
|
||||||
|
|
||||||
|
Internal build — all rights reserved.
|
||||||
@@ -1,366 +1,94 @@
|
|||||||
# AgentHub
|
# AgentHub
|
||||||
|
|
||||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
**Tiếng Việt** · [English](README.en.md)
|
||||||
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; **workspaces** track
|
|
||||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
|
||||||
sessions, workspace runtimes, and Claude Code's own configuration.
|
|
||||||
|
|
||||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
|
||||||
every decision.
|
(và Codex) ngay trên trình duyệt.
|
||||||
|
|
||||||
Internal build — all rights reserved.
|
| Phần | English |
|
||||||
|
| --- | --- |
|
||||||
|
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
|
||||||
|
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
|
||||||
|
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
|
||||||
|
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
|
||||||
|
|
||||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
<a id="requirements"></a>
|
||||||
|
|
||||||
## Main features
|
## Yêu cầu
|
||||||
|
|
||||||
**Observe**
|
- **Node.js ≥ 22.5**
|
||||||
|
- **git**
|
||||||
|
- **tmux**
|
||||||
|
|
||||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
Cài:
|
||||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
|
||||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
|
||||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
|
||||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
|
||||||
targets that fire on them.
|
|
||||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
|
||||||
startup; archives from another machine come in through Settings → Import
|
|
||||||
History (idempotent, never double-counts tokens).
|
|
||||||
- **Remote sources.** Pull session history from other machines over SSH
|
|
||||||
(`agenthub remote-sources`).
|
|
||||||
|
|
||||||
**Drive**
|
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
|
||||||
|
- macOS: `brew install node git tmux`
|
||||||
|
|
||||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
<a id="install-claude-code"></a>
|
||||||
install a pipeline template (none ships), a workspace moves through its stages and
|
|
||||||
the dashboard shows where it is.
|
|
||||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
|
||||||
tool stream, so a session that never calls `agenthub stage` still shows
|
|
||||||
progress — rendered dashed amber and never as done, because an inference is
|
|
||||||
not evidence.
|
|
||||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
|
||||||
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
|
||||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
|
||||||
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
|
||||||
setting. **This is the part that needs `tmux` installed** — see
|
|
||||||
[Prerequisites](#prerequisites).
|
|
||||||
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
|
||||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
|
||||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
|
||||||
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
|
||||||
changed files, a full review of every diff, or one file at a time, comparing
|
|
||||||
either uncommitted work or the whole branch against its base. There is no
|
|
||||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
|
||||||
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
|
||||||
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
|
||||||
|
|
||||||
**Configure**
|
## Cách 1 — Cài qua Claude Code (khuyên dùng)
|
||||||
|
|
||||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
|
||||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
|
||||||
other its conversation with a prompt box — sending a message resumes it,
|
|
||||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
|
||||||
prompt box that starts a new session, Recents, and the workspace's own
|
|
||||||
Instructions, Memory, Context files (picked with + or dragged in from the OS)
|
|
||||||
and extra Folders, which Claude and Codex sessions start with. Files and
|
|
||||||
images dragged onto a terminal or a prompt box are uploaded and attached by
|
|
||||||
their saved path.
|
|
||||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
|
||||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
|
||||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
|
||||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
|
||||||
untracked worktrees a workspace's Add folder suggests.
|
|
||||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
|
||||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
|
||||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
|
||||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
|
||||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
|
||||||
`settings.json` stay read-only: they race with the live CLI.
|
|
||||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
|
||||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
|
||||||
light: Default, Apple Light, Canvas, Orca Light. Each mode
|
|
||||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
|
||||||
Per-browser only.
|
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
|
||||||
Pop, Alert — off by default) played with every notification shown, and each
|
|
||||||
notification type can pick its own.
|
|
||||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
|
||||||
a UI in English, Vietnamese, Chinese and Korean.
|
|
||||||
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
|
||||||
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
|
|
||||||
Claude Code statusline with session and today's cost
|
|
||||||
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
|
||||||
Prometheus rules + Grafana boards for `/api/metrics`
|
|
||||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
|
||||||
- **Server mode for a shared dashboard** (`agenthub server setup`): a login on
|
|
||||||
every page and API call, with admin / member / analyst roles. It is off by
|
|
||||||
default, and a local install does not change. It is login only, without
|
|
||||||
per-user isolation yet ([docs/SERVER-MODE.md](docs/SERVER-MODE.md)).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
| Need | Version | Required for |
|
|
||||||
|---|---|---|
|
|
||||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
|
||||||
| **npm** | 9+ | Ships with Node |
|
|
||||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
|
||||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
|
||||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
|
||||||
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
|
||||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
|
||||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
|
||||||
|
|
||||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
|
||||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
|
||||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
|
||||||
v26.8.1).
|
|
||||||
|
|
||||||
### Installing tmux
|
|
||||||
|
|
||||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
|
||||||
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
|
||||||
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install tmux # Debian / Ubuntu
|
|
||||||
sudo dnf install tmux # Fedora / RHEL
|
|
||||||
brew install tmux # macOS
|
|
||||||
sudo pacman -S tmux # Arch
|
|
||||||
```
|
|
||||||
|
|
||||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
|
||||||
|
|
||||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
|
||||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
|
||||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
|
||||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
|
||||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
|
||||||
|
|
||||||
Verify, both from your shell and from the server's point of view:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -V # e.g. tmux 3.4
|
|
||||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
|
||||||
```
|
|
||||||
|
|
||||||
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
|
||||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
|
||||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
|
||||||
dashboard never kills the run.
|
|
||||||
|
|
||||||
## Install as a Claude Code plugin
|
|
||||||
|
|
||||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
|
||||||
`npm run setup`:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
/plugin install agenthub@agenthub
|
/plugin install agenthub@agenthub
|
||||||
```
|
```
|
||||||
|
|
||||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
```
|
||||||
PATH; it runs detached, so the session never waits on
|
/agenthub:agenthub-open
|
||||||
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
|
||||||
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
|
||||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
|
||||||
Details, including what to delete on uninstall:
|
|
||||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
|
||||||
|
|
||||||
## Install from a checkout
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run setup # root and client dependencies
|
|
||||||
npm run build # builds the client into client/dist
|
|
||||||
npm start # serves the built client and the API on :4820
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open <http://localhost:4820>.
|
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
|
||||||
|
trên trình duyệt là xong.
|
||||||
|
|
||||||
Development, with hot reload:
|
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||||
|
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||||
|
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
|
||||||
|
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
|
||||||
|
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
|
||||||
|
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
|
||||||
|
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
|
||||||
|
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
|
||||||
|
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
|
||||||
|
|
||||||
|
<a id="install-terminal"></a>
|
||||||
|
|
||||||
|
## Cách 2 — Cài bằng terminal (không qua Claude Code)
|
||||||
|
|
||||||
|
Chạy lần lượt trong terminal:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # server on :4820, Vite client on :5173
|
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||||
|
cd AgentHub
|
||||||
|
npm run setup # cài thư viện + lệnh agenthub
|
||||||
|
npm run build # build giao diện
|
||||||
|
npm start # chạy dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
Mở <http://localhost:4820> trên trình duyệt.
|
||||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
|
||||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
|
||||||
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
|
||||||
every event is counted twice.
|
|
||||||
|
|
||||||
### Run as a service (auto-start on boot)
|
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
|
||||||
|
|
||||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
|
||||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
|
||||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
|
||||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
|
||||||
to trigger it:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # the service serves the built client, same as npm start
|
agenthub service install
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
|
||||||
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. The same is a switch in the dashboard — Settings → About →
|
|
||||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
|
||||||
unit and never stops the running server). Plugin installs get it only once
|
|
||||||
that switch is on — see
|
|
||||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
|
||||||
|
|
||||||
## The CLI
|
<a id="more-docs"></a>
|
||||||
|
|
||||||
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
## Tài liệu thêm
|
||||||
|
|
||||||
```bash
|
- README tiếng Anh: [README.en.md](README.en.md)
|
||||||
agenthub status # is the dashboard up
|
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
|
||||||
agenthub start # start it in the background and wait for healthy
|
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
|
||||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
|
||||||
agenthub sessions # recent sessions
|
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||||
agenthub workspaces # workspaces with stage and progress
|
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
|
||||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
|
||||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
|
||||||
agenthub stage <name> # declare the current lane's stage
|
|
||||||
agenthub tail # live event feed
|
|
||||||
```
|
|
||||||
|
|
||||||
`agenthub --help` lists the rest.
|
Internal build — all rights reserved.
|
||||||
|
|
||||||
## Workspaces
|
|
||||||
|
|
||||||
A workspace is a working directory the dashboard watches. Two kinds:
|
|
||||||
|
|
||||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
|
||||||
never reset or deleted.
|
|
||||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
|
||||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
|
||||||
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
|
||||||
dashboard's record of it: the working tree, its branch and its database stay,
|
|
||||||
for you to delete yourself.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
|
||||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
|
||||||
```
|
|
||||||
|
|
||||||
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
|
||||||
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
|
||||||
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
|
||||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
|
||||||
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
|
||||||
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
|
||||||
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
|
||||||
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
|
||||||
it asks you to trust them (**Trust all and continue**), and from then on each
|
|
||||||
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
|
||||||
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
|
||||||
card and as a notification while Codex waits for an approval. A Codex workspace also
|
|
||||||
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
|
||||||
advanced; without hooks the dashboard mirrors its running/idle status from the
|
|
||||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
|
||||||
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
|
||||||
|
|
||||||
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
|
||||||
usage are read from agy's own conversation files
|
|
||||||
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
|
||||||
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
|
||||||
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
|
||||||
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
|
||||||
the same tab, add a live feed: each tool call in the session's timeline and a
|
|
||||||
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
|
||||||
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
|
||||||
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
|
||||||
on an API error or a permission refused in headless mode, so a session gone
|
|
||||||
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
|
||||||
for a session the hooks report on (a long tool call writes nothing, and after a
|
|
||||||
Stop agy may still be waiting for you), after two minutes for one without
|
|
||||||
hooks. Antigravity workspaces carry no pipeline either.
|
|
||||||
|
|
||||||
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
|
||||||
read from Cursor's agent transcripts
|
|
||||||
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
|
||||||
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
|
||||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
|
||||||
cost.
|
|
||||||
|
|
||||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
|
||||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
|
||||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
|
||||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
|
||||||
Further development is planned.
|
|
||||||
|
|
||||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
|
||||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
|
||||||
move backwards between work sessions. Detection never writes the declared stage,
|
|
||||||
and an inferred node never renders as done.
|
|
||||||
|
|
||||||
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
|
||||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
|
||||||
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
|
||||||
current declared stage against the new template's nodes and warn if it no
|
|
||||||
longer matches one.
|
|
||||||
|
|
||||||
A workspace can also run **its own application stack**, isolated per workspace, when its
|
|
||||||
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
|
||||||
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
|
||||||
ports and per-workspace directories derive from it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
agenthub workspace up # boot the stack of the lane owning this directory
|
|
||||||
agenthub workspace runtime # slot, ports, service health
|
|
||||||
agenthub workspace logs api # tail a service log
|
|
||||||
agenthub workspace down
|
|
||||||
```
|
|
||||||
|
|
||||||
Services are fully detached, so restarting the dashboard never stops a running
|
|
||||||
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
|
||||||
same user and share the network.
|
|
||||||
|
|
||||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
|
||||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
|
||||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
|
||||||
`POST /:id/git/:action`), and the full
|
|
||||||
runtime/profile contract.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run test:server # node:test
|
|
||||||
npm run test:client # Vitest
|
|
||||||
```
|
|
||||||
|
|
||||||
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
|
||||||
runs Prettier, then the tests the commit needs: both full suites for a big or
|
|
||||||
risky commit (over 10 code files, a dependency, build/test config or the hook
|
|
||||||
itself, a deleted file), only the related tests for a small one (`vitest
|
|
||||||
related` for `client/`, the server test files that name each other staged
|
|
||||||
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
|
||||||
both full suites.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
| Path | What |
|
|
||||||
|---|---|
|
|
||||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
|
||||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
|
||||||
| `bin/agenthub.js` | CLI |
|
|
||||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
|
||||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
|
||||||
| `docs/` | Architecture, API, workspaces, database |
|
|
||||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
|
||||||
|
|
||||||
## Docs
|
|
||||||
|
|
||||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
|
||||||
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
|
|
||||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
|
||||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
|
||||||
- [`INSTALL.md`](INSTALL.md)
|
|
||||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
|
||||||
|
|||||||
+123
-7
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const { spawn } = require("node:child_process");
|
const { spawn, spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
// Resolve the repo root relative to this script's REAL location so the CLI
|
// Resolve the repo root relative to this script's REAL location so the CLI
|
||||||
// works both from a checkout (./bin/agenthub.js) and through the global symlink
|
// works both from a checkout (./bin/agenthub.js) and through the global symlink
|
||||||
@@ -1397,14 +1397,36 @@ async function cmdReinstallHooks() {
|
|||||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||||
* the live URL when a server is already up. The child is fully detached with
|
* the live URL when a server is already up. The child is fully detached with
|
||||||
* its output appended to data/agenthub-server.log, so closing this terminal does
|
* its output appended to data/agenthub-server.log, so closing this terminal does
|
||||||
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
|
* not stop the dashboard; stop it later with `agenthub stop`.
|
||||||
* printed and registered in ~/.claude/.agenthub.json).
|
|
||||||
*/
|
*/
|
||||||
async function cmdStart(flags) {
|
async function cmdStart(flags) {
|
||||||
|
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||||
|
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||||
if (await serverIsUp()) {
|
if (await serverIsUp()) {
|
||||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||||
|
// knows the runtime dir, the UI bundle and the autostart unit.
|
||||||
|
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
|
||||||
|
spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
|
||||||
|
{
|
||||||
|
stdio: "inherit",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const deadline = Date.now() + 30_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await serverIsUp()) {
|
||||||
|
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
|
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
|
||||||
if (!fs.existsSync(clientDist)) {
|
if (!fs.existsSync(clientDist)) {
|
||||||
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
|
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
|
||||||
@@ -1447,7 +1469,7 @@ async function cmdStart(flags) {
|
|||||||
console.log(
|
console.log(
|
||||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||||
);
|
);
|
||||||
console.log(c.dim(` Stop it with: kill ${child.pid}`));
|
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tick++;
|
tick++;
|
||||||
@@ -1463,6 +1485,79 @@ async function cmdStart(flags) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
|
||||||
|
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
|
||||||
|
* through an enabled autostart unit (`server/lib/quit.js`).
|
||||||
|
*/
|
||||||
|
async function cmdStop() {
|
||||||
|
if (!(await serverIsUp())) {
|
||||||
|
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
|
||||||
|
console.log(
|
||||||
|
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await post("/api/settings/quit", undefined, { allowError: true });
|
||||||
|
if (res?.status) {
|
||||||
|
// Server mode: a shell on the host carries no login, and Quit is an admin's.
|
||||||
|
if (res.data?.error?.code === "EAUTH" || res.data?.error?.code === "EADMIN") {
|
||||||
|
console.error(
|
||||||
|
`${c.red("✖ This dashboard runs in server mode: Quit needs an admin login.")}\n` +
|
||||||
|
" Log in as an admin and use Settings → System → Quit AgentHub, or stop its service:\n" +
|
||||||
|
` ${c.bold("sudo systemctl stop agenthub")} ${c.dim("# system unit (one Linux user per account)")}\n` +
|
||||||
|
` ${c.bold("systemctl --user stop agenthub")} ${c.dim("# per-user unit")}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const msg = res.data?.error?.message || `HTTP ${res.status}`;
|
||||||
|
console.error(c.red(`✖ POST /api/settings/quit → ${msg}`));
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const { killed } = res;
|
||||||
|
const deadline = Date.now() + 15_000;
|
||||||
|
while ((await serverIsUp()) && Date.now() < deadline) {
|
||||||
|
await new Promise((r) => setTimeout(r, 250));
|
||||||
|
}
|
||||||
|
if (await serverIsUp()) {
|
||||||
|
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uninstall AgentHub (scripts/uninstall.js): without `--yes` it only lists what
|
||||||
|
* would go. Needs no server — it is the thing being removed.
|
||||||
|
*/
|
||||||
|
function cmdUninstall(flags) {
|
||||||
|
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
|
||||||
|
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
|
||||||
|
const uninstall = require(path.join(REPO_ROOT, "scripts", "uninstall.js"));
|
||||||
|
if (flags.yes !== true) {
|
||||||
|
const steps = uninstall.plan();
|
||||||
|
if (!steps.length) return console.log(`${c.dim("○")} Nothing of AgentHub's is left here`);
|
||||||
|
console.log(c.bold("agenthub uninstall --yes would:"));
|
||||||
|
for (const s of steps) {
|
||||||
|
console.log(` ${c.red("✖")} ${s.label}`);
|
||||||
|
for (const i of s.items) console.log(c.dim(` ${i}`));
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
c.dim(
|
||||||
|
" Kept: the AI tools' own conversations, workspace worktrees and branches, your repos."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.error(c.red("Nothing was removed. Re-run with --yes: agenthub uninstall --yes"));
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = uninstall.run({ onLine: (l) => console.log(l) });
|
||||||
|
console.log(c.dim(`Log: ${r.log}`));
|
||||||
|
if (!r.ok) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
||||||
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
||||||
@@ -2731,6 +2826,16 @@ const COMMAND_GROUPS = [
|
|||||||
[
|
[
|
||||||
["status", "", "Up/down indicator for the dashboard server"],
|
["status", "", "Up/down indicator for the dashboard server"],
|
||||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||||
|
[
|
||||||
|
"stop",
|
||||||
|
"",
|
||||||
|
"Quit: close every workspace terminal and stop the server until `agenthub start`",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"uninstall",
|
||||||
|
"[--yes]",
|
||||||
|
"Remove AgentHub from this machine, its database included (lists first without --yes)",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"service",
|
"service",
|
||||||
"install|uninstall|status [--port N]",
|
"install|uninstall|status [--port N]",
|
||||||
@@ -2744,7 +2849,7 @@ const COMMAND_GROUPS = [
|
|||||||
[
|
[
|
||||||
"users",
|
"users",
|
||||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||||
"Server mode's accounts (add: --role admin|analyst|member)",
|
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"accounts",
|
"accounts",
|
||||||
@@ -2948,6 +3053,11 @@ const COMMAND_GROUPS = [
|
|||||||
"",
|
"",
|
||||||
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"tray",
|
||||||
|
"",
|
||||||
|
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
|
||||||
|
],
|
||||||
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
||||||
[
|
[
|
||||||
"statusline install|uninstall",
|
"statusline install|uninstall",
|
||||||
@@ -3881,7 +3991,7 @@ async function cmdUsers(sub, positional, flags) {
|
|||||||
return;
|
return;
|
||||||
case "add": {
|
case "add": {
|
||||||
need();
|
need();
|
||||||
const role = typeof flags.role === "string" ? flags.role : "member";
|
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||||
return;
|
return;
|
||||||
@@ -3925,7 +4035,7 @@ async function cmdUsers(sub, positional, flags) {
|
|||||||
default:
|
default:
|
||||||
console.error(
|
console.error(
|
||||||
c.red(
|
c.red(
|
||||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|member] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | assign <workspace id> <name> | audit [--limit N]"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -4000,6 +4110,10 @@ async function runCommand(argv) {
|
|||||||
return cmdStatus();
|
return cmdStatus();
|
||||||
case "start":
|
case "start":
|
||||||
return cmdStart(flags);
|
return cmdStart(flags);
|
||||||
|
case "stop":
|
||||||
|
return cmdStop();
|
||||||
|
case "uninstall":
|
||||||
|
return cmdUninstall(flags);
|
||||||
case "service":
|
case "service":
|
||||||
return cmdService(positional[0], flags);
|
return cmdService(positional[0], flags);
|
||||||
case "server":
|
case "server":
|
||||||
@@ -4135,6 +4249,8 @@ async function runCommand(argv) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||||
|
case "tray":
|
||||||
|
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
|
||||||
case "open":
|
case "open":
|
||||||
return cmdOpen();
|
return cmdOpen();
|
||||||
case "version":
|
case "version":
|
||||||
|
|||||||
+3
-3
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
|||||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
|
||||||
|
|
||||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ client/
|
|||||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||||
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
|
||||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||||
│ │
|
│ │
|
||||||
@@ -259,7 +259,7 @@ graph TB
|
|||||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||||
Feed[ActivityFeed<br/>streaming event log]
|
Feed[ActivityFeed<br/>streaming event log]
|
||||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
|
||||||
|
|
||||||
App --> Layout
|
App --> Layout
|
||||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||||
|
|||||||
Generated
+629
-548
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"@codemirror/view": "^6.43.13",
|
"@codemirror/view": "^6.43.13",
|
||||||
"@fontsource/inter": "^5.2.8",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@fontsource/maple-mono": "^5.3.0",
|
||||||
"@xterm/addon-canvas": "^0.7.0",
|
"@xterm/addon-canvas": "^0.7.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
|
|||||||
const SnapshotView = lazy(() =>
|
const SnapshotView = lazy(() =>
|
||||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||||
);
|
);
|
||||||
|
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -209,6 +210,14 @@ export default function App() {
|
|||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<Users />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/*"
|
path="settings/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ import { fmtCost, timeAgo } from "../lib/format";
|
|||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
|
import { usePermissions } from "./AuthGate";
|
||||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
@@ -284,7 +285,9 @@ export function AlertsNotifications() {
|
|||||||
const { t } = useTranslation("alerts");
|
const { t } = useTranslation("alerts");
|
||||||
const { t: ts } = useTranslation("settings");
|
const { t: ts } = useTranslation("settings");
|
||||||
|
|
||||||
const [tab, setTab] = useState<TabKey>("rules");
|
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||||
|
|
||||||
// Rules
|
// Rules
|
||||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||||
@@ -361,8 +364,8 @@ export function AlertsNotifications() {
|
|||||||
}, [unackedOnly, alerts.length, t]);
|
}, [unackedOnly, alerts.length, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRules();
|
if (isAdmin) loadRules();
|
||||||
}, [loadRules]);
|
}, [loadRules, isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAlerts();
|
loadAlerts();
|
||||||
@@ -504,7 +507,7 @@ export function AlertsNotifications() {
|
|||||||
(form.rule_type !== "budget" ||
|
(form.rule_type !== "budget" ||
|
||||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||||
|
|
||||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||||
{
|
{
|
||||||
key: "rules",
|
key: "rules",
|
||||||
label: ts("alertsHub.tabRules"),
|
label: ts("alertsHub.tabRules"),
|
||||||
@@ -519,6 +522,7 @@ export function AlertsNotifications() {
|
|||||||
badge: unacked || undefined,
|
badge: unacked || undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -1086,7 +1090,7 @@ export function AlertsNotifications() {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!alert.acknowledged_at && (
|
{!alert.acknowledged_at && alert.can_ack !== false && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onAck(alert.id)}
|
onClick={() => onAck(alert.id)}
|
||||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||||
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||||
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||||
* previous user's data. `useAuth()` gives the sidebar the user and `logout`.
|
* previous user's data. `useAuth()` gives the sidebar the user and `logout`;
|
||||||
|
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -28,6 +29,29 @@ const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout:
|
|||||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||||
export const useAuth = () => useContext(AuthContext);
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
||||||
|
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
||||||
|
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
||||||
|
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
||||||
|
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
||||||
|
* only their own — the one that matters is an analyst looking at another
|
||||||
|
* user's.
|
||||||
|
*/
|
||||||
|
export function usePermissions() {
|
||||||
|
const { state } = useAuth();
|
||||||
|
const user = state.mode === "server" ? state.user : null;
|
||||||
|
const local = state.mode !== "server";
|
||||||
|
const isAdmin = local || user?.role === "admin";
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
seesAll: local || (!!user && user.role !== "developer"),
|
||||||
|
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||||
|
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||||
|
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The mode this browser last saw, so a local dashboard paints at once. */
|
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||||
const MODE_KEY = "agenthub.authMode";
|
const MODE_KEY = "agenthub.authMode";
|
||||||
const RETRY_MS = 2000;
|
const RETRY_MS = 2000;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||||
import { usePets } from "./usePets";
|
import { usePets } from "./usePets";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
const ITEM =
|
const ITEM =
|
||||||
@@ -50,6 +51,8 @@ export function PetMenu({
|
|||||||
const bundleInput = useRef<HTMLInputElement>(null);
|
const bundleInput = useRef<HTMLInputElement>(null);
|
||||||
const [choosing, setChoosing] = useState(false);
|
const [choosing, setChoosing] = useState(false);
|
||||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||||
|
// Adding or deleting a pet changes the whole install: an admin's.
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
// Closes on a press outside the menu, or Escape.
|
// Closes on a press outside the menu, or Escape.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -133,6 +136,7 @@ export function PetMenu({
|
|||||||
</button>
|
</button>
|
||||||
{list?.featured.map((f) => {
|
{list?.featured.map((f) => {
|
||||||
const have = installed.get(f.id);
|
const have = installed.get(f.id);
|
||||||
|
if (!have && !isAdmin) return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -173,48 +177,54 @@ export function PetMenu({
|
|||||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||||
{mark(p.id)}
|
{mark(p.id)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{isAdmin && (
|
||||||
type="button"
|
<button
|
||||||
data-testid={`pet-delete-${p.id}`}
|
type="button"
|
||||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
data-testid={`pet-delete-${p.id}`}
|
||||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||||
disabled={busy !== null}
|
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||||
onClick={() => void remove(p)}
|
disabled={busy !== null}
|
||||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
onClick={() => void remove(p)}
|
||||||
>
|
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="my-1.5 border-t border-border" />
|
<div className="my-1.5 border-t border-border" />
|
||||||
<button
|
{isAdmin && (
|
||||||
type="button"
|
<>
|
||||||
role="menuitem"
|
<button
|
||||||
disabled={busy !== null}
|
type="button"
|
||||||
className={ITEM}
|
role="menuitem"
|
||||||
onClick={() => imageInput.current?.click()}
|
disabled={busy !== null}
|
||||||
>
|
className={ITEM}
|
||||||
{busy === "image" ? (
|
onClick={() => imageInput.current?.click()}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
>
|
||||||
) : (
|
{busy === "image" ? (
|
||||||
<Upload className="h-4 w-4" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{t("sagi.pet.upload")}
|
<Upload className="h-4 w-4" />
|
||||||
</button>
|
)}
|
||||||
<button
|
{t("sagi.pet.upload")}
|
||||||
type="button"
|
</button>
|
||||||
role="menuitem"
|
<button
|
||||||
disabled={busy !== null}
|
type="button"
|
||||||
className={ITEM}
|
role="menuitem"
|
||||||
onClick={() => bundleInput.current?.click()}
|
disabled={busy !== null}
|
||||||
>
|
className={ITEM}
|
||||||
{busy === "bundle" ? (
|
onClick={() => bundleInput.current?.click()}
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
>
|
||||||
) : (
|
{busy === "bundle" ? (
|
||||||
<PackageOpen className="h-4 w-4" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{t("sagi.pet.import")}
|
<PackageOpen className="h-4 w-4" />
|
||||||
</button>
|
)}
|
||||||
|
{t("sagi.pet.import")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<a
|
<a
|
||||||
href="https://codex-pets.net/"
|
href="https://codex-pets.net/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file PetSprite.tsx
|
* @file PetSprite.tsx
|
||||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||||
|
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||||
|
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||||
|
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||||
|
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||||
|
* Codex's own pet, and a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
|
||||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
|
||||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
|
||||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||||
* back to Sagi instead of an empty box.
|
* back to Sagi instead of an empty box.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
|
|||||||
import type { Mood } from "./brain";
|
import type { Mood } from "./brain";
|
||||||
|
|
||||||
const CELL = { w: 192, h: 208 };
|
const CELL = { w: 192, h: 208 };
|
||||||
const SHEET = { cols: 8, rows: 9 };
|
const COLS = 8;
|
||||||
/** Frames in each row of the Codex atlas. */
|
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
const frames = (n: number, step: number, last: number) =>
|
||||||
|
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||||
|
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||||
|
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||||
|
* how a sleeping pet breathes here. */
|
||||||
|
const DURATIONS = [
|
||||||
|
[280, 110, 110, 140, 140, 320], // idle
|
||||||
|
frames(8, 120, 220), // run right
|
||||||
|
frames(8, 120, 220), // run left
|
||||||
|
frames(4, 140, 280), // waving
|
||||||
|
frames(5, 140, 280), // jumping
|
||||||
|
frames(8, 140, 240), // failed
|
||||||
|
frames(6, 150, 260), // waiting
|
||||||
|
frames(6, 120, 220), // running (working)
|
||||||
|
frames(6, 150, 280), // review
|
||||||
|
];
|
||||||
|
const SLEEP_SLOWDOWN = 6;
|
||||||
|
/** Rows that play three times and settle into idle; the others loop. */
|
||||||
|
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||||
|
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||||
|
const GAZE_ROW = 9;
|
||||||
/** Which row plays for each mood. */
|
/** Which row plays for each mood. */
|
||||||
const ROW: Record<Mood, number> = {
|
const ROW: Record<Mood, number> = {
|
||||||
idle: 0,
|
idle: 0,
|
||||||
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
|
|||||||
/** What the user is doing to the pet, which outranks its mood. */
|
/** What the user is doing to the pet, which outranks its mood. */
|
||||||
export type PetAction = "run-left" | "run-right" | "wave";
|
export type PetAction = "run-left" | "run-right" | "wave";
|
||||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
|
||||||
const PACE: [number, number][] = [
|
|
||||||
[220, 1400], // idle: a breath, then a pause
|
|
||||||
[120, 120], // run right — keeps up with the drag
|
|
||||||
[120, 120], // run left
|
|
||||||
[180, 400], // waving
|
|
||||||
[160, 700], // jumping
|
|
||||||
[200, 1200], // failed
|
|
||||||
[220, 900], // waiting
|
|
||||||
[170, 400], // running (working)
|
|
||||||
[220, 900], // review
|
|
||||||
];
|
|
||||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
|
||||||
|
|
||||||
export function PetSprite({
|
export function PetSprite({
|
||||||
id,
|
id,
|
||||||
kind,
|
kind,
|
||||||
mood,
|
mood,
|
||||||
action,
|
action,
|
||||||
|
gaze,
|
||||||
size,
|
size,
|
||||||
reducedMotion,
|
reducedMotion,
|
||||||
onBroken,
|
onBroken,
|
||||||
@@ -63,6 +73,8 @@ export function PetSprite({
|
|||||||
kind: "codex" | "image";
|
kind: "codex" | "image";
|
||||||
mood: Mood;
|
mood: Mood;
|
||||||
action?: PetAction | null;
|
action?: PetAction | null;
|
||||||
|
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||||
|
gaze?: number | null;
|
||||||
size: number;
|
size: number;
|
||||||
reducedMotion: boolean;
|
reducedMotion: boolean;
|
||||||
onBroken: () => void;
|
onBroken: () => void;
|
||||||
@@ -70,24 +82,30 @@ export function PetSprite({
|
|||||||
const url = api.pets.spriteUrl(id);
|
const url = api.pets.spriteUrl(id);
|
||||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||||
const sleeping = !action && mood === "sleeping";
|
const sleeping = !action && mood === "sleeping";
|
||||||
const [frame, setFrame] = useState(0);
|
// The cell on show, tagged with the row it was played for: a cell left over
|
||||||
|
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||||
|
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||||
|
const [rows, setRows] = useState(9);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFrame(0);
|
setCell({ of: row, row, col: 0 });
|
||||||
if (kind !== "codex" || reducedMotion) return;
|
if (kind !== "codex" || reducedMotion) return;
|
||||||
const count = FRAMES[row] ?? 1;
|
const play = (r: number, slow = 1) =>
|
||||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||||
let f = 0;
|
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||||
|
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||||
|
let i = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
|
// `i` always indexes `list`: it wraps at its end.
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
timer = window.setTimeout(
|
timer = window.setTimeout(() => {
|
||||||
() => {
|
if (++i === list.length) {
|
||||||
f = (f + 1) % count;
|
i = 0;
|
||||||
setFrame(f);
|
if (MOMENTS.has(row)) list = play(0);
|
||||||
tick();
|
}
|
||||||
},
|
setCell(list[i]!);
|
||||||
f === count - 1 ? hold : step
|
tick();
|
||||||
);
|
}, list[i]!.ms);
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
@@ -96,10 +114,13 @@ export function PetSprite({
|
|||||||
// A background image reports no error of its own: probe it once.
|
// A background image reports no error of its own: probe it once.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (kind !== "codex") return;
|
if (kind !== "codex") return;
|
||||||
|
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||||
const probe = new Image();
|
const probe = new Image();
|
||||||
|
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||||
probe.onerror = onBroken;
|
probe.onerror = onBroken;
|
||||||
probe.src = url;
|
probe.src = url;
|
||||||
return () => {
|
return () => {
|
||||||
|
probe.onload = null;
|
||||||
probe.onerror = null;
|
probe.onerror = null;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -120,11 +141,18 @@ export function PetSprite({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const pose = gaze != null && rows > GAZE_ROW && !action && mood === "idle" ? gaze : null;
|
||||||
|
const shown =
|
||||||
|
pose != null
|
||||||
|
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||||
|
: cell.of === row
|
||||||
|
? cell
|
||||||
|
: { row, col: 0 };
|
||||||
const scale = size / CELL.h;
|
const scale = size / CELL.h;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="pet-sprite"
|
data-testid="pet-sprite"
|
||||||
data-row={row}
|
data-row={shown.row}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
width: Math.round(CELL.w * scale),
|
width: Math.round(CELL.w * scale),
|
||||||
@@ -132,8 +160,8 @@ export function PetSprite({
|
|||||||
margin: "0 auto",
|
margin: "0 auto",
|
||||||
backgroundImage: `url("${url}")`,
|
backgroundImage: `url("${url}")`,
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||||
|
* bubble/panel render in a self-clamping flyout so
|
||||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||||
*
|
*
|
||||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||||
@@ -244,6 +245,32 @@ function SagiBody() {
|
|||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
|
||||||
|
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
|
||||||
|
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
|
||||||
|
const btn = useRef<HTMLButtonElement>(null);
|
||||||
|
const [gaze, setGaze] = useState<number | null>(null);
|
||||||
|
const wearsSheet = "kind" in pet && pet.kind === "codex";
|
||||||
|
useEffect(() => {
|
||||||
|
if (!wearsSheet || reducedMotion) return;
|
||||||
|
let rest = 0;
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const r = btn.current?.getBoundingClientRect();
|
||||||
|
if (!r) return;
|
||||||
|
const deg =
|
||||||
|
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
|
||||||
|
Math.PI;
|
||||||
|
setGaze(Math.round((deg + 360) / 22.5) % 16);
|
||||||
|
window.clearTimeout(rest);
|
||||||
|
rest = window.setTimeout(() => setGaze(null), 2000);
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", onMove);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", onMove);
|
||||||
|
window.clearTimeout(rest);
|
||||||
|
};
|
||||||
|
}, [wearsSheet, reducedMotion]);
|
||||||
|
|
||||||
const onNavigate = useCallback(
|
const onNavigate = useCallback(
|
||||||
(route: string) => {
|
(route: string) => {
|
||||||
navigate(route);
|
navigate(route);
|
||||||
@@ -334,6 +361,7 @@ function SagiBody() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
ref={btn}
|
||||||
className="sagi-avatar-btn"
|
className="sagi-avatar-btn"
|
||||||
data-dragging={place.dragging ? "1" : "0"}
|
data-dragging={place.dragging ? "1" : "0"}
|
||||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||||
@@ -368,6 +396,7 @@ function SagiBody() {
|
|||||||
mood={brain.mood}
|
mood={brain.mood}
|
||||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||||
|
gaze={gaze}
|
||||||
size={size}
|
size={size}
|
||||||
reducedMotion={reducedMotion}
|
reducedMotion={reducedMotion}
|
||||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||||
|
|||||||
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
|
|||||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||||
expect(sprite.dataset.row).toBe("7");
|
expect(sprite.dataset.row).toBe("7");
|
||||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||||
act(() => vi.advanceTimersByTime(130));
|
act(() => vi.advanceTimersByTime(110));
|
||||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||||
act(() => vi.advanceTimersByTime(50));
|
act(() => vi.advanceTimersByTime(10));
|
||||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||||
rerender(
|
rerender(
|
||||||
<PetSprite
|
<PetSprite
|
||||||
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
|
|||||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("holds a loop's last frame before starting over", () => {
|
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
|
||||||
render(
|
render(
|
||||||
<PetSprite
|
<PetSprite
|
||||||
id="cp-goku"
|
id="cp-goku"
|
||||||
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const sprite = screen.getByTestId("pet-sprite");
|
const sprite = screen.getByTestId("pet-sprite");
|
||||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
|
||||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||||
act(() => vi.advanceTimersByTime(1000));
|
act(() => vi.advanceTimersByTime(310));
|
||||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||||
act(() => vi.advanceTimersByTime(400));
|
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
|
||||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||||
|
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||||
|
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||||
|
const sprite = screen.getByTestId("pet-sprite");
|
||||||
|
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||||
|
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||||
|
expect(sprite.dataset.row).toBe("4");
|
||||||
|
act(() => vi.advanceTimersByTime(1));
|
||||||
|
expect(sprite.dataset.row).toBe("0");
|
||||||
|
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||||
|
act(() => vi.advanceTimersByTime(10_000));
|
||||||
|
expect(sprite.dataset.row).toBe("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||||
|
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"Image",
|
||||||
|
class {
|
||||||
|
naturalHeight = 2288;
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
set src(_: string) {
|
||||||
|
this.onload?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||||
|
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||||
|
const sprite = screen.getByTestId("pet-sprite");
|
||||||
|
// Pose 12 (270°, left) = row 10, column 4.
|
||||||
|
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||||
|
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||||
|
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||||
|
expect(sprite.dataset.row).toBe("7");
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||||
|
render(
|
||||||
|
<PetSprite
|
||||||
|
id="cp-goku"
|
||||||
|
kind="codex"
|
||||||
|
mood="idle"
|
||||||
|
gaze={4}
|
||||||
|
size={208}
|
||||||
|
reducedMotion={false}
|
||||||
|
onBroken={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||||
|
});
|
||||||
|
|
||||||
it("holds the first frame under reduced motion", () => {
|
it("holds the first frame under reduced motion", () => {
|
||||||
render(
|
render(
|
||||||
<PetSprite
|
<PetSprite
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ export interface SelectOption<T extends string> {
|
|||||||
hint?: string;
|
hint?: string;
|
||||||
/** Optional leading icon, shown in the trigger and the list. */
|
/** Optional leading icon, shown in the trigger and the list. */
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
|
/** Draw a rule above this option: it starts a group of its own. */
|
||||||
|
divider?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for {@link Select}. */
|
/** Props for {@link Select}. */
|
||||||
@@ -272,6 +274,8 @@ export function Select<T extends string>({
|
|||||||
onClick={() => choose(opt)}
|
onClick={() => choose(opt)}
|
||||||
onMouseEnter={() => setActive(idx)}
|
onMouseEnter={() => setActive(idx)}
|
||||||
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
className={`w-full cursor-pointer text-left px-3 py-1.5 transition-colors ${
|
||||||
|
opt.divider ? "mt-1 border-t border-border pt-2" : ""
|
||||||
|
} ${
|
||||||
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
isActive ? "bg-accent/15" : isSelected ? "bg-surface-3" : "hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,10 +70,11 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
UsersRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { useAuth } from "./AuthGate";
|
import { useAuth, usePermissions } from "./AuthGate";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { WorkspaceTree } from "./WorkspaceTree";
|
import { WorkspaceTree } from "./WorkspaceTree";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
@@ -191,6 +192,8 @@ export function Sidebar({
|
|||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
|
// Checking for (and applying) an update acts on the host: an admin's.
|
||||||
|
const canUpdate = usePermissions().isAdmin;
|
||||||
const asideRef = useRef<HTMLElement>(null);
|
const asideRef = useRef<HTMLElement>(null);
|
||||||
const drawerOpen = Boolean(drawer?.open);
|
const drawerOpen = Boolean(drawer?.open);
|
||||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||||
@@ -464,7 +467,15 @@ export function Sidebar({
|
|||||||
user knows there's more to reach without inspecting the scrollbar. */}
|
user knows there's more to reach without inspecting the scrollbar. */}
|
||||||
<div className="flex-1 min-h-0 relative flex">
|
<div className="flex-1 min-h-0 relative flex">
|
||||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
{[
|
||||||
|
...NAV_KEYS,
|
||||||
|
// Server mode: the Users page, for an admin or analyst.
|
||||||
|
...(auth.state.mode === "server" &&
|
||||||
|
auth.state.user &&
|
||||||
|
auth.state.user.role !== "developer"
|
||||||
|
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||||
|
: []),
|
||||||
|
].map(({ to, icon: Icon, key }) => {
|
||||||
const label = t(key);
|
const label = t(key);
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -584,7 +595,7 @@ export function Sidebar({
|
|||||||
checking,
|
checking,
|
||||||
available: updateAvailable,
|
available: updateAvailable,
|
||||||
failed: checkError,
|
failed: checkError,
|
||||||
onCheck: onCheckUpdates,
|
onCheck: canUpdate ? onCheckUpdates : undefined,
|
||||||
}}
|
}}
|
||||||
onResetStats={() => {
|
onResetStats={() => {
|
||||||
eventCountRef.current = 0;
|
eventCountRef.current = 0;
|
||||||
@@ -626,7 +637,8 @@ interface ConnectionStatusModalProps {
|
|||||||
checking: boolean;
|
checking: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
onCheck: () => void;
|
/** Absent for a user who may not update (server mode, not an admin). */
|
||||||
|
onCheck?: () => void;
|
||||||
};
|
};
|
||||||
onResetStats: () => void;
|
onResetStats: () => void;
|
||||||
}
|
}
|
||||||
@@ -768,18 +780,20 @@ function ConnectionStatusModal({
|
|||||||
{update.title}
|
{update.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{update.onCheck && (
|
||||||
type="button"
|
<button
|
||||||
onClick={update.onCheck}
|
type="button"
|
||||||
disabled={update.checking}
|
onClick={update.onCheck}
|
||||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
disabled={update.checking}
|
||||||
>
|
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||||
<RefreshCw
|
>
|
||||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
<RefreshCw
|
||||||
aria-hidden
|
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||||
/>
|
aria-hidden
|
||||||
{t("nav:checkForUpdates")}
|
/>
|
||||||
</button>
|
{t("nav:checkForUpdates")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
|
||||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||||
*
|
*
|
||||||
* ## Testing pointers
|
* ## Testing pointers
|
||||||
@@ -52,11 +51,6 @@
|
|||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
* When behavior changes, update the `@file` overview and relevant tests.
|
||||||
*
|
*
|
||||||
* **TextSkeleton**
|
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
|
||||||
* When behavior changes, update the `@file` overview and relevant tests.
|
|
||||||
*
|
|
||||||
* **TableRowSkeleton**
|
* **TableRowSkeleton**
|
||||||
* Part of this module's public contract. Downstream imports should treat
|
* Part of this module's public contract. Downstream imports should treat
|
||||||
* the signature and return type as stable unless release notes say otherwise.
|
* the signature and return type as stable unless release notes say otherwise.
|
||||||
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
|||||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-line text placeholder. Good for trend/sub labels.
|
|
||||||
*/
|
|
||||||
export function TextSkeleton({
|
|
||||||
className = "",
|
|
||||||
width = "w-16",
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
width?: string;
|
|
||||||
}) {
|
|
||||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||||
* the skeleton lines up under existing headers.
|
* the skeleton lines up under existing headers.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||||
import { api, type RunHandle } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||||
@@ -35,7 +35,15 @@ import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
|||||||
|
|
||||||
const COST_POLL_MS = 30_000;
|
const COST_POLL_MS = 30_000;
|
||||||
const QUOTA_POLL_MS = 5 * 60_000;
|
const QUOTA_POLL_MS = 5 * 60_000;
|
||||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||||
|
claude: "Claude",
|
||||||
|
codex: "Codex",
|
||||||
|
antigravity: "Antigravity",
|
||||||
|
cursor: "Cursor",
|
||||||
|
grok: "Grok",
|
||||||
|
kimi: "Kimi",
|
||||||
|
opencode: "OpenCode Go",
|
||||||
|
};
|
||||||
|
|
||||||
function localDate(d = new Date()): string {
|
function localDate(d = new Date()): string {
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
@@ -123,6 +131,7 @@ const QUOTA_ERROR_KEY: Record<string, string> = {
|
|||||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||||
|
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||||
@@ -133,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
|||||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||||
|
{q.plan && (
|
||||||
|
<span
|
||||||
|
data-testid="status-bar-quota-plan"
|
||||||
|
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||||
|
>
|
||||||
|
{q.plan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{many && q.active !== false && (
|
{many && q.active !== false && (
|
||||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||||
{t("statusBar.quotaActive")}
|
{t("statusBar.quotaActive")}
|
||||||
@@ -145,12 +162,17 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
|||||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||||
{q.windows.map((w) => {
|
{q.windows.map((w, i) => {
|
||||||
const p = Math.round(w.usedPercent ?? 0);
|
const p = Math.round(w.usedPercent ?? 0);
|
||||||
const resets = untilText(w.resetsAt);
|
const resets = untilText(w.resetsAt);
|
||||||
return (
|
return (
|
||||||
<Fragment key={w.label}>
|
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
||||||
|
{w.group && w.group !== q.windows[i - 1]?.group && (
|
||||||
|
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
||||||
|
{w.group}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="text-fg-muted">
|
<span className="text-fg-muted">
|
||||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||||
</span>
|
</span>
|
||||||
@@ -161,16 +183,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
||||||
{resets && (
|
<span className="text-right text-fg-muted">
|
||||||
<>
|
{resets && t("statusBar.quotaResets", { time: resets })}
|
||||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
|
||||||
<span aria-hidden>{resets}</span>
|
|
||||||
<span className="sr-only">
|
|
||||||
{t("statusBar.quotaResets", { time: resets })}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ import { copyText } from "../lib/clipboard";
|
|||||||
import { toast } from "../lib/toast";
|
import { toast } from "../lib/toast";
|
||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
|
import { usePermissions } from "./AuthGate";
|
||||||
|
|
||||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||||
@@ -125,6 +126,7 @@ function loadDismissedSha(): string | null {
|
|||||||
* @returns `null` when no update is available or this release was dismissed.
|
* @returns `null` when no update is available or this release was dismissed.
|
||||||
*/
|
*/
|
||||||
export function UpdateNotifier() {
|
export function UpdateNotifier() {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const { t } = useTranslation("updates");
|
const { t } = useTranslation("updates");
|
||||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||||
@@ -190,7 +192,9 @@ export function UpdateNotifier() {
|
|||||||
status?.latest_release ||
|
status?.latest_release ||
|
||||||
status?.remote_sha ||
|
status?.remote_sha ||
|
||||||
(installed ? `installed:${installed.root}` : null);
|
(installed ? `installed:${installed.root}` : null);
|
||||||
|
// Updating replaces the host's install: only an admin is offered it.
|
||||||
const show = Boolean(
|
const show = Boolean(
|
||||||
|
isAdmin &&
|
||||||
status?.update_available &&
|
status?.update_available &&
|
||||||
dismissKey &&
|
dismissKey &&
|
||||||
dismissedSha !== dismissKey &&
|
dismissedSha !== dismissKey &&
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||||
* live tab is opened from its session row. Live status comes from the shared
|
* live tab is opened from its session row. Live status comes from the shared
|
||||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||||
@@ -28,7 +28,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { Plus, Search } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||||
import type { Lane } from "../lib/types";
|
import type { Lane } from "../lib/types";
|
||||||
import { useLanes } from "../hooks/useLanes";
|
import { useWorkLanes } from "../hooks/useLanes";
|
||||||
import { useAiTools } from "../hooks/useAiTools";
|
import { useAiTools } from "../hooks/useAiTools";
|
||||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||||
import {
|
import {
|
||||||
@@ -70,7 +70,7 @@ export function WorkspaceTree() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const selected = selectedFrom(location.pathname, location.search);
|
const selected = selectedFrom(location.pathname, location.search);
|
||||||
const { lanes, refresh: refreshLanes } = useLanes();
|
const { lanes, refresh: refreshLanes } = useWorkLanes();
|
||||||
const prefs = useProjectTreePrefs();
|
const prefs = useProjectTreePrefs();
|
||||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @file AlertsNotifications.roles.test.tsx
|
||||||
|
* @description Server mode, not an admin: alert rules and channels fire for
|
||||||
|
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
|
||||||
|
* Acknowledge only on alerts the server marks theirs (`can_ack`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
|
||||||
|
const rulesList = vi.fn(async () => ({ rules: [] }));
|
||||||
|
const alert = (id: number, can_ack: boolean) => ({
|
||||||
|
id,
|
||||||
|
rule_id: "r1",
|
||||||
|
rule_name: `rule ${id}`,
|
||||||
|
rule_type: "error_rate",
|
||||||
|
session_id: null,
|
||||||
|
agent_id: null,
|
||||||
|
message: `boom ${id}`,
|
||||||
|
details: null,
|
||||||
|
triggered_at: "2026-10-08T00:00:00.000Z",
|
||||||
|
acknowledged_at: null,
|
||||||
|
can_ack,
|
||||||
|
});
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
alerts: {
|
||||||
|
rules: { list: () => rulesList() },
|
||||||
|
list: vi.fn(async () => ({
|
||||||
|
alerts: [alert(1, true), alert(2, false)],
|
||||||
|
total: 2,
|
||||||
|
unacked: 2,
|
||||||
|
})),
|
||||||
|
ack: vi.fn(),
|
||||||
|
ackAll: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../AuthGate", () => ({
|
||||||
|
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AlertsNotifications } from "../AlertsNotifications";
|
||||||
|
|
||||||
|
describe("AlertsNotifications for a non-admin", () => {
|
||||||
|
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<AlertsNotifications />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("boom 2")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
|
||||||
|
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
|
||||||
|
expect(rulesList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,25 +2,52 @@
|
|||||||
* @file AuthGate.test.tsx
|
* @file AuthGate.test.tsx
|
||||||
* @description The login gate: local mode renders the app untouched, server
|
* @description The login gate: local mode renders the app untouched, server
|
||||||
* mode shows the login page until a login succeeds, a wrong password says so,
|
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||||
* and a later 401 `EAUTH` brings the login page back.
|
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||||
|
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||||
|
* analyst reads everything but works only in their own workspaces, a
|
||||||
|
* developer only ever sees (and so changes) their own.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { act, render, screen } from "@testing-library/react";
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import "../../i18n";
|
import "../../i18n";
|
||||||
|
|
||||||
const me = vi.fn();
|
const me = vi.fn();
|
||||||
const login = vi.fn();
|
const login = vi.fn();
|
||||||
|
const LANES = [
|
||||||
|
{ id: 1, owner_id: 5 },
|
||||||
|
{ id: 2, owner_id: 6 },
|
||||||
|
];
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||||
api: { auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() } },
|
api: {
|
||||||
|
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||||
|
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { AuthGate } from "../AuthGate";
|
import { AuthGate, usePermissions } from "../AuthGate";
|
||||||
|
import { useWorkLanes } from "../../hooks/useLanes";
|
||||||
|
|
||||||
const user = { id: 1, username: "mia", role: "member" };
|
function Probe() {
|
||||||
|
const p = usePermissions();
|
||||||
|
const { lanes } = useWorkLanes();
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
{[
|
||||||
|
`admin:${p.isAdmin}`,
|
||||||
|
`all:${p.seesAll}`,
|
||||||
|
`mine:${p.canEditLane({ owner_id: 5 })}`,
|
||||||
|
`theirs:${p.canEditLane({ owner_id: 6 })}`,
|
||||||
|
`work:${lanes.map((l) => l.id).join(",")}`,
|
||||||
|
].join(" ")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { id: 1, username: "mia", role: "developer" };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
me.mockReset();
|
me.mockReset();
|
||||||
@@ -98,3 +125,37 @@ describe("AuthGate", () => {
|
|||||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("usePermissions / useWorkLanes", () => {
|
||||||
|
const as = async (state: unknown) => {
|
||||||
|
cleanup();
|
||||||
|
me.mockResolvedValue(state);
|
||||||
|
render(
|
||||||
|
<AuthGate>
|
||||||
|
<Probe />
|
||||||
|
</AuthGate>
|
||||||
|
);
|
||||||
|
return (await screen.findByText(/work:\d/)).textContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("lets an admin and local mode do everything", async () => {
|
||||||
|
for (const state of [
|
||||||
|
{ mode: "local" },
|
||||||
|
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
|
||||||
|
]) {
|
||||||
|
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets an analyst read everything but work only in their own workspaces", async () => {
|
||||||
|
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
|
||||||
|
"admin:false all:true mine:true theirs:false work:1"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
|
||||||
|
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
|
||||||
|
"admin:false all:false mine:true theirs:true work:1,2"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function today() {
|
|||||||
|
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
|
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
sessions: {
|
sessions: {
|
||||||
// 3 active sessions, 1 of them waiting on the user.
|
// 3 active sessions, 1 of them waiting on the user.
|
||||||
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
||||||
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
|
|||||||
active: true,
|
active: true,
|
||||||
label: "Work",
|
label: "Work",
|
||||||
account: "me@example.com",
|
account: "me@example.com",
|
||||||
|
plan: "max",
|
||||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||||
windows: [
|
windows: [
|
||||||
{
|
{
|
||||||
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
|
|||||||
windows: [],
|
windows: [],
|
||||||
error: "EUPSTREAM",
|
error: "EUPSTREAM",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "antigravity",
|
||||||
|
active: true,
|
||||||
|
windows: [
|
||||||
|
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
|
||||||
|
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
|
||||||
|
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cursor",
|
||||||
|
active: true,
|
||||||
|
account: "c@example.com",
|
||||||
|
plan: "pro",
|
||||||
|
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
checkedAt: "",
|
checkedAt: "",
|
||||||
}),
|
}),
|
||||||
@@ -214,10 +232,10 @@ describe("StatusBar", () => {
|
|||||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||||
await user.hover(chips);
|
await user.hover(chips);
|
||||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
||||||
expect(work).toHaveTextContent("Workin use");
|
expect(work).toHaveTextContent("Workmaxin use");
|
||||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||||
expect(work).toHaveTextContent("5h95%41m");
|
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||||
expect(work).toHaveTextContent("Week32%");
|
expect(work).toHaveTextContent("Week32%");
|
||||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||||
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
|
|||||||
expect(codex).toHaveTextContent("dev@example.com");
|
expect(codex).toHaveTextContent("dev@example.com");
|
||||||
expect(codex).toHaveTextContent("Sign-in expired");
|
expect(codex).toHaveTextContent("Sign-in expired");
|
||||||
expect(panel).toHaveTextContent("Could not read the quota");
|
expect(panel).toHaveTextContent("Could not read the quota");
|
||||||
|
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
|
||||||
|
expect(chips).toHaveTextContent("Antigravity50% used");
|
||||||
|
expect(cursor).toHaveTextContent("c@example.compro");
|
||||||
|
expect(cursor).toHaveTextContent("Month40%");
|
||||||
|
expect(chips).toHaveTextContent("Cursor40% used");
|
||||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||||
await user.unhover(chips);
|
await user.unhover(chips);
|
||||||
await user.hover(panel);
|
await user.hover(panel);
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
})),
|
})),
|
||||||
cwds: vi.fn(async () => ({ items: [] })),
|
cwds: vi.fn(async () => ({ items: [] })),
|
||||||
kill: vi.fn(async () => ({ ok: true })),
|
kill: vi.fn(async () => ({ ok: true })),
|
||||||
history: vi.fn(async () => ({ items: [] })),
|
|
||||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
|
||||||
},
|
},
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||||
@@ -180,6 +178,10 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||||
await screen.findByText("Session 8");
|
await screen.findByText("Session 8");
|
||||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||||
|
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||||
|
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||||
|
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||||
@@ -309,6 +311,23 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||||
|
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
|
||||||
|
total: 2,
|
||||||
|
limit: 20,
|
||||||
|
offset: 0,
|
||||||
|
}));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderTree();
|
||||||
|
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
|
||||||
|
);
|
||||||
|
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
|
||||||
|
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
|
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
|
||||||
vi.mocked(api.run.list).mockImplementation(
|
vi.mocked(api.run.list).mockImplementation(
|
||||||
async () =>
|
async () =>
|
||||||
@@ -351,97 +370,6 @@ describe("WorkspaceTree", () => {
|
|||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists a workspace's closed terminals, and a click opens a new shell tab", async () => {
|
|
||||||
vi.mocked(api.run.history).mockImplementation(
|
|
||||||
async () =>
|
|
||||||
({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: "agenthub-lane-1-2",
|
|
||||||
tool: "shell",
|
|
||||||
status: "running",
|
|
||||||
started_at: "2026-10-06T10:00:00Z",
|
|
||||||
ended_at: null,
|
|
||||||
isLive: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agenthub-lane-1-3@a",
|
|
||||||
tool: "shell",
|
|
||||||
status: "killed",
|
|
||||||
started_at: "2026-10-06T09:00:00Z",
|
|
||||||
ended_at: "2026-10-06T09:05:00Z",
|
|
||||||
isLive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agenthub-lane-1-1@b",
|
|
||||||
tool: "shell",
|
|
||||||
status: "exited",
|
|
||||||
started_at: "2026-10-06T08:00:00Z",
|
|
||||||
ended_at: "2026-10-06T08:05:00Z",
|
|
||||||
isLive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agenthub-lane-1-1@c",
|
|
||||||
tool: "shell",
|
|
||||||
status: "exited",
|
|
||||||
started_at: "2026-10-06T07:00:00Z",
|
|
||||||
ended_at: "2026-10-06T07:05:00Z",
|
|
||||||
isLive: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "agenthub-lane-1-1@d",
|
|
||||||
tool: "shell",
|
|
||||||
status: "exited",
|
|
||||||
started_at: "2026-10-06T06:00:00Z",
|
|
||||||
ended_at: "2026-10-06T06:05:00Z",
|
|
||||||
isLive: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as never
|
|
||||||
);
|
|
||||||
vi.mocked(api.lanes.action).mockImplementation(
|
|
||||||
async () => ({ ok: true, run_id: "agenthub-lane-1-3" }) as never
|
|
||||||
);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderTree();
|
|
||||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
|
||||||
const closed = await screen.findByTestId("workspace-terminal-closed-agenthub-lane-1-3@a");
|
|
||||||
expect(closed).toHaveTextContent("Terminal");
|
|
||||||
expect(api.run.history).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ laneId: 1, tool: "shell" })
|
|
||||||
);
|
|
||||||
// The live one is not history, and only the newest three are listed.
|
|
||||||
expect(screen.queryByTestId("workspace-terminal-closed-agenthub-lane-1-2")).toBeNull();
|
|
||||||
expect(screen.getAllByTestId(/^workspace-terminal-closed-/)).toHaveLength(3);
|
|
||||||
await user.click(closed);
|
|
||||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { shell: true, slot: "auto" });
|
|
||||||
expect(screen.getByTestId("where").textContent).toBe("/workspace?lane=1&run=agenthub-lane-1-3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a closed terminal from its hover X and its right-click menu", async () => {
|
|
||||||
const shell = (id: string) => ({
|
|
||||||
id,
|
|
||||||
tool: "shell",
|
|
||||||
status: "exited",
|
|
||||||
started_at: "2026-10-06T08:00:00Z",
|
|
||||||
ended_at: "2026-10-06T08:05:00Z",
|
|
||||||
isLive: false,
|
|
||||||
});
|
|
||||||
vi.mocked(api.run.history).mockImplementation(
|
|
||||||
async () => ({ items: [shell("t-a"), shell("t-b")] }) as never
|
|
||||||
);
|
|
||||||
const user = userEvent.setup();
|
|
||||||
renderTree();
|
|
||||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
|
||||||
await user.click(await screen.findByTestId("workspace-terminal-forget-t-a"));
|
|
||||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-a");
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-a")).toBeNull());
|
|
||||||
fireEvent.contextMenu(screen.getByTestId("workspace-terminal-closed-t-b"));
|
|
||||||
await user.click(screen.getByText("Remove from list"));
|
|
||||||
expect(api.run.forgetHistory).toHaveBeenCalledWith("t-b");
|
|
||||||
await waitFor(() => expect(screen.queryByTestId("workspace-terminal-closed-t-b")).toBeNull());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects by URL, and a click selects and expands", async () => {
|
it("selects by URL, and a click selects and expands", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderTree("/workspace?lane=2");
|
renderTree("/workspace?lane=2");
|
||||||
|
|||||||
@@ -76,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
|||||||
interface ConversationViewProps {
|
interface ConversationViewProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
initialTranscriptId?: string | null;
|
initialTranscriptId?: string | null;
|
||||||
|
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||||
|
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||||
|
fill?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
export function ConversationView({
|
||||||
|
sessionId,
|
||||||
|
initialTranscriptId,
|
||||||
|
fill = false,
|
||||||
|
}: ConversationViewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -91,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||||
|
// Scrolled up from the end: a chat (`fill`) offers the way back down.
|
||||||
|
const [awayFromEnd, setAwayFromEnd] = useState(false);
|
||||||
|
|
||||||
// Track JSONL line numbers for incremental requests and history loading
|
// Track JSONL line numbers for incremental requests and history loading
|
||||||
const lastLineRef = useRef(0);
|
const lastLineRef = useRef(0);
|
||||||
@@ -370,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
// Detect if at bottom
|
// Detect if at bottom
|
||||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||||
isAtBottomRef.current = atBottom;
|
isAtBottomRef.current = atBottom;
|
||||||
|
setAwayFromEnd(!atBottom);
|
||||||
|
|
||||||
// Hide "new messages" indicator when scrolled to bottom
|
// Hide "new messages" indicator when scrolled to bottom
|
||||||
if (atBottom) {
|
if (atBottom) {
|
||||||
@@ -390,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
|
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||||
{/* Toolbar - always rendered after the initial load so users can
|
{/* Toolbar - always rendered after the initial load so users can
|
||||||
refresh even when no messages have streamed yet. */}
|
refresh even when no messages have streamed yet. */}
|
||||||
{!loading && (
|
{!loading && !fill && (
|
||||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||||
{transcripts.length > 1 && (
|
{transcripts.length > 1 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -441,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto"
|
className="flex-1 overflow-y-auto"
|
||||||
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||||
>
|
>
|
||||||
{/* History loading indicator */}
|
{/* History loading indicator */}
|
||||||
{loadingHistory && (
|
{loadingHistory && (
|
||||||
@@ -474,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Claude Desktop's round "back to the end" button */}
|
||||||
|
{fill && awayFromEnd && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="conversation-to-end"
|
||||||
|
onClick={() => {
|
||||||
|
scrollToBottom();
|
||||||
|
setShowNewMsg(false);
|
||||||
|
}}
|
||||||
|
aria-label={t("conversation.newMessages")}
|
||||||
|
title={t("conversation.newMessages")}
|
||||||
|
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-4 w-4" />
|
||||||
|
{showNewMsg && (
|
||||||
|
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* New messages indicator */}
|
{/* New messages indicator */}
|
||||||
{showNewMsg && (
|
{showNewMsg && !fill && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
|
|||||||
text: string;
|
text: string;
|
||||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
|
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
|
||||||
|
prose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HEADING_STYLES = [
|
const HEADING_STYLES = [
|
||||||
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
|
|||||||
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
|
||||||
const blocks = parseBlocks(text);
|
const blocks = parseBlocks(text);
|
||||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||||
|
// A paragraph or list item takes the root's size and colour in prose.
|
||||||
|
const bodyText = prose ? "" : "text-sm text-fg-secondary";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
|
<div
|
||||||
|
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
|
||||||
|
>
|
||||||
{blocks.map((b, idx) => {
|
{blocks.map((b, idx) => {
|
||||||
switch (b.kind) {
|
switch (b.kind) {
|
||||||
case "code":
|
case "code":
|
||||||
@@ -420,7 +426,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||||
>
|
>
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
<li key={i} className="text-sm text-fg-secondary">
|
<li key={i} className={bodyText}>
|
||||||
{renderListItem(item, `li${idx}-${i}`)}
|
{renderListItem(item, `li${idx}-${i}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -433,7 +439,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||||
>
|
>
|
||||||
{b.items.map((item, i) => (
|
{b.items.map((item, i) => (
|
||||||
<li key={i} className="text-sm text-fg-secondary">
|
<li key={i} className={bodyText}>
|
||||||
{renderListItem(item, `li${idx}-${i}`)}
|
{renderListItem(item, `li${idx}-${i}`)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
|||||||
|
|
||||||
case "para":
|
case "para":
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
|
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
|
||||||
{renderInline(b.text, `p${idx}`)}
|
{renderInline(b.text, `p${idx}`)}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
|
|||||||
headerText: "text-status-warning",
|
headerText: "text-status-warning",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
import { ToolCallBlock } from "./ToolCallBlock";
|
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||||
import { MarkdownContent } from "./MarkdownContent";
|
import { MarkdownContent } from "./MarkdownContent";
|
||||||
import { fmt, formatModelName } from "../../lib/format";
|
import { fmt, formatModelName } from "../../lib/format";
|
||||||
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
import { parseTuiSegments, stripAnsi, hasTuiTags, type TuiSegment } from "./tuiSegments";
|
||||||
@@ -264,6 +264,60 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A text block of any sender: a task notification or skill body collapsed,
|
||||||
|
* TUI markup as segments, anything else as markdown. */
|
||||||
|
function renderTextBlock(text: string, key: number): React.ReactNode {
|
||||||
|
// Detect task notifications, collapsed by default
|
||||||
|
if (isTaskNotification(text)) {
|
||||||
|
return (
|
||||||
|
<CollapsibleBlock
|
||||||
|
key={key}
|
||||||
|
text={text}
|
||||||
|
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />}
|
||||||
|
title={i18n.t("sessions:conversation.taskNotification")}
|
||||||
|
borderClass="border-syntax-property/20"
|
||||||
|
bgClass="bg-syntax-property/5"
|
||||||
|
textClass="text-syntax-property"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect skill content, collapsed by default
|
||||||
|
if (isSkillContent(text)) {
|
||||||
|
const pathMatch = text.match(/^Base directory for this skill:\s*(\S+)/);
|
||||||
|
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
||||||
|
return (
|
||||||
|
<CollapsibleBlock
|
||||||
|
key={key}
|
||||||
|
text={text}
|
||||||
|
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
||||||
|
title={skillPath}
|
||||||
|
borderClass="border-syntax-builtin/20"
|
||||||
|
bgClass="bg-syntax-builtin/5"
|
||||||
|
textClass="text-syntax-builtin"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
||||||
|
// can appear inline (sometimes interleaved with prose). Parse the text
|
||||||
|
// into segments and render each with the appropriate visual treatment.
|
||||||
|
if (hasTuiTags(text)) {
|
||||||
|
const segments = parseTuiSegments(text);
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-2 min-w-0">
|
||||||
|
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="min-w-0">
|
||||||
|
<MarkdownContent text={stripAnsi(text)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Generic collapsible content block */
|
/** Generic collapsible content block */
|
||||||
function CollapsibleBlock({
|
function CollapsibleBlock({
|
||||||
text,
|
text,
|
||||||
@@ -326,10 +380,46 @@ export function messageKeys(messages: TranscriptMessage[]): string[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One thing on screen, in the Claude Desktop layout: a human message as a
|
||||||
|
* bubble, the assistant's prose bare, and everything it did between two
|
||||||
|
* pieces of prose — tool calls and thinking, across messages (Claude Code
|
||||||
|
* writes each as its own line) — as ONE collapsed line; any other sender
|
||||||
|
* keeps its attributed row. */
|
||||||
|
type Item =
|
||||||
|
| { kind: "event"; key: string; msg: TranscriptMessage }
|
||||||
|
| { kind: "row"; key: string; msg: TranscriptMessage }
|
||||||
|
| { kind: "user"; key: string; msg: TranscriptMessage }
|
||||||
|
| { kind: "text"; key: string; msg: TranscriptMessage; text: string }
|
||||||
|
| { kind: "work"; key: string; blocks: TranscriptContent[] };
|
||||||
|
|
||||||
|
export function chatItems(messages: TranscriptMessage[], keys: string[]): Item[] {
|
||||||
|
const items: Item[] = [];
|
||||||
|
messages.forEach((msg, idx) => {
|
||||||
|
const key = keys[idx]!;
|
||||||
|
if (msg.type === "session_event") return items.push({ kind: "event", key, msg });
|
||||||
|
// Purely tool_result: shown inside its call's block.
|
||||||
|
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) return;
|
||||||
|
const sender = msg.sender ?? (msg.type === "assistant" ? "assistant" : "user");
|
||||||
|
if (sender === "user") return items.push({ kind: "user", key, msg });
|
||||||
|
if (sender !== "assistant") return items.push({ kind: "row", key, msg });
|
||||||
|
msg.content.forEach((block, b) => {
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if (block.type === "tool_use" || (block.type === "thinking" && block.text)) {
|
||||||
|
if (last?.kind === "work") last.blocks.push(block);
|
||||||
|
else items.push({ kind: "work", key: `${key}:${b}`, blocks: [block] });
|
||||||
|
} else if (block.type === "text" && block.text?.trim()) {
|
||||||
|
items.push({ kind: "text", key: `${key}:${b}`, msg, text: block.text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageList({ messages, loading }: MessageListProps) {
|
export function MessageList({ messages, loading }: MessageListProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||||
|
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||||
{messages.map((msg, idx) => {
|
{items.map((item) => {
|
||||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
switch (item.kind) {
|
||||||
// not as a user/assistant row.
|
case "event":
|
||||||
if (msg.type === "session_event") {
|
return (
|
||||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
<SessionEventRow
|
||||||
|
key={item.key}
|
||||||
|
title={item.msg.title}
|
||||||
|
timestamp={item.msg.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "row":
|
||||||
|
return <MessageRow key={item.key} msg={item.msg} toolResultMap={toolResultMap} />;
|
||||||
|
case "user":
|
||||||
|
return <UserBubble key={item.key} msg={item.msg} />;
|
||||||
|
case "text":
|
||||||
|
return <AssistantText key={item.key} msg={item.msg} text={item.text} />;
|
||||||
|
case "work":
|
||||||
|
return <WorkGroup key={item.key} blocks={item.blocks} toolResultMap={toolResultMap} />;
|
||||||
}
|
}
|
||||||
// Skip user messages that are purely tool_result - they're rendered inside ToolCallBlock
|
|
||||||
if (msg.type === "user" && !msg.content.some((c) => c.type === "text")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <MessageRow key={keys[idx]} msg={msg} toolResultMap={toolResultMap} />;
|
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const meta = "text-[10px] font-mono text-fg-muted";
|
||||||
|
|
||||||
|
/** The human's turn: a bubble on the right, its time on hover. A long one (a
|
||||||
|
* paste, a compaction summary) shows its first lines until opened. */
|
||||||
|
const LONG_BUBBLE = 800;
|
||||||
|
const UserBubble = memo(function UserBubble({ msg }: { msg: TranscriptMessage }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const long = msg.content.reduce((n, c) => n + (c.text?.length ?? 0), 0) > LONG_BUBBLE;
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="chat-user"
|
||||||
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 60px" }}
|
||||||
|
className="group flex flex-col items-end gap-1"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] min-w-0 space-y-2 rounded-2xl bg-surface-2 px-4 py-2.5 text-sm text-fg-primary ${
|
||||||
|
long && !open
|
||||||
|
? "max-h-40 overflow-hidden [mask-image:linear-gradient(to_bottom,black_60%,transparent)]"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.content.map((block, b) => {
|
||||||
|
if (block.type !== "text" || !block.text) return null;
|
||||||
|
// Plain typing reads as typed; anything structured keeps its rendering.
|
||||||
|
const plain =
|
||||||
|
!hasTuiTags(block.text) &&
|
||||||
|
!isSkillContent(block.text) &&
|
||||||
|
!isTaskNotification(block.text);
|
||||||
|
return plain ? (
|
||||||
|
<p key={b} className="whitespace-pre-wrap break-words leading-relaxed">
|
||||||
|
{stripAnsi(block.text).trim()}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
renderTextBlock(block.text, b)
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{long && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="text-xs text-fg-muted hover:text-fg-secondary"
|
||||||
|
>
|
||||||
|
{open ? t("conversation.showLess") : t("conversation.showMore")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{msg.timestamp && (
|
||||||
|
<span className={`${meta} opacity-0 transition-opacity group-hover:opacity-100`}>
|
||||||
|
{formatLocalTime(msg.timestamp)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The assistant's prose, bare on the page; model, tokens and time on hover. */
|
||||||
|
const AssistantText = memo(function AssistantText({
|
||||||
|
msg,
|
||||||
|
text,
|
||||||
|
}: {
|
||||||
|
msg: TranscriptMessage;
|
||||||
|
text: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="chat-assistant"
|
||||||
|
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 120px" }}
|
||||||
|
className="group min-w-0"
|
||||||
|
>
|
||||||
|
{hasTuiTags(text) || isSkillContent(text) || isTaskNotification(text) ? (
|
||||||
|
renderTextBlock(text, 0)
|
||||||
|
) : (
|
||||||
|
<MarkdownContent text={stripAnsi(text)} prose />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`${meta} mt-1 flex gap-2 opacity-0 transition-opacity group-hover:opacity-100`}
|
||||||
|
>
|
||||||
|
{msg.model && <span>{formatModelName(msg.model)}</span>}
|
||||||
|
{msg.usage && (
|
||||||
|
<span>
|
||||||
|
↓ {fmt(msg.usage.input_tokens)} · ↑ {fmt(msg.usage.output_tokens)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{msg.timestamp && <span>{formatLocalTime(msg.timestamp)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A muted line that opens to show what it stands for. */
|
||||||
|
function Disclosure({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className="inline-flex items-center gap-1 text-sm text-fg-muted transition-colors hover:text-fg-secondary"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="mt-2 space-y-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The kind of work a tool call is, for the group's label (Claude Code and
|
||||||
|
* Codex names); anything else counts as a tool. */
|
||||||
|
const WORK_KIND: Record<string, string> = {
|
||||||
|
bash: "commands",
|
||||||
|
shell: "commands",
|
||||||
|
exec_command: "commands",
|
||||||
|
powershell: "commands",
|
||||||
|
edit: "edits",
|
||||||
|
multiedit: "edits",
|
||||||
|
write: "edits",
|
||||||
|
notebookedit: "edits",
|
||||||
|
apply_patch: "edits",
|
||||||
|
read: "reads",
|
||||||
|
grep: "searches",
|
||||||
|
glob: "searches",
|
||||||
|
ls: "searches",
|
||||||
|
websearch: "web",
|
||||||
|
webfetch: "web",
|
||||||
|
task: "agents",
|
||||||
|
agent: "agents",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "Ran 3 commands, edited 2 files" — or "Thinking" when it only thought. */
|
||||||
|
export function workLabel(blocks: TranscriptContent[]): string {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (b.type !== "tool_use") continue;
|
||||||
|
const kind = WORK_KIND[(b.name ?? "").toLowerCase()] ?? "tools";
|
||||||
|
counts.set(kind, (counts.get(kind) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
if (!counts.size) return i18n.t("sessions:conversation.thinking");
|
||||||
|
const text = [...counts]
|
||||||
|
.map(([kind, count]) => i18n.t(`sessions:conversation.work.${kind}`, { count }))
|
||||||
|
.join(", ");
|
||||||
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step inside an opened group, as Claude Desktop lists them: its own
|
||||||
|
* description (Claude Code's Bash `description`) or the tool and target as
|
||||||
|
* the title, the call itself under it in a capped scroll box, the result one
|
||||||
|
* click further. Thinking is a step too, closed. */
|
||||||
|
function WorkStep({
|
||||||
|
block,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
block: TranscriptContent;
|
||||||
|
result: TranscriptContent | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const thinking = block.type === "thinking";
|
||||||
|
const [open, setOpen] = useState(!thinking);
|
||||||
|
const [showResult, setShowResult] = useState(false);
|
||||||
|
const input = block.input as Record<string, unknown> | undefined;
|
||||||
|
const title = thinking
|
||||||
|
? t("conversation.thinking")
|
||||||
|
: typeof input?.description === "string" && input.description
|
||||||
|
? input.description
|
||||||
|
: [block.name, buildSummary(block)].filter(Boolean).join(" ");
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
className={`flex w-full min-w-0 items-center gap-1 text-left text-sm transition-colors hover:text-fg-secondary ${
|
||||||
|
result?.is_error ? "text-status-danger" : "text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{thinking ? (
|
||||||
|
<div className="max-h-60 overflow-auto text-fg-muted">
|
||||||
|
<MarkdownContent text={block.text ?? ""} dense />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="max-h-60 overflow-auto">
|
||||||
|
{/* The description is the title already. */}
|
||||||
|
{renderInput(
|
||||||
|
input ? { ...block, input: { ...input, description: undefined } } : block
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{result && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={showResult}
|
||||||
|
onClick={() => setShowResult(!showResult)}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-fg-muted hover:text-fg-secondary"
|
||||||
|
>
|
||||||
|
{result.is_error ? t("conversation.tool.error") : t("conversation.tool.output")}
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3 w-3 transition-transform ${showResult ? "rotate-90" : ""}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{result && showResult && (
|
||||||
|
<div className="max-h-60 overflow-auto">
|
||||||
|
{renderResult(result, block.name ?? "")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the assistant did between two replies as one line, opening onto
|
||||||
|
* one framed list of its steps. */
|
||||||
|
const WorkGroup = memo(function WorkGroup({
|
||||||
|
blocks,
|
||||||
|
toolResultMap,
|
||||||
|
}: {
|
||||||
|
blocks: TranscriptContent[];
|
||||||
|
toolResultMap: ReturnType<typeof buildToolResultMap>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div data-testid="chat-tools">
|
||||||
|
<Disclosure label={workLabel(blocks)}>
|
||||||
|
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||||
|
{blocks.map((block, i) => (
|
||||||
|
<WorkStep
|
||||||
|
key={block.id ?? i}
|
||||||
|
block={block}
|
||||||
|
result={block.id ? (toolResultMap.get(block.id) ?? null) : null}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Disclosure>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
/** One user/assistant row. Memoized: a live tail append re-renders only the
|
||||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||||
const MessageRow = memo(function MessageRow({
|
const MessageRow = memo(function MessageRow({
|
||||||
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
|
|||||||
|
|
||||||
{/* Content blocks */}
|
{/* Content blocks */}
|
||||||
{msg.content.map((block, bIdx) => {
|
{msg.content.map((block, bIdx) => {
|
||||||
if (block.type === "text" && block.text) {
|
if (block.type === "text" && block.text) return renderTextBlock(block.text, bIdx);
|
||||||
// Detect task notifications, collapsed by default
|
|
||||||
if (isTaskNotification(block.text)) {
|
|
||||||
return (
|
|
||||||
<CollapsibleBlock
|
|
||||||
key={bIdx}
|
|
||||||
text={block.text}
|
|
||||||
icon={
|
|
||||||
<ScrollText className="w-3.5 h-3.5 text-syntax-property/70 flex-shrink-0" />
|
|
||||||
}
|
|
||||||
title={t("conversation.taskNotification")}
|
|
||||||
borderClass="border-syntax-property/20"
|
|
||||||
bgClass="bg-syntax-property/5"
|
|
||||||
textClass="text-syntax-property"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect skill content, collapsed by default
|
|
||||||
if (isSkillContent(block.text)) {
|
|
||||||
const pathMatch = block.text.match(/^Base directory for this skill:\s*(\S+)/);
|
|
||||||
const skillPath = pathMatch ? pathMatch[1]! : "Skill";
|
|
||||||
return (
|
|
||||||
<CollapsibleBlock
|
|
||||||
key={bIdx}
|
|
||||||
text={block.text}
|
|
||||||
icon={<ScrollText className="w-3.5 h-3.5 text-syntax-builtin/70 flex-shrink-0" />}
|
|
||||||
title={skillPath}
|
|
||||||
borderClass="border-syntax-builtin/20"
|
|
||||||
bgClass="bg-syntax-builtin/5"
|
|
||||||
textClass="text-syntax-builtin"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mixed TUI markup: caveat / command / stdout / stderr / system-reminder
|
|
||||||
// can appear inline (sometimes interleaved with prose). Parse the text
|
|
||||||
// into segments and render each with the appropriate visual treatment.
|
|
||||||
if (hasTuiTags(block.text)) {
|
|
||||||
const segments = parseTuiSegments(block.text);
|
|
||||||
return (
|
|
||||||
<div key={bIdx} className="space-y-2 min-w-0">
|
|
||||||
{segments.map((s, sIdx) => renderSegment(s, sIdx))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={bIdx} className="min-w-0">
|
|
||||||
<MarkdownContent text={stripAnsi(block.text)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === "thinking" && block.text) {
|
if (block.type === "thinking" && block.text) {
|
||||||
const thinkKey = bIdx;
|
const thinkKey = bIdx;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
||||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
export function buildSummary(toolUse: TranscriptContent): string | null {
|
||||||
const input = toolUse.input;
|
const input = toolUse.input;
|
||||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||||
const obj = input as Record<string, unknown>;
|
const obj = input as Record<string, unknown>;
|
||||||
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
|
|||||||
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
|
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
|
||||||
|
|
||||||
/** Render the input pane with tool-aware formatting. */
|
/** Render the input pane with tool-aware formatting. */
|
||||||
function renderInput(toolUse: TranscriptContent) {
|
export function renderInput(toolUse: TranscriptContent) {
|
||||||
const input = toolUse.input;
|
const input = toolUse.input;
|
||||||
if (!input) return null;
|
if (!input) return null;
|
||||||
|
|
||||||
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Render the result pane: detect diff/json/text. */
|
/** Render the result pane: detect diff/json/text. */
|
||||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
export function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||||
const text = toolResult.output ?? "";
|
const text = toolResult.output ?? "";
|
||||||
if (text.length === 0)
|
if (text.length === 0)
|
||||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file MessageList.sender.test.tsx
|
* @file MessageList.sender.test.tsx
|
||||||
* @description Verifies the transcript renders each message under its TRUE
|
* @description Verifies the transcript renders each message by its TRUE
|
||||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
* layout), Main agent / System under their own label — instead of treating
|
||||||
|
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||||
|
* and folds a run of tool calls into one line.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MessageList } from "../MessageList";
|
import { MessageList, workLabel } from "../MessageList";
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||||
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
|
|||||||
];
|
];
|
||||||
render(<MessageList messages={messages} loading={false} />);
|
render(<MessageList messages={messages} loading={false} />);
|
||||||
|
|
||||||
expect(screen.getByText("User")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
|
||||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
|
||||||
expect(screen.getByText("System")).toBeInTheDocument();
|
expect(screen.getByText("System")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
|
|||||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||||
];
|
];
|
||||||
render(<MessageList messages={messages} loading={false} />);
|
render(<MessageList messages={messages} loading={false} />);
|
||||||
expect(screen.getByText("User")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
|
||||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds consecutive tool calls — one per transcript line — into one line", () => {
|
||||||
|
const call = (id: string) =>
|
||||||
|
msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||||
|
});
|
||||||
|
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||||
|
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
|
||||||
|
render(<MessageList messages={messages} loading={false} />);
|
||||||
|
const group = screen.getAllByTestId("chat-tools");
|
||||||
|
expect(group).toHaveLength(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
|
||||||
|
expect(group[0]).toHaveTextContent("echo b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a mixed run by what it did, like Claude Desktop", () => {
|
||||||
|
const use = (name: string) => ({ type: "tool_use" as const, name });
|
||||||
|
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
|
||||||
|
"Ran 2 commands, edited a file, read a file"
|
||||||
|
);
|
||||||
|
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file AddLaneModal.tsx
|
* @file AddLaneModal.tsx
|
||||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||||
* segmented toggle: "Repo" adopts an existing directory as-is via
|
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||||||
|
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||||
* main repo you want stage detection on); "Worktree" provisions a
|
* main repo you want stage detection on); "Worktree" provisions a
|
||||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||||
@@ -94,7 +95,7 @@ export function AddLaneModal({
|
|||||||
cwdSuggestions: CwdSuggestion[];
|
cwdSuggestions: CwdSuggestion[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||||
const [sourceRepo, setSourceRepo] = useState("");
|
const [sourceRepo, setSourceRepo] = useState("");
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [branch, setBranch] = useState("");
|
const [branch, setBranch] = useState("");
|
||||||
@@ -130,7 +131,7 @@ export function AddLaneModal({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
setMode("worktree");
|
setMode("repo");
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setBranch("");
|
setBranch("");
|
||||||
@@ -151,6 +152,8 @@ export function AddLaneModal({
|
|||||||
if (!open || !prefill) return;
|
if (!open || !prefill) return;
|
||||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||||
if (prefill.branch) {
|
if (prefill.branch) {
|
||||||
|
// A branch only means something to a worktree.
|
||||||
|
setMode("worktree");
|
||||||
setBranch(prefill.branch);
|
setBranch(prefill.branch);
|
||||||
setBranchMode("existing");
|
setBranchMode("existing");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
* file's original text (HEAD, or a branch's merge base), editable while the
|
* file's original text (HEAD, or a branch's merge base), editable while the
|
||||||
* diff re-colours as you type. `unified` is one editor with the removed lines
|
* diff re-colours as you type. `unified` is one editor with the removed lines
|
||||||
* inline above their replacement (`unifiedMergeView`); `split` is the
|
* inline above their replacement (`unifiedMergeView`); `split` is the
|
||||||
* original read-only on the left beside the editable file (`MergeView`).
|
* original read-only on the left beside the editable file, JetBrains-shaped —
|
||||||
|
* two scrollers kept on matching lines, no padding, a curved band per changed
|
||||||
|
* block between them (`splitDiff.ts`).
|
||||||
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
* `collapse` folds unchanged stretches away — the panel's Changes view —
|
||||||
* and every changed block carries a Revert that puts the original back in the
|
* and every changed block carries a Revert that puts the original back in the
|
||||||
* editor (nothing is written until Save). Green marks a line added whole, red
|
* editor (nothing is written until Save). Green marks a line added whole, red
|
||||||
@@ -11,17 +13,39 @@
|
|||||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||||
|
* Given `onBlock`, each changed block also gets the read-only diff's bar under
|
||||||
|
* its last line — Undo (asks twice) and the block's commit checkbox
|
||||||
|
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
|
||||||
|
* the editor holds unsaved text, because git acts on the file on disk.
|
||||||
|
* Given `blame`, a gutter left of the line numbers names each line's last
|
||||||
|
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||||
|
* a function that moves the cursor to the next / previous changed block.
|
||||||
|
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||||
|
* Chat chip at the end of its first line (inline, so it covers no code),
|
||||||
|
* which hands the panel the selection's 1-based lines.
|
||||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Compartment,
|
||||||
|
EditorSelection,
|
||||||
|
EditorState,
|
||||||
|
RangeSetBuilder,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
|
Text,
|
||||||
|
} from "@codemirror/state";
|
||||||
import {
|
import {
|
||||||
Decoration,
|
Decoration,
|
||||||
EditorView,
|
EditorView,
|
||||||
ViewPlugin,
|
ViewPlugin,
|
||||||
|
WidgetType,
|
||||||
drawSelection,
|
drawSelection,
|
||||||
|
gutter,
|
||||||
|
GutterMarker,
|
||||||
highlightActiveLine,
|
highlightActiveLine,
|
||||||
highlightActiveLineGutter,
|
highlightActiveLineGutter,
|
||||||
keymap,
|
keymap,
|
||||||
@@ -30,8 +54,12 @@ import {
|
|||||||
type ViewUpdate,
|
type ViewUpdate,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||||
|
import { chunksOf, splitDiff } from "./splitDiff";
|
||||||
import { highlight, tokenClass } from "../../lib/highlight";
|
import { highlight, tokenClass } from "../../lib/highlight";
|
||||||
|
import type { LaneBlame } from "../../lib/types";
|
||||||
|
|
||||||
|
type Blame = Extract<LaneBlame, { available: true }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when a letter or digit of a line (`text`, starting at document offset
|
* True when a letter or digit of a line (`text`, starting at document offset
|
||||||
@@ -60,14 +88,14 @@ const mark = (cls: string) => {
|
|||||||
* either side of a split view (`a` is the original). */
|
* either side of a split view (`a` is the original). */
|
||||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||||
const out = new RangeSetBuilder<Decoration>();
|
const out = new RangeSetBuilder<Decoration>();
|
||||||
const info = getChunks(view.state);
|
const info = chunksOf(view.state);
|
||||||
const a = info?.side === "a";
|
const a = info?.side === "a";
|
||||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||||
const chunks = info?.chunks ?? [];
|
const chunks = info?.chunks ?? [];
|
||||||
const doc = view.state.doc;
|
const doc = view.state.doc;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
for (let pos = from; pos <= to; ) {
|
for (let pos = from; pos <= to;) {
|
||||||
const line = doc.lineAt(pos);
|
const line = doc.lineAt(pos);
|
||||||
pos = line.to + 1;
|
pos = line.to + 1;
|
||||||
if (line.number <= last) continue;
|
if (line.number <= last) continue;
|
||||||
@@ -139,6 +167,166 @@ const diffColours = (lang: string) =>
|
|||||||
{ decorations: (p) => p.decorations }
|
{ decorations: (p) => p.decorations }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
||||||
|
* removal): what the panel matches against git's own change blocks. */
|
||||||
|
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
||||||
|
export type BlockAction = "include" | "undo";
|
||||||
|
|
||||||
|
/** One Annotate cell: the line's commit date and author, its subject on hover. */
|
||||||
|
class BlameMarker extends GutterMarker {
|
||||||
|
constructor(
|
||||||
|
readonly text: string,
|
||||||
|
readonly title: string
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
override eq(other: BlameMarker) {
|
||||||
|
return other.text === this.text && other.title === this.title;
|
||||||
|
}
|
||||||
|
override toDOM() {
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.textContent = this.text;
|
||||||
|
el.title = this.title;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
|
||||||
|
* lines are matched by number, so unsaved edits above a line shift its
|
||||||
|
* annotation until the file is saved and re-annotated. */
|
||||||
|
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
|
||||||
|
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
|
||||||
|
return gutter({
|
||||||
|
class: "cm-ah-blame",
|
||||||
|
lineMarker(view, line) {
|
||||||
|
const sha = shaAt(view.state, line.from);
|
||||||
|
if (sha === undefined) return null;
|
||||||
|
const c = sha ? blame.commits[sha] : undefined;
|
||||||
|
return c
|
||||||
|
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
|
||||||
|
: new BlameMarker(notCommitted, notCommitted);
|
||||||
|
},
|
||||||
|
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
|
||||||
|
domEventHandlers: {
|
||||||
|
mousedown(view, line) {
|
||||||
|
const sha = shaAt(view.state, line.from);
|
||||||
|
if (sha) onClick(sha);
|
||||||
|
return !!sha;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chunk side's first line and line count. `to` is the start of the line
|
||||||
|
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
||||||
|
const lineSpan = (doc: Text, from: number, to: number): [number, number] => {
|
||||||
|
const first = doc.lineAt(Math.min(from, doc.length)).number;
|
||||||
|
return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
class BlockBar extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly block: EditorBlock,
|
||||||
|
readonly build: (b: EditorBlock) => HTMLElement
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
eq(o: BlockBar) {
|
||||||
|
const a = this.block;
|
||||||
|
const b = o.block;
|
||||||
|
return (
|
||||||
|
a.oldFrom === b.oldFrom &&
|
||||||
|
a.oldCount === b.oldCount &&
|
||||||
|
a.newFrom === b.newFrom &&
|
||||||
|
a.newCount === b.newCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toDOM() {
|
||||||
|
return this.build(this.block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const barsOn = StateEffect.define<boolean>();
|
||||||
|
|
||||||
|
/** One bar per chunk, under its last line (above a pure removal), rebuilt
|
||||||
|
* whenever the chunks are — and dropped while `barsOn` is false. */
|
||||||
|
function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||||
|
const decos = (doc: Text, chunks: readonly Chunk[]) => {
|
||||||
|
const out = new RangeSetBuilder<Decoration>();
|
||||||
|
for (const c of chunks) {
|
||||||
|
const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA);
|
||||||
|
const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB);
|
||||||
|
const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length);
|
||||||
|
out.add(
|
||||||
|
pos,
|
||||||
|
pos,
|
||||||
|
Decoration.widget({
|
||||||
|
block: true,
|
||||||
|
side: newCount ? 1 : -1,
|
||||||
|
widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return out.finish();
|
||||||
|
};
|
||||||
|
return StateField.define<{ on: boolean; deco: DecorationSet }>({
|
||||||
|
create: () => ({ on: false, deco: Decoration.none }),
|
||||||
|
update(v, tr) {
|
||||||
|
let on = v.on;
|
||||||
|
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
||||||
|
const chunks = chunksOf(tr.state)?.chunks;
|
||||||
|
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
|
||||||
|
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
||||||
|
}
|
||||||
|
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatChip extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
readonly start: number,
|
||||||
|
readonly end: number,
|
||||||
|
readonly build: (start: number, end: number) => HTMLElement
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
eq(o: ChatChip) {
|
||||||
|
return this.start === o.start && this.end === o.end;
|
||||||
|
}
|
||||||
|
toDOM() {
|
||||||
|
return this.build(this.start, this.end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add to Chat inline at the end of a non-empty selection's first line —
|
||||||
|
* the empty space right of the code, so it covers no line. `build` makes its
|
||||||
|
* DOM from the selection's first and last 1-based line. A selection ending
|
||||||
|
* at a line's start (a whole-line drag) does not take that line. */
|
||||||
|
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||||
|
return StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
if (!tr.docChanged && !tr.selection) return deco;
|
||||||
|
const { from, to, empty } = tr.state.selection.main;
|
||||||
|
if (empty) return Decoration.none;
|
||||||
|
const doc = tr.state.doc;
|
||||||
|
const first = doc.lineAt(from);
|
||||||
|
const last = doc.lineAt(to);
|
||||||
|
const end = to === last.from && last.number > first.number ? last.number - 1 : last.number;
|
||||||
|
const widget = new ChatChip(first.number, end, build);
|
||||||
|
return Decoration.set(Decoration.widget({ widget, side: 1 }).range(first.to));
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||||
|
const UNDO =
|
||||||
|
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||||
|
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
||||||
|
|
||||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||||
@@ -192,14 +380,21 @@ const theme = EditorView.theme({
|
|||||||
background: "rgb(var(--surface-2))",
|
background: "rgb(var(--surface-2))",
|
||||||
},
|
},
|
||||||
".cm-chunkButtons": { zIndex: "1" },
|
".cm-chunkButtons": { zIndex: "1" },
|
||||||
|
".cm-ah-blame .cm-gutterElement": {
|
||||||
|
color: "rgb(var(--fg-muted))",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "10px",
|
||||||
|
paddingRight: "6px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
* the click. The split layout draws its own » in the gutter. */
|
||||||
function revertButton(label: string, arrow = false) {
|
function revertButton(label: string) {
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
b.textContent = arrow ? "→" : label;
|
b.textContent = label;
|
||||||
b.title = label;
|
b.title = label;
|
||||||
b.setAttribute("aria-label", label);
|
b.setAttribute("aria-label", label);
|
||||||
b.className =
|
b.className =
|
||||||
@@ -217,6 +412,12 @@ export default function DiffEditor({
|
|||||||
revertLabel,
|
revertLabel,
|
||||||
onChange,
|
onChange,
|
||||||
onSave,
|
onSave,
|
||||||
|
onBlock,
|
||||||
|
blockIncluded,
|
||||||
|
blame,
|
||||||
|
onBlameClick,
|
||||||
|
navRef,
|
||||||
|
onAddToChat,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
/** The text the diff is taken against; read once, at mount. */
|
/** The text the diff is taken against; read once, at mount. */
|
||||||
@@ -231,15 +432,85 @@ export default function DiffEditor({
|
|||||||
revertLabel: string;
|
revertLabel: string;
|
||||||
onChange: (text: string) => void;
|
onChange: (text: string) => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
|
||||||
|
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
||||||
|
/** Whether a block is ticked for the commit (its checkbox's state). */
|
||||||
|
blockIncluded?: (block: EditorBlock) => boolean;
|
||||||
|
/** Annotate: each line's last commit; absent = no gutter. */
|
||||||
|
blame?: Blame;
|
||||||
|
onBlameClick?: (sha: string) => void;
|
||||||
|
/** Set to "go to the next (1) / previous (-1) changed block". */
|
||||||
|
navRef?: { current: ((dir: 1 | -1) => void) | null };
|
||||||
|
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
|
||||||
|
onAddToChat?: (start: number, end: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
const host = useRef<HTMLDivElement>(null);
|
const host = useRef<HTMLDivElement>(null);
|
||||||
const view = useRef<EditorView | null>(null);
|
const view = useRef<EditorView | null>(null);
|
||||||
const cb = useRef({ onChange, onSave });
|
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
||||||
cb.current = { onChange, onSave };
|
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
|
||||||
|
const blameSlot = useRef(new Compartment()).current;
|
||||||
|
|
||||||
|
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
||||||
|
* zero height so it never pushes lines (or a split view's alignment). */
|
||||||
|
const buildBar = (block: EditorBlock) => {
|
||||||
|
const tr = cb.current.t;
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "flex h-0";
|
||||||
|
const pin = wrap.appendChild(document.createElement("span"));
|
||||||
|
pin.className = "sticky right-2 ml-auto h-0 w-0 self-start";
|
||||||
|
const bar = pin.appendChild(document.createElement("span"));
|
||||||
|
bar.dataset.testid = "lane-editor-block-bar";
|
||||||
|
bar.className =
|
||||||
|
"absolute right-0 top-0.5 z-10 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";
|
||||||
|
const chip = (text: string, title: string, cls: string) => {
|
||||||
|
const b = bar.appendChild(document.createElement("button"));
|
||||||
|
b.type = "button";
|
||||||
|
b.textContent = text;
|
||||||
|
b.title = title;
|
||||||
|
b.setAttribute("aria-label", title);
|
||||||
|
b.className = `${CHIP} ${cls}`;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
||||||
|
// The block's commit checkbox, a real one so it reads like the diff's.
|
||||||
|
const include = bar.appendChild(document.createElement("input"));
|
||||||
|
include.type = "checkbox";
|
||||||
|
include.checked = cb.current.blockIncluded?.(block) ?? true;
|
||||||
|
include.title = tr("changes.includeBlock");
|
||||||
|
include.setAttribute("aria-label", tr("changes.includeBlock"));
|
||||||
|
include.className = "h-3 w-3 cursor-pointer";
|
||||||
|
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
||||||
|
let timer = 0;
|
||||||
|
const arm = (on: boolean) => {
|
||||||
|
undo.dataset.armed = on ? "1" : "";
|
||||||
|
undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort");
|
||||||
|
const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk");
|
||||||
|
undo.title = title;
|
||||||
|
undo.setAttribute("aria-label", title);
|
||||||
|
undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`;
|
||||||
|
};
|
||||||
|
undo.onclick = () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
if (undo.dataset.armed) {
|
||||||
|
arm(false);
|
||||||
|
cb.current.onBlock?.("undo", block);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
arm(true);
|
||||||
|
timer = window.setTimeout(() => arm(false), 3000);
|
||||||
|
};
|
||||||
|
include.onchange = () => cb.current.onBlock?.("include", block);
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||||
const common = [lineNumbers(), diffColours(lang), theme];
|
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
|
||||||
|
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
|
||||||
|
// would rewrite every line ending.
|
||||||
|
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
|
||||||
|
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
|
||||||
const editing = [
|
const editing = [
|
||||||
...common,
|
...common,
|
||||||
highlightActiveLineGutter(),
|
highlightActiveLineGutter(),
|
||||||
@@ -261,25 +532,41 @@ export default function DiffEditor({
|
|||||||
]),
|
]),
|
||||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||||
EditorView.updateListener.of((u) => {
|
EditorView.updateListener.of((u) => {
|
||||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
|
||||||
}),
|
}),
|
||||||
|
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
|
||||||
|
blameSlot.of([]),
|
||||||
|
...(onAddToChat
|
||||||
|
? [
|
||||||
|
chatChip((start, end) => {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.dataset.testid = "lane-editor-add-to-chat";
|
||||||
|
b.textContent = cb.current.t("changes.addToChat");
|
||||||
|
b.className =
|
||||||
|
"ml-3 rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent align-middle hover:bg-surface-2";
|
||||||
|
// Keep the editor's focus (and so its selection) through the click.
|
||||||
|
b.onmousedown = (e) => e.preventDefault();
|
||||||
|
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||||
|
return b;
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
let v: EditorView;
|
let v: EditorView;
|
||||||
let destroy: () => void;
|
let destroy: () => void;
|
||||||
if (layout === "split") {
|
if (layout === "split") {
|
||||||
const mv = new MergeView({
|
const sd = splitDiff({
|
||||||
parent: host.current!,
|
parent: host.current!,
|
||||||
a: {
|
original,
|
||||||
doc: original,
|
value,
|
||||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||||
},
|
extB: editing,
|
||||||
b: { doc: value, extensions: editing },
|
collapse: folded,
|
||||||
revertControls: "a-to-b",
|
revertLabel,
|
||||||
renderRevertControl: () => revertButton(revertLabel, true),
|
|
||||||
collapseUnchanged: folded,
|
|
||||||
});
|
});
|
||||||
v = mv.b;
|
v = sd.b;
|
||||||
destroy = () => mv.destroy();
|
destroy = sd.destroy;
|
||||||
} else {
|
} else {
|
||||||
v = new EditorView({
|
v = new EditorView({
|
||||||
parent: host.current!,
|
parent: host.current!,
|
||||||
@@ -306,15 +593,56 @@ export default function DiffEditor({
|
|||||||
}
|
}
|
||||||
v.focus();
|
v.focus();
|
||||||
view.current = v;
|
view.current = v;
|
||||||
return destroy;
|
if (navRef) {
|
||||||
|
// The B side's chunks, unified or split: the cursor goes to the start
|
||||||
|
// of the next / previous one and it scrolls into view.
|
||||||
|
navRef.current = (dir) => {
|
||||||
|
const ed = view.current;
|
||||||
|
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
|
||||||
|
if (!ed || !chunks.length) return;
|
||||||
|
const at = ed.state.selection.main.head;
|
||||||
|
const next =
|
||||||
|
dir === 1
|
||||||
|
? chunks.find((c) => c.fromB > at)
|
||||||
|
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
|
||||||
|
if (!next) return;
|
||||||
|
ed.dispatch({
|
||||||
|
selection: EditorSelection.cursor(next.fromB),
|
||||||
|
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
|
||||||
|
});
|
||||||
|
ed.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (navRef) navRef.current = null;
|
||||||
|
destroy();
|
||||||
|
};
|
||||||
// One mount per view: the parent keys this component by file and shape.
|
// One mount per view: the parent keys this component by file and shape.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Annotate on / off without remounting the view.
|
||||||
|
useEffect(() => {
|
||||||
|
view.current?.dispatch({
|
||||||
|
effects: blameSlot.reconfigure(
|
||||||
|
blame
|
||||||
|
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
|
||||||
|
cb.current.onBlameClick?.(sha)
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [blame, blameSlot]);
|
||||||
|
|
||||||
|
const bars = !!onBlock;
|
||||||
|
useEffect(() => {
|
||||||
|
view.current?.dispatch({ effects: barsOn.of(bars) });
|
||||||
|
}, [bars]);
|
||||||
|
|
||||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = view.current;
|
const v = view.current;
|
||||||
if (v && v.state.doc.toString() !== value) {
|
if (v && v.state.sliceDoc() !== value) {
|
||||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@@ -324,7 +652,9 @@ export default function DiffEditor({
|
|||||||
ref={host}
|
ref={host}
|
||||||
data-testid="lane-file-editor"
|
data-testid="lane-file-editor"
|
||||||
data-layout={layout}
|
data-layout={layout}
|
||||||
className="min-h-0 flex-1 overflow-hidden rounded border border-border-light [&_.cm-mergeView]:h-full [&_.cm-mergeView]:overflow-auto"
|
data-bars={bars}
|
||||||
|
// With the git bars up, a block's local Revert would be a second Undo.
|
||||||
|
className="[&[data-bars=true]_.cm-chunkButtons]:hidden [&[data-bars=true]_.cm-merge-revert_button]:hidden min-h-0 flex-1 overflow-hidden rounded border border-border-light"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|||||||
return locks;
|
return locks;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
|
||||||
active: "bg-status-success",
|
|
||||||
// A ring: a surface-4 fill vanished on the light themes.
|
|
||||||
idle: "border border-fg-muted",
|
|
||||||
dead: "bg-status-danger",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function since(sec: number | null): string {
|
export function since(sec: number | null): string {
|
||||||
if (sec === null) return "—";
|
if (sec === null) return "—";
|
||||||
if (sec < 60) return `${sec}s`;
|
if (sec < 60) return `${sec}s`;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,15 @@
|
|||||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||||
* right for the case that matters (one edited span on a line), degrades to
|
* right for the case that matters (one edited span on a line), degrades to
|
||||||
* "the whole line changed" otherwise, and costs nothing.
|
* "the whole line changed" otherwise, and costs nothing.
|
||||||
* - Line pairing for split view happens here, on the rows the server already
|
* - Side by side is JetBrains-shaped and laid out here, on the rows the
|
||||||
* numbered. The server stays the single source of what changed; this file
|
* server already numbered: within a hunk each column lists only its own
|
||||||
* only decides how it looks.
|
* lines, one after another, so the sides drift apart past a change and a
|
||||||
|
* curved band in the middle gutter joins each block to its counterpart
|
||||||
|
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
|
||||||
|
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
|
||||||
|
* split row is ROW_PX tall, which makes a band's y pure arithmetic. 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
|
* 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 —
|
* 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
|
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||||
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||||
|
import { bandPath } from "../../lib/diffConnectors";
|
||||||
|
|
||||||
export type DiffLayout = "unified" | "split";
|
export type DiffLayout = "unified" | "split";
|
||||||
|
|
||||||
@@ -220,29 +227,28 @@ function emphasis(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
|
||||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
* padding that closes the shorter side of a hunk. */
|
||||||
|
type SplitRow = { left: number; right: number };
|
||||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
/** 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). */
|
* index in `hunk.lines` (what the unified view renders by). */
|
||||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
|
||||||
|
|
||||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
// 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
|
// columns and every re-render of the same data share one pass. A refetch
|
||||||
// brings new objects and the old entries go with them.
|
// brings new objects and the old entries go with them.
|
||||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||||
|
|
||||||
/** Pair each run of removed lines with the run of added lines following it,
|
/** Pair each run of removed lines with the run of added lines following it
|
||||||
* carrying indices so emphasis lands without searching the hunk again. */
|
* for emphasis, keyed by line index so it lands without searching the hunk
|
||||||
|
* again; and pack each column's own lines into the split rows. */
|
||||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||||
const hit = analysed.get(hunk);
|
const hit = analysed.get(hunk);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
const lines = hunk.lines;
|
const lines = hunk.lines;
|
||||||
const rows: PairRow[] = [];
|
|
||||||
const emph = new Map<number, [number, number]>();
|
const emph = new Map<number, [number, number]>();
|
||||||
for (let i = 0; i < lines.length; ) {
|
for (let i = 0; i < lines.length;) {
|
||||||
const line = lines[i]!;
|
if (lines[i]!.type === "ctx") {
|
||||||
if (line.type === "ctx") {
|
|
||||||
rows.push({ left: line, right: line, emph: null });
|
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
|||||||
emph.set(delStart + k, e.del);
|
emph.set(delStart + k, e.del);
|
||||||
emph.set(addStart + k, e.add);
|
emph.set(addStart + k, e.add);
|
||||||
}
|
}
|
||||||
rows.push({ left, right, emph: e });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const left: number[] = [];
|
||||||
|
const right: number[] = [];
|
||||||
|
lines.forEach((l, i) => {
|
||||||
|
if (l.type !== "add") left.push(i);
|
||||||
|
if (l.type !== "del") right.push(i);
|
||||||
|
});
|
||||||
|
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
|
||||||
|
left: left[i] ?? -1,
|
||||||
|
right: right[i] ?? -1,
|
||||||
|
}));
|
||||||
const out = { rows, emph };
|
const out = { rows, emph };
|
||||||
analysed.set(hunk, out);
|
analysed.set(hunk, out);
|
||||||
return out;
|
return out;
|
||||||
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
|
|||||||
/** First/last index in `hunk.lines` (unified). */
|
/** First/last index in `hunk.lines` (unified). */
|
||||||
firstLine: number;
|
firstLine: number;
|
||||||
lastLine: number;
|
lastLine: number;
|
||||||
/** First/last split-view row. */
|
/** First split-view row (either side): the bar's arrows scroll here. */
|
||||||
firstRow: number;
|
firstRow: number;
|
||||||
|
/** The right column's row its review bar hangs under. */
|
||||||
lastRow: number;
|
lastRow: number;
|
||||||
|
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
|
||||||
|
* point between two rows where a pure insertion / removal sits. */
|
||||||
|
left: [number, number];
|
||||||
|
right: [number, number];
|
||||||
};
|
};
|
||||||
|
|
||||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||||
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
|||||||
const hit = blocksOf.get(hunk);
|
const hit = blocksOf.get(hunk);
|
||||||
if (hit) return hit;
|
if (hit) return hit;
|
||||||
const out: LaneDiffChange[] = [];
|
const out: LaneDiffChange[] = [];
|
||||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
|
||||||
hunk.lines.forEach((line, i) => {
|
hunk.lines.forEach((line, i) => {
|
||||||
if (line.type === "ctx") return;
|
if (line.type === "ctx") return;
|
||||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||||
if (!c) {
|
if (!c) {
|
||||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
c = {
|
||||||
|
hunk,
|
||||||
|
block: out.length,
|
||||||
|
firstLine: i,
|
||||||
|
lastLine: i,
|
||||||
|
firstRow: -1,
|
||||||
|
lastRow: -1,
|
||||||
|
left: [0, 0],
|
||||||
|
right: [0, 0],
|
||||||
|
};
|
||||||
out.push(c);
|
out.push(c);
|
||||||
}
|
}
|
||||||
c.lastLine = i;
|
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;
|
|
||||||
});
|
});
|
||||||
|
// A side's rows are its lines in order, so a block's rows there are the
|
||||||
|
// count of that side's lines before it, plus its own.
|
||||||
|
for (const c of out) {
|
||||||
|
let l = 0;
|
||||||
|
let r = 0;
|
||||||
|
hunk.lines.forEach((line, i) => {
|
||||||
|
if (i >= c.firstLine) return;
|
||||||
|
if (line.type !== "add") l += 1;
|
||||||
|
if (line.type !== "del") r += 1;
|
||||||
|
});
|
||||||
|
let dl = 0;
|
||||||
|
let dr = 0;
|
||||||
|
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
|
||||||
|
if (hunk.lines[i]!.type === "del") dl += 1;
|
||||||
|
else dr += 1;
|
||||||
|
}
|
||||||
|
c.left = [l, l + dl];
|
||||||
|
c.right = [r, r + dr];
|
||||||
|
c.firstRow = Math.min(l, r);
|
||||||
|
// A pure removal hangs its bar under the right row its band points at.
|
||||||
|
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
|
||||||
|
}
|
||||||
blocksOf.set(hunk, out);
|
blocksOf.set(hunk, out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -397,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
|||||||
* block's last line. A context rather than a prop because lines render from
|
* 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. */
|
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||||
const HunkActions = createContext<{
|
const HunkActions = createContext<{
|
||||||
actions: (change: LaneDiffChange) => ReactNode;
|
/** null = no bar; the anchors still mark each block for navigation. */
|
||||||
|
actions: ((change: LaneDiffChange) => ReactNode) | null;
|
||||||
changes: LaneDiffChange[];
|
changes: LaneDiffChange[];
|
||||||
go: (index: number) => void;
|
go: (index: number) => void;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
@@ -414,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
|
|||||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const ctx = useContext(HunkActions);
|
const ctx = useContext(HunkActions);
|
||||||
if (!ctx) return null;
|
if (!ctx?.actions) return null;
|
||||||
const index = ctx.changes.indexOf(change);
|
const index = ctx.changes.indexOf(change);
|
||||||
const total = ctx.changes.length;
|
const total = ctx.changes.length;
|
||||||
const nav =
|
const nav =
|
||||||
@@ -547,37 +591,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One column's row i of a hunk: that side's i-th line, or the padding
|
||||||
|
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
|
||||||
function SplitLine({
|
function SplitLine({
|
||||||
row,
|
hunk,
|
||||||
|
i,
|
||||||
lang,
|
lang,
|
||||||
side,
|
side,
|
||||||
style,
|
|
||||||
bar,
|
bar,
|
||||||
start,
|
start,
|
||||||
}: {
|
}: {
|
||||||
row: PairRow;
|
hunk: LaneDiffHunk;
|
||||||
|
i: number;
|
||||||
lang: string;
|
lang: string;
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
style?: CSSProperties;
|
|
||||||
bar?: LaneDiffChange;
|
bar?: LaneDiffChange;
|
||||||
start?: LaneDiffChange;
|
start?: LaneDiffChange;
|
||||||
}) {
|
}) {
|
||||||
const line = side === "left" ? row.left : row.right;
|
const { rows, emph } = analyseHunk(hunk);
|
||||||
|
const at = rows[i]![side];
|
||||||
|
const line = at < 0 ? null : hunk.lines[at]!;
|
||||||
|
const e = at < 0 ? undefined : emph.get(at);
|
||||||
const anchor = useAnchor(start);
|
const anchor = useAnchor(start);
|
||||||
return (
|
return (
|
||||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
|
||||||
<Gutter
|
<Gutter
|
||||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||||
commentable={side === "right"}
|
commentable={side === "right"}
|
||||||
/>
|
/>
|
||||||
<span className="whitespace-pre px-1">
|
<span className="whitespace-pre px-1">
|
||||||
{line ? (
|
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||||
<Code
|
|
||||||
text={line.text}
|
|
||||||
lang={lang}
|
|
||||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
{bar && <HunkBar change={bar} />}
|
{bar && <HunkBar change={bar} />}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -598,11 +641,12 @@ function SplitColumn({
|
|||||||
<>
|
<>
|
||||||
{hunks.map((hunk, h) => (
|
{hunks.map((hunk, h) => (
|
||||||
<div key={`${hunk.header}:${h}`}>
|
<div key={`${hunk.header}:${h}`}>
|
||||||
<HunkHeader hunk={hunk} />
|
<HunkHeader hunk={hunk} fixed />
|
||||||
{analyseHunk(hunk).rows.map((row, i) => (
|
{analyseHunk(hunk).rows.map((_, i) => (
|
||||||
<SplitLine
|
<SplitLine
|
||||||
key={i}
|
key={i}
|
||||||
row={row}
|
hunk={hunk}
|
||||||
|
i={i}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
side={side}
|
side={side}
|
||||||
// One bar per change block: on the right (new) column only.
|
// One bar per change block: on the right (new) column only.
|
||||||
@@ -638,10 +682,10 @@ function SplitSlice({
|
|||||||
) : (
|
) : (
|
||||||
<SplitLine
|
<SplitLine
|
||||||
key={range.start + k}
|
key={range.start + k}
|
||||||
row={analyseHunk(hunk).rows[i]!}
|
hunk={hunk}
|
||||||
|
i={i}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
side={side}
|
side={side}
|
||||||
style={FIXED}
|
|
||||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||||
start={startAtRow(hunk, i)}
|
start={startAtRow(hunk, i)}
|
||||||
/>
|
/>
|
||||||
@@ -657,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Axis =
|
type Axis =
|
||||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
|
||||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
|
||||||
const AXES: Axis[] = [
|
const AXES: Axis[] = [
|
||||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||||
["scrollTop", "scrollHeight", "clientHeight"],
|
["scrollTop", "scrollHeight", "clientHeight"],
|
||||||
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
/** The middle gutter's width: room for an S-curve to read as one. */
|
||||||
|
const GUTTER_PX = 36;
|
||||||
|
|
||||||
|
const BAND = {
|
||||||
|
add: "fill-status-success/20 stroke-status-success/60",
|
||||||
|
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||||
|
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Every change block's band, in the columns' content coordinates: a hunk's
|
||||||
|
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
|
||||||
|
function bandsOf(items: Item[]) {
|
||||||
|
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
|
||||||
|
items.forEach(({ hunk, i }, h) => {
|
||||||
|
if (i >= 0) return;
|
||||||
|
for (const c of changeBlocks(hunk)) {
|
||||||
|
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
|
||||||
|
out.push({
|
||||||
|
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
|
||||||
|
kind,
|
||||||
|
top: Math.min(y0, z0),
|
||||||
|
bottom: Math.max(y1, z1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both sides, each its own scroller but kept in step on both axes, and the
|
||||||
|
* gutter between them that draws each block's band, scrolled with them. */
|
||||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||||
const left = useRef<HTMLDivElement>(null);
|
const left = useRef<HTMLDivElement>(null);
|
||||||
const right = useRef<HTMLDivElement>(null);
|
const right = useRef<HTMLDivElement>(null);
|
||||||
|
const mid = useRef<HTMLDivElement>(null);
|
||||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||||
|
const bands = useMemo(() => bandsOf(items), [items]);
|
||||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||||
const win = useWindow(items.length);
|
const win = useWindow(items.length);
|
||||||
|
|
||||||
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
|||||||
const dst = from === "left" ? right.current : left.current;
|
const dst = from === "left" ? right.current : left.current;
|
||||||
if (!src || !dst) return;
|
if (!src || !dst) return;
|
||||||
follow(src, dst);
|
follow(src, dst);
|
||||||
|
if (mid.current) mid.current.scrollTop = src.scrollTop;
|
||||||
win.onScroll(src);
|
win.onScroll(src);
|
||||||
};
|
};
|
||||||
|
// Windowed, only the bands that reach the rendered rows are drawn.
|
||||||
|
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
|
||||||
|
|
||||||
const column = (side: "left" | "right") =>
|
const column = (side: "left" | "right") =>
|
||||||
win.on ? (
|
win.on ? (
|
||||||
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
|||||||
>
|
>
|
||||||
{column("left")}
|
{column("left")}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
ref={mid}
|
||||||
|
data-testid="lane-diff-connectors"
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
|
||||||
|
style={{ width: GUTTER_PX }}
|
||||||
|
>
|
||||||
|
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
|
||||||
|
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
|
||||||
|
{bands
|
||||||
|
.filter((b) => b.bottom >= from && b.top <= to)
|
||||||
|
.map((b) => (
|
||||||
|
<path
|
||||||
|
key={`${b.top}:${b.d}`}
|
||||||
|
d={b.d}
|
||||||
|
data-kind={b.kind}
|
||||||
|
className={BAND[b.kind]}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={right}
|
ref={right}
|
||||||
data-testid="lane-diff-split-right"
|
data-testid="lane-diff-split-right"
|
||||||
onScroll={() => sync("right")}
|
onScroll={() => sync("right")}
|
||||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
className="w-1/2 min-w-0 overflow-auto"
|
||||||
>
|
>
|
||||||
{column("right")}
|
{column("right")}
|
||||||
</div>
|
</div>
|
||||||
@@ -826,7 +931,9 @@ export function LaneDiffView({
|
|||||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||||
};
|
};
|
||||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
// Provided without actions too: every block's first row carries its
|
||||||
|
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
|
||||||
|
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
|
||||||
|
|
||||||
if (file.binary)
|
if (file.binary)
|
||||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
* 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
|
* 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.
|
* resent with the typed credential — the token is never rendered back.
|
||||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
|
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||||
* asks through the themed confirm; the rows wrap in a narrow panel.
|
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -234,6 +234,45 @@ export function LaneGitBar({
|
|||||||
await refreshBranches();
|
await refreshBranches();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Writes that leave this machine (push, publish) or delete a ref ask
|
||||||
|
// first. Asked HERE, at the click — never inside `run` — so the retry a git
|
||||||
|
// login sends is not asked twice.
|
||||||
|
const confirmRun = async (
|
||||||
|
opts: { title: string; message: string; destructive?: boolean },
|
||||||
|
action: Action,
|
||||||
|
body?: RunBody
|
||||||
|
) => {
|
||||||
|
if (await confirmDialog({ destructive: false, confirmLabel: opts.title, ...opts })) {
|
||||||
|
void run(action, body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const push = () =>
|
||||||
|
confirmRun(
|
||||||
|
git.upstream
|
||||||
|
? {
|
||||||
|
title: t("gitBar.push"),
|
||||||
|
message: t("gitBar.pushConfirm", {
|
||||||
|
count: git.ahead ?? 0,
|
||||||
|
branch: git.branch,
|
||||||
|
upstream: git.upstream,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: t("gitBar.publishBranch"),
|
||||||
|
message: t("gitBar.publishBranchConfirm", { branch: git.branch }),
|
||||||
|
},
|
||||||
|
"push"
|
||||||
|
);
|
||||||
|
const publish = () =>
|
||||||
|
confirmRun(
|
||||||
|
{
|
||||||
|
title: t("gitBar.publishApply"),
|
||||||
|
message: t("gitBar.publishConfirm", { branch: git.branch, url: url.trim() }),
|
||||||
|
},
|
||||||
|
"publish",
|
||||||
|
{ url: url.trim() }
|
||||||
|
);
|
||||||
|
|
||||||
const merge = async (branch: string) => {
|
const merge = async (branch: string) => {
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: t("gitBar.merge"),
|
title: t("gitBar.merge"),
|
||||||
@@ -322,7 +361,11 @@ export function LaneGitBar({
|
|||||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||||
busy={busy === "pull"}
|
busy={busy === "pull"}
|
||||||
disabled={!git.hasRemote || !!busy || merging}
|
disabled={!git.hasRemote || !!busy || merging}
|
||||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
title={
|
||||||
|
merging
|
||||||
|
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||||
|
: t("gitBar.pullHint")
|
||||||
|
}
|
||||||
onClick={() => run("pull")}
|
onClick={() => run("pull")}
|
||||||
/>
|
/>
|
||||||
<Btn
|
<Btn
|
||||||
@@ -338,7 +381,7 @@ export function LaneGitBar({
|
|||||||
? t("gitBar.publishBranchHint")
|
? t("gitBar.publishBranchHint")
|
||||||
: t("gitBar.pushHint")
|
: t("gitBar.pushHint")
|
||||||
}
|
}
|
||||||
onClick={() => run("push")}
|
onClick={() => void push()}
|
||||||
/>
|
/>
|
||||||
{!git.hasRemote && (
|
{!git.hasRemote && (
|
||||||
<Btn
|
<Btn
|
||||||
@@ -362,7 +405,7 @@ export function LaneGitBar({
|
|||||||
// beats a red box after it.
|
// beats a red box after it.
|
||||||
title={
|
title={
|
||||||
merging
|
merging
|
||||||
? t("gitBar.merging")
|
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||||
: dirty > 0
|
: dirty > 0
|
||||||
? t("gitBar.switchDirty")
|
? t("gitBar.switchDirty")
|
||||||
: t("gitBar.switchHint")
|
: t("gitBar.switchHint")
|
||||||
@@ -434,7 +477,17 @@ export function LaneGitBar({
|
|||||||
busy={busy === "branch-delete"}
|
busy={busy === "branch-delete"}
|
||||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||||
title={t("gitBar.branchDeleteHint")}
|
title={t("gitBar.branchDeleteHint")}
|
||||||
onClick={() => void run("branch-delete", { branch: picked })}
|
onClick={() =>
|
||||||
|
void confirmRun(
|
||||||
|
{
|
||||||
|
title: t("gitBar.branchDelete"),
|
||||||
|
message: t("gitBar.branchDeleteConfirm", { branch: picked }),
|
||||||
|
destructive: true,
|
||||||
|
},
|
||||||
|
"branch-delete",
|
||||||
|
{ branch: picked }
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{dirty > 0 && (
|
{dirty > 0 && (
|
||||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||||
@@ -474,7 +527,7 @@ export function LaneGitBar({
|
|||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (isSubmitEnter(e) && url.trim()) void run("publish", { url: url.trim() });
|
if (isSubmitEnter(e) && url.trim()) void publish();
|
||||||
}}
|
}}
|
||||||
// The server decides which URLs are acceptable (https without a
|
// The server decides which URLs are acceptable (https without a
|
||||||
// token, ssh, user@host:path); this only names the two usual ones.
|
// token, ssh, user@host:path); this only names the two usual ones.
|
||||||
@@ -488,7 +541,7 @@ export function LaneGitBar({
|
|||||||
busy={busy === "publish"}
|
busy={busy === "publish"}
|
||||||
disabled={!url.trim()}
|
disabled={!url.trim()}
|
||||||
title={t("gitBar.publishHint")}
|
title={t("gitBar.publishHint")}
|
||||||
onClick={() => void run("publish", { url: url.trim() })}
|
onClick={() => void publish()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -128,12 +128,12 @@ export function SessionReplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView sessionId={sessionId} />
|
<ConversationView sessionId={sessionId} fill />
|
||||||
</div>
|
</div>
|
||||||
{livePane ? (
|
{livePane ? (
|
||||||
<div
|
<div
|
||||||
data-testid="session-replay-live"
|
data-testid="session-replay-live"
|
||||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||||
<button
|
<button
|
||||||
@@ -148,7 +148,7 @@ export function SessionReplay({
|
|||||||
) : session && !resumable ? (
|
) : session && !resumable ? (
|
||||||
<div
|
<div
|
||||||
data-testid="session-replay-no-resume"
|
data-testid="session-replay-no-resume"
|
||||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||||
>
|
>
|
||||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||||
<button
|
<button
|
||||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
bare
|
bare
|
||||||
images={images}
|
images={images}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
|
|||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { startInFreeTabDetailed } from "./startSession";
|
import { startInFreeTabDetailed } from "./startSession";
|
||||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
@@ -52,6 +53,7 @@ export function SessionResumeButton({
|
|||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lanes } = useLanes();
|
const { lanes } = useLanes();
|
||||||
|
const { canEditLane } = usePermissions();
|
||||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -77,7 +79,8 @@ export function SessionResumeButton({
|
|||||||
? null
|
? null
|
||||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||||
owningLane(session.cwd, tool, lanes));
|
owningLane(session.cwd, tool, lanes));
|
||||||
if (!lane || runs === null) return null;
|
// Server mode: an analyst may resume only in a workspace of their own.
|
||||||
|
if (!lane || !canEditLane(lane) || runs === null) return null;
|
||||||
const run = liveRunFor(lane, runs, session.id);
|
const run = liveRunFor(lane, runs, session.id);
|
||||||
const same = (a: string, b: string) => strip(a) === strip(b);
|
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||||
// Its own worktree counts too: the server resumes it there (`claude
|
// Its own worktree counts too: the server resumes it there (`claude
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||||
* `ConsoleTabs.tsx`). Inside
|
* `ConsoleTabs.tsx`). Inside
|
||||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
||||||
|
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Plus,
|
Plus,
|
||||||
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -101,6 +103,9 @@ type HomeProps = Parameters<typeof HomeBody>[0];
|
|||||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||||
* model, an open folder form, a late upload's answer) belongs to ONE
|
* model, an open folder form, a late upload's answer) belongs to ONE
|
||||||
* workspace, and the page swaps `lane` without unmounting. */
|
* workspace, and the page swaps `lane` without unmounting. */
|
||||||
|
/** The AI menu row that opens a terminal; no catalog id can collide (ids are slugs). */
|
||||||
|
const OPEN_TERMINAL = "__terminal__";
|
||||||
|
|
||||||
export function WorkspaceHome(props: HomeProps) {
|
export function WorkspaceHome(props: HomeProps) {
|
||||||
return <HomeBody key={props.lane.id} {...props} />;
|
return <HomeBody key={props.lane.id} {...props} />;
|
||||||
}
|
}
|
||||||
@@ -311,6 +316,24 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The AI menu's Open terminal: a plain shell in the first free slot, opened
|
||||||
|
// at once — the same start the session view's tab bar `+` makes.
|
||||||
|
const openTerminal = async () => {
|
||||||
|
if (startingRef.current) return;
|
||||||
|
startingRef.current = true;
|
||||||
|
setStarting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||||
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(message(err));
|
||||||
|
} finally {
|
||||||
|
startingRef.current = false;
|
||||||
|
setStarting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images);
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
// Enter reaches here past the disabled button: the same guards again.
|
// Enter reaches here past the disabled button: the same guards again.
|
||||||
@@ -518,19 +541,29 @@ function HomeBody({
|
|||||||
label={t("home.ai")}
|
label={t("home.ai")}
|
||||||
value={tool}
|
value={tool}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
|
// Not a tool: an action. The AI chosen for the prompt stays.
|
||||||
|
if (v === OPEN_TERMINAL) return void openTerminal();
|
||||||
setTool(v);
|
setTool(v);
|
||||||
setBypass(false);
|
setBypass(false);
|
||||||
}}
|
}}
|
||||||
options={toolOptions.map((id) => ({
|
options={[
|
||||||
value: id,
|
...toolOptions.map((id) => ({
|
||||||
label: aiTools.label(id),
|
value: id,
|
||||||
icon: (
|
label: aiTools.label(id),
|
||||||
<AiToolIcon
|
icon: (
|
||||||
tool={{ id, label: aiTools.label(id) }}
|
<AiToolIcon
|
||||||
className="h-4 w-4 !p-0.5"
|
tool={{ id, label: aiTools.label(id) }}
|
||||||
/>
|
className="h-4 w-4 !p-0.5"
|
||||||
),
|
/>
|
||||||
}))}
|
),
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
value: OPEN_TERMINAL,
|
||||||
|
label: t("home.openTerminal"),
|
||||||
|
icon: <SquareTerminal className="h-4 w-4 text-fg-muted" />,
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
@@ -743,6 +776,16 @@ function HomeBody({
|
|||||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="workspace-recents-less"
|
||||||
|
onClick={() => setRecentLimit(RECENT_PAGE)}
|
||||||
|
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{t("tree.showLess")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<SessionFold
|
<SessionFold
|
||||||
testId={`workspace-archived-${lane.id}`}
|
testId={`workspace-archived-${lane.id}`}
|
||||||
@@ -890,6 +933,7 @@ function HomeBody({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||||
* sidebar: the ones the workspace OWNS and has not archived
|
* sidebar: the ones the workspace OWNS and has not archived
|
||||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||||
|
* pane above the ended ones) — never the other
|
||||||
* sessions that ran in its folder. A row's dot
|
* sessions that ran in its folder. A row's dot
|
||||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||||
@@ -12,17 +13,15 @@
|
|||||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||||
* which never has a session) as **Terminal** — and under the sessions the last
|
* which never has a session) as **Terminal** while it is open — a closed one
|
||||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
* leaves no row; Ctrl/Cmd+click opens
|
||||||
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
* it in a split in the tab on screen) — attaching, never starting or
|
||||||
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
|
||||||
* it in a split beside the focused pane) — attaching, never starting or
|
|
||||||
* resuming anything;
|
* resuming anything;
|
||||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||||
* loads every one. A
|
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||||
* live tab whose session was archived is not brought back as a New session,
|
* live tab whose session was archived is not brought back as a New session,
|
||||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||||
* dot carries its meaning as screen-reader text.
|
* dot carries its meaning as screen-reader text.
|
||||||
@@ -32,24 +31,20 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { Archive, GitBranch, SquareTerminal, X } from "lucide-react";
|
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
import { api, type RunHandle } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
|
||||||
import { startInFreeTab } from "./startSession";
|
|
||||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { timeAgo } from "../../lib/format";
|
import { timeAgo } from "../../lib/format";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { useContextMenu } from "../ContextMenu";
|
||||||
import { SessionMenu } from "./SessionMenu";
|
import { SessionMenu } from "./SessionMenu";
|
||||||
|
|
||||||
export const SESSION_LIMIT = 6;
|
export const SESSION_LIMIT = 6;
|
||||||
/** Closed Terminal tabs listed under a workspace's sessions. */
|
|
||||||
export const CLOSED_TERMINALS = 3;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The live pane holding a session: the run the hooks bound it to
|
* The live pane holding a session: the run the hooks bound it to
|
||||||
@@ -278,52 +273,17 @@ export function WorkspaceSessionList({
|
|||||||
3000
|
3000
|
||||||
);
|
);
|
||||||
const refreshKey = `${hookKey}:${version}`;
|
const refreshKey = `${hookKey}:${version}`;
|
||||||
// A Terminal tab closing is when one joins the history.
|
|
||||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
|
||||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
if (hidden) return;
|
|
||||||
let live = true;
|
|
||||||
api.run
|
|
||||||
// Live ones come back too (isLive): ask for enough to fill the list anyway.
|
|
||||||
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
|
|
||||||
.then(
|
|
||||||
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
|
|
||||||
)
|
|
||||||
.catch(() => undefined);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
};
|
|
||||||
}, [lane.id, shellKey, hidden]);
|
|
||||||
const reopenShell = async (split: boolean) => {
|
|
||||||
try {
|
|
||||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
|
||||||
navigate(
|
|
||||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
|
|
||||||
split ? { state: { split: true } } : undefined
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const closedCtx = useContextMenu<RunHistoryItem>();
|
|
||||||
const forgetShell = async (r: RunHistoryItem) => {
|
|
||||||
try {
|
|
||||||
await api.run.forgetHistory(r.id);
|
|
||||||
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||||
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
const shown = (sessions ?? []).filter(
|
|
||||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
|
||||||
);
|
|
||||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||||
|
// Live sessions first, ended ones under them — each group stays newest
|
||||||
|
// first (the sort is stable over the server's order).
|
||||||
|
const shown = (sessions ?? [])
|
||||||
|
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||||
|
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||||
// A live tab whose session has no row yet: shown as a New session at the
|
// A live tab whose session has no row yet: shown as a New session at the
|
||||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||||
// during a search — it has no name to match.
|
// during a search — it has no name to match.
|
||||||
@@ -440,9 +400,7 @@ export function WorkspaceSessionList({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
// Not during a search, like the live ones: they have no name to match.
|
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
|
||||||
const closed = q ? [] : closedShells;
|
|
||||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||||
@@ -599,42 +557,6 @@ export function WorkspaceSessionList({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{closed.map((r) => (
|
|
||||||
<li key={r.id} className="group relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`workspace-terminal-closed-${r.id}`}
|
|
||||||
onClick={(e) => void reopenShell(e.ctrlKey || e.metaKey)}
|
|
||||||
onContextMenu={(e) => closedCtx.open(e, r)}
|
|
||||||
title={t("tree.closedTerminal")}
|
|
||||||
className="flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="h-1.5 w-1.5 shrink-0 rounded-full border border-fg-muted"
|
|
||||||
/>
|
|
||||||
<span className="sr-only">{t("tree.state.idle")}</span>
|
|
||||||
<span className="truncate">{t("tabs.terminal")}</span>
|
|
||||||
<span className="ml-auto shrink-0 text-[11px]">
|
|
||||||
{timeAgo(r.ended_at ?? r.started_at, true)}
|
|
||||||
</span>
|
|
||||||
<SquareTerminal
|
|
||||||
aria-hidden
|
|
||||||
className="h-3.5 w-3.5 shrink-0 opacity-70 group-hover:invisible"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid={`workspace-terminal-forget-${r.id}`}
|
|
||||||
onClick={() => void forgetShell(r)}
|
|
||||||
title={t("tree.forgetTerminal")}
|
|
||||||
aria-label={t("tree.forgetTerminal")}
|
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
</ul>
|
||||||
{more > 0 && !q && (
|
{more > 0 && !q && (
|
||||||
<button
|
<button
|
||||||
@@ -646,6 +568,16 @@ export function WorkspaceSessionList({
|
|||||||
{t("tree.seeAll", { count: more })}
|
{t("tree.seeAll", { count: more })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`workspace-sessions-less-${lane.id}`}
|
||||||
|
onClick={() => setLimit(SESSION_LIMIT)}
|
||||||
|
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{t("tree.showLess")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{failure}
|
{failure}
|
||||||
{confirmArchive}
|
{confirmArchive}
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
@@ -658,42 +590,6 @@ export function WorkspaceSessionList({
|
|||||||
onClose={ctx.close}
|
onClose={ctx.close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{closedCtx.menu && (
|
|
||||||
<ContextMenu
|
|
||||||
x={closedCtx.menu.x}
|
|
||||||
y={closedCtx.menu.y}
|
|
||||||
onClose={closedCtx.close}
|
|
||||||
testId={`terminal-menu-${closedCtx.menu.item.id}`}
|
|
||||||
>
|
|
||||||
<MenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
closedCtx.close();
|
|
||||||
void reopenShell(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("tree.reopenTerminal")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onSelect={() => {
|
|
||||||
closedCtx.close();
|
|
||||||
void reopenShell(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("sessionMenu.openSplit")}
|
|
||||||
</MenuItem>
|
|
||||||
<MenuSeparator />
|
|
||||||
<MenuItem
|
|
||||||
danger
|
|
||||||
onSelect={() => {
|
|
||||||
const item = closedCtx.menu!.item;
|
|
||||||
closedCtx.close();
|
|
||||||
void forgetShell(item);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("tree.forgetTerminal")}
|
|
||||||
</MenuItem>
|
|
||||||
</ContextMenu>
|
|
||||||
)}
|
|
||||||
{sessionCtx.menu && (
|
{sessionCtx.menu && (
|
||||||
<SessionMenu
|
<SessionMenu
|
||||||
x={sessionCtx.menu.x}
|
x={sessionCtx.menu.x}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { AddLaneModal } from "../AddLaneModal";
|
import { AddLaneModal } from "../AddLaneModal";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
@@ -119,6 +119,13 @@ function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The modal opens on Repo; worktree tests switch to Worktree first. */
|
||||||
|
function renderWorktree(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||||
|
const r = renderModal(over);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Worktree" }));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
||||||
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
|
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
|
||||||
* then click the field to reclaim focus before typing. */
|
* then click the field to reclaim focus before typing. */
|
||||||
@@ -127,7 +134,7 @@ async function focusField(user: ReturnType<typeof userEvent.setup>, el: HTMLElem
|
|||||||
await user.click(el);
|
await user.click(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fills the default "Worktree" mode's form up through a resolved branch
|
/** Fills the "Worktree" mode's form up through a resolved branch
|
||||||
* list, title, and new-branch name - everything Add lane needs to enable. */
|
* list, title, and new-branch name - everything Add lane needs to enable. */
|
||||||
async function fillWorktreeForm(
|
async function fillWorktreeForm(
|
||||||
user: ReturnType<typeof userEvent.setup>,
|
user: ReturnType<typeof userEvent.setup>,
|
||||||
@@ -173,7 +180,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
|||||||
|
|
||||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -188,7 +195,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
|||||||
it("checks an existing branch out, sending no base", async () => {
|
it("checks an existing branch out, sending no base", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -211,7 +218,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
|||||||
|
|
||||||
it("greys out a branch another worktree already holds", async () => {
|
it("greys out a branch another worktree already holds", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -229,7 +236,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
|||||||
it("lets the server name a new branch when the field is left empty", async () => {
|
it("lets the server name a new branch when the field is left empty", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -253,11 +260,11 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — worktree mode (default)", () => {
|
describe("AddLaneModal — worktree mode", () => {
|
||||||
// The branch NAME is no longer part of this list: an empty one means "let
|
// The branch NAME is no longer part of this list: an empty one means "let
|
||||||
// the server name it" (issue #34).
|
// the server name it" (issue #34).
|
||||||
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
it("disables confirm until a repo, a title and a resolved branch list are all present", () => {
|
||||||
renderModal();
|
renderWorktree();
|
||||||
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Add workspace" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -267,7 +274,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
current: "main",
|
current: "main",
|
||||||
worktrees: [],
|
worktrees: [],
|
||||||
});
|
});
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -284,7 +291,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
|
|
||||||
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
|
it("stays disabled and shows a quiet hint when the path is not a resolvable repo", async () => {
|
||||||
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
|
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -303,7 +310,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
current: "main",
|
current: "main",
|
||||||
worktrees: [],
|
worktrees: [],
|
||||||
});
|
});
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
@@ -328,7 +335,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderModal({ onClose, onAdded });
|
renderWorktree({ onClose, onAdded });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
await fillWorktreeForm(user, { title: "New feature", branch: "feat/new-feature" });
|
||||||
@@ -360,7 +367,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
vi.mocked(api.lanes.worktree).mockRejectedValue(new Error("EWORKTREEDIRCOLLISION"));
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderModal({ onClose });
|
renderWorktree({ onClose });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user, { title: "New feature" });
|
await fillWorktreeForm(user, { title: "New feature" });
|
||||||
@@ -385,7 +392,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
lane: { id: 42, title: "demo", cwd: "/lanes/demo", status: "provisioning" } as Lane,
|
||||||
});
|
});
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
renderModal({ onAdded });
|
renderWorktree({ onAdded });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user);
|
await fillWorktreeForm(user);
|
||||||
@@ -410,7 +417,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderModal({ onAdded, onClose });
|
renderWorktree({ onAdded, onClose });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user, { title: "demo2" });
|
await fillWorktreeForm(user, { title: "demo2" });
|
||||||
@@ -431,7 +438,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
});
|
});
|
||||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderModal({ onClose });
|
renderWorktree({ onClose });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user, { title: "demo3" });
|
await fillWorktreeForm(user, { title: "demo3" });
|
||||||
@@ -463,7 +470,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "provisioning" }) })
|
||||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
.mockResolvedValueOnce({ lane: laneFixture({ id: 45, status: "failed" }) });
|
||||||
const onAdded = vi.fn();
|
const onAdded = vi.fn();
|
||||||
renderModal({ onAdded });
|
renderWorktree({ onAdded });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
await fillWorktreeForm(user, { title: "demo4" });
|
await fillWorktreeForm(user, { title: "demo4" });
|
||||||
@@ -477,6 +484,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — repo mode (adopt)", () => {
|
describe("AddLaneModal — repo mode (adopt)", () => {
|
||||||
|
it("opens in Repo mode by default, with no branch fields", () => {
|
||||||
|
renderModal();
|
||||||
|
expect(screen.getByRole("button", { name: "Repo" })).toHaveAttribute("aria-pressed", "true");
|
||||||
|
expect(screen.queryByTestId("add-lane-branch-section")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the branch fields and enables confirm on a path alone", async () => {
|
it("hides the branch fields and enables confirm on a path alone", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -727,7 +740,7 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
|||||||
|
|
||||||
it("jumps to Repo mode from the branch choice", async () => {
|
it("jumps to Repo mode from the branch choice", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderModal();
|
renderWorktree();
|
||||||
const repoField = screen.getByLabelText("Source repository");
|
const repoField = screen.getByLabelText("Source repository");
|
||||||
await focusField(user, repoField);
|
await focusField(user, repoField);
|
||||||
await user.type(repoField, "/Users/tester/projects/repo");
|
await user.type(repoField, "/Users/tester/projects/repo");
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
* @file Tests for the Changes panel's CodeMirror editor: which changed lines
|
||||||
* count as edited in place (blue) rather than added or removed whole, and the
|
* count as edited in place (blue) rather than added or removed whole, and the
|
||||||
* editor's own diff classes on a real mount.
|
* editor's own diff classes on a real mount, the per-block Undo / Keep bar,
|
||||||
|
* and the Add to Chat chip on a selection.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
import DiffEditor, { keepsText } from "../DiffEditor";
|
import DiffEditor, { keepsText } from "../DiffEditor";
|
||||||
|
|
||||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||||
@@ -72,6 +74,81 @@ describe("DiffEditor", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("side by side is JetBrains-shaped: no padding, one curved band per block", async () => {
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
// Three unchanged lines apart, or the diff joins the two into one chunk.
|
||||||
|
value={"a\nB\nc\nd\nf\nnew one\nnew two\ne"}
|
||||||
|
original={"a\nb\nc\nd\nf\ne"}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-editor")).toHaveLength(2));
|
||||||
|
// Each side scrolls its own lines: nothing is padded to line them up.
|
||||||
|
expect(container.querySelector(".cm-mergeSpacer")).toBeNull();
|
||||||
|
expect(container.querySelectorAll(".cm-scroller")).toHaveLength(2);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
container.querySelectorAll('[data-testid="lane-editor-connectors"] path')
|
||||||
|
).toHaveLength(2)
|
||||||
|
);
|
||||||
|
const kinds = [
|
||||||
|
...container.querySelectorAll<SVGPathElement>('[data-testid="lane-editor-connectors"] path'),
|
||||||
|
].map((p) => p.dataset.kind);
|
||||||
|
expect(kinds).toEqual(["mod", "add"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("» puts the original block back on the right, as an edit", async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
value={"keep\nconst x = 2;\nend"}
|
||||||
|
original={"keep\nconst x = 1;\nend"}
|
||||||
|
lang="ts"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={onChange}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = await waitFor(() => {
|
||||||
|
const b = container.querySelector<HTMLButtonElement>(".cm-merge-revert button");
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
return b!;
|
||||||
|
});
|
||||||
|
fireEvent.mouseDown(button);
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith("keep\nconst x = 1;\nend");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds the same unchanged stretch away on both sides", async () => {
|
||||||
|
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout="split"
|
||||||
|
collapse
|
||||||
|
value={`${same}\nchanged`}
|
||||||
|
original={`${same}\noriginal`}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(2));
|
||||||
|
const [left, right] = [...container.querySelectorAll<HTMLElement>(".cm-collapsedLines")];
|
||||||
|
expect(left!.textContent).toBe(right!.textContent);
|
||||||
|
// One click opens the stretch on both sides.
|
||||||
|
fireEvent.click(right!);
|
||||||
|
await waitFor(() => expect(container.querySelectorAll(".cm-collapsedLines")).toHaveLength(0));
|
||||||
|
});
|
||||||
|
|
||||||
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
it("folds unchanged lines away when collapsed, and offers Revert on a block", async () => {
|
||||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
@@ -90,4 +167,161 @@ describe("DiffEditor", () => {
|
|||||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("annotates each line in a gutter, and hands a click its commit", async () => {
|
||||||
|
const onBlameClick = vi.fn();
|
||||||
|
const props = {
|
||||||
|
value: "a\nnew",
|
||||||
|
original: "a",
|
||||||
|
lang: "plain",
|
||||||
|
label: "Edit",
|
||||||
|
revertLabel: "Revert",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
onBlameClick,
|
||||||
|
};
|
||||||
|
const sha = "f".repeat(40);
|
||||||
|
const blame = {
|
||||||
|
available: true as const,
|
||||||
|
path: "x",
|
||||||
|
commits: {
|
||||||
|
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
|
||||||
|
},
|
||||||
|
lines: [sha, null],
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(<DiffEditor {...props} />);
|
||||||
|
expect(container.querySelector(".cm-ah-blame")).toBeNull();
|
||||||
|
rerender(<DiffEditor {...props} blame={blame} />);
|
||||||
|
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
|
||||||
|
// The first element is CodeMirror's invisible width spacer.
|
||||||
|
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
|
||||||
|
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
|
||||||
|
fireEvent.mouseDown(cells[0]!.parentElement!);
|
||||||
|
expect(onBlameClick).toHaveBeenCalledWith(sha);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
|
||||||
|
const onBlock = vi.fn();
|
||||||
|
const props = {
|
||||||
|
value: "a\nB\nc\nnew\ne",
|
||||||
|
original: "a\nb\nc\nd\ne",
|
||||||
|
lang: "plain",
|
||||||
|
label: "Edit",
|
||||||
|
revertLabel: "Revert",
|
||||||
|
onChange: vi.fn(),
|
||||||
|
onSave: vi.fn(),
|
||||||
|
};
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||||
|
"data-bars",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
const [first, second] = [
|
||||||
|
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
|
||||||
|
];
|
||||||
|
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
|
||||||
|
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
|
||||||
|
fireEvent.click(boxes[1]!);
|
||||||
|
expect(onBlock).toHaveBeenLastCalledWith("include", {
|
||||||
|
oldFrom: 4,
|
||||||
|
oldCount: 1,
|
||||||
|
newFrom: 4,
|
||||||
|
newCount: 1,
|
||||||
|
});
|
||||||
|
// Undo asks twice.
|
||||||
|
const firstUndo = first!.querySelector("button")!;
|
||||||
|
fireEvent.click(firstUndo);
|
||||||
|
expect(onBlock).toHaveBeenCalledTimes(1);
|
||||||
|
fireEvent.click(firstUndo);
|
||||||
|
expect(onBlock).toHaveBeenLastCalledWith("undo", {
|
||||||
|
oldFrom: 2,
|
||||||
|
oldCount: 1,
|
||||||
|
newFrom: 2,
|
||||||
|
newCount: 1,
|
||||||
|
});
|
||||||
|
// Unsaved text (no onBlock): no bars, Revert back.
|
||||||
|
rerender(<DiffEditor {...props} />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="lane-editor-block-bar"]')).toBeNull()
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||||
|
"data-bars",
|
||||||
|
"false"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["unified", "split"] as const)(
|
||||||
|
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
|
||||||
|
async (layout) => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
layout={layout}
|
||||||
|
value={"a\r\nB\r\nc"}
|
||||||
|
original={"a\r\nb\r\nc"}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={onChange}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onBlock={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
|
||||||
|
);
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
|
||||||
|
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
|
||||||
|
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
|
||||||
|
// Only B changed: the CRs are not read as an edit on every line.
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.querySelectorAll(".cm-editor")
|
||||||
|
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
|
||||||
|
).toHaveLength(1);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||||
|
const onAddToChat = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<DiffEditor
|
||||||
|
value={"a\nb\nc\nd"}
|
||||||
|
original={"a\nb\nc\nd"}
|
||||||
|
lang="plain"
|
||||||
|
label="Edit"
|
||||||
|
revertLabel="Revert"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onSave={vi.fn()}
|
||||||
|
onAddToChat={onAddToChat}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||||
|
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
|
||||||
|
// b..c, ending at the start of d: d is not taken.
|
||||||
|
view.dispatch({ selection: { anchor: 2, head: 6 } });
|
||||||
|
const chip = await waitFor(() => {
|
||||||
|
const b = container.querySelector<HTMLButtonElement>(
|
||||||
|
'[data-testid="lane-editor-add-to-chat"]'
|
||||||
|
);
|
||||||
|
expect(b).not.toBeNull();
|
||||||
|
return b!;
|
||||||
|
});
|
||||||
|
// Inline at the end of the first selected line, never a tooltip over the
|
||||||
|
// line above it.
|
||||||
|
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||||
|
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||||
|
fireEvent.click(chip);
|
||||||
|
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||||
|
view.dispatch({ selection: { anchor: 6 } });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,15 +6,19 @@
|
|||||||
* once, the range toggle changes which comparison is requested, History opens
|
* once, the range toggle changes which comparison is requested, History opens
|
||||||
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
* one commit, and Files quick-opens a whole file. Also pins the per-file git
|
||||||
* writes — including discard needing two clicks before the server hears
|
* writes — including discard needing two clicks before the server hears
|
||||||
* `confirm` — and the degrade for a cwd git cannot read.
|
* `confirm` — the JetBrains commit (ticked files, Changes ticked and
|
||||||
|
* Unversioned Files not by default, Commit and Push, Add to VCS / .gitignore,
|
||||||
|
* a change block's own checkbox making a hunk-level commit), a file's
|
||||||
|
* history, Annotate, next/previous file, and the degrade for a cwd git
|
||||||
|
* cannot read.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||||
import { confirmDiscard } from "../../../lib/confirm";
|
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import type { LaneChanges } from "../../../lib/types";
|
import type { LaneChanges } from "../../../lib/types";
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
gitLs: vi.fn(),
|
gitLs: vi.fn(),
|
||||||
gitTree: vi.fn(),
|
gitTree: vi.fn(),
|
||||||
gitTreeAll: vi.fn(),
|
gitTreeAll: vi.fn(),
|
||||||
|
gitBlame: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -125,6 +130,11 @@ async function toDiff(user: ReturnType<typeof userEvent.setup>) {
|
|||||||
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
await user.click(await screen.findByRole("button", { name: "Changes", pressed: true }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The overview row's checkbox for one path. */
|
||||||
|
function tickOf(path: string) {
|
||||||
|
return screen.getByRole("checkbox", { name: `Include ${path} in the commit` });
|
||||||
|
}
|
||||||
|
|
||||||
/** The overview row's discard button for one path. */
|
/** The overview row's discard button for one path. */
|
||||||
function discardOf(path: string) {
|
function discardOf(path: string) {
|
||||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||||
@@ -133,7 +143,6 @@ function discardOf(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
|
||||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||||
vi.mocked(api.lanes.changes).mockReset();
|
vi.mocked(api.lanes.changes).mockReset();
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||||
@@ -153,6 +162,14 @@ beforeEach(() => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
vi.mocked(api.lanes.gitBranches)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
branches: ["feat/thing", "other"],
|
||||||
|
current: "feat/thing",
|
||||||
|
worktrees: [],
|
||||||
|
});
|
||||||
vi.mocked(api.lanes.gitLs)
|
vi.mocked(api.lanes.gitLs)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
@@ -255,7 +272,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "vs base" }));
|
// No git facts (an older server): the base has no name to show.
|
||||||
|
await user.click(screen.getByRole("button", { name: "Compared to base branch" }));
|
||||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,60 +289,36 @@ describe("LaneChangesPanel", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages one file and asks the server for that path alone", async () => {
|
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
|
||||||
// Both rows offer it (one modified, one untracked); the first is the
|
// Only the untracked row offers it: a tracked file is already in VCS.
|
||||||
// modified file the assertion below names.
|
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
expect(more).toHaveLength(0);
|
||||||
await user.click(stage!);
|
expect(tickOf("notes.txt")).not.toBeChecked();
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
await user.click(add!);
|
||||||
paths: ["server/lib/git-ops.js"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a never-stage file out of Stage all, without telling git", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
|
||||||
await screen.findByTestId("lane-changes-overview");
|
|
||||||
const [keep] = screen.getAllByRole("button", { name: /Never stage/ });
|
|
||||||
await user.click(keep!); // the first row: server/lib/git-ops.js
|
|
||||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
|
||||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([
|
|
||||||
"server/lib/git-ops.js",
|
|
||||||
]);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Stage all (1)" }));
|
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||||
// And back: the row's button now offers the way out.
|
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
|
||||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
|
||||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("draws a never-stage file orange in the Files tree", async () => {
|
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
|
||||||
window.localStorage.setItem("agenthub.changes.neverStage.7", JSON.stringify(["notes.txt"]));
|
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
|
||||||
available: true,
|
|
||||||
path: "",
|
|
||||||
entries: [
|
|
||||||
{ name: "notes.txt", path: "notes.txt", dir: false, status: "??" },
|
|
||||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
|
||||||
],
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<Tabbed laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
await user.click(tickOf("docs/API.md"));
|
||||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
|
||||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
expect(group).toBePartiallyChecked();
|
||||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
await user.click(group); // some ticked -> untick all
|
||||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
await user.click(group); // -> tick all
|
||||||
|
expect(tickOf("docs/API.md")).toBeChecked();
|
||||||
|
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists changes as a folder tree: a folder folds, and stages every file in it", async () => {
|
it("lists changes as a folder tree: a folder folds, and its box unticks every file in it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
@@ -332,16 +326,15 @@ describe("LaneChangesPanel", () => {
|
|||||||
const folder = within(list).getByTitle("server/lib");
|
const folder = within(list).getByTitle("server/lib");
|
||||||
expect(folder).toHaveTextContent("1 file");
|
expect(folder).toHaveTextContent("1 file");
|
||||||
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
expect(within(list).getByTitle("server/lib/git-ops.js")).toHaveTextContent("git-ops.js");
|
||||||
|
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||||
|
name: "Tick every file in server/lib",
|
||||||
|
});
|
||||||
|
expect(box).toBeChecked();
|
||||||
|
await user.click(box);
|
||||||
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
await user.click(folder);
|
await user.click(folder);
|
||||||
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||||
await user.click(
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
within(folder.parentElement!).getByRole("button", {
|
|
||||||
name: "Stage every file in this folder",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
|
||||||
paths: ["server/lib/git-ops.js"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
it("heads the panel with the lane's git line, and a stage re-reads it", async () => {
|
||||||
@@ -364,7 +357,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-git-bar")).toBeInTheDocument();
|
||||||
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
expect(api.lanes.git).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||||
await user.click(stage!);
|
await user.click(stage!);
|
||||||
// The header's counts moved with the index: waiting out its 30s poll
|
// The header's counts moved with the index: waiting out its 30s poll
|
||||||
// would leave them wrong beside the list that just changed.
|
// would leave them wrong beside the list that just changed.
|
||||||
@@ -411,43 +404,61 @@ describe("LaneChangesPanel", () => {
|
|||||||
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(api.lanes.gitLog).toHaveBeenCalledTimes(1));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages every file the index lacks in one call, from above the list", async () => {
|
it("commits exactly the ticked files: Changes ticked, Unversioned Files not, by default", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
const actions = screen.getByTestId("lane-changes-actions");
|
const actions = screen.getByTestId("lane-changes-actions");
|
||||||
// Commit box and Stage all come BEFORE the file list.
|
// The commit box comes BEFORE the file list.
|
||||||
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
expect(actions.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||||
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
expect(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
await user.click(tickOf("notes.txt"));
|
||||||
// The already-staged docs/API.md is not sent again.
|
await user.click(tickOf("docs/API.md"));
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
await user.type(screen.getByLabelText("Commit message"), "fix: picked");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Commit (2)" }));
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||||
|
message: "fix: picked",
|
||||||
paths: ["server/lib/git-ops.js", "notes.txt"],
|
paths: ["server/lib/git-ops.js", "notes.txt"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Commit and Push commits the ticked files, then pushes", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-commit");
|
||||||
|
await user.type(screen.getByLabelText("Commit message"), "feat: ship");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Commit and Push" }));
|
||||||
|
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledTimes(2));
|
||||||
|
expect(vi.mocked(api.lanes.gitAction).mock.calls).toEqual([
|
||||||
|
[7, "commit", { message: "feat: ship", paths: ["server/lib/git-ops.js", "docs/API.md"] }],
|
||||||
|
[7, "push", {}],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("fills the commit box with the AI-suggested message", async () => {
|
it("fills the commit box with the AI-suggested message", async () => {
|
||||||
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
await user.click(screen.getByTestId("lane-changes-suggest-message"));
|
||||||
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7);
|
expect(api.lanes.suggestCommitMessage).toHaveBeenCalledWith(7, [
|
||||||
|
"server/lib/git-ops.js",
|
||||||
|
"docs/API.md",
|
||||||
|
]);
|
||||||
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
expect(await screen.findByDisplayValue("docs: update API")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists staged files apart from the changes the next commit leaves behind", async () => {
|
it("lists tracked changes apart from Unversioned Files, staged or not", async () => {
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
|
||||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
|
||||||
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
expect(within(tracked).queryByText("notes.txt")).toBeNull();
|
||||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
|
||||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
it("reads a whole markdown file rendered after Preview, and back as source after Raw", async () => {
|
||||||
@@ -618,21 +629,21 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("right-click on a changed file offers stage, a two-click discard and copy path", async () => {
|
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
const list = await screen.findByTestId("lane-changes-overview");
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||||
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
const right = () => user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||||
|
|
||||||
|
// Commit File: that file alone is ticked, and the message box has focus.
|
||||||
await right();
|
await right();
|
||||||
await user.click(
|
await user.click(menu().getByText("Commit File…"));
|
||||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||||
);
|
expect(tickOf("docs/API.md")).not.toBeChecked();
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
expect(screen.getByLabelText("Commit message")).toHaveFocus();
|
||||||
paths: ["server/lib/git-ops.js"],
|
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
|
||||||
});
|
|
||||||
vi.mocked(api.lanes.gitAction).mockClear();
|
|
||||||
|
|
||||||
await right();
|
await right();
|
||||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||||
@@ -645,10 +656,123 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await right();
|
await right();
|
||||||
await user.click(
|
await user.click(menu().getByText("Copy relative path"));
|
||||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
|
||||||
);
|
|
||||||
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
expect(await navigator.clipboard.readText()).toBe("server/lib/git-ops.js");
|
||||||
|
|
||||||
|
// Copy as Patch: that file's diff, read for its path, as a unified patch.
|
||||||
|
vi.mocked(api.lanes.changes).mockResolvedValueOnce(
|
||||||
|
changes({
|
||||||
|
files: [{ ...changes().files![0]!, hunks: [HUNK] } as never],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await right();
|
||||||
|
await user.click(menu().getByText("Copy as Patch to Clipboard"));
|
||||||
|
await waitFor(async () =>
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(
|
||||||
|
[
|
||||||
|
"diff --git a/server/lib/git-ops.js b/server/lib/git-ops.js",
|
||||||
|
"--- a/server/lib/git-ops.js",
|
||||||
|
"+++ b/server/lib/git-ops.js",
|
||||||
|
"@@ -1,2 +1,3 @@",
|
||||||
|
" keep",
|
||||||
|
"-old line",
|
||||||
|
"+new line",
|
||||||
|
"",
|
||||||
|
].join("\n")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(api.lanes.changes).toHaveBeenCalledWith(7, {
|
||||||
|
range: "work",
|
||||||
|
path: "server/lib/git-ops.js",
|
||||||
|
context: 3,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-click on an unversioned file offers Add to VCS and Add to .gitignore", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
|
const row = within(list).getByTitle("notes.txt");
|
||||||
|
const menu = () => within(screen.getByTestId("lane-changes-file-menu"));
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
await user.click(menu().getByText("Add to .gitignore"));
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "ignore", { paths: ["notes.txt"] });
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
expect(menu().getByText("Add to VCS")).toBeInTheDocument();
|
||||||
|
// A tracked file is already in VCS, and git would not ignore it anyway.
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
await user.pointer({
|
||||||
|
keys: "[MouseRight]",
|
||||||
|
target: within(list).getByTitle("docs/API.md"),
|
||||||
|
});
|
||||||
|
expect(menu().queryByText("Add to .gitignore")).toBeNull();
|
||||||
|
expect(menu().queryByText("Add to VCS")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Show History lists one file's commits and opens the file at a commit", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.pointer({
|
||||||
|
keys: "[MouseRight]",
|
||||||
|
target: within(list).getByTitle("server/lib/git-ops.js"),
|
||||||
|
});
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("lane-changes-file-menu")).getByText("Show History")
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, {
|
||||||
|
limit: 50,
|
||||||
|
file: "server/lib/git-ops.js",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("lane-changes-history-file")).toHaveTextContent(
|
||||||
|
"server/lib/git-ops.js"
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByText("feat: the commit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||||
|
range: "commit",
|
||||||
|
sha: "a".repeat(40),
|
||||||
|
path: "server/lib/git-ops.js",
|
||||||
|
context: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks to the next file from an open one, in list order", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Next file" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.changes).toHaveBeenLastCalledWith(7, {
|
||||||
|
range: "work",
|
||||||
|
path: "notes.txt",
|
||||||
|
context: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Previous file" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annotates the open file with who last changed each line", async () => {
|
||||||
|
vi.mocked(api.lanes.gitBlame).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
path: "server/lib/git-ops.js",
|
||||||
|
commits: {},
|
||||||
|
lines: [],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
const list = await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(within(list).getByTitle("server/lib/git-ops.js"));
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Annotate" }));
|
||||||
|
expect(api.lanes.gitBlame).toHaveBeenCalledWith(7, "server/lib/git-ops.js");
|
||||||
|
expect(screen.getByRole("button", { name: "Annotate" })).toHaveAttribute(
|
||||||
|
"aria-pressed",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
it("disarms a pending discard when the lane changes, so one click cannot discard in another lane", async () => {
|
||||||
@@ -739,6 +863,132 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
expect(await screen.findByTestId("lane-changes-commit-chip")).toHaveTextContent("aaaaaaa");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("history rewrites, amend and vs base", () => {
|
||||||
|
const facts = (over: Record<string, unknown> = {}) => ({
|
||||||
|
available: true as const,
|
||||||
|
branch: "feat/thing",
|
||||||
|
head: "aaaaaaa",
|
||||||
|
subject: "feat: the commit",
|
||||||
|
dirty: 2,
|
||||||
|
untracked: 1,
|
||||||
|
files: [],
|
||||||
|
hasRemote: false,
|
||||||
|
upstream: null,
|
||||||
|
ahead: null,
|
||||||
|
behind: null,
|
||||||
|
base: "main",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const commitMenu = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
const row = await within(await screen.findByTestId("lane-changes-history")).findByText(
|
||||||
|
"feat: the commit"
|
||||||
|
);
|
||||||
|
await user.pointer({ keys: "[MouseRight]", target: row });
|
||||||
|
return screen.getByTestId("lane-changes-commit-menu");
|
||||||
|
};
|
||||||
|
const withParent = () =>
|
||||||
|
vi.mocked(api.lanes.gitLog).mockResolvedValue({
|
||||||
|
available: true,
|
||||||
|
commits: [
|
||||||
|
{
|
||||||
|
sha: "a".repeat(40),
|
||||||
|
short: "aaaaaaa",
|
||||||
|
subject: "feat: the commit",
|
||||||
|
author: "t",
|
||||||
|
date: "2026-09-22T01:00:00Z",
|
||||||
|
refs: ["HEAD -> feat/thing"],
|
||||||
|
parents: ["b".repeat(40)],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts a commit and undoes the top one from the history menu", async () => {
|
||||||
|
withParent();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
|
||||||
|
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "revert", { sha: "a".repeat(40) });
|
||||||
|
|
||||||
|
await user.click(within(await commitMenu(user)).getByText("Undo commit (keep changes)"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "undo-commit", {
|
||||||
|
confirm: true,
|
||||||
|
sha: "a".repeat(40),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cherry-picks only from another branch's history", async () => {
|
||||||
|
withParent();
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
expect(within(await commitMenu(user)).queryByText(/Cherry-pick/)).toBeNull();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
|
||||||
|
await user.selectOptions(await screen.findByTestId("lane-changes-history-ref"), "other");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitLog).toHaveBeenLastCalledWith(7, { limit: 50, ref: "other" })
|
||||||
|
);
|
||||||
|
await user.click(within(await commitMenu(user)).getByText("Cherry-pick onto feat/thing"));
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "cherry-pick", { sha: "a".repeat(40) });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks before a revert and an amend, and a No sends nothing", async () => {
|
||||||
|
withParent();
|
||||||
|
vi.mocked(confirmDialog).mockClear();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
await user.click(within(await commitMenu(user)).getByText("Revert this commit"));
|
||||||
|
await waitFor(() => expect(confirmDialog).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("amends the last commit, keeping its message when none is typed", async () => {
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByTestId("lane-changes-amend"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Amend" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "amend", {
|
||||||
|
sha: "aaaaaaa",
|
||||||
|
paths: ["server/lib/git-ops.js", "docs/API.md"],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the base in full, and on the base branch itself shows no toggle at all", async () => {
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||||
|
const { unmount } = render(<LaneChangesPanel laneId={7} />);
|
||||||
|
expect(await screen.findByRole("button", { name: "Compared to main" })).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValue(facts({ branch: "main" }));
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-git");
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
expect(screen.queryByRole("button", { name: "Compared to main" })).toBeNull();
|
||||||
|
// A lone "Uncommitted" would be a toggle with nothing to toggle to.
|
||||||
|
expect(screen.queryByRole("button", { name: "Uncommitted" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
|
it("expands a folder in place, keeping its siblings visible, instead of navigating into it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
||||||
@@ -806,7 +1056,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByText("src")).toBeInTheDocument();
|
expect(screen.getByText("src")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights modified and untracked files in the Files tree, and folders holding them", async () => {
|
it("colours the Files tree like JetBrains: modified blue, unversioned orange, added green", async () => {
|
||||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||||
available: true,
|
available: true,
|
||||||
path: "",
|
path: "",
|
||||||
@@ -816,6 +1066,8 @@ describe("LaneChangesPanel", () => {
|
|||||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||||
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
||||||
{ name: "keep.txt", path: "keep.txt", dir: false },
|
{ name: "keep.txt", path: "keep.txt", dir: false },
|
||||||
|
{ name: "add.txt", path: "add.txt", dir: false, status: "A " },
|
||||||
|
{ name: "gone.txt", path: "gone.txt", dir: false, status: " D" },
|
||||||
],
|
],
|
||||||
truncated: false,
|
truncated: false,
|
||||||
});
|
});
|
||||||
@@ -826,9 +1078,12 @@ describe("LaneChangesPanel", () => {
|
|||||||
|
|
||||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||||
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
|
expect(await row("a.txt")).toHaveAttribute("data-tree-status", "M");
|
||||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
expect(await row("a.txt")).toHaveClass("text-diff-mod");
|
||||||
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
expect(await row("new.txt")).toHaveAttribute("data-tree-status", "U");
|
||||||
expect(await row("new.txt")).toHaveClass("text-status-success");
|
expect(await row("new.txt")).toHaveClass("text-kept");
|
||||||
|
expect(await row("add.txt")).toHaveClass("text-status-success");
|
||||||
|
expect(await row("gone.txt")).toHaveClass("text-fg-muted");
|
||||||
|
expect(await row("src")).toHaveClass("text-diff-mod");
|
||||||
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
expect(await row("src")).toHaveAttribute("data-tree-status", "dir");
|
||||||
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
expect(await row("keep.txt")).not.toHaveAttribute("data-tree-status");
|
||||||
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
expect(await row("docs")).not.toHaveAttribute("data-tree-status");
|
||||||
@@ -937,18 +1192,6 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
expect(screen.getByTestId("lane-file-editor")).toHaveValue("xy");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unstages every staged file in one call, on the same row as Stage all and commit", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
|
||||||
await screen.findByTestId("lane-changes-overview");
|
|
||||||
const row = screen.getByTestId("lane-changes-actions");
|
|
||||||
expect(row).toContainElement(screen.getByTestId("lane-changes-commit"));
|
|
||||||
expect(row).toContainElement(screen.getByRole("button", { name: "Stage all (2)" }));
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Unstage all (1)" }));
|
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "unstage", { paths: ["docs/API.md"] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("switches the diff to side-by-side", async () => {
|
it("switches the diff to side-by-side", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
@@ -1025,22 +1268,14 @@ describe("LaneChangesPanel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("commits the index from the overview, never the whole tree", async () => {
|
it("cannot commit with nothing ticked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-commit");
|
||||||
|
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
|
||||||
// The box exists only because a file is staged — with a clean index there
|
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
|
||||||
// is nothing this button could mean.
|
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
|
||||||
const box = await screen.findByTestId("lane-changes-commit");
|
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
|
||||||
await user.type(screen.getByLabelText("Commit message"), "fix: the staged half");
|
|
||||||
await user.click(screen.getByRole("button", { name: "Commit 1 staged" }));
|
|
||||||
|
|
||||||
expect(box).toBeInTheDocument();
|
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
|
||||||
message: "fix: the staged half",
|
|
||||||
only: "staged",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drops a half-typed commit message on a lane switch", async () => {
|
it("drops a half-typed commit message on a lane switch", async () => {
|
||||||
@@ -1053,24 +1288,10 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the commit box when nothing is staged", async () => {
|
it("hides the commit box when there is nothing to commit", async () => {
|
||||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
|
||||||
changes({
|
|
||||||
files: [
|
|
||||||
{
|
|
||||||
path: "server/lib/git-ops.js",
|
|
||||||
status: " M",
|
|
||||||
untracked: false,
|
|
||||||
unstaged: true,
|
|
||||||
added: 1,
|
|
||||||
deleted: 0,
|
|
||||||
binary: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByText("Nothing uncommitted.");
|
||||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1160,22 +1381,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
await screen.findByTestId("lane-changes-review");
|
await screen.findByTestId("lane-changes-review");
|
||||||
};
|
};
|
||||||
|
|
||||||
it("stages one hunk by its @@ line, at the context the diff was read with", async () => {
|
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
|
||||||
const user = userEvent.setup();
|
|
||||||
await openReview(user);
|
|
||||||
// Two hunks on the tracked file; the untracked one has none to act on.
|
|
||||||
const stage = screen.getAllByRole("button", { name: "Stage this hunk" });
|
|
||||||
expect(stage).toHaveLength(2);
|
|
||||||
await user.click(stage[1]!);
|
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
|
||||||
path: "server/lib/git-ops.js",
|
|
||||||
hunk: "@@ -9,2 +10,3 @@",
|
|
||||||
block: 0,
|
|
||||||
context: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("gives each +/- block inside ONE hunk its own bar and acts on that block only (#50)", async () => {
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
@@ -1218,13 +1424,35 @@ describe("LaneChangesPanel", () => {
|
|||||||
expect.stringContaining("1 of 2"),
|
expect.stringContaining("1 of 2"),
|
||||||
expect.stringContaining("2 of 2"),
|
expect.stringContaining("2 of 2"),
|
||||||
]);
|
]);
|
||||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
expect(boxes).toHaveLength(2);
|
||||||
path: "server/lib/git-ops.js",
|
await user.click(boxes[1]!);
|
||||||
hunk: "@@ -1,4 +1,5 @@",
|
expect(boxes[0]).toBeChecked();
|
||||||
block: 1,
|
expect(boxes[1]).not.toBeChecked();
|
||||||
context: 3,
|
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||||
});
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("commits only the ticked change blocks of a partly ticked file (hunks)", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await openReview(user);
|
||||||
|
// The second hunk's only block unticked: the file is partial.
|
||||||
|
await user.click(
|
||||||
|
screen.getAllByRole("checkbox", { name: "Include this change in the commit" })[1]!
|
||||||
|
);
|
||||||
|
expect(tickOf("server/lib/git-ops.js")).toBePartiallyChecked();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Changes" }));
|
||||||
|
await screen.findByTestId("lane-changes-commit");
|
||||||
|
await user.type(screen.getByLabelText("Commit message"), "fix: half");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Commit (1)" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "commit", {
|
||||||
|
message: "fix: half",
|
||||||
|
paths: [],
|
||||||
|
hunks: { "server/lib/git-ops.js": [{ hunk: "@@ -1,2 +1,3 @@", blocks: [0] }] },
|
||||||
|
context: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
it("hangs one review bar under each actionable hunk, counting them", async () => {
|
||||||
@@ -1266,14 +1494,18 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages a whole file from its review header without folding it", async () => {
|
it("unticks a whole file from its review header without folding it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
await openReview(user);
|
await openReview(user);
|
||||||
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
|
||||||
await user.click(stageFile!);
|
await user.click(tickOf("server/lib/git-ops.js"));
|
||||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||||
paths: ["server/lib/git-ops.js"],
|
// Every block of it follows the file.
|
||||||
});
|
for (const box of screen.getAllByRole("checkbox", {
|
||||||
|
name: "Include this change in the commit",
|
||||||
|
}))
|
||||||
|
expect(box).not.toBeChecked();
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -1483,7 +1715,7 @@ describe("LaneChangesPanel", () => {
|
|||||||
);
|
);
|
||||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||||
await screen.findByTestId("lane-changes-overview");
|
await screen.findByTestId("lane-changes-overview");
|
||||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
const [stage] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||||
await user.click(stage!);
|
await user.click(stage!);
|
||||||
|
|
||||||
rerender(<LaneChangesPanel laneId={8} />);
|
rerender(<LaneChangesPanel laneId={8} />);
|
||||||
@@ -1600,6 +1832,8 @@ describe("a merge in progress (issue #45)", () => {
|
|||||||
// Not offered as an ordinary stage: that would mark it resolved blind.
|
// Not offered as an ordinary stage: that would mark it resolved blind.
|
||||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||||
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
expect(screen.getByRole("button", { name: "Complete merge" })).toBeDisabled();
|
||||||
|
// A partial commit cannot finish a merge: Complete is the way out.
|
||||||
|
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||||
|
|
||||||
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
await user.click(within(group).getByRole("button", { name: "Take theirs" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@@ -1647,3 +1881,44 @@ describe("a merge in progress (issue #45)", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("gitChangeFor", () => {
|
||||||
|
// One hunk, two change blocks three lines apart: an edit and a removal.
|
||||||
|
const file = {
|
||||||
|
path: "a.txt",
|
||||||
|
status: " M",
|
||||||
|
untracked: false,
|
||||||
|
added: 1,
|
||||||
|
deleted: 2,
|
||||||
|
binary: false,
|
||||||
|
hunks: [
|
||||||
|
{
|
||||||
|
header: "@@ -1,6 +1,5 @@",
|
||||||
|
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: "ctx" as const, oldLine: 3, newLine: 3, text: "c" },
|
||||||
|
{ type: "ctx" as const, oldLine: 4, newLine: 4, text: "d" },
|
||||||
|
{ type: "del" as const, oldLine: 5, newLine: null, text: "e" },
|
||||||
|
{ type: "ctx" as const, oldLine: 6, newLine: 5, text: "f" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("finds git's block by its line span on both sides", () => {
|
||||||
|
expect(gitChangeFor(file, { oldFrom: 1, oldCount: 1, newFrom: 1, newCount: 1 })?.block).toBe(0);
|
||||||
|
// A pure removal: matched on the old side alone.
|
||||||
|
expect(gitChangeFor(file, { oldFrom: 5, oldCount: 1, newFrom: 5, newCount: 0 })?.block).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds none when the editor split the change differently", () => {
|
||||||
|
expect(
|
||||||
|
gitChangeFor(file, { oldFrom: 1, oldCount: 2, newFrom: 1, newCount: 1 })
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
visibleRange,
|
visibleRange,
|
||||||
} from "../LaneDiffView";
|
} from "../LaneDiffView";
|
||||||
import type { LaneChangeFile } from "../../../lib/types";
|
import type { LaneChangeFile } from "../../../lib/types";
|
||||||
|
import { bandPath } from "../../../lib/diffConnectors";
|
||||||
|
|
||||||
const FILE: LaneChangeFile = {
|
const FILE: LaneChangeFile = {
|
||||||
path: "server/lib/git-ops.js",
|
path: "server/lib/git-ops.js",
|
||||||
@@ -59,8 +60,8 @@ describe("LaneDiffView — side by side", () => {
|
|||||||
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
expect(screen.getByTestId("lane-diff-split-right").className).toContain("overflow-auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
it("pads the shorter side only at the hunk's end, each pad one line tall", () => {
|
||||||
// Two lines removed, none added: the right column's two rows are fillers.
|
// Two lines removed, none added: the right column's two rows are padding.
|
||||||
const file = fileWith([
|
const file = fileWith([
|
||||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||||
@@ -72,7 +73,39 @@ describe("LaneDiffView — side by side", () => {
|
|||||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||||
];
|
];
|
||||||
expect(fillers).toHaveLength(2);
|
expect(fillers).toHaveLength(2);
|
||||||
for (const row of fillers) expect(row.style.minHeight).toBe("16px");
|
for (const row of fillers) expect(row.style.height).toBe("16px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("packs each side's own lines, JetBrains-style, and joins every block with a band", () => {
|
||||||
|
const file = fileWith([
|
||||||
|
{ type: "ctx", oldLine: 1, newLine: 1, text: "top" },
|
||||||
|
{ type: "del", oldLine: 2, newLine: null, text: "old a" },
|
||||||
|
{ type: "del", oldLine: 3, newLine: null, text: "old b" },
|
||||||
|
{ type: "add", oldLine: null, newLine: 2, text: "new a" },
|
||||||
|
{ type: "ctx", oldLine: 4, newLine: 3, text: "mid" },
|
||||||
|
{ type: "add", oldLine: null, newLine: 4, text: "extra" },
|
||||||
|
{ type: "ctx", oldLine: 5, newLine: 5, text: "end" },
|
||||||
|
]);
|
||||||
|
render(<LaneDiffView file={file} layout="split" />);
|
||||||
|
const texts = (id: string) =>
|
||||||
|
[...screen.getByTestId(id).querySelectorAll<HTMLElement>("div.flex.w-max")].map((r) =>
|
||||||
|
r.textContent?.replace(/^\d+/, "")
|
||||||
|
);
|
||||||
|
// No gap inside a hunk: a side's lines follow one another (5 each, no pad).
|
||||||
|
expect(texts("lane-diff-split-left")).toEqual(["top", "old a", "old b", "mid", "end"]);
|
||||||
|
expect(texts("lane-diff-split-right")).toEqual(["top", "new a", "mid", "extra", "end"]);
|
||||||
|
// Two blocks, two bands: rows start under the one-row hunk header.
|
||||||
|
const bands = [
|
||||||
|
...screen.getByTestId("lane-diff-connectors").querySelectorAll<SVGPathElement>("path"),
|
||||||
|
];
|
||||||
|
expect(bands.map((b) => b.dataset.kind)).toEqual(["mod", "add"]);
|
||||||
|
expect(bands[0]!.getAttribute("d")).toBe(
|
||||||
|
bandPath(36, 2 * ROW_PX, 4 * ROW_PX, 2 * ROW_PX, 3 * ROW_PX)
|
||||||
|
);
|
||||||
|
// The insertion is a wedge from the left's point between "mid" and "end".
|
||||||
|
expect(bands[1]!.getAttribute("d")).toBe(
|
||||||
|
bandPath(36, 5 * ROW_PX, 5 * ROW_PX, 4 * ROW_PX, 5 * ROW_PX)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
it("caps a review diff only when it windows; the single-file view fills its parent", () => {
|
||||||
@@ -414,8 +447,8 @@ describe("LaneDiffView — windowing", () => {
|
|||||||
expect(l.length).toBeLessThan(300);
|
expect(l.length).toBeLessThan(300);
|
||||||
expect(l).toHaveLength(r.length);
|
expect(l).toHaveLength(r.length);
|
||||||
expect(right.textContent).toContain("line 2001;");
|
expect(right.textContent).toContain("line 2001;");
|
||||||
// same rows: the right column's add has its filler row on the left
|
// Packed: the left holds only the context lines, so it runs twice as far.
|
||||||
expect(left.textContent).toContain("line 2000;");
|
expect(left.textContent).toContain("line 4000;");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("windows a whole-file read too", () => {
|
it("windows a whole-file read too", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { LaneGitBar } from "../LaneGitBar";
|
import { LaneGitBar } from "../LaneGitBar";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
import type { LaneGitFacts } from "../../../lib/types";
|
import type { LaneGitFacts } from "../../../lib/types";
|
||||||
|
|
||||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||||
@@ -129,6 +130,40 @@ describe("LaneGitBar", () => {
|
|||||||
expect(onChanged).toHaveBeenCalled();
|
expect(onChanged).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("asks before every outward or ref-deleting write, and a No sends nothing", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
const { rerender } = render(
|
||||||
|
<LaneGitBar laneId={3} git={facts({ ahead: 2 })} onChanged={vi.fn()} />
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||||
|
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("2 commit") })
|
||||||
|
);
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A Yes goes through.
|
||||||
|
await user.click(screen.getByRole("button", { name: /Push/ }));
|
||||||
|
await waitFor(() => expect(api.lanes.gitAction).toHaveBeenCalledWith(3, "push", {}));
|
||||||
|
|
||||||
|
vi.mocked(api.lanes.gitAction).mockClear();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
rerender(
|
||||||
|
<LaneGitBar
|
||||||
|
laneId={3}
|
||||||
|
git={facts({ hasRemote: false, upstream: null, ahead: null, behind: null })}
|
||||||
|
onChanged={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await user.click(screen.getByRole("button", { name: "Publish…" }));
|
||||||
|
await user.type(screen.getByLabelText(/Remote URL/), "git@host:o/r.git");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Publish" }));
|
||||||
|
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("git@host:o/r.git") })
|
||||||
|
);
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -338,6 +338,21 @@ describe("WorkspaceHome", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a terminal tab at once from the AI menu, without a prompt", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHome();
|
||||||
|
await screen.findByTestId("workspace-recent-s-old");
|
||||||
|
const ai = screen.getByTestId("workspace-home-tool");
|
||||||
|
await user.click(within(ai).getByRole("button"));
|
||||||
|
await user.click(screen.getByRole("option", { name: /Open terminal/ }));
|
||||||
|
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("where").textContent).toMatch(/^\/workspace\?lane=1&run=/)
|
||||||
|
);
|
||||||
|
// The AI picked for the next prompt is untouched.
|
||||||
|
expect(within(ai).getByRole("button")).not.toHaveTextContent(/terminal/i);
|
||||||
|
});
|
||||||
|
|
||||||
it("says so when the server finds all ten sessions in use", async () => {
|
it("says so when the server finds all ten sessions in use", async () => {
|
||||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||||
@@ -382,6 +397,24 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { archived: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows more recents a page at a time, and collapses them again", async () => {
|
||||||
|
vi.mocked(api.sessions.list).mockImplementation(async (params) => {
|
||||||
|
const all = (params as { archived?: boolean }).archived
|
||||||
|
? []
|
||||||
|
: Array.from({ length: 35 }, (_, i) => ({ ...sessions[1]!, id: `s-${i}` }));
|
||||||
|
const limit = (params as { limit?: number }).limit ?? 30;
|
||||||
|
return { sessions: all.slice(0, limit), total: all.length, limit, offset: 0 };
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderHome();
|
||||||
|
await user.click(await screen.findByTestId("workspace-recents-more"));
|
||||||
|
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 60 });
|
||||||
|
await user.click(await screen.findByTestId("workspace-recents-less"));
|
||||||
|
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 30 });
|
||||||
|
expect(await screen.findByTestId("workspace-recents-more")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("workspace-recents-less")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
it("opens a live recent's pane and an ended one's conversation, resuming nothing", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderHome();
|
renderHome();
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
/**
|
||||||
|
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
|
||||||
|
* (read-only, left) and the working file (editable, right) are two
|
||||||
|
* CodeMirror views in their OWN scrollers, with no padding to line them up —
|
||||||
|
* each shows its real lines — and the gutter between them draws, for every
|
||||||
|
* changed block, a curved band joining its lines on the left to its lines on
|
||||||
|
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
|
||||||
|
* Scrolling either side scrolls the other to the matching line (`mapLine`:
|
||||||
|
* 1:1 outside a block, proportional inside one).
|
||||||
|
*
|
||||||
|
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
|
||||||
|
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
|
||||||
|
* every edit of the right side; the changed-line, changed-text and gutter
|
||||||
|
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
|
||||||
|
* unchanged stretches (opened on both sides at once) and the revert's text
|
||||||
|
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
EditorState,
|
||||||
|
RangeSet,
|
||||||
|
StateEffect,
|
||||||
|
StateField,
|
||||||
|
Text,
|
||||||
|
type Extension,
|
||||||
|
} from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
GutterMarker,
|
||||||
|
WidgetType,
|
||||||
|
gutterLineClass,
|
||||||
|
type DecorationSet,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { Chunk, getChunks } from "@codemirror/merge";
|
||||||
|
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
|
||||||
|
|
||||||
|
/** The chunks a side carries, the side it is, and (right side) the original. */
|
||||||
|
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
|
||||||
|
|
||||||
|
const setChunks = StateEffect.define<readonly Chunk[]>();
|
||||||
|
const uncollapse = StateEffect.define<number>();
|
||||||
|
|
||||||
|
/** Each side's chunks. The right side recomputes them as it is edited; the
|
||||||
|
* left, read-only, is handed the new list (`setChunks`). */
|
||||||
|
const splitField = StateField.define<SplitState>({
|
||||||
|
create: () => ({ chunks: [], side: "b", orig: null }),
|
||||||
|
update(v, tr) {
|
||||||
|
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
|
||||||
|
if (v.orig && tr.docChanged) {
|
||||||
|
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The chunks and side of an editor in either merge layout — unified (the
|
||||||
|
* merge package's own state) or this split. */
|
||||||
|
export function chunksOf(
|
||||||
|
state: EditorState
|
||||||
|
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
|
||||||
|
return getChunks(state) ?? state.field(splitField, false) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedLine = Decoration.line({ class: "cm-changedLine" });
|
||||||
|
const changedText = Decoration.mark({ class: "cm-changedText" });
|
||||||
|
const changedGutter = new (class extends GutterMarker {
|
||||||
|
override elementClass = "cm-changedLineGutter";
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** The lines (and, inside them, the changed spans) of this side's chunks. */
|
||||||
|
function changedRanges(state: EditorState) {
|
||||||
|
const { chunks, side } = state.field(splitField);
|
||||||
|
const doc = state.doc;
|
||||||
|
const lines: number[] = [];
|
||||||
|
const spans: [number, number][] = [];
|
||||||
|
for (const c of chunks) {
|
||||||
|
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
||||||
|
if (from >= to) continue;
|
||||||
|
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
|
||||||
|
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
|
||||||
|
const line = doc.line(n);
|
||||||
|
lines.push(line.from);
|
||||||
|
for (const ch of c.changes) {
|
||||||
|
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
|
||||||
|
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
|
||||||
|
if (s < e) spans.push([s, e]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lines, spans };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
|
||||||
|
const { lines, spans } = changedRanges(state);
|
||||||
|
return Decoration.set(
|
||||||
|
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
|
||||||
|
RangeSet.of(
|
||||||
|
changedRanges(state).lines.map((p) => changedGutter.range(p)),
|
||||||
|
true
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** The other side's editor, for a fold opened on one side to open on both. */
|
||||||
|
const siblings = new WeakMap<EditorView, EditorView>();
|
||||||
|
|
||||||
|
/** `pos` on one side, moved past the chunks before it to the other (ported). */
|
||||||
|
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
|
||||||
|
let ours = 0;
|
||||||
|
let theirs = 0;
|
||||||
|
for (const c of chunks) {
|
||||||
|
if ((isA ? c.fromA : c.fromB) >= pos) break;
|
||||||
|
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
|
||||||
|
}
|
||||||
|
return theirs + (pos - ours);
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollapseWidget extends WidgetType {
|
||||||
|
constructor(readonly lines: number) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
override eq(o: CollapseWidget) {
|
||||||
|
return this.lines === o.lines;
|
||||||
|
}
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "cm-collapsedLines";
|
||||||
|
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
|
||||||
|
el.addEventListener("click", (e) => {
|
||||||
|
const pos = view.posAtDOM(e.target as Node);
|
||||||
|
view.dispatch({ effects: uncollapse.of(pos) });
|
||||||
|
const { chunks, side } = view.state.field(splitField);
|
||||||
|
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
override ignoreEvent(e: Event) {
|
||||||
|
return e instanceof MouseEvent;
|
||||||
|
}
|
||||||
|
override get estimatedHeight() {
|
||||||
|
return 27;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folded unchanged stretches: built once at mount, then only mapped,
|
||||||
|
* opened by a click, and dropped where an edit grows a chunk into them. */
|
||||||
|
const collapsedField = StateField.define<DecorationSet>({
|
||||||
|
create: () => Decoration.none,
|
||||||
|
update(deco, tr) {
|
||||||
|
deco = deco.map(tr.changes);
|
||||||
|
for (const e of tr.effects) {
|
||||||
|
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
|
||||||
|
}
|
||||||
|
const { chunks, side } = tr.state.field(splitField);
|
||||||
|
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
|
||||||
|
const touching: number[] = [];
|
||||||
|
for (const c of chunks) {
|
||||||
|
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
|
||||||
|
touching.push(from);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
|
||||||
|
}
|
||||||
|
return deco;
|
||||||
|
},
|
||||||
|
provide: (f) => EditorView.decorations.from(f),
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
|
||||||
|
const { chunks, side } = state.field(splitField);
|
||||||
|
const doc = state.doc;
|
||||||
|
const out = [];
|
||||||
|
let prev = 1;
|
||||||
|
for (let i = 0; ; i += 1) {
|
||||||
|
const c = i < chunks.length ? chunks[i]! : null;
|
||||||
|
const from = i ? prev + margin : 1;
|
||||||
|
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
|
||||||
|
const lines = to - from + 1;
|
||||||
|
if (lines >= minLines) {
|
||||||
|
out.push(
|
||||||
|
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
|
||||||
|
doc.line(from).from,
|
||||||
|
doc.line(to).to
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!c) break;
|
||||||
|
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
|
||||||
|
}
|
||||||
|
return Decoration.set(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chunk side as 0-based half-open lines; an empty side is the point
|
||||||
|
* before the line it sits above (past the end: after the last line). */
|
||||||
|
function sideLines(doc: Text, from: number, to: number): [number, number] {
|
||||||
|
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
|
||||||
|
if (from >= to) return [first, first];
|
||||||
|
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
|
||||||
|
return chunks.map((c) => {
|
||||||
|
const [a0, a1] = sideLines(a, c.fromA, c.toA);
|
||||||
|
const [b0, b1] = sideLines(b, c.fromB, c.toB);
|
||||||
|
return { a0, a1, b0, b1 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the document starts inside the view's scroll content. */
|
||||||
|
const docOffset = (v: EditorView) =>
|
||||||
|
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
|
||||||
|
|
||||||
|
/** The fractional line at the top of the view (a folded stretch spreads its
|
||||||
|
* height over the lines it hides). */
|
||||||
|
function topLine(v: EditorView) {
|
||||||
|
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
|
||||||
|
const blk = v.lineBlockAtHeight(h);
|
||||||
|
const first = v.state.doc.lineAt(blk.from).number - 1;
|
||||||
|
const n = v.state.doc.lineAt(blk.to).number - first;
|
||||||
|
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
|
||||||
|
return first + frac * n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Document y of a fractional line (0-based). */
|
||||||
|
function yOf(v: EditorView, line: number) {
|
||||||
|
const doc = v.state.doc;
|
||||||
|
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
|
||||||
|
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
|
||||||
|
const first = doc.lineAt(blk.from).number - 1;
|
||||||
|
const n = doc.lineAt(blk.to).number - first;
|
||||||
|
return blk.top + ((line - first) / n) * blk.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gutter's width: room for an S-curve and the » under it. */
|
||||||
|
const GUTTER_PX = 40;
|
||||||
|
const BAND: Record<"add" | "del" | "mod", string> = {
|
||||||
|
add: "fill-status-success/20 stroke-status-success/60",
|
||||||
|
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||||
|
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||||
|
};
|
||||||
|
const SVG = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
export function splitDiff({
|
||||||
|
parent,
|
||||||
|
original,
|
||||||
|
value,
|
||||||
|
extA,
|
||||||
|
extB,
|
||||||
|
collapse,
|
||||||
|
revertLabel,
|
||||||
|
}: {
|
||||||
|
parent: HTMLElement;
|
||||||
|
original: string;
|
||||||
|
value: string;
|
||||||
|
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
|
||||||
|
extA: Extension[];
|
||||||
|
extB: Extension[];
|
||||||
|
collapse?: { margin: number; minSize: number };
|
||||||
|
revertLabel: string;
|
||||||
|
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||||
|
const origDoc = Text.of(original.split(/\r?\n/));
|
||||||
|
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
|
||||||
|
|
||||||
|
const root = parent.appendChild(document.createElement("div"));
|
||||||
|
root.className = "flex h-full min-h-0";
|
||||||
|
const pane = () => {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "h-full min-w-0 flex-1";
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
const paneA = root.appendChild(pane());
|
||||||
|
const gutter = root.appendChild(document.createElement("div"));
|
||||||
|
gutter.className =
|
||||||
|
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
||||||
|
gutter.style.width = `${GUTTER_PX}px`;
|
||||||
|
gutter.dataset.testid = "lane-editor-connectors";
|
||||||
|
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
||||||
|
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
||||||
|
const buttons = gutter.appendChild(document.createElement("div"));
|
||||||
|
const paneB = root.appendChild(pane());
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
const draw = () => {
|
||||||
|
if (frame) return;
|
||||||
|
const run = () => {
|
||||||
|
frame = 0;
|
||||||
|
paint();
|
||||||
|
};
|
||||||
|
frame =
|
||||||
|
typeof requestAnimationFrame === "function"
|
||||||
|
? requestAnimationFrame(run)
|
||||||
|
: window.setTimeout(run);
|
||||||
|
};
|
||||||
|
|
||||||
|
const side = (s: "a" | "b"): Extension[] => [
|
||||||
|
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
||||||
|
chunkDecorations,
|
||||||
|
chunkGutter,
|
||||||
|
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
||||||
|
...(collapse
|
||||||
|
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
||||||
|
: []),
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const a = new EditorView({
|
||||||
|
parent: paneA,
|
||||||
|
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
||||||
|
});
|
||||||
|
const b = new EditorView({
|
||||||
|
parent: paneB,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
...side("b"),
|
||||||
|
...extB,
|
||||||
|
// The left side is told the chunks the right side's edit produced.
|
||||||
|
EditorView.updateListener.of((u) => {
|
||||||
|
const now = u.state.field(splitField).chunks;
|
||||||
|
if (now !== u.startState.field(splitField).chunks)
|
||||||
|
a.dispatch({ effects: setChunks.of(now) });
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
siblings.set(a, b);
|
||||||
|
siblings.set(b, a);
|
||||||
|
|
||||||
|
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
||||||
|
|
||||||
|
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
||||||
|
// caused comes back as the other side's own event: `echo` holds where we
|
||||||
|
// put it, so that event is recognised and not mirrored back.
|
||||||
|
const echo = { a: -1, b: -1 };
|
||||||
|
const follow = (from: "a" | "b") => () => {
|
||||||
|
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
||||||
|
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
||||||
|
echo[from] = -1;
|
||||||
|
draw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const line = mapLine(topLine(src), spans(), from === "a");
|
||||||
|
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
||||||
|
const before = dst.scrollDOM.scrollTop;
|
||||||
|
dst.scrollDOM.scrollTop = target;
|
||||||
|
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
const onA = follow("a");
|
||||||
|
const onB = follow("b");
|
||||||
|
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
||||||
|
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
||||||
|
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||||
|
resize?.observe(root);
|
||||||
|
|
||||||
|
// » puts the original block back on the right (the merge package's rule:
|
||||||
|
// a non-empty source brings its line break unless it ends the document).
|
||||||
|
buttons.addEventListener("mousedown", (e) => {
|
||||||
|
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
||||||
|
const c = b.state.field(splitField).chunks[i];
|
||||||
|
if (!c) return;
|
||||||
|
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
||||||
|
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
||||||
|
b.dispatch({
|
||||||
|
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
||||||
|
userEvent: "revert",
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
function paint() {
|
||||||
|
const g = gutter.getBoundingClientRect();
|
||||||
|
const height = gutter.clientHeight;
|
||||||
|
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
||||||
|
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
||||||
|
const paths: SVGPathElement[] = [];
|
||||||
|
const btns: HTMLButtonElement[] = [];
|
||||||
|
spans().forEach((s, i) => {
|
||||||
|
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
||||||
|
// Off screen on both sides: nothing to draw (no layout = draw all).
|
||||||
|
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
||||||
|
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
||||||
|
const p = document.createElementNS(SVG, "path");
|
||||||
|
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
||||||
|
p.setAttribute("class", BAND[kind]);
|
||||||
|
p.setAttribute("vector-effect", "non-scaling-stroke");
|
||||||
|
p.dataset.kind = kind;
|
||||||
|
paths.push(p);
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = "»";
|
||||||
|
btn.title = revertLabel;
|
||||||
|
btn.setAttribute("aria-label", revertLabel);
|
||||||
|
btn.dataset.chunk = String(i);
|
||||||
|
btn.className =
|
||||||
|
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||||
|
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
||||||
|
btns.push(btn);
|
||||||
|
});
|
||||||
|
svg.replaceChildren(...paths);
|
||||||
|
buttons.replaceChildren(...btns);
|
||||||
|
}
|
||||||
|
draw();
|
||||||
|
|
||||||
|
return {
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
destroy: () => {
|
||||||
|
if (frame)
|
||||||
|
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
||||||
|
resize?.disconnect();
|
||||||
|
a.scrollDOM.removeEventListener("scroll", onA);
|
||||||
|
b.scrollDOM.removeEventListener("scroll", onB);
|
||||||
|
a.destroy();
|
||||||
|
b.destroy();
|
||||||
|
root.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||||
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
|
* at its right end. A click shows a tab; Ctrl/Cmd+click on a one-pane tab (or
|
||||||
* a `+` item) brings it into the tab on screen, split to the right, and a
|
* a `+` item) brings it into the tab on screen, in the same order as a join, and a
|
||||||
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
* one-pane tab drags onto the panes (`PaneGrid.tsx`) to join that tab. Any
|
||||||
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
* tab drags along the bar to reorder it, and a pane header dragged onto the
|
||||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||||
|
|||||||
@@ -12,6 +12,10 @@
|
|||||||
* new lane's own live session for its slot, or an empty pane — never the
|
* new lane's own live session for its slot, or an empty pane — never the
|
||||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||||
|
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
||||||
|
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||||
|
* it. Chat or terminal is the viewer's own Chat UI setting (Settings →
|
||||||
|
* Appearance, `lib/chatUi.ts`, per account) — one choice for every pane.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -23,6 +27,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
|
import { PaneChat } from "./PaneChat";
|
||||||
|
import { useChatUi } from "../../lib/chatUi";
|
||||||
|
|
||||||
|
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||||
|
|
||||||
export interface LaneConsolePaneProps {
|
export interface LaneConsolePaneProps {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
@@ -70,6 +78,12 @@ export function LaneConsolePane({
|
|||||||
// The tool of the run on screen — a session's own, which need not be the
|
// The tool of the run on screen — a session's own, which need not be the
|
||||||
// workspace's; the workspace's with no run.
|
// workspace's; the workspace's with no run.
|
||||||
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
const paneTool = handle?.tool ?? currentLane?.tool ?? "claude";
|
||||||
|
const chat = useChatUi();
|
||||||
|
// A run learns its session id after it starts (Codex): the page's poll has it.
|
||||||
|
const chatSession = handle
|
||||||
|
? (activeRuns?.items.find((r) => r.id === handle.id)?.sessionId ?? handle.sessionId)
|
||||||
|
: null;
|
||||||
|
const chatable = !!handle && !!chatSession && CHAT_TOOLS.has(handle.tool);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onHasActiveRunChange?.(handle !== null);
|
onHasActiveRunChange?.(handle !== null);
|
||||||
@@ -173,11 +187,22 @@ export function LaneConsolePane({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||||
|
{/* Over the terminal, never instead of it: unmounting would drop the attach. */}
|
||||||
|
{chatable && chat && (
|
||||||
|
<div className="absolute inset-0 z-20 flex flex-col bg-surface-0">
|
||||||
|
<PaneChat
|
||||||
|
runId={handle.id}
|
||||||
|
sessionId={chatSession}
|
||||||
|
tool={handle.tool}
|
||||||
|
cwd={handle.cwd || currentLane?.cwd || ""}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<TerminalView
|
<TerminalView
|
||||||
runId={handle.id}
|
runId={handle.id}
|
||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus && !(chatable && chat)}
|
||||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||||
// the tab bar moves on (another tab, or the home's prompt box)
|
// the tab bar moves on (another tab, or the home's prompt box)
|
||||||
// rather than leaving a dead terminal the user has to click out of.
|
// rather than leaving a dead terminal the user has to click out of.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/**
|
||||||
|
* @file PaneChat.tsx
|
||||||
|
* @description A live pane's Chat view, the way Claude Desktop shows a session:
|
||||||
|
* the session's conversation (`ConversationView`, kept current by its own poll)
|
||||||
|
* above a prompt box. It lies OVER the pane's terminal, which stays mounted and
|
||||||
|
* attached underneath — a message is sent through that terminal
|
||||||
|
* (`submitToRun`: a bracketed paste, then Enter), exactly as if typed there, so
|
||||||
|
* nothing new reaches the server. A permission prompt or a menu still needs the
|
||||||
|
* terminal; the hint under the box says so.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ArrowUp } from "lucide-react";
|
||||||
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
|
import { PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
||||||
|
import { submitToRun } from "../../lib/panePaste";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
|
export function PaneChat({
|
||||||
|
runId,
|
||||||
|
sessionId,
|
||||||
|
tool,
|
||||||
|
cwd,
|
||||||
|
}: {
|
||||||
|
runId: string;
|
||||||
|
sessionId: string;
|
||||||
|
tool: string;
|
||||||
|
cwd: string;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const text = promptWithImages(prompt, images);
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
if (!text || uploading) return;
|
||||||
|
if (!submitToRun(runId, text)) {
|
||||||
|
toast.error(t("chat.notSent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPrompt("");
|
||||||
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
|
setImages([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="pane-chat" className="flex min-h-0 flex-1 flex-col gap-2 pb-3">
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<ConversationView sessionId={sessionId} fill />
|
||||||
|
</div>
|
||||||
|
<div className="mx-4">
|
||||||
|
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||||
|
<PromptEditor
|
||||||
|
bare
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
onBusyChange={setUploading}
|
||||||
|
value={prompt}
|
||||||
|
onChange={setPrompt}
|
||||||
|
onSubmit={send}
|
||||||
|
placeholder={t("chat.placeholder")}
|
||||||
|
rows={2}
|
||||||
|
slash={tool === "claude"}
|
||||||
|
fileCwd={cwd}
|
||||||
|
/>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-[11px] text-fg-muted">{t("chat.hint")}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-send"
|
||||||
|
onClick={send}
|
||||||
|
disabled={uploading || !text}
|
||||||
|
aria-label={t("chat.send")}
|
||||||
|
title={t("chat.send")}
|
||||||
|
className="ml-auto flex h-8 w-8 items-center justify-center rounded-full bg-accent-solid text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
* header: drag it to move the pane (drop on another pane's edge to split
|
* header: drag it to move the pane (drop on another pane's edge to split
|
||||||
* there, on its middle to swap), pick another live session for it, split it
|
* there, on its middle to swap), pick another live session for it, split it
|
||||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||||
* in a tab of its own — so its icon is not the tab's stop X). The grid shows
|
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||||
|
* session there (X, `onStop`) without taking it out first. The grid shows
|
||||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||||
* shows its state dot, as its tab does.
|
* shows its state dot, as its tab does.
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Rows2,
|
Rows2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
@@ -309,8 +311,11 @@ export function PaneGrid({
|
|||||||
toolLabel,
|
toolLabel,
|
||||||
onNewSplit,
|
onNewSplit,
|
||||||
stateOf,
|
stateOf,
|
||||||
|
onStop,
|
||||||
}: {
|
}: {
|
||||||
layout: PaneLayout;
|
layout: PaneLayout;
|
||||||
|
/** Stops the session in `slot` (the header's X). */
|
||||||
|
onStop?: (slot: number) => void;
|
||||||
/** A live AI run's state for its header dot (none for a shell). */
|
/** A live AI run's state for its header dot (none for a shell). */
|
||||||
stateOf?: (r: RunHandle) => RunState | null;
|
stateOf?: (r: RunHandle) => RunState | null;
|
||||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||||
@@ -514,6 +519,18 @@ export function PaneGrid({
|
|||||||
>
|
>
|
||||||
<Minimize2 className="h-3.5 w-3.5" />
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{run && onStop && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`pane-stop-${slot}`}
|
||||||
|
title={t("panes.stop")}
|
||||||
|
aria-label={t("panes.stop")}
|
||||||
|
onClick={() => onStop(slot)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -557,6 +574,11 @@ export function PaneGrid({
|
|||||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||||
{t("panes.closeShort")}
|
{t("panes.closeShort")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{onStop && runOf(paneCtx.menu.item) && (
|
||||||
|
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||||
|
{t("panes.stop")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
{dividers.map((d) => (
|
{dividers.map((d) => (
|
||||||
|
|||||||
@@ -31,7 +31,10 @@
|
|||||||
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
* clipboard (`@xterm/addon-clipboard`, write-only: no program in the pane may
|
||||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||||
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
* overlay says keystrokes are not reaching the pane; resizes reach tmux at
|
||||||
* most every 150 ms.
|
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||||
|
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||||
|
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||||
|
* here takes the lead back (`resize` with `drive: true`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||||
@@ -46,6 +49,7 @@ import { useTheme } from "../../hooks/useTheme";
|
|||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||||
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
|
|
||||||
type DropState = "hint" | "uploading" | "failed";
|
type DropState = "hint" | "uploading" | "failed";
|
||||||
|
|
||||||
@@ -217,7 +221,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
// under the canvas renderer (which only stops a glyph from shoving its
|
// under the canvas renderer (which only stops a glyph from shoving its
|
||||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||||
// its `vietnamese` subset so this covers the diacritics too.
|
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||||
|
// Appearance may swap it for the rounded Maple Mono (also complete) or
|
||||||
|
// the browser's own `monospace` (lib/terminalFont.ts).
|
||||||
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
// NO `convertEol`. It rewrites every bare LF as CR+LF, i.e. it forces the
|
||||||
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
// cursor to column 0 on a line feed — and xterm's own docs say not to use
|
||||||
// it on a PTY, where termios already does that translation for the streams
|
// it on a PTY, where termios already does that translation for the streams
|
||||||
@@ -229,7 +235,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
fontFamily: '"JetBrains Mono", monospace',
|
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||||
theme: paletteRef.current,
|
theme: paletteRef.current,
|
||||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||||
});
|
});
|
||||||
@@ -325,6 +331,25 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let ws: WebSocket | null = null;
|
let ws: WebSocket | null = null;
|
||||||
|
// Another browser drives this pane's size: the grid it set, or null.
|
||||||
|
let follow: { cols: number; rows: number } | null = null;
|
||||||
|
// A follower's grid may not fit this container: shrink it (never grow).
|
||||||
|
const scaleToFit = () => {
|
||||||
|
const el = term.element;
|
||||||
|
const box = containerRef.current;
|
||||||
|
if (!el || !box) return;
|
||||||
|
el.style.transformOrigin = "0 0";
|
||||||
|
el.style.transform = "";
|
||||||
|
// `.xterm` is a block as wide as the box; the grid's size is the screen's.
|
||||||
|
const grid = el.querySelector<HTMLElement>(".xterm-screen");
|
||||||
|
if (!follow || !grid?.offsetWidth || !grid.offsetHeight) return;
|
||||||
|
const s = Math.min(
|
||||||
|
1,
|
||||||
|
box.clientWidth / grid.offsetWidth,
|
||||||
|
box.clientHeight / grid.offsetHeight
|
||||||
|
);
|
||||||
|
if (s < 1) el.style.transform = `scale(${s})`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||||
@@ -339,10 +364,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||||
* resize the real tmux pane down to nothing.
|
* resize the real tmux pane down to nothing.
|
||||||
*/
|
*/
|
||||||
const sendSizeNow = () => {
|
const sendSizeNow = (drive = false) => {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
if (term.cols < 2 || term.rows < 2) return;
|
if (term.cols < 2 || term.rows < 2) return;
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||||
|
};
|
||||||
|
// This browser is the one in use now: back to its own size, and the
|
||||||
|
// pane (and every other viewer) follows it.
|
||||||
|
const takeLead = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
|
follow = null;
|
||||||
|
scaleToFit();
|
||||||
|
fit.fit();
|
||||||
|
sendSizeNow(true);
|
||||||
};
|
};
|
||||||
// A divider drag or a window resize fires a resize per frame; tmux and the
|
// A divider drag or a window resize fires a resize per frame; tmux and the
|
||||||
// program in the pane redraw on each, so the LAST size is sent once things
|
// program in the pane redraw on each, so the LAST size is sent once things
|
||||||
@@ -397,7 +432,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
if (data.startsWith('{"type"')) {
|
if (data.startsWith('{"type"')) {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(data);
|
const msg = JSON.parse(data);
|
||||||
if (msg.type === "exit") {
|
if (msg.type === "size") {
|
||||||
|
follow = { cols: msg.cols, rows: msg.rows };
|
||||||
|
term.resize(msg.cols, msg.rows);
|
||||||
|
requestAnimationFrame(scaleToFit);
|
||||||
|
} else if (msg.type === "lead") {
|
||||||
|
takeLead();
|
||||||
|
} else if (msg.type === "exit") {
|
||||||
ended = true;
|
ended = true;
|
||||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||||
onExitRef.current?.();
|
onExitRef.current?.();
|
||||||
@@ -426,6 +467,8 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
connect();
|
connect();
|
||||||
|
|
||||||
const dataDisposable = term.onData((data) => {
|
const dataDisposable = term.onData((data) => {
|
||||||
|
// A keystroke takes the lead; a mouse report (wheel, hover) does not.
|
||||||
|
if (follow && !/^\x1b\[(<|M)/.test(data)) takeLead();
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -435,16 +478,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
const resizeDisposable = term.onResize(sendSize);
|
const resizeDisposable = term.onResize(sendSize);
|
||||||
|
|
||||||
// Re-assert on focus. Several attach clients can share one tmux session
|
// Re-assert on focus. Several attach clients can share one tmux session
|
||||||
// (a second tab, another lane pane), and tmux's `window-size latest` sizes
|
// (a second tab, another browser): coming back to this one makes it the
|
||||||
// the window to whichever client was last active — resizing this pane out
|
// driver again, and the others follow its size.
|
||||||
// from under a terminal that is never told. Coming back to it says so.
|
const reassert = takeLead;
|
||||||
const reassert = () => sendSizeNow();
|
|
||||||
term.textarea?.addEventListener("focus", reassert);
|
term.textarea?.addEventListener("focus", reassert);
|
||||||
|
|
||||||
const refit = () => {
|
const refit = () => {
|
||||||
// Never fit a collapsed console — see sendSize.
|
// Never fit a collapsed console — see sendSize.
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||||
|
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
fit.fit(); // a real change fires onResize, which reports it
|
||||||
};
|
};
|
||||||
const resizeObserver = new ResizeObserver(refit);
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
@@ -499,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
() =>
|
() =>
|
||||||
registerPane(
|
registerPane(
|
||||||
runId,
|
runId,
|
||||||
(text) => {
|
(text, submit) => {
|
||||||
termRef.current?.paste(text);
|
termRef.current?.paste(text);
|
||||||
termRef.current?.focus();
|
// Enter a beat after the paste, so a TUI that times its paste
|
||||||
|
// detection never reads it as a newline inside the paste. A submit
|
||||||
|
// comes from the pane's Chat view, which keeps the keyboard.
|
||||||
|
if (submit)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send("\r");
|
||||||
|
}, 100);
|
||||||
|
else termRef.current?.focus();
|
||||||
},
|
},
|
||||||
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
// On screen (a console hidden with CSS measures 0×0) AND attached:
|
||||||
// `onData` drops input while the socket is connecting or reconnecting,
|
// `onData` drops input while the socket is connecting or reconnecting,
|
||||||
@@ -510,6 +560,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
),
|
),
|
||||||
[runId]
|
[runId]
|
||||||
);
|
);
|
||||||
|
// The Workspace's focused pane takes those pastes even when the focus moved
|
||||||
|
// without a click inside the terminal (a tab, Alt+1–4, the sidebar).
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) notePaneFocus(runId);
|
||||||
|
}, [autoFocus, runId]);
|
||||||
|
|
||||||
const runFind = (backwards = false) => {
|
const runFind = (backwards = false) => {
|
||||||
const q = find ?? "";
|
const q = find ?? "";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { LaneConsolePane } from "../LaneConsolePane";
|
|||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import type { Lane } from "../../../lib/types";
|
import type { Lane } from "../../../lib/types";
|
||||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||||
|
import { __resetChatUi } from "../../../lib/chatUi";
|
||||||
|
|
||||||
vi.mock("../TerminalView", () => ({
|
vi.mock("../TerminalView", () => ({
|
||||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||||
@@ -24,8 +25,15 @@ vi.mock("../TerminalView", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../PaneChat", () => ({
|
||||||
|
PaneChat: ({ sessionId }: { sessionId: string }) => (
|
||||||
|
<div data-testid="pane-chat" data-session-id={sessionId} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: {
|
api: {
|
||||||
|
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||||
lanes: {
|
lanes: {
|
||||||
ensure: vi.fn(),
|
ensure: vi.fn(),
|
||||||
action: vi.fn(),
|
action: vi.fn(),
|
||||||
@@ -139,6 +147,34 @@ describe("LaneConsolePane", () => {
|
|||||||
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
expect(screen.getByTestId("pane-no-run")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows a Claude pane as a chat OVER its terminal when the viewer's Chat UI is on", async () => {
|
||||||
|
// Unmounting the terminal would drop the attach the chat sends through.
|
||||||
|
vi.mocked(api.me.prefs).mockResolvedValueOnce({
|
||||||
|
prefs: { "agenthub.chatUi": "1" },
|
||||||
|
onboardedAt: null,
|
||||||
|
});
|
||||||
|
__resetChatUi();
|
||||||
|
const live = {
|
||||||
|
id: "agenthub-lane-1",
|
||||||
|
laneId: 1,
|
||||||
|
status: "running" as const,
|
||||||
|
cwd: "/workspace/a",
|
||||||
|
model: null,
|
||||||
|
permissionMode: null,
|
||||||
|
effort: null,
|
||||||
|
resumeSessionId: null,
|
||||||
|
sessionId: "s-1",
|
||||||
|
startedAt: null,
|
||||||
|
promptPreview: null,
|
||||||
|
tool: "claude" as const,
|
||||||
|
};
|
||||||
|
render(<LaneConsolePane {...baseProps()} activeRuns={{ items: [live] }} />);
|
||||||
|
expect(await screen.findByTestId("pane-chat")).toHaveAttribute("data-session-id", "s-1");
|
||||||
|
expect(screen.getByTestId("terminal-view")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("pane-view-chat")).toBeNull(); // no per-pane switch
|
||||||
|
__resetChatUi();
|
||||||
|
});
|
||||||
|
|
||||||
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
it("swaps to the newly selected lane's own terminal instead of keeping the old one", async () => {
|
||||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||||
const run = (id: string, laneId: number) => ({
|
const run = (id: string, laneId: number) => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { TerminalView, loadXterm } from "../TerminalView";
|
import { TerminalView, loadXterm } from "../TerminalView";
|
||||||
import { pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
|
|
||||||
const writeMock = vi.fn();
|
const writeMock = vi.fn();
|
||||||
const onDataHandlers: Array<(d: string) => void> = [];
|
const onDataHandlers: Array<(d: string) => void> = [];
|
||||||
@@ -69,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
|
|||||||
attachCustomKeyEventHandler: keyHandlerMock,
|
attachCustomKeyEventHandler: keyHandlerMock,
|
||||||
focus: focusMock,
|
focus: focusMock,
|
||||||
paste: pasteMock,
|
paste: pasteMock,
|
||||||
|
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||||
@@ -311,6 +312,25 @@ describe("TerminalView", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("takes the workspace's paste once it is the focused pane, clicked into or not", () => {
|
||||||
|
const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800);
|
||||||
|
try {
|
||||||
|
const both = (focus: number) => (
|
||||||
|
<>
|
||||||
|
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
|
||||||
|
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const { rerender } = render(both(0));
|
||||||
|
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9");
|
||||||
|
// Alt+2, a tab click: the Workspace moves the focus, the DOM may not.
|
||||||
|
rerender(both(1));
|
||||||
|
expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1");
|
||||||
|
} finally {
|
||||||
|
width.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("takes the keyboard as soon as it mounts", () => {
|
it("takes the keyboard as soon as it mounts", () => {
|
||||||
// The terminal IS the page here — without this the first thing typed after
|
// The terminal IS the page here — without this the first thing typed after
|
||||||
// opening a lane went nowhere until the user clicked the pane.
|
// opening a lane went nowhere until the user clicked the pane.
|
||||||
@@ -349,7 +369,9 @@ describe("TerminalView", () => {
|
|||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const ws = MockWebSocket.instances[0]!;
|
const ws = MockWebSocket.instances[0]!;
|
||||||
ws.onopen?.();
|
ws.onopen?.();
|
||||||
expect(ws.sent).toContain(JSON.stringify({ type: "resize", cols: 131, rows: 28 }));
|
expect(ws.sent).toContain(
|
||||||
|
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||||
@@ -366,7 +388,34 @@ describe("TerminalView", () => {
|
|||||||
onResizeHandlers.forEach((fn) => fn());
|
onResizeHandlers.forEach((fn) => fn());
|
||||||
expect(ws.sent).toEqual([]);
|
expect(ws.sent).toEqual([]);
|
||||||
await new Promise((r) => setTimeout(r, 200));
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 127, rows: 28 })]);
|
expect(ws.sent).toEqual([
|
||||||
|
JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows another browser's size until a keystroke here takes the lead", () => {
|
||||||
|
// Two browsers of different sizes on one pane: the one not in use renders
|
||||||
|
// the driver's grid instead of resizing tmux under it.
|
||||||
|
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
|
||||||
|
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
|
||||||
|
try {
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws.onopen?.();
|
||||||
|
ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' });
|
||||||
|
expect([termState.cols, termState.rows]).toEqual([90, 20]);
|
||||||
|
ws.sent.length = 0;
|
||||||
|
onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch
|
||||||
|
expect(ws.sent).toEqual(["\x1b[<64;5;5M"]);
|
||||||
|
onDataHandlers.forEach((fn) => fn("a"));
|
||||||
|
expect(ws.sent[1]).toBe(
|
||||||
|
JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true })
|
||||||
|
);
|
||||||
|
expect(ws.sent[2]).toBe("a");
|
||||||
|
} finally {
|
||||||
|
w.mockRestore();
|
||||||
|
h.mockRestore();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never reports a collapsed console's zero size", () => {
|
it("never reports a collapsed console's zero size", () => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
|
||||||
* A right-click on an installed row opens its Update / Set default /
|
* A right-click on an installed row opens its Update / Set default /
|
||||||
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
* Enable-Disable (plus Copy path) as a menu; the buttons stay.
|
||||||
|
* A click on an installed row (outside its buttons) opens that tool's settings
|
||||||
|
* tab when the hub gives it one (`settingsHref`); the name is the keyboard link.
|
||||||
* Refresh re-reads the login shell's PATH and re-detects. Icons come from
|
* 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
|
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||||
* working.
|
* working.
|
||||||
@@ -19,12 +21,14 @@
|
|||||||
import { Switch } from "../Switch";
|
import { Switch } from "../Switch";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
type Confirm = { kind: "install" | "update"; tool: AiTool } | { kind: "update-all" };
|
||||||
|
|
||||||
@@ -115,9 +119,17 @@ function JobLog({ job }: { job: AiToolJob }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiToolsSettings() {
|
export function AiToolsSettings({
|
||||||
|
settingsHref,
|
||||||
|
}: {
|
||||||
|
/** A tool's own settings page, when it has one. */
|
||||||
|
settingsHref?: (id: string) => string | undefined;
|
||||||
|
} = {}) {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
|
const navigate = useNavigate();
|
||||||
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
const { tools, loaded, defaultId, refresh, update, reload } = useAiTools();
|
||||||
|
// Installing, updating and switching tools changes the host: an admin's.
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||||
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
|
|||||||
{t("aiTools.installed", { count: installed.length })}
|
{t("aiTools.installed", { count: installed.length })}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{updateAllTools.length > 0 && (
|
{isAdmin && updateAllTools.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirm({ kind: "update-all" })}
|
onClick={() => setConfirm({ kind: "update-all" })}
|
||||||
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
|
|||||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{isAdmin && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => void run("refresh", refresh)}
|
type="button"
|
||||||
disabled={busy !== null}
|
onClick={() => void run("refresh", refresh)}
|
||||||
className={GHOST_BTN}
|
disabled={busy !== null}
|
||||||
>
|
className={GHOST_BTN}
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
>
|
||||||
{t("aiTools.refresh")}
|
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||||
</button>
|
{t("aiTools.refresh")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||||
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
|
|||||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||||
{installed.map((tool) => {
|
{installed.map((tool) => {
|
||||||
const isDefault = tool.id === defaultId;
|
const isDefault = tool.id === defaultId;
|
||||||
|
const href = settingsHref?.(tool.id);
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={tool.id}
|
key={tool.id}
|
||||||
data-testid={`ai-tool-${tool.id}`}
|
data-testid={`ai-tool-${tool.id}`}
|
||||||
onContextMenu={(e) => ctx.open(e, tool)}
|
onContextMenu={(e) => ctx.open(e, tool)}
|
||||||
className="px-3 py-2.5"
|
onClick={(e) => {
|
||||||
|
// The row's own buttons, switch, links and log keep their click.
|
||||||
|
if (href && !(e.target as Element).closest("button, a, input, pre"))
|
||||||
|
navigate(href);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-2.5 ${href ? "cursor-pointer hover:bg-surface-3" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
|
|||||||
<AiToolIcon tool={tool} />
|
<AiToolIcon tool={tool} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||||
<span className="truncate">{tool.label}</span>
|
{href ? (
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
title={t("aiTools.openSettings", { tool: tool.label })}
|
||||||
|
className="truncate hover:text-accent hover:underline"
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{tool.label}</span>
|
||||||
|
)}
|
||||||
<Homepage tool={tool} />
|
<Homepage tool={tool} />
|
||||||
{isDefault && (
|
{isDefault && (
|
||||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||||
@@ -279,31 +309,39 @@ export function AiToolsSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||||
<div className="flex min-w-20 shrink-0 justify-end">
|
{isAdmin && (
|
||||||
{tool.update && (
|
<>
|
||||||
<button
|
<div className="flex min-w-20 shrink-0 justify-end">
|
||||||
type="button"
|
{tool.update && (
|
||||||
disabled={jobBusy}
|
<button
|
||||||
onClick={() => setConfirm({ kind: "update", tool })}
|
type="button"
|
||||||
className={GHOST_BTN}
|
disabled={jobBusy}
|
||||||
>
|
onClick={() => setConfirm({ kind: "update", tool })}
|
||||||
{t("aiTools.update")}
|
className={GHOST_BTN}
|
||||||
</button>
|
>
|
||||||
)}
|
{t("aiTools.update")}
|
||||||
</div>
|
</button>
|
||||||
<div className="flex min-w-28 shrink-0 justify-end">
|
)}
|
||||||
{!isDefault && (
|
</div>
|
||||||
<button
|
<div className="flex min-w-28 shrink-0 justify-end">
|
||||||
type="button"
|
{!isDefault && (
|
||||||
disabled={!tool.enabled || busy !== null}
|
<button
|
||||||
onClick={() => setDefault(tool)}
|
type="button"
|
||||||
className={GHOST_BTN}
|
disabled={!tool.enabled || busy !== null}
|
||||||
>
|
onClick={() => setDefault(tool)}
|
||||||
{t("aiTools.setDefault")}
|
className={GHOST_BTN}
|
||||||
</button>
|
>
|
||||||
)}
|
{t("aiTools.setDefault")}
|
||||||
</div>
|
</button>
|
||||||
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
)}
|
||||||
|
</div>
|
||||||
|
<ToolSwitch
|
||||||
|
tool={tool}
|
||||||
|
disabled={busy !== null}
|
||||||
|
onToggle={() => toggle(tool)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{logFor(tool.id)}
|
{logFor(tool.id)}
|
||||||
</li>
|
</li>
|
||||||
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
|
|||||||
</ul>
|
</ul>
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="ai-tool-menu">
|
||||||
{ctx.menu.item.update && (
|
{isAdmin && ctx.menu.item.update && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
disabled={jobBusy}
|
disabled={jobBusy}
|
||||||
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
||||||
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
|
|||||||
{t("aiTools.update")}
|
{t("aiTools.update")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
{ctx.menu.item.id !== defaultId && (
|
{isAdmin && ctx.menu.item.id !== defaultId && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
disabled={!ctx.menu.item.enabled || busy !== null}
|
disabled={!ctx.menu.item.enabled || busy !== null}
|
||||||
onSelect={pick(setDefault)}
|
onSelect={pick(setDefault)}
|
||||||
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
|
|||||||
{t("aiTools.setDefault")}
|
{t("aiTools.setDefault")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
{isAdmin && (
|
||||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||||
</MenuItem>
|
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{ctx.menu.item.path && (
|
{ctx.menu.item.path && (
|
||||||
<>
|
<>
|
||||||
<MenuSeparator />
|
{isAdmin && <MenuSeparator />}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||||
>
|
>
|
||||||
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Homepage tool={tool} />
|
<Homepage tool={tool} />
|
||||||
{tool.install && (
|
{isAdmin && tool.install && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={jobBusy}
|
disabled={jobBusy}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.tsx
|
* @file AppearanceSettings.tsx
|
||||||
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
* @description Settings → Appearance (`/settings/appearance`): the interface language, the
|
||||||
|
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||||
|
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
|
||||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||||
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
* `index.css`. Each card's swatch re-scopes the theme's CSS variables on its
|
||||||
@@ -11,13 +13,21 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||||
import { PetSettings } from "./PetSettings";
|
import { PetSettings } from "./PetSettings";
|
||||||
|
import { ChatUiSettings } from "./ChatUiSettings";
|
||||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import {
|
||||||
|
TERMINAL_FONTS,
|
||||||
|
readTerminalFont,
|
||||||
|
writeTerminalFont,
|
||||||
|
type TerminalFont,
|
||||||
|
} from "../../lib/terminalFont";
|
||||||
|
|
||||||
const THEME_LABELS: Record<string, string> = {
|
const THEME_LABELS: Record<string, string> = {
|
||||||
darcula: "Darcula",
|
darcula: "Darcula",
|
||||||
@@ -65,6 +75,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
|||||||
export function AppearanceSettings() {
|
export function AppearanceSettings() {
|
||||||
const { t, i18n } = useTranslation("settings");
|
const { t, i18n } = useTranslation("settings");
|
||||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||||
|
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||||
|
const pickTermFont = (font: TerminalFont) => {
|
||||||
|
writeTerminalFont(font);
|
||||||
|
setTermFont(font);
|
||||||
|
};
|
||||||
|
|
||||||
const reset = async () => {
|
const reset = async () => {
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
@@ -102,6 +117,39 @@ export function AppearanceSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
id="terminal-font"
|
||||||
|
title={t("terminalFont.title")}
|
||||||
|
description={t("terminalFont.description")}
|
||||||
|
>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("terminalFont.label")}
|
||||||
|
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("terminalFont.title")}
|
||||||
|
value={termFont}
|
||||||
|
onChange={pickTermFont}
|
||||||
|
options={(Object.keys(TERMINAL_FONTS) as TerminalFont[]).map((f) => ({
|
||||||
|
value: f,
|
||||||
|
label: t(`terminalFont.names.${f}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
data-testid="terminal-font-preview"
|
||||||
|
aria-label={t("terminalFont.preview")}
|
||||||
|
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||||
|
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||||
|
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||||
|
>
|
||||||
|
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||||
|
</p>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<ChatUiSettings />
|
||||||
|
|
||||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
|
|||||||
@@ -3,15 +3,22 @@
|
|||||||
* whether the dashboard starts at boot/login through the per-user service
|
* whether the dashboard starts at boot/login through the per-user service
|
||||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||||
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
* `GET/PUT /api/settings/autostart`. Off by default; a change never starts or
|
||||||
* stops the running server.
|
* stops the running server. Beside it, **Quit AgentHub** (issue #65, 9router's
|
||||||
|
* tray Quit): `POST /api/settings/quit` closes every workspace terminal and
|
||||||
|
* stops the server, which stays stopped until `agenthub start` — or the next
|
||||||
|
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||||
|
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
||||||
|
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
||||||
|
* the machine, database included (scripts/uninstall.js).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api, type AutostartStatus } from "../../lib/api";
|
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
export function AutostartSettings() {
|
export function AutostartSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
@@ -36,6 +43,57 @@ export function AutostartSettings() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tray, setTray] = useState<TrayStatus | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.tray
|
||||||
|
.get()
|
||||||
|
.then(setTray)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const changeTray = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
setTray(await api.settings.tray.set(enabled));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [quitting, setQuitting] = useState(false);
|
||||||
|
const quit = async () => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("quit.confirmTitle"),
|
||||||
|
message: t(status?.enabled ? "quit.confirmMessageAutostart" : "quit.confirmMessage"),
|
||||||
|
confirmLabel: t("quit.button"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setQuitting(true);
|
||||||
|
try {
|
||||||
|
await api.settings.quit();
|
||||||
|
toast.success(t("quit.done"));
|
||||||
|
} catch (err) {
|
||||||
|
setQuitting(false);
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [uninstalling, setUninstalling] = useState(false);
|
||||||
|
const uninstall = async () => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("uninstall.confirmTitle"),
|
||||||
|
message: t("uninstall.confirmMessage"),
|
||||||
|
confirmLabel: t("uninstall.button"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
setUninstalling(true);
|
||||||
|
try {
|
||||||
|
const { log } = await api.settings.uninstall();
|
||||||
|
toast.success(t("uninstall.done", { log }));
|
||||||
|
} catch (err) {
|
||||||
|
setUninstalling(false);
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const note = !status
|
const note = !status
|
||||||
? undefined
|
? undefined
|
||||||
: !status.available
|
: !status.available
|
||||||
@@ -53,6 +111,43 @@ export function AutostartSettings() {
|
|||||||
disabled={!status?.available || status.foreign || saving}
|
disabled={!status?.available || status.foreign || saving}
|
||||||
onChange={(v) => void change(v)}
|
onChange={(v) => void change(v)}
|
||||||
/>
|
/>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("tray.label")}
|
||||||
|
description={tray && !tray.available ? t("tray.unavailable") : t("tray.desc")}
|
||||||
|
checked={!!tray?.available && tray.enabled}
|
||||||
|
disabled={!tray?.available}
|
||||||
|
onChange={(v) => void changeTray(v)}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("quit.label")}
|
||||||
|
description={t("quit.desc")}
|
||||||
|
control={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="quit-agenthub"
|
||||||
|
disabled={quitting}
|
||||||
|
onClick={() => void quit()}
|
||||||
|
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("quit.button")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("uninstall.label")}
|
||||||
|
description={t("uninstall.desc")}
|
||||||
|
control={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="uninstall-agenthub"
|
||||||
|
disabled={uninstalling}
|
||||||
|
onClick={() => void uninstall()}
|
||||||
|
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("uninstall.button")}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/**
|
||||||
|
* @file BackupSettings.tsx
|
||||||
|
* @description Settings → Data → Backup: the daily database backup of
|
||||||
|
* `server/lib/db-backup.js` — the folder (best on another disk), the server's
|
||||||
|
* local hour, how many to keep, "Back up now" (polls `GET /api/settings/backup`
|
||||||
|
* while it runs, then toasts the outcome) and the newest backup in the folder.
|
||||||
|
* Saved through `PUT /api/settings/backup`, sending only the fields changed; a
|
||||||
|
* field the environment set is read-only. Restoring is manual and the card says
|
||||||
|
* how.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, DatabaseBackup } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { BackupState } from "../../lib/api";
|
||||||
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
import { SettingsSection } from "./SettingsRow";
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, h) => h);
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
export function BackupSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [state, setState] = useState<BackupState | null>(null);
|
||||||
|
const [dir, setDir] = useState("");
|
||||||
|
const [hour, setHour] = useState(2);
|
||||||
|
const [keep, setKeep] = useState("7");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const apply = (s: BackupState) => {
|
||||||
|
setState(s);
|
||||||
|
setDir(s.dir);
|
||||||
|
setHour(s.hour);
|
||||||
|
setKeep(String(s.keep));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.backup
|
||||||
|
.get()
|
||||||
|
.then(apply)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A backup takes tens of seconds on a large database: poll until it ends.
|
||||||
|
const running = !!state?.running;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
api.settings.backup
|
||||||
|
.get()
|
||||||
|
.then((s) => {
|
||||||
|
setState(s); // the form keeps its drafts
|
||||||
|
if (s.running || !s.last) return;
|
||||||
|
if (s.last.ok) toast.success(t("backup.done", { file: s.last.file }));
|
||||||
|
else toast.error(t("backup.failed", { error: s.last.error }));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [running, t]);
|
||||||
|
|
||||||
|
const keepN = Number(keep);
|
||||||
|
const keepValid = Number.isInteger(keepN) && keepN >= 1 && keepN <= 365;
|
||||||
|
const patch: { dir?: string; hour?: number; keep?: number } = {};
|
||||||
|
if (state) {
|
||||||
|
if (dir.trim() !== state.dir) patch.dir = dir.trim();
|
||||||
|
if (hour !== state.hour) patch.hour = hour;
|
||||||
|
if (keepN !== state.keep) patch.keep = keepN;
|
||||||
|
}
|
||||||
|
const changed = Object.keys(patch).length > 0;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
apply(await api.settings.backup.save(patch));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNow() {
|
||||||
|
try {
|
||||||
|
setState(await api.settings.backup.run());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const owned = state?.env_owned;
|
||||||
|
const newest = state?.files[0];
|
||||||
|
const total = state?.files.reduce((n, f) => n + f.size, 0) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="backup"
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<DatabaseBackup className="w-4 h-4 text-fg-muted" />
|
||||||
|
{t("backup.title")}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={t("backup.description")}
|
||||||
|
>
|
||||||
|
<div className="py-3 space-y-3">
|
||||||
|
<label className="block text-xs text-fg-muted">
|
||||||
|
{t("backup.folder")}
|
||||||
|
<input
|
||||||
|
className="input mt-1 w-full font-mono text-xs"
|
||||||
|
placeholder="/mnt/hdd/agenthub-backups"
|
||||||
|
value={dir}
|
||||||
|
disabled={!state || owned?.dir}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDir(e.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-fg-muted">{t("backup.folderHint")}</p>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="w-28">
|
||||||
|
<Select
|
||||||
|
value={String(hour)}
|
||||||
|
onChange={(v) => setHour(Number(v))}
|
||||||
|
label={t("backup.hour")}
|
||||||
|
disabled={!state || owned?.hour}
|
||||||
|
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="text-xs text-fg-muted">
|
||||||
|
{t("backup.keep")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
step={1}
|
||||||
|
className="input mt-1 block w-24 text-sm"
|
||||||
|
value={keep}
|
||||||
|
disabled={!state || owned?.keep}
|
||||||
|
onChange={(e) => setKeep(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={saving || !changed || !keepValid}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={!state?.dir || running || changed}
|
||||||
|
onClick={() => void runNow()}
|
||||||
|
>
|
||||||
|
<DatabaseBackup className={`w-4 h-4 ${running ? "animate-pulse" : ""}`} />{" "}
|
||||||
|
{running ? t("backup.running") : t("backup.runNow")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-status-danger">{error}</p>}
|
||||||
|
<p className="text-xs text-fg-secondary">
|
||||||
|
{newest
|
||||||
|
? t("backup.newest", {
|
||||||
|
time: formatDateTime(newest.mtime),
|
||||||
|
size: formatBytes(newest.size),
|
||||||
|
n: state?.files.length,
|
||||||
|
total: formatBytes(total),
|
||||||
|
})
|
||||||
|
: state?.dir
|
||||||
|
? t("backup.none")
|
||||||
|
: t("backup.off")}
|
||||||
|
</p>
|
||||||
|
{state?.last && !state.last.ok && (
|
||||||
|
<p className="text-xs text-status-danger">
|
||||||
|
{t("backup.lastFailed", {
|
||||||
|
time: formatDateTime(state.last.at),
|
||||||
|
error: state.last.error,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state?.dir_error && <p className="text-xs text-status-danger">{state.dir_error}</p>}
|
||||||
|
<p className="text-xs text-fg-muted">{t("backup.restoreHint")}</p>
|
||||||
|
{(owned?.dir || owned?.hour || owned?.keep) && (
|
||||||
|
<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("privacy.envOwned")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||||
|
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||||
|
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||||
|
* account on a shared dashboard keeps its own choice on any browser.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
export function ChatUiSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const chatUi = useChatUi();
|
||||||
|
return (
|
||||||
|
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("chatUi.label")}
|
||||||
|
description={t("chatUi.labelDesc")}
|
||||||
|
checked={chatUi}
|
||||||
|
onChange={(on) =>
|
||||||
|
saveChatUi(on).catch((err: unknown) =>
|
||||||
|
toast.error(
|
||||||
|
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
* @description The dashboard's own settings that belong to Claude Code, shown
|
* @description The dashboard's own settings that belong to Claude Code, shown
|
||||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
|
||||||
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
|
* plugin provides them), and a link to the Claude table on the Pricing page.
|
||||||
|
* Changing CLAUDE_HOME asks first.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
|
|||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { Skeleton } from "../Skeleton";
|
import { Skeleton } from "../Skeleton";
|
||||||
import { HomeDirSetting } from "./HomeDirSetting";
|
import { HomeDirSetting } from "./HomeDirSetting";
|
||||||
import { PricingSettings } from "./PricingSettings";
|
import { PricingLink } from "./PricingSettings";
|
||||||
|
|
||||||
type HooksInfo = { installed: boolean; path: string; hooks: Record<string, boolean> };
|
type HooksInfo = {
|
||||||
|
installed: boolean;
|
||||||
|
path: string;
|
||||||
|
hooks: Record<string, boolean>;
|
||||||
|
source?: "plugin";
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claude Code's AgentHub settings: pricing, hook status and CLAUDE_HOME.
|
* Claude Code's AgentHub settings: CLAUDE_HOME, hook status and the pricing link.
|
||||||
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
* @param onHomeChanged - called after CLAUDE_HOME was saved (the page re-reads
|
||||||
* the config it lists, which lives under that home).
|
* the config it lists, which lives under that home).
|
||||||
* @returns The three sections, each with its own id.
|
* @returns The three sections, each with its own id.
|
||||||
@@ -205,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
{hooks.source === "plugin" ? (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("hooks.plugin")}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-fg-muted font-mono truncate">{hooks.path}</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PricingSettings vendor="claude" allowReset />
|
<PricingLink vendor="claude" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
|
|||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { usePets, useWornPet } from "../Sagi/usePets";
|
import { usePets, useWornPet } from "../Sagi/usePets";
|
||||||
import type { PetView } from "../../lib/api";
|
import type { PetView } from "../../lib/api";
|
||||||
|
import { usePermissions } from "../AuthGate";
|
||||||
|
|
||||||
const PREVIEW = 72;
|
const PREVIEW = 72;
|
||||||
|
|
||||||
@@ -121,6 +122,8 @@ export function PetSettings() {
|
|||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const worn = useWornPet();
|
const worn = useWornPet();
|
||||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||||
|
// Pets are stored for the whole install: adding or deleting one is an admin's.
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
const imageInput = useRef<HTMLInputElement>(null);
|
const imageInput = useRef<HTMLInputElement>(null);
|
||||||
const bundleInput = useRef<HTMLInputElement>(null);
|
const bundleInput = useRef<HTMLInputElement>(null);
|
||||||
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
|
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
|
||||||
@@ -219,6 +222,7 @@ export function PetSettings() {
|
|||||||
/>
|
/>
|
||||||
{list?.featured.map((f) => {
|
{list?.featured.map((f) => {
|
||||||
const have = installed.get(f.id);
|
const have = installed.get(f.id);
|
||||||
|
if (!have && !isAdmin) return null;
|
||||||
return (
|
return (
|
||||||
<PetCard
|
<PetCard
|
||||||
key={f.id}
|
key={f.id}
|
||||||
@@ -252,50 +256,54 @@ export function PetSettings() {
|
|||||||
disabled={busy !== null}
|
disabled={busy !== null}
|
||||||
preview={<Preview pet={p} size={PREVIEW} />}
|
preview={<Preview pet={p} size={PREVIEW} />}
|
||||||
onPick={() => wear(p)}
|
onPick={() => wear(p)}
|
||||||
onDelete={() => void remove(p)}
|
onDelete={isAdmin ? () => void remove(p) : undefined}
|
||||||
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
|
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
{isAdmin && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
className="btn-ghost text-xs"
|
<button
|
||||||
disabled={busy !== null}
|
type="button"
|
||||||
onClick={() => imageInput.current?.click()}
|
className="btn-ghost text-xs"
|
||||||
>
|
disabled={busy !== null}
|
||||||
{busy === "image" ? (
|
onClick={() => imageInput.current?.click()}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
>
|
||||||
) : (
|
{busy === "image" ? (
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{t("sagi.pet.upload")}
|
<Upload className="h-3.5 w-3.5" />
|
||||||
</button>
|
)}
|
||||||
<button
|
{t("sagi.pet.upload")}
|
||||||
type="button"
|
</button>
|
||||||
className="btn-ghost text-xs"
|
<button
|
||||||
disabled={busy !== null}
|
type="button"
|
||||||
onClick={() => bundleInput.current?.click()}
|
className="btn-ghost text-xs"
|
||||||
>
|
disabled={busy !== null}
|
||||||
{busy === "bundle" ? (
|
onClick={() => bundleInput.current?.click()}
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
>
|
||||||
) : (
|
{busy === "bundle" ? (
|
||||||
<PackageOpen className="h-3.5 w-3.5" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
)}
|
) : (
|
||||||
{t("sagi.pet.import")}
|
<PackageOpen className="h-3.5 w-3.5" />
|
||||||
</button>
|
)}
|
||||||
<a
|
{t("sagi.pet.import")}
|
||||||
href="https://codex-pets.net/"
|
</button>
|
||||||
target="_blank"
|
<a
|
||||||
rel="noreferrer noopener"
|
href="https://codex-pets.net/"
|
||||||
className="btn-ghost text-xs"
|
target="_blank"
|
||||||
>
|
rel="noreferrer noopener"
|
||||||
<ExternalLink className="h-3.5 w-3.5" />
|
className="btn-ghost text-xs"
|
||||||
{t("sagi.pet.browse")}
|
>
|
||||||
</a>
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
</div>
|
{t("sagi.pet.browse")}
|
||||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -19,7 +19,18 @@
|
|||||||
import { Checkbox } from "../Checkbox";
|
import { Checkbox } from "../Checkbox";
|
||||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
DollarSign,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
RotateCcw,
|
||||||
|
Info,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
|||||||
|
|
||||||
export type PricingVendor = "claude" | "openai" | "google";
|
export type PricingVendor = "claude" | "openai" | "google";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tool page's pointer to its vendor's table on the one Pricing page — the
|
||||||
|
* table itself lives only there, so a price is never shown (or edited) twice.
|
||||||
|
*/
|
||||||
|
export function PricingLink({ vendor }: { vendor: PricingVendor }) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<div className="card flex items-center justify-between gap-4 p-5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||||
|
<DollarSign className="h-4 w-4 text-fg-muted" />
|
||||||
|
{t("pricingLink.title")}
|
||||||
|
</h3>
|
||||||
|
<p className="mt-0.5 text-xs text-fg-muted">{t("pricingLink.description")}</p>
|
||||||
|
</div>
|
||||||
|
<Link to={`/settings/pricing#pricing-${vendor}`} className="btn-ghost shrink-0 text-xs">
|
||||||
|
{t("pricingLink.open")}
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Every mounted table's `load`: a change in one reloads them all, and loads
|
// Every mounted table's `load`: a change in one reloads them all, and loads
|
||||||
// fired together share one request.
|
// fired together share one request.
|
||||||
const tables = new Set<() => void>();
|
const tables = new Set<() => void>();
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file UsersSettings.tsx
|
|
||||||
* @description Settings → Users, server mode only and only for an admin or
|
|
||||||
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
|
||||||
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
|
||||||
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
|
||||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
|
||||||
* `agenthub users …` on the host.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
|
||||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
|
||||||
import { SettingsSection } from "./SettingsRow";
|
|
||||||
|
|
||||||
export function UsersSettings() {
|
|
||||||
const { t } = useTranslation("settings");
|
|
||||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
|
||||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.users
|
|
||||||
.list()
|
|
||||||
.then((r) => setUsers(r.users))
|
|
||||||
.catch((e: Error) => setError(e.message));
|
|
||||||
api.users
|
|
||||||
.audit()
|
|
||||||
.then((r) => setAudit(r.entries))
|
|
||||||
.catch(() => setAudit([]));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
|
||||||
{error && (
|
|
||||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{users && (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm" data-testid="users-table">
|
|
||||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
|
||||||
<tr>
|
|
||||||
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
|
||||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
|
||||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
|
||||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/60">
|
|
||||||
{users.map((u) => (
|
|
||||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
|
||||||
<td className="py-2 pr-3">
|
|
||||||
{u.username}
|
|
||||||
{u.disabled && (
|
|
||||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-3">{u.role}</td>
|
|
||||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
|
||||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
|
||||||
<td
|
|
||||||
className="py-2 pr-3 text-right tabular-nums"
|
|
||||||
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
|
||||||
>
|
|
||||||
{fmtCost(u.last7d.cost)}
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="py-2 text-right tabular-nums"
|
|
||||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
|
||||||
>
|
|
||||||
{fmtCost(u.last30d.cost)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
|
||||||
</SettingsSection>
|
|
||||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
|
||||||
{audit?.length === 0 && (
|
|
||||||
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
|
||||||
)}
|
|
||||||
{audit && audit.length > 0 && (
|
|
||||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
|
||||||
{audit.map((a) => (
|
|
||||||
<li key={a.id} className="flex gap-3 py-1.5">
|
|
||||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
|
||||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
|
||||||
{a.reader} → {a.owner}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</SettingsSection>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
* a homepage link, Refresh re-detects, every updater gets Update without a version
|
* a homepage link, Refresh re-detects, every updater gets Update without a version
|
||||||
* lookup, and Install/Update confirm the exact command, then show the polled
|
* lookup, and Install/Update confirm the exact command, then show the polled
|
||||||
* job log, folded once it is done. An installed row's right-click menu runs
|
* job log, folded once it is done. An installed row's right-click menu runs
|
||||||
* the same Set default / enable switch as its controls.
|
* the same Set default / enable switch as its controls. A click on a row (or
|
||||||
|
* its name link) opens the tool's settings page; its buttons do not.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
|||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { AiToolsSettings } from "../AiToolsSettings";
|
import { AiToolsSettings } from "../AiToolsSettings";
|
||||||
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
||||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||||
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function Where() {
|
||||||
|
const loc = useLocation();
|
||||||
|
return <span data-testid="where">{loc.pathname}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
describe("AiToolsSettings", () => {
|
describe("AiToolsSettings", () => {
|
||||||
|
it("opens a tool's settings from its row or name, never from its buttons", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={["/settings/agents"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="*"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<AiToolsSettings
|
||||||
|
settingsHref={(id) => (id === "gemini" ? `/settings/agents/${id}` : undefined)}
|
||||||
|
/>
|
||||||
|
<Where />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||||
|
await user.click(within(gemini).getByText("Set default"));
|
||||||
|
expect(screen.getByTestId("where").textContent).toBe("/settings/agents");
|
||||||
|
await user.click(gemini);
|
||||||
|
expect(screen.getByTestId("where").textContent).toBe("/settings/agents/gemini");
|
||||||
|
expect(within(gemini).getByRole("link", { name: "Gemini" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/settings/agents/gemini"
|
||||||
|
);
|
||||||
|
// A tool without a page has no link.
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("ai-tool-claude")).queryByRole("link", { name: "Claude Code" })
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("splits installed tools from the ones available to install", async () => {
|
it("splits installed tools from the ones available to install", async () => {
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
||||||
const claude = screen.getByTestId("ai-tool-claude");
|
const claude = screen.getByTestId("ai-tool-claude");
|
||||||
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
|
|||||||
|
|
||||||
it("toggles a tool and sets the default through the API", async () => {
|
it("toggles a tool and sets the default through the API", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||||
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
|
|||||||
|
|
||||||
it("refreshes detection", async () => {
|
it("refreshes detection", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await screen.findByTestId("ai-tool-claude");
|
await screen.findByTestId("ai-tool-claude");
|
||||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||||
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
expect(api.aiTools.refresh).toHaveBeenCalledOnce();
|
||||||
@@ -129,7 +170,7 @@ describe("AiToolsSettings", () => {
|
|||||||
|
|
||||||
it("sets the default and disables a tool from its right-click menu", async () => {
|
it("sets the default and disables a tool from its right-click menu", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||||
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
||||||
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
|
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
|
||||||
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
vi.mocked(api.aiTools.job)
|
vi.mocked(api.aiTools.job)
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
.mockResolvedValue(job({ status: "ok", exitCode: 0, log: "added 1 package\n" }));
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
await user.click(await screen.findByRole("button", { name: /Available to install/ }));
|
||||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||||
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
await user.click(within(qwen).getByRole("button", { name: "Install" }));
|
||||||
@@ -212,7 +253,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
|
|
||||||
it("names the vendor script for a script install", async () => {
|
it("names the vendor script for a script install", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await openAvailable();
|
await openAvailable();
|
||||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||||
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
await user.click(within(cursor).getByRole("button", { name: "Install" }));
|
||||||
@@ -222,7 +263,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("offers Update on every installed tool with an updater", async () => {
|
it("offers Update on every installed tool with an updater", async () => {
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
for (const id of ["claude", "codex", "gemini"]) {
|
for (const id of ["claude", "codex", "gemini"]) {
|
||||||
const row = await screen.findByTestId(`ai-tool-${id}`);
|
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||||
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
expect(within(row).getByRole("button", { name: "Update" })).toBeInTheDocument();
|
||||||
@@ -231,7 +272,7 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
|
|
||||||
it("Update all lists every installed tool's updater", async () => {
|
it("Update all lists every installed tool's updater", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||||
expect(cmd).toHaveTextContent("claude update");
|
expect(cmd).toHaveTextContent("claude update");
|
||||||
@@ -241,13 +282,13 @@ describe("AiToolsSettings — install and update", () => {
|
|||||||
|
|
||||||
it("hides Update all when no tool has an updater", async () => {
|
it("hides Update all when no tool has an updater", async () => {
|
||||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||||
render(<AiToolsSettings />);
|
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await screen.findByTestId("ai-tool-claude");
|
await screen.findByTestId("ai-tool-claude");
|
||||||
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the letter when the icon fails", async () => {
|
it("falls back to the letter when the icon fails", async () => {
|
||||||
const { container } = render(<AiToolsSettings />);
|
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||||
await openAvailable();
|
await openAvailable();
|
||||||
await screen.findByTestId("ai-tool-qwen");
|
await screen.findByTestId("ai-tool-qwen");
|
||||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file AppearanceSettings.test.tsx
|
* @file AppearanceSettings.test.tsx
|
||||||
* @description Tests for the Settings → Appearance section: the language switch, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@ describe("AppearanceSettings", () => {
|
|||||||
it("the System mode is checked after choosing it", async () => {
|
it("the System mode is checked after choosing it", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AppearanceSettings />);
|
render(<AppearanceSettings />);
|
||||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
await user.click(screen.getByRole("radio", { name: "System" }));
|
||||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
|
||||||
expect(localStorage.getItem("theme")).toBe("system");
|
expect(localStorage.getItem("theme")).toBe("system");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,4 +84,21 @@ describe("AppearanceSettings", () => {
|
|||||||
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
expect(container.querySelector('[data-mode="light"][data-theme="canvas"]')).not.toBeNull();
|
||||||
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
expect(container.querySelector('[data-mode="light"]:not([data-theme])')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("picks the terminal font, JetBrains Mono by default, and warns on System", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AppearanceSettings />);
|
||||||
|
const group = screen.getByRole("radiogroup", { name: "Terminal font" });
|
||||||
|
expect(within(group).getByRole("radio", { name: "JetBrains Mono" })).toHaveAttribute(
|
||||||
|
"aria-checked",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
await user.click(within(group).getByRole("radio", { name: "Maple Mono (rounded)" }));
|
||||||
|
expect(localStorage.getItem("agenthub.terminal.font")).toBe("maple");
|
||||||
|
expect(screen.getByTestId("terminal-font-preview").style.fontFamily).toContain("Maple Mono");
|
||||||
|
expect(screen.queryByText(/may lack some Vietnamese letters/)).toBeNull();
|
||||||
|
await user.click(within(group).getByRole("radio", { name: "System font" }));
|
||||||
|
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||||
|
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests Settings → About's Quit AgentHub button (issue #65): it asks
|
||||||
|
* first, says when start-at-login will bring the dashboard back, and only a
|
||||||
|
* confirmed ask reaches POST /api/settings/quit. Plus the status-area icon
|
||||||
|
* switch, which goes straight to PUT /api/settings/tray, and Uninstall
|
||||||
|
* AgentHub, which also asks first.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { AutostartSettings } from "../AutostartSettings";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import * as confirm from "../../../lib/confirm";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: {
|
||||||
|
settings: {
|
||||||
|
autostart: { get: vi.fn(), set: vi.fn() },
|
||||||
|
tray: { get: vi.fn(), set: vi.fn() },
|
||||||
|
quit: vi.fn(),
|
||||||
|
uninstall: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const status = { available: true, kind: "systemd", enabled: true, foreign: false };
|
||||||
|
const trayOff = { available: true, enabled: false, running: false };
|
||||||
|
|
||||||
|
describe("Quit AgentHub", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||||
|
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||||
|
vi.mocked(api.settings.quit).mockReset().mockResolvedValue({ ok: true, killed: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the ask is cancelled", async () => {
|
||||||
|
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||||
|
render(<AutostartSettings />);
|
||||||
|
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||||
|
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||||
|
expect(api.settings.quit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quits once confirmed, warning that start-at-login brings it back", async () => {
|
||||||
|
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||||
|
render(<AutostartSettings />);
|
||||||
|
await waitFor(() => expect(api.settings.autostart.get).toHaveBeenCalled());
|
||||||
|
await screen.findByRole("switch", { checked: true });
|
||||||
|
fireEvent.click(screen.getByTestId("quit-agenthub"));
|
||||||
|
await waitFor(() => expect(api.settings.quit).toHaveBeenCalledTimes(1));
|
||||||
|
expect(ask.mock.calls[0]![0].message).toMatch(/next boot\/login/);
|
||||||
|
expect(screen.getByTestId("quit-agenthub")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns the status-area icon on from its switch", async () => {
|
||||||
|
vi.mocked(api.settings.tray.set).mockResolvedValue({
|
||||||
|
...trayOff,
|
||||||
|
enabled: true,
|
||||||
|
running: true,
|
||||||
|
});
|
||||||
|
render(<AutostartSettings />);
|
||||||
|
await waitFor(() => expect(api.settings.tray.get).toHaveBeenCalled());
|
||||||
|
const icon = screen.getAllByRole("switch")[1]!;
|
||||||
|
await waitFor(() => expect(icon).toBeEnabled());
|
||||||
|
fireEvent.click(icon);
|
||||||
|
await waitFor(() => expect(api.settings.tray.set).toHaveBeenCalledWith(true));
|
||||||
|
await waitFor(() => expect(icon).toBeChecked());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Uninstall AgentHub", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.settings.autostart.get).mockResolvedValue(status as never);
|
||||||
|
vi.mocked(api.settings.tray.get).mockResolvedValue(trayOff);
|
||||||
|
vi.mocked(api.settings.uninstall)
|
||||||
|
.mockReset()
|
||||||
|
.mockResolvedValue({ ok: true, log: "/tmp/agenthub-uninstall-1.log" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the ask is cancelled", async () => {
|
||||||
|
vi.spyOn(confirm, "confirmDialog").mockResolvedValue(false);
|
||||||
|
render(<AutostartSettings />);
|
||||||
|
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
|
||||||
|
await waitFor(() => expect(confirm.confirmDialog).toHaveBeenCalled());
|
||||||
|
expect(api.settings.uninstall).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uninstalls once confirmed, saying the database goes too", async () => {
|
||||||
|
const ask = vi.spyOn(confirm, "confirmDialog").mockResolvedValue(true);
|
||||||
|
render(<AutostartSettings />);
|
||||||
|
fireEvent.click(screen.getByTestId("uninstall-agenthub"));
|
||||||
|
await waitFor(() => expect(api.settings.uninstall).toHaveBeenCalledTimes(1));
|
||||||
|
expect(ask.mock.calls[0]![0].message).toMatch(/database/);
|
||||||
|
expect(screen.getByTestId("uninstall-agenthub")).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* @file BackupSettings.test.tsx
|
||||||
|
* @description Settings → Data → Backup: a save sends only the fields that
|
||||||
|
* changed, "Back up now" is off until a folder is saved, and the newest backup
|
||||||
|
* in the folder 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";
|
||||||
|
|
||||||
|
const { backup } = vi.hoisted(() => ({
|
||||||
|
backup: { get: vi.fn(), save: vi.fn(), run: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", () => ({ api: { settings: { backup } } }));
|
||||||
|
|
||||||
|
import { BackupSettings } from "../BackupSettings";
|
||||||
|
|
||||||
|
const off = {
|
||||||
|
dir: "",
|
||||||
|
hour: 2,
|
||||||
|
keep: 7,
|
||||||
|
running: false,
|
||||||
|
last: null,
|
||||||
|
files: [],
|
||||||
|
dir_error: null,
|
||||||
|
env_owned: { dir: false, hour: false, keep: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.changeLanguage("en");
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("BackupSettings", () => {
|
||||||
|
it("saves only the folder when only the folder changed, then allows a run", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
backup.get.mockResolvedValue(off);
|
||||||
|
backup.save.mockImplementation((p) => Promise.resolve({ ok: true, ...off, ...p }));
|
||||||
|
render(<BackupSettings />);
|
||||||
|
const run = await screen.findByRole("button", { name: /Back up now/ });
|
||||||
|
expect(run).toBeDisabled();
|
||||||
|
expect(screen.getByText("Backups are off.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText("Backup folder"), "/mnt/hdd/b");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(backup.save).toHaveBeenCalledWith({ dir: "/mnt/hdd/b" }));
|
||||||
|
await waitFor(() => expect(run).toBeEnabled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the newest backup", async () => {
|
||||||
|
backup.get.mockResolvedValue({
|
||||||
|
...off,
|
||||||
|
dir: "/mnt/hdd/b",
|
||||||
|
files: [
|
||||||
|
{ name: "agenthub-20261008-020000.db", size: 2048, mtime: "2026-10-08T02:00:30Z" },
|
||||||
|
{ name: "agenthub-20261007-020000.db", size: 1024, mtime: "2026-10-07T02:00:30Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<BackupSettings />);
|
||||||
|
expect(await screen.findByText(/2\.0 KB · 2 kept, 3\.0 KB in all/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,20 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* @file ClaudeAgentHubSettings.test.tsx
|
* @file ClaudeAgentHubSettings.test.tsx
|
||||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
* @description Settings → Claude Code → AgentHub: the hook status with
|
||||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
* Reinstall (named as the plugin's when the plugin provides the hooks),
|
||||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
* CLAUDE_HOME, and a link to the Claude table on the Pricing page — the
|
||||||
|
* table itself is shown only there.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { render as rtlRender, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
import type { ModelPricing } from "../../../lib/types";
|
|
||||||
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
import { settleConfirm, subscribeConfirm } from "../../../lib/confirm";
|
||||||
|
|
||||||
|
const render = (ui: ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
/** Say yes to every confirm (a CLAUDE_HOME change now asks first). */
|
||||||
function confirmYes() {
|
function confirmYes() {
|
||||||
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
return subscribeConfirm((p) => p && queueMicrotask(() => settleConfirm(true)));
|
||||||
@@ -36,24 +40,9 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
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(() => {
|
beforeEach(() => {
|
||||||
i18n.changeLanguage("en");
|
i18n.changeLanguage("en");
|
||||||
vi.mocked(api.pricing.list)
|
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValue({ pricing: [RULE] });
|
|
||||||
vi.mocked(api.settings.info)
|
vi.mocked(api.settings.info)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
@@ -76,10 +65,15 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ClaudeAgentHubSettings", () => {
|
describe("ClaudeAgentHubSettings", () => {
|
||||||
it("renders pricing, hook status and CLAUDE_HOME in their own sections", async () => {
|
it("renders hook status and CLAUDE_HOME, and links to the Claude prices", async () => {
|
||||||
const { container } = render(<ClaudeAgentHubSettings />);
|
const { container } = render(<ClaudeAgentHubSettings />);
|
||||||
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
|
||||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
"href",
|
||||||
|
"/settings/pricing#pricing-claude"
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("claude-opus-5-5")).toBeNull();
|
||||||
|
expect(api.pricing.list).not.toHaveBeenCalled();
|
||||||
|
for (const id of ["hooks", "claude-home"]) {
|
||||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||||
}
|
}
|
||||||
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
const hooks = container.querySelector("section#hooks") as HTMLElement;
|
||||||
@@ -121,14 +115,19 @@ describe("ClaudeAgentHubSettings", () => {
|
|||||||
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
expect(vi.mocked(api.settings.info).mock.calls.length).toBeGreaterThan(reads);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets pricing only on the second click", async () => {
|
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
|
||||||
const user = userEvent.setup();
|
vi.mocked(api.settings.info).mockResolvedValue({
|
||||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
hooks: {
|
||||||
|
installed: true,
|
||||||
|
source: "plugin",
|
||||||
|
path: "/home/u/.claude/settings.json",
|
||||||
|
hooks: { UserPromptSubmit: true },
|
||||||
|
},
|
||||||
|
} as unknown as Awaited<ReturnType<typeof api.settings.info>>);
|
||||||
render(<ClaudeAgentHubSettings />);
|
render(<ClaudeAgentHubSettings />);
|
||||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
const note = await screen.findByText(/provided by the agenthub plugin/i);
|
||||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
const hooks = note.closest("section#hooks") as HTMLElement;
|
||||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
|
||||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
|
||||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,17 @@
|
|||||||
* refetches), and a WebSocket reconnect refetches the whole list because the
|
* refetches), and a WebSocket reconnect refetches the whole list because the
|
||||||
* frames missed while disconnected are never replayed. The copy is dropped
|
* frames missed while disconnected are never replayed. The copy is dropped
|
||||||
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
* when the last component unmounts, so a fresh mount always reads fresh rows.
|
||||||
|
* `useWorkLanes()` is the same list narrowed to the workspaces this user may
|
||||||
|
* work in (server mode: an analyst's own), for the Workspace page and tree.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useSyncExternalStore } from "react";
|
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { keepIfSame } from "../lib/keepIfSame";
|
import { keepIfSame } from "../lib/keepIfSame";
|
||||||
import type { Lane, WSMessage } from "../lib/types";
|
import type { Lane, WSMessage } from "../lib/types";
|
||||||
|
import { useAuth } from "../components/AuthGate";
|
||||||
|
|
||||||
let lanes: Lane[] | null = null;
|
let lanes: Lane[] | null = null;
|
||||||
// Bumped when the store is torn down, so a reply to a request made before the
|
// Bumped when the store is torn down, so a reply to a request made before the
|
||||||
@@ -92,3 +95,21 @@ export function useLanes() {
|
|||||||
}, []);
|
}, []);
|
||||||
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
|
return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspaces this user WORKS in — the Workspace page and its sidebar tree.
|
||||||
|
* Server mode: an analyst gets every workspace from the API (to read) but may
|
||||||
|
* change only their own, so only those are listed here; other users' work is
|
||||||
|
* read on the Sessions, Analytics and Users pages.
|
||||||
|
*/
|
||||||
|
export function useWorkLanes() {
|
||||||
|
const all = useLanes();
|
||||||
|
const { state } = useAuth();
|
||||||
|
const analystId =
|
||||||
|
state.mode === "server" && state.user?.role === "analyst" ? state.user.id : null;
|
||||||
|
const lanes = useMemo(
|
||||||
|
() => (analystId == null ? all.lanes : all.lanes.filter((l) => l.owner_id === analystId)),
|
||||||
|
[all.lanes, analystId]
|
||||||
|
);
|
||||||
|
return { ...all, lanes };
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,7 @@ describe("i18n resources", () => {
|
|||||||
await i18n.changeLanguage("vi");
|
await i18n.changeLanguage("vi");
|
||||||
|
|
||||||
expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
|
expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
|
||||||
expect(i18n.t("nav:agentBoard")).toBe("Bảng Kanban");
|
expect(i18n.t("nav:settings")).toBe("Cài đặt");
|
||||||
expect(i18n.t("nav:languageShort.vi")).toBe("VI");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should keep Agent terminology untranslated in the vi locale", async () => {
|
it("should keep Agent terminology untranslated in the vi locale", async () => {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export const i18nReady = i18n
|
|||||||
"alerts",
|
"alerts",
|
||||||
"splash",
|
"splash",
|
||||||
"lanes",
|
"lanes",
|
||||||
|
"users",
|
||||||
],
|
],
|
||||||
defaultNS: "common",
|
defaultNS: "common",
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
|
|||||||
@@ -4,12 +4,9 @@
|
|||||||
"export": "Export",
|
"export": "Export",
|
||||||
"totalSessions": "Total Sessions",
|
"totalSessions": "Total Sessions",
|
||||||
"totalAgents": "Total Agents",
|
"totalAgents": "Total Agents",
|
||||||
"totalTokens": "Total Tokens",
|
|
||||||
"totalCost": "Total Cost",
|
"totalCost": "Total Cost",
|
||||||
"totalEvents": "Total Events",
|
"totalEvents": "Total Events",
|
||||||
"cacheHitRate": "% cache hit rate",
|
|
||||||
"perSession": " per session",
|
"perSession": " per session",
|
||||||
"noDataCost": "No data",
|
|
||||||
"eventCountLabel_one": "{{count}} event",
|
"eventCountLabel_one": "{{count}} event",
|
||||||
"eventCountLabel_other": "{{count}} events",
|
"eventCountLabel_other": "{{count}} events",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -30,14 +30,12 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
"claudeHome": "Claude home",
|
|
||||||
"projectClaudeDir": "Project .claude",
|
"projectClaudeDir": "Project .claude",
|
||||||
"projectRoot": "Project root",
|
"projectRoot": "Project root",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
"summary": "Quick summary",
|
"summary": "Quick summary",
|
||||||
"user": "user",
|
"user": "user",
|
||||||
"project": "project",
|
"project": "project"
|
||||||
"items": "items"
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"search": "Search by name…",
|
"search": "Search by name…",
|
||||||
@@ -47,32 +45,19 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"copyPath": "Copy path",
|
"copyPath": "Copy path",
|
||||||
"copyName": "Copy name",
|
"copyName": "Copy name",
|
||||||
"copied": "Copied",
|
|
||||||
"truncated": "(truncated for preview)",
|
"truncated": "(truncated for preview)",
|
||||||
"size": "Size",
|
|
||||||
"modified": "Modified",
|
|
||||||
"scopeLabel": "Scope",
|
"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",
|
"preview": "Preview",
|
||||||
"raw": "Raw"
|
"raw": "Raw"
|
||||||
},
|
},
|
||||||
"skills": {
|
|
||||||
"name": "Name",
|
|
||||||
"description": "Description"
|
|
||||||
},
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"model": "Model",
|
|
||||||
"tools": "Tools"
|
"tools": "Tools"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"manifest": "Manifest",
|
|
||||||
"marketplace": "Marketplace",
|
|
||||||
"version": "Version",
|
|
||||||
"installedAt": "Installed",
|
"installedAt": "Installed",
|
||||||
"lastUpdated": "Updated",
|
"lastUpdated": "Updated",
|
||||||
"installPath": "Install path",
|
|
||||||
"missing": "Install path missing",
|
"missing": "Install path missing",
|
||||||
"manifestMissing": "No installed_plugins.json found at {{path}}",
|
"manifestMissing": "No installed_plugins.json found at {{path}}",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
@@ -90,8 +75,7 @@
|
|||||||
"hooks_other": "{{count}} hook scripts",
|
"hooks_other": "{{count}} hook scripts",
|
||||||
"author": "Author",
|
"author": "Author",
|
||||||
"homepage": "Homepage",
|
"homepage": "Homepage",
|
||||||
"license": "License",
|
"license": "License"
|
||||||
"description": "Description"
|
|
||||||
},
|
},
|
||||||
"marketplaces": {
|
"marketplaces": {
|
||||||
"manifest": "Known marketplaces manifest",
|
"manifest": "Known marketplaces manifest",
|
||||||
@@ -100,11 +84,9 @@
|
|||||||
"lastUpdated": "Last synced",
|
"lastUpdated": "Last synced",
|
||||||
"pluginCount": "Plugins",
|
"pluginCount": "Plugins",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
"installLocation": "Install path",
|
|
||||||
"addCmd": "claude plugin marketplace add <repo>"
|
"addCmd": "claude plugin marketplace add <repo>"
|
||||||
},
|
},
|
||||||
"keybindings": {
|
"keybindings": {
|
||||||
"file": "Source file",
|
|
||||||
"missing": "No keybindings.json found at {{path}}",
|
"missing": "No keybindings.json found at {{path}}",
|
||||||
"context": "Context",
|
"context": "Context",
|
||||||
"key": "Key",
|
"key": "Key",
|
||||||
@@ -128,8 +110,7 @@
|
|||||||
},
|
},
|
||||||
"hookScripts": {
|
"hookScripts": {
|
||||||
"title": "~/.claude/hooks/ scripts",
|
"title": "~/.claude/hooks/ scripts",
|
||||||
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them.",
|
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them."
|
||||||
"empty": "No scripts in ~/.claude/hooks/."
|
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"userScope": "User-scoped servers",
|
"userScope": "User-scoped servers",
|
||||||
@@ -143,8 +124,6 @@
|
|||||||
"env": "Env names",
|
"env": "Env names",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"headers": "Header names",
|
"headers": "Header names",
|
||||||
"kind": "Kind",
|
|
||||||
"source": "Source",
|
|
||||||
"noServers": "No MCP servers found.",
|
"noServers": "No MCP servers found.",
|
||||||
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
|
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
|
||||||
},
|
},
|
||||||
@@ -162,8 +141,6 @@
|
|||||||
"rawJson": "Raw JSON"
|
"rawJson": "Raw JSON"
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
"userScope": "User memory",
|
|
||||||
"projectScope": "Project memory",
|
|
||||||
"missing": "No CLAUDE.md found.",
|
"missing": "No CLAUDE.md found.",
|
||||||
"autoTitle": "Project memory store",
|
"autoTitle": "Project memory store",
|
||||||
"autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.",
|
"autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.",
|
||||||
@@ -241,15 +218,9 @@
|
|||||||
"backups": {
|
"backups": {
|
||||||
"title": "Backups",
|
"title": "Backups",
|
||||||
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
||||||
"viewLink": "Show backup history",
|
|
||||||
"openButton": "Backups",
|
"openButton": "Backups",
|
||||||
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
|
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
|
||||||
"restoreHint": "Restore by copying the backup back into place (the backup is kept):",
|
"restoreHint": "Restore by copying the backup back into place (the backup is kept):"
|
||||||
"size": "Size",
|
|
||||||
"created": "Created",
|
|
||||||
"type": "Type",
|
|
||||||
"scopeLabel": "Scope",
|
|
||||||
"filterAll": "All types"
|
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"usageCheck": "Check quota",
|
"usageCheck": "Check quota",
|
||||||
@@ -314,9 +285,7 @@
|
|||||||
"title": "Plugins run through the `claude` CLI",
|
"title": "Plugins run through the `claude` CLI",
|
||||||
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
|
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
|
||||||
"install": "Install a plugin:",
|
"install": "Install a plugin:",
|
||||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
"installCmd": "claude plugin install <marketplace>/<plugin>"
|
||||||
"uninstall": "Uninstall this plugin:",
|
|
||||||
"uninstallButton": "Copy uninstall command"
|
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "MCP servers are managed by the CLI",
|
"title": "MCP servers are managed by the CLI",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Roots",
|
"rootsTitle": "Roots",
|
||||||
"codexHome": "Codex home",
|
|
||||||
"projectCodexDir": "Project .codex",
|
"projectCodexDir": "Project .codex",
|
||||||
"projectRoot": "Project root",
|
"projectRoot": "Project root",
|
||||||
"summary": "Quick summary",
|
"summary": "Quick summary",
|
||||||
@@ -72,9 +71,6 @@
|
|||||||
"saving": "Creating…",
|
"saving": "Creating…",
|
||||||
"close": "Close"
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"config": {
|
|
||||||
"missing": "No config.toml found."
|
|
||||||
},
|
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"addTitle": "Add an MCP server",
|
"addTitle": "Add an MCP server",
|
||||||
"nameLabel": "Name",
|
"nameLabel": "Name",
|
||||||
|
|||||||
@@ -21,14 +21,11 @@
|
|||||||
"live": "Live",
|
"live": "Live",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"running": "running",
|
"running": "running",
|
||||||
"dashboardRun": "Run",
|
|
||||||
"days": "days",
|
"days": "days",
|
||||||
"hours": "hours",
|
"hours": "hours",
|
||||||
"updated": "Updated ",
|
"updated": "Updated ",
|
||||||
"lastUpdated": "Last updated: ",
|
"lastUpdated": "Last updated: ",
|
||||||
"showMore": "Show more ({{count}} remaining)",
|
|
||||||
"plusMore": "+{{count}} more",
|
"plusMore": "+{{count}} more",
|
||||||
"ofSessions": "% of sessions",
|
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -89,14 +86,8 @@
|
|||||||
"cost": {
|
"cost": {
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"cost": "Cost",
|
"cost": "Cost",
|
||||||
"totalCost": "Total Cost",
|
|
||||||
"totalEstimatedCost": "Total Estimated Cost"
|
"totalEstimatedCost": "Total Estimated Cost"
|
||||||
},
|
},
|
||||||
"duration": {
|
|
||||||
"h_m": "{{h}}h {{m}}m",
|
|
||||||
"m_s": "{{m}}m {{s}}s",
|
|
||||||
"s": "{{s}}s"
|
|
||||||
},
|
|
||||||
"eventDetail": {
|
"eventDetail": {
|
||||||
"summary": "Summary",
|
"summary": "Summary",
|
||||||
"seeDetailsBelow": "Check below for full details in {{fields}}",
|
"seeDetailsBelow": "Check below for full details in {{fields}}",
|
||||||
@@ -137,8 +128,6 @@
|
|||||||
"rawPayload": "Raw Payload",
|
"rawPayload": "Raw Payload",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"noPayload": "No payload captured for this event.",
|
|
||||||
"viewDetails": "View details",
|
|
||||||
"expand": "Expand event",
|
"expand": "Expand event",
|
||||||
"collapse": "Collapse event"
|
"collapse": "Collapse event"
|
||||||
},
|
},
|
||||||
@@ -157,12 +146,7 @@
|
|||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"noResults": "No events match the current filters",
|
"noResults": "No events match the current filters",
|
||||||
"grouped": "Group by tool call",
|
|
||||||
"flat": "Flat list",
|
|
||||||
"groupEventCount_one": "{{count}} event",
|
|
||||||
"groupEventCount_other": "{{count}} events",
|
|
||||||
"help": {
|
"help": {
|
||||||
"toggle": "Help",
|
|
||||||
"title": "About events, filters, and grouping",
|
"title": "About events, filters, and grouping",
|
||||||
"subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.",
|
"subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.",
|
||||||
"valuesTitle": "What each filter accepts",
|
"valuesTitle": "What each filter accepts",
|
||||||
@@ -176,10 +160,8 @@
|
|||||||
"statusesTitle": "Status badges",
|
"statusesTitle": "Status badges",
|
||||||
"statusesIntro": "Status is derived from event_type. It's what you see on each row.",
|
"statusesIntro": "Status is derived from event_type. It's what you see on each row.",
|
||||||
"statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.",
|
"statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.",
|
||||||
"statusConnectedDesc": "Tool call completed and returned a result (PostToolUse).",
|
|
||||||
"statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).",
|
"statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).",
|
||||||
"statusErrorDesc": "An error was recorded (error, APIError).",
|
"statusErrorDesc": "An error was recorded (error, APIError).",
|
||||||
"statusIdleDesc": "Lifecycle or metadata (Notification, TurnDuration, SessionStart, SessionEnd).",
|
|
||||||
"lifecycleTitle": "Event lifecycle",
|
"lifecycleTitle": "Event lifecycle",
|
||||||
"lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:",
|
"lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:",
|
||||||
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"eventsToday": "Events Today",
|
"eventsToday": "Events Today",
|
||||||
"totalEvents": "Total Events",
|
"totalEvents": "Total Events",
|
||||||
"totalCost": "Total Cost",
|
"totalCost": "Total Cost",
|
||||||
"viewBoard": "View Board",
|
|
||||||
"viewAll": "View All",
|
"viewAll": "View All",
|
||||||
"activeAgentsSection": "Active Agents",
|
"activeAgentsSection": "Active Agents",
|
||||||
"recentActivity": "Recent Activity",
|
"recentActivity": "Recent Activity",
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
"noAgentsDesc": "Agents will appear here when a Claude Code session is running.",
|
"noAgentsDesc": "Agents will appear here when a Claude Code session is running.",
|
||||||
"noActivity": "No activity yet",
|
"noActivity": "No activity yet",
|
||||||
"noActivityDesc": "Events from Claude Code sessions will stream here in real-time.",
|
"noActivityDesc": "Events from Claude Code sessions will stream here in real-time.",
|
||||||
"failedConnect": "Failed to connect to server",
|
|
||||||
"failedLoad": "Failed to load data",
|
"failedLoad": "Failed to load data",
|
||||||
"activeTrend": " active",
|
"activeTrend": " active",
|
||||||
"totalTrend": " in active sessions",
|
"totalTrend": " in active sessions",
|
||||||
|
|||||||
@@ -19,15 +19,6 @@
|
|||||||
"sessionError": "Session Error",
|
"sessionError": "Session Error",
|
||||||
"subagentSpawned": "Subagent Spawned"
|
"subagentSpawned": "Subagent Spawned"
|
||||||
},
|
},
|
||||||
"toolLegend": {
|
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment.",
|
||||||
"read": "Read",
|
"adminOnly": "Only an admin can do this. Ask an admin of this AgentHub if you need it."
|
||||||
"write": "Write",
|
|
||||||
"edit": "Edit",
|
|
||||||
"bash": "Bash",
|
|
||||||
"grep": "Grep",
|
|
||||||
"glob": "Glob",
|
|
||||||
"agent": "Agent",
|
|
||||||
"other": "Other"
|
|
||||||
},
|
|
||||||
"badResponse": "The server sent an unexpected response instead of data. The dashboard may be restarting, or a proxy is answering in its place — reload in a moment."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,18 @@
|
|||||||
{
|
{
|
||||||
"title": "Kanban Board",
|
"title": "Kanban Board",
|
||||||
"subtitle": "Kanban view of all agents by status",
|
"subtitle": "Kanban view of all agents by status",
|
||||||
"subtitleSessions": "Kanban view of all sessions by status",
|
|
||||||
"viewToggle": {
|
|
||||||
"agents": "Agents",
|
|
||||||
"sessions": "Sessions"
|
|
||||||
},
|
|
||||||
"noAgents": "No agents tracked yet",
|
"noAgents": "No agents tracked yet",
|
||||||
"noAgentsDesc": "Start a Claude Code session with hooks installed to see agents appear here.",
|
"noAgentsDesc": "Start a Claude Code session with hooks installed to see agents appear here.",
|
||||||
"agentCount": "{{count}} agent tracked",
|
"agentCount": "{{count}} agent tracked",
|
||||||
"agentCount_plural": "{{count}} agents tracked",
|
|
||||||
"noAgentsInColumn": "No agents",
|
|
||||||
"noSessions": "No sessions tracked yet",
|
"noSessions": "No sessions tracked yet",
|
||||||
"noSessionsDesc": "Start a Claude Code session with hooks installed to see sessions appear here.",
|
"noSessionsDesc": "Start a Claude Code session with hooks installed to see sessions appear here.",
|
||||||
"sessionCount": "{{count}} session tracked",
|
"sessionCount": "{{count}} session tracked",
|
||||||
"sessionCount_plural": "{{count}} sessions tracked",
|
|
||||||
"noSessionsInColumn": "No sessions",
|
|
||||||
"ran": "ran ",
|
"ran": "ran ",
|
||||||
"running": "running ",
|
"running": "running ",
|
||||||
"session": {
|
"session": {
|
||||||
"anonymous": "Untitled session",
|
|
||||||
"agentSummary_one": "{{count}} agent",
|
|
||||||
"agentSummary_other": "{{count}} agents",
|
|
||||||
"subagentSummary_one": "{{count}} subagent",
|
"subagentSummary_one": "{{count}} subagent",
|
||||||
"subagentSummary_other": "{{count}} subagents",
|
"subagentSummary_other": "{{count}} subagents",
|
||||||
"turnSummary_one": "{{count}} turn",
|
"turnSummary_one": "{{count}} turn",
|
||||||
"turnSummary_other": "{{count}} turns"
|
"turnSummary_other": "{{count}} turns"
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"agent": {
|
|
||||||
"working": "Agent is actively running a tool call right now.\n\nEntered on PreToolUse or UserPromptSubmit, exits on Stop (turn finished).",
|
|
||||||
"waiting": "Agent finished its turn and is blocked on the human - either Claude is waiting for the next user message or has prompted for permission to use a tool.\n\nCleared on the next PreToolUse / PostToolUse, or when the session ends.",
|
|
||||||
"completed": "Agent has fully ended for this session - typically because SessionEnd fired or, for subagents, SubagentStop was processed.\n\nNo further activity will be recorded against this agent.",
|
|
||||||
"error": "Agent recorded an error and will not continue. For main agents this comes from a Stop with stop_reason=error; for subagents, from a failed run."
|
|
||||||
},
|
|
||||||
"session": {
|
|
||||||
"active": "Session is mid-turn - Claude is currently running and the user isn't blocking anything. Brief by nature: a session moves through Active during tool calls and back to Waiting at end of turn.",
|
|
||||||
"waiting": "Session is sitting on the user. Either Claude finished its turn (most common) or it has surfaced a permission prompt and is blocked until the human responds.\n\nLook here for sessions you owe a reply to.",
|
|
||||||
"completed": "Session has fully closed - SessionEnd fired (CLI exited cleanly) or the user explicitly closed it.\n\nAll agents on this session are also completed.",
|
|
||||||
"error": "Session ended with an error - Claude returned stop_reason=error, an unrecoverable failure occurred, or an upstream API error terminated the run.",
|
|
||||||
"abandoned": "Session went stale without a clean SessionEnd. Marked abandoned after 5+ minutes of no activity (orphaned by /resume, network drop, or the CLI being killed). Distinct from Completed: we never saw a real exit signal."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
{
|
{
|
||||||
"action.clear": "clear state",
|
|
||||||
"action.forget": "Delete workspace",
|
|
||||||
"action.purge": "delete history",
|
|
||||||
"action.reset": "reset worktree",
|
"action.reset": "reset worktree",
|
||||||
"action.start": "start",
|
|
||||||
"action.stop": "stop",
|
|
||||||
"actionError": "Workspace action failed: {{message}}",
|
"actionError": "Workspace action failed: {{message}}",
|
||||||
"actionErrorUnknown": "Unknown error",
|
"actionErrorUnknown": "Unknown error",
|
||||||
"add": "Add workspace",
|
"add": "Add workspace",
|
||||||
@@ -58,11 +53,7 @@
|
|||||||
"changes.collapseContext": "Show fewer surrounding lines",
|
"changes.collapseContext": "Show fewer surrounding lines",
|
||||||
"changes.collapseFile": "Collapse {{path}}",
|
"changes.collapseFile": "Collapse {{path}}",
|
||||||
"changes.commitPlaceholder": "Commit message",
|
"changes.commitPlaceholder": "Commit message",
|
||||||
"changes.commitStaged": "Commit {{count}} staged",
|
"changes.suggestMessage": "Suggest a commit message with AI (Claude, ticked files)",
|
||||||
"changes.suggestMessage": "Suggest a commit message with AI (Claude, staged changes)",
|
|
||||||
"changes.neverStage": "Never stage — Stage all skips this file",
|
|
||||||
"changes.allowStage": "Include in Stage all again",
|
|
||||||
"changes.keptHint": "Never staged by Stage all (this browser)",
|
|
||||||
"changes.conflicted": "conflict",
|
"changes.conflicted": "conflict",
|
||||||
"changes.discard": "Discard this file's changes",
|
"changes.discard": "Discard this file's changes",
|
||||||
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
"changes.discardConfirm": "Click again to discard — this cannot be undone",
|
||||||
@@ -79,6 +70,7 @@
|
|||||||
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
"changes.commentLine": "Comment on line {{n}} — pasted into this workspace's pane",
|
||||||
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
"changes.commentOn": "Comment on {{at}} — pasted into the pane you used last; nothing is sent until you press Enter there",
|
||||||
"changes.commentSend": "Paste into pane",
|
"changes.commentSend": "Paste into pane",
|
||||||
|
"changes.addToChat": "Add to Chat",
|
||||||
"changes.commentCancel": "Cancel",
|
"changes.commentCancel": "Cancel",
|
||||||
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
|
"changes.commentNoPane": "No terminal of this workspace is on screen — open its session (or narrow the Changes panel) first",
|
||||||
"changes.layout.split": "Side-by-side",
|
"changes.layout.split": "Side-by-side",
|
||||||
@@ -93,8 +85,9 @@
|
|||||||
"changes.noMatches": "No tracked file matches.",
|
"changes.noMatches": "No tracked file matches.",
|
||||||
"changes.openFile": "Open {{path}}",
|
"changes.openFile": "Open {{path}}",
|
||||||
"changes.quickOpen": "Find a file by path…",
|
"changes.quickOpen": "Find a file by path…",
|
||||||
"changes.range.branch": "vs base",
|
"changes.range.branch": "Compared to {{base}}",
|
||||||
"changes.range.branchHint": "base...HEAD — everything this workspace's branch adds on top of its base",
|
"changes.range.baseBranch": "base branch",
|
||||||
|
"changes.range.branchHint": "{{base}}...HEAD — everything this branch adds on top of {{base}}",
|
||||||
"changes.range.work": "Uncommitted",
|
"changes.range.work": "Uncommitted",
|
||||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||||
"changes.refresh": "Refresh",
|
"changes.refresh": "Refresh",
|
||||||
@@ -104,13 +97,7 @@
|
|||||||
"changes.size.full": "Full screen",
|
"changes.size.full": "Full screen",
|
||||||
"changes.fileCount_one": "{{count}} file",
|
"changes.fileCount_one": "{{count}} file",
|
||||||
"changes.fileCount_other": "{{count}} files",
|
"changes.fileCount_other": "{{count}} files",
|
||||||
"changes.stageFolder": "Stage every file in this folder",
|
|
||||||
"changes.unstageFolder": "Unstage every file in this folder",
|
|
||||||
"changes.stageAll": "Stage all ({{count}})",
|
|
||||||
"changes.unstageAll": "Unstage all ({{count}})",
|
|
||||||
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
"changes.dropEdits": "Discard your unsaved edits to this file?",
|
||||||
"changes.stagedGroup": "Staged Changes",
|
|
||||||
"changes.unstagedGroup": "Changes",
|
|
||||||
"changes.stage": "Stage this file",
|
"changes.stage": "Stage this file",
|
||||||
"changes.title": "Changes",
|
"changes.title": "Changes",
|
||||||
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
"changes.truncated": "Some changes were left out to keep this panel responsive.",
|
||||||
@@ -118,14 +105,6 @@
|
|||||||
"changes.unstage": "Unstage this file",
|
"changes.unstage": "Unstage this file",
|
||||||
"changes.changesView": "Changes",
|
"changes.changesView": "Changes",
|
||||||
"changes.wholeFile": "Whole file",
|
"changes.wholeFile": "Whole file",
|
||||||
"confirmRemoveCancel": "Cancel",
|
|
||||||
"confirmRemoveConfirm": "Remove",
|
|
||||||
"confirmRemoveMessage": "This action cannot be undone.",
|
|
||||||
"confirmRemoveTitle": "Remove workspace?",
|
|
||||||
"countDead": "dead",
|
|
||||||
"countNeedsYou": "need you",
|
|
||||||
"countRunning": "running",
|
|
||||||
"countTotal": "workspaces",
|
|
||||||
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.",
|
"destructive.blocked.adopted": "This is an adopted directory and cannot be changed by a worktree action.",
|
||||||
"destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.",
|
"destructive.blocked.missing": "The workspace directory is missing, so this action cannot continue.",
|
||||||
"destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
|
"destructive.blocked.unreadable": "The workspace directory cannot be read as a Git worktree, so this action cannot continue.",
|
||||||
@@ -197,10 +176,6 @@
|
|||||||
"gitBar.switchHint": "Switch this worktree to another local branch",
|
"gitBar.switchHint": "Switch this worktree to another local branch",
|
||||||
"gitBar.switchLabel": "Switch branch…",
|
"gitBar.switchLabel": "Switch branch…",
|
||||||
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
"gitBar.unbornHint": "Commit first, then publish this repo to a remote.",
|
||||||
"kind.adopted": "adopted",
|
|
||||||
"kind.managed": "managed",
|
|
||||||
"laneDetail.hide": "Hide details",
|
|
||||||
"laneDetail.show": "Workspace details",
|
|
||||||
"locks.held_one": "{{count}} lock held",
|
"locks.held_one": "{{count}} lock held",
|
||||||
"locks.held_other": "{{count}} locks held",
|
"locks.held_other": "{{count}} locks held",
|
||||||
"mode.repo": "Repo",
|
"mode.repo": "Repo",
|
||||||
@@ -214,36 +189,28 @@
|
|||||||
"preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
|
"preflightErrorWithMessage": "Could not load the current workspace facts: {{message}}",
|
||||||
"proof.ticketReport": "Task report",
|
"proof.ticketReport": "Task report",
|
||||||
"renameLabel": "Rename this workspace",
|
"renameLabel": "Rename this workspace",
|
||||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
|
||||||
"proxy.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"proxy.label": "AI Proxy",
|
"proxy.label": "AI Proxy",
|
||||||
"runtime.boot": "▶ up",
|
|
||||||
"runtime.busy.down": "stopping…",
|
|
||||||
"runtime.busy.up": "booting…",
|
|
||||||
"runtime.down": "down",
|
"runtime.down": "down",
|
||||||
"runtime.healthy": "healthy",
|
"runtime.healthy": "healthy",
|
||||||
"runtime.partial": "partial",
|
"runtime.partial": "partial",
|
||||||
"runtime.slot": "slot {{slot}}",
|
"runtime.slot": "slot {{slot}}",
|
||||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||||
"runtime.stop": "■ down",
|
|
||||||
"runtime.toggleTitle": "Boot or stop this workspace's own application stack (separate from its Claude run)",
|
|
||||||
"status.failed": "failed",
|
"status.failed": "failed",
|
||||||
"status.idle": "idle",
|
"status.idle": "idle",
|
||||||
"status.provisioning": "provisioning",
|
"status.provisioning": "provisioning",
|
||||||
"status.running": "running",
|
"status.running": "running",
|
||||||
"title": "Workspaces",
|
"title": "Workspaces",
|
||||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
|
||||||
"worktrees.heading_one": "{{count}} worktree",
|
"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.undoHunk": "Undo this hunk",
|
||||||
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
"changes.undoHunkConfirm": "Click again to undo this hunk — this cannot be undone",
|
||||||
"changes.stageHunkShort": "Keep",
|
|
||||||
"changes.undoHunkShort": "Undo",
|
"changes.undoHunkShort": "Undo",
|
||||||
"changes.undoHunkShortConfirm": "Confirm undo",
|
"changes.undoHunkShortConfirm": "Confirm undo",
|
||||||
"changes.edit": "Edit",
|
"changes.edit": "Edit",
|
||||||
"changes.save": "Save",
|
"changes.save": "Save",
|
||||||
"changes.revertBlock": "Revert",
|
"changes.revertBlock": "Revert",
|
||||||
|
"changes.blockNotInDiff": "That change is no longer in git's diff; refresh and retry",
|
||||||
"changes.cancelEdit": "Cancel",
|
"changes.cancelEdit": "Cancel",
|
||||||
"changes.preview": "Preview",
|
"changes.preview": "Preview",
|
||||||
"changes.raw": "Raw",
|
"changes.raw": "Raw",
|
||||||
@@ -269,14 +236,35 @@
|
|||||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
"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 workspace change now — if an agent is working in this workspace, let it finish first.",
|
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this workspace change now — if an agent is working in this workspace, let it finish first.",
|
||||||
|
"gitBar.pushConfirm": "Push {{count}} commit(s) from {{branch}} to {{upstream}}? Anyone with access to the remote will see them, and a push cannot be taken back from here.",
|
||||||
|
"gitBar.publishBranchConfirm": "Publish branch {{branch}} to the remote? Anyone with access to the remote will see it.",
|
||||||
|
"gitBar.publishConfirm": "Add {{url}} as this repo's remote and push {{branch}} to it? Anyone with access to that remote will see the code.",
|
||||||
|
"gitBar.branchDeleteConfirm": "Delete the local branch {{branch}}? Git refuses if it holds commits merged nowhere else.",
|
||||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
"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",
|
"gitBar.merging": "A {{op}} is in progress: finish or abort it first",
|
||||||
"changes.conflictsGroup": "Merge conflicts",
|
"changes.conflictsGroup": "Merge conflicts",
|
||||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
"changes.mergeBanner": "A {{op}} is in progress — {{count}} file(s) still conflicted",
|
||||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
"changes.mergeReady": "A {{op}} is in progress — every conflict is resolved",
|
||||||
"changes.mergeCommit": "Complete merge",
|
"changes.mergeCommit": "Complete {{op}}",
|
||||||
"changes.mergeAbort": "Abort merge",
|
"changes.mergeAbort": "Abort {{op}}",
|
||||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||||
|
"changes.discardShortConfirm": "Discard?",
|
||||||
|
"changes.amend": "Amend",
|
||||||
|
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
|
||||||
|
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||||
|
"changes.historyOf": "History of branch",
|
||||||
|
"changes.cherryPick": "Cherry-pick onto {{branch}}",
|
||||||
|
"changes.revertCommit": "Revert this commit",
|
||||||
|
"changes.undoCommit": "Undo commit (keep changes)",
|
||||||
|
"changes.undoCommitTitle": "Undo the last commit?",
|
||||||
|
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||||
|
"changes.undoCommitConfirm": "Undo commit",
|
||||||
|
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||||
|
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||||
|
"changes.amendTitle": "Amend the last commit?",
|
||||||
|
"changes.amendConfirm": "\"{{subject}}\" is replaced by a new commit with the staged changes folded in. The server refuses if it is already pushed.",
|
||||||
|
"changes.opConflicts": "The {{op}} stopped on {{count}} conflict(s) — resolve them in Changes",
|
||||||
|
"changes.opDone": "{{op}} done",
|
||||||
"changes.takeOurs": "Keep mine",
|
"changes.takeOurs": "Keep mine",
|
||||||
"changes.takeTheirs": "Take theirs",
|
"changes.takeTheirs": "Take theirs",
|
||||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||||
@@ -330,9 +318,6 @@
|
|||||||
"tree.allTools": "All tools",
|
"tree.allTools": "All tools",
|
||||||
"tree.newSession": "New session",
|
"tree.newSession": "New session",
|
||||||
"tree.noSessions": "No sessions yet",
|
"tree.noSessions": "No sessions yet",
|
||||||
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
|
|
||||||
"tree.reopenTerminal": "Open a new terminal",
|
|
||||||
"tree.forgetTerminal": "Remove from list",
|
|
||||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||||
"tree.renameSession": "Rename session",
|
"tree.renameSession": "Rename session",
|
||||||
@@ -346,6 +331,7 @@
|
|||||||
"tree.unarchive": "Unarchive",
|
"tree.unarchive": "Unarchive",
|
||||||
"home.breadcrumb": "Workspaces",
|
"home.breadcrumb": "Workspaces",
|
||||||
"home.ai": "AI",
|
"home.ai": "AI",
|
||||||
|
"home.openTerminal": "Open terminal",
|
||||||
"home.repoRoot": "Repository root",
|
"home.repoRoot": "Repository root",
|
||||||
"home.mainBranch": "Main worktree's branch",
|
"home.mainBranch": "Main worktree's branch",
|
||||||
"home.worktreeCount_one": "{{count}} worktree",
|
"home.worktreeCount_one": "{{count}} worktree",
|
||||||
@@ -376,6 +362,7 @@
|
|||||||
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
|
"home.memoryEmpty": "Memory shows here once the folder has a CLAUDE.md or AGENTS.md, or Claude saves memory for it.",
|
||||||
"home.context": "Context",
|
"home.context": "Context",
|
||||||
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
|
"home.contextEmpty": "Add PDFs, documents, or other text to reference in this workspace.",
|
||||||
|
"home.contextSnapshot": "Each file is copied when you add it — later edits to the original are not seen. Remove and add it again, or add its folder under Folder to always read the latest.",
|
||||||
"home.addFile": "Add files",
|
"home.addFile": "Add files",
|
||||||
"home.folder": "Folder",
|
"home.folder": "Folder",
|
||||||
"home.ownFolder": "Workspace folder",
|
"home.ownFolder": "Workspace folder",
|
||||||
@@ -414,6 +401,7 @@
|
|||||||
"panes.splitNew.right": "Split right with a new terminal",
|
"panes.splitNew.right": "Split right with a new terminal",
|
||||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||||
"panes.closeShort": "Move to own tab",
|
"panes.closeShort": "Move to own tab",
|
||||||
|
"panes.stop": "Stop session",
|
||||||
"changes.copyPath": "Copy relative path",
|
"changes.copyPath": "Copy relative path",
|
||||||
"changes.viewCommit": "View commit",
|
"changes.viewCommit": "View commit",
|
||||||
"changes.copySha": "Copy commit SHA",
|
"changes.copySha": "Copy commit SHA",
|
||||||
@@ -441,6 +429,7 @@
|
|||||||
"tree.showMore_one": "Show {{count}} more",
|
"tree.showMore_one": "Show {{count}} more",
|
||||||
"tree.showMore_other": "Show {{count}} more",
|
"tree.showMore_other": "Show {{count}} more",
|
||||||
"tree.seeAll": "See all ({{count}} more)",
|
"tree.seeAll": "See all ({{count}} more)",
|
||||||
|
"tree.showLess": "Show less",
|
||||||
"home.contextPanel": "Workspace context",
|
"home.contextPanel": "Workspace context",
|
||||||
"row.state.needsYou": "needs you",
|
"row.state.needsYou": "needs you",
|
||||||
"row.state.error": "error",
|
"row.state.error": "error",
|
||||||
@@ -454,5 +443,30 @@
|
|||||||
"runtime.listening": "listening",
|
"runtime.listening": "listening",
|
||||||
"runtime.notListening": "not listening",
|
"runtime.notListening": "not listening",
|
||||||
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
|
"replay.pasted": "That session was already open in a tab — your message is pasted there; press Enter to send it.",
|
||||||
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}"
|
"replay.notPasted": "That session is open in a tab, but your message could not be pasted there: {{text}}",
|
||||||
|
"changes.changesGroup": "Changes",
|
||||||
|
"changes.unversionedGroup": "Unversioned Files",
|
||||||
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
|
"changes.commitAndPush": "Commit and Push",
|
||||||
|
"changes.amendAndPush": "Amend and Push",
|
||||||
|
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||||
|
"changes.tickFile": "Include {{path}} in the commit",
|
||||||
|
"changes.tickFolder": "Tick every file in {{path}}",
|
||||||
|
"changes.commitFile": "Commit File…",
|
||||||
|
"changes.jumpToSource": "Jump to Source",
|
||||||
|
"changes.copyPatch": "Copy as Patch to Clipboard",
|
||||||
|
"changes.noPatch": "This file has no text diff to copy",
|
||||||
|
"changes.addToVcs": "Add to VCS",
|
||||||
|
"changes.addToGitignore": "Add to .gitignore",
|
||||||
|
"changes.includeBlock": "Include this change in the commit",
|
||||||
|
"changes.prevDiff": "Previous difference (Shift+F7)",
|
||||||
|
"changes.nextDiff": "Next difference (F7)",
|
||||||
|
"changes.prevFile": "Previous file",
|
||||||
|
"changes.nextFile": "Next file",
|
||||||
|
"changes.showHistory": "Show History",
|
||||||
|
"changes.historyOfFile": "History of {{path}}",
|
||||||
|
"changes.historyWholeBranch": "Whole branch",
|
||||||
|
"changes.annotate": "Annotate",
|
||||||
|
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
|
||||||
|
"changes.notCommitted": "Not committed yet"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"activityFeed": "Activity Feed",
|
"activityFeed": "Activity Feed",
|
||||||
"agentBoard": "Kanban Board",
|
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"avg": "avg",
|
"avg": "avg",
|
||||||
@@ -19,20 +18,11 @@
|
|||||||
"daysAgo": "{{count}}d ago",
|
"daysAgo": "{{count}}d ago",
|
||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"eventsLastMin": "Last 60s",
|
"eventsLastMin": "Last 60s",
|
||||||
"eventsReceived": "Events received",
|
|
||||||
"eventsTotal": "Total",
|
"eventsTotal": "Total",
|
||||||
"expand": "Expand sidebar",
|
"expand": "Expand sidebar",
|
||||||
"hoursAgo": "{{count}}h ago",
|
"hoursAgo": "{{count}}h ago",
|
||||||
"justNow": "just now",
|
"justNow": "just now",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageNames": {
|
|
||||||
"en": "English",
|
|
||||||
"vi": "Vietnamese"
|
|
||||||
},
|
|
||||||
"languageShort": {
|
|
||||||
"en": "EN",
|
|
||||||
"vi": "VI"
|
|
||||||
},
|
|
||||||
"lastEvent": "Last event",
|
"lastEvent": "Last event",
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"minutesAgo": "{{count}}m ago",
|
"minutesAgo": "{{count}}m ago",
|
||||||
@@ -55,7 +45,6 @@
|
|||||||
"unitPerSec": "events/s",
|
"unitPerSec": "events/s",
|
||||||
"upToDate": "Up to date",
|
"upToDate": "Up to date",
|
||||||
"updateAvailable": "Update available",
|
"updateAvailable": "Update available",
|
||||||
"website": "Website",
|
|
||||||
"workspace": "Workspace",
|
"workspace": "Workspace",
|
||||||
"wsEndpoint": "WebSocket",
|
"wsEndpoint": "WebSocket",
|
||||||
"releaseAvailable": "{{version}} available",
|
"releaseAvailable": "{{version}} available",
|
||||||
@@ -81,6 +70,7 @@
|
|||||||
"quotaFailed": "Could not read the quota",
|
"quotaFailed": "Could not read the quota",
|
||||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||||
"quotaActive": "in use",
|
"quotaActive": "in use",
|
||||||
|
"quotaTokenExpired": "Sign-in expired: use the tool once so it signs in again",
|
||||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||||
"quotaSignInExpired": "Sign-in expired",
|
"quotaSignInExpired": "Sign-in expired",
|
||||||
@@ -94,5 +84,6 @@
|
|||||||
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
"resizeSidebar": "Resize sidebar (double-click to reset)",
|
||||||
"closeMenu": "Close menu",
|
"closeMenu": "Close menu",
|
||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"now": "now"
|
"now": "now",
|
||||||
|
"users": "Users"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
{
|
{
|
||||||
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
"emptyPane": "No session in this pane. Start one from the workspace's prompt box.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"newSession": "New session",
|
"newSession": "New session"
|
||||||
"send": "Send",
|
|
||||||
"sending": "Sending…",
|
|
||||||
"start": "Run",
|
|
||||||
"starting": "Starting…",
|
|
||||||
"stop": "Stop",
|
|
||||||
"stopping": "Stopping…"
|
|
||||||
},
|
},
|
||||||
"autocomplete": {
|
"autocomplete": {
|
||||||
"fileHint": "Type @ to reference a file",
|
"fileHint": "Type @ to reference a file",
|
||||||
"groupBuiltin": "Built-in (CLI only - won't run from here)",
|
|
||||||
"groupFiles": "Files in cwd",
|
|
||||||
"groupProject": "Project commands",
|
|
||||||
"groupUser": "User commands",
|
|
||||||
"noMatches": "No matches",
|
"noMatches": "No matches",
|
||||||
"slashHint": "Type / for slash commands"
|
"slashHint": "Type / for slash commands"
|
||||||
},
|
},
|
||||||
"binary": {
|
"binary": {
|
||||||
"found": "Using {{path}}",
|
|
||||||
"missing": "The `{{cli}}` CLI isn't on your PATH. Install {{tool}} or set PATH so the dashboard can spawn it."
|
"missing": "The `{{cli}}` CLI isn't on your PATH. Install {{tool}} or set PATH so the dashboard can spawn it."
|
||||||
},
|
},
|
||||||
"concurrency": {
|
|
||||||
"active_one": "{{count}} run already in flight",
|
|
||||||
"active_other": "{{count}} runs already in flight",
|
|
||||||
"atCap": "Concurrency cap reached ({{max}}). Stop one of the running runs to start a new one.",
|
|
||||||
"viewActive": "View active runs"
|
|
||||||
},
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"body": "Type a prompt and click Run. Output streams here as the model thinks, calls tools, and answers.",
|
"body": "Type a prompt and click Run. Output streams here as the model thinks, calls tools, and answers.",
|
||||||
"title": "Nothing yet"
|
"title": "Nothing yet"
|
||||||
@@ -40,28 +23,16 @@
|
|||||||
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
|
"runStartedButNotAttached": "Run is live but couldn't attach to console: {{message}}",
|
||||||
"sendFailed": "Couldn't send: {{message}}"
|
"sendFailed": "Couldn't send: {{message}}"
|
||||||
},
|
},
|
||||||
"events": {
|
|
||||||
"claude": "Claude",
|
|
||||||
"collapse": "Collapse",
|
|
||||||
"expand": "Expand",
|
|
||||||
"system": "System",
|
|
||||||
"thinking": "Thinking",
|
|
||||||
"tool": "Tool",
|
|
||||||
"toolResult": "Tool result",
|
|
||||||
"you": "You"
|
|
||||||
},
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"approval": "Approval",
|
"approval": "Approval",
|
||||||
"approvalNever": "Never",
|
"approvalNever": "Never",
|
||||||
"approvalOnRequest": "On request",
|
"approvalOnRequest": "On request",
|
||||||
"cwd": "Working directory",
|
|
||||||
"cwdGroups": {
|
"cwdGroups": {
|
||||||
"dashboard": "Dashboard server",
|
"dashboard": "Dashboard server",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"repo": "Source repos",
|
"repo": "Source repos",
|
||||||
"recent": "Recent - used by Claude Code"
|
"recent": "Recent - used by Claude Code"
|
||||||
},
|
},
|
||||||
"cwdHint": "Absolute path. Defaults to the dashboard's own cwd.",
|
|
||||||
"cwdNoMatches": "No matches. Type an absolute path to use it directly.",
|
"cwdNoMatches": "No matches. Type an absolute path to use it directly.",
|
||||||
"cwdPlaceholder": "Type to search or paste an absolute path…",
|
"cwdPlaceholder": "Type to search or paste an absolute path…",
|
||||||
"effort": "Thinking effort",
|
"effort": "Thinking effort",
|
||||||
@@ -82,64 +53,15 @@
|
|||||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||||
"permissionMode": "Permission mode",
|
"permissionMode": "Permission mode",
|
||||||
"permissionPlan": "plan (read-only planning)",
|
"permissionPlan": "plan (read-only planning)",
|
||||||
"prompt": "Prompt",
|
|
||||||
"promptPlaceholder": "Ask Claude anything…",
|
|
||||||
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
|
|
||||||
"sandbox": "Sandbox",
|
"sandbox": "Sandbox",
|
||||||
"sandboxFullAccess": "Full access (dangerous)",
|
"sandboxFullAccess": "Full access (dangerous)",
|
||||||
"sandboxReadOnly": "Read only",
|
"sandboxReadOnly": "Read only",
|
||||||
"sandboxWorkspaceWrite": "Workspace write"
|
"sandboxWorkspaceWrite": "Workspace write"
|
||||||
},
|
},
|
||||||
"footer": {
|
|
||||||
"cost": "Cost",
|
|
||||||
"duration": "Duration",
|
|
||||||
"exitCode": "Exit code",
|
|
||||||
"sessionId": "Session ID",
|
|
||||||
"turns": "Turns"
|
|
||||||
},
|
|
||||||
"hint": {
|
"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.",
|
"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.",
|
"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.",
|
"sandboxWarning": "Codex will run commands with no sandbox."
|
||||||
"shortcut": "Enter to send · Shift+Enter for newline"
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"conversation": "Conversation",
|
|
||||||
"conversationHint": "Multi-turn - keep typing follow-ups while the agent works.",
|
|
||||||
"headless": "One-shot",
|
|
||||||
"headlessHint": "Single prompt, single response. Stdin closes after spawn.",
|
|
||||||
"label": "Mode"
|
|
||||||
},
|
|
||||||
"resume": {
|
|
||||||
"clear": "Clear selection",
|
|
||||||
"freshHint": "Start a fresh Claude Code session.",
|
|
||||||
"freshOption": "New session",
|
|
||||||
"label": "Source session",
|
|
||||||
"lastOption": "Resume last session",
|
|
||||||
"noSessions": "No sessions found.",
|
|
||||||
"originalCwd": "Original cwd",
|
|
||||||
"pickSession": "Pick a session…",
|
|
||||||
"preview": "Last activity",
|
|
||||||
"resumeHint": "Pick a session from your history and continue the conversation. Cwd is locked to the original.",
|
|
||||||
"search": "Search by ID, cwd, or status…",
|
|
||||||
"selectedBadge": "Resuming"
|
|
||||||
},
|
|
||||||
"statusHeader": {
|
|
||||||
"cwd": "Cwd",
|
|
||||||
"duration": "Elapsed",
|
|
||||||
"effort": "Effort",
|
|
||||||
"envelopes": "Events",
|
|
||||||
"model": "Model",
|
|
||||||
"permissionMode": "Mode",
|
|
||||||
"session": "Session"
|
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": "Cache hit",
|
|
||||||
"cost": "Cost",
|
|
||||||
"input": "Input",
|
|
||||||
"label": "Context",
|
|
||||||
"output": "Output"
|
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Drop files or images to attach",
|
"hint": "Drop files or images to attach",
|
||||||
@@ -159,5 +81,11 @@
|
|||||||
"findPrev": "Previous match",
|
"findPrev": "Previous match",
|
||||||
"findNext": "Next match",
|
"findNext": "Next match",
|
||||||
"findClose": "Close find"
|
"findClose": "Close find"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"placeholder": "Write a message…",
|
||||||
|
"send": "Send",
|
||||||
|
"hint": "Enter sends · permission prompts and menus need the terminal (Settings → Appearance → Chat UI)",
|
||||||
|
"notSent": "The terminal is not connected — the message was not sent."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"filterError": "Error",
|
"filterError": "Error",
|
||||||
"filterAbandoned": "Abandoned",
|
"filterAbandoned": "Abandoned",
|
||||||
"sessionCount": "{{count}} session recorded",
|
"sessionCount": "{{count}} session recorded",
|
||||||
"sessionCount_plural": "{{count}} sessions recorded",
|
|
||||||
"noSessions": "No sessions found",
|
"noSessions": "No sessions found",
|
||||||
"noSessionsDesc": "Try adjusting your search or filters.",
|
"noSessionsDesc": "Try adjusting your search or filters.",
|
||||||
"noSessionsHint": "Start a Claude Code session with hooks installed to begin tracking.",
|
"noSessionsHint": "Start a Claude Code session with hooks installed to begin tracking.",
|
||||||
@@ -26,14 +25,12 @@
|
|||||||
"generic": "Claude Code is paused in this session until you respond in the terminal."
|
"generic": "Claude Code is paused in this session until you respond in the terminal."
|
||||||
},
|
},
|
||||||
"failedLoad": "Failed to load session",
|
"failedLoad": "Failed to load session",
|
||||||
"loading": "Loading session...",
|
|
||||||
"notFound": "Session not found",
|
"notFound": "Session not found",
|
||||||
"backToSessions": "Back to Sessions",
|
"backToSessions": "Back to Sessions",
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"noAgents": "No agents recorded.",
|
"noAgents": "No agents recorded.",
|
||||||
"unparented": "Unparented Subagents",
|
"unparented": "Unparented Subagents",
|
||||||
"costBreakdown": "Cost Breakdown",
|
"costBreakdown": "Cost Breakdown",
|
||||||
"eventTimeline": "Event Timeline",
|
|
||||||
"noEvents": "No events recorded.",
|
"noEvents": "No events recorded.",
|
||||||
"conversation": "Conversation",
|
"conversation": "Conversation",
|
||||||
"timeline": "Timeline ({{shown}}/{{total}})",
|
"timeline": "Timeline ({{shown}}/{{total}})",
|
||||||
@@ -151,6 +148,8 @@
|
|||||||
"persistedOutput": "Persisted output",
|
"persistedOutput": "Persisted output",
|
||||||
"taskNotification": "Task notification",
|
"taskNotification": "Task notification",
|
||||||
"thinking": "Thinking",
|
"thinking": "Thinking",
|
||||||
|
"showMore": "Show more",
|
||||||
|
"showLess": "Show less",
|
||||||
"chars_one": "{{formatted}} char",
|
"chars_one": "{{formatted}} char",
|
||||||
"chars_other": "{{formatted}} chars",
|
"chars_other": "{{formatted}} chars",
|
||||||
"sender": {
|
"sender": {
|
||||||
@@ -181,6 +180,22 @@
|
|||||||
"statusError": "error",
|
"statusError": "error",
|
||||||
"statusOk": "ok",
|
"statusOk": "ok",
|
||||||
"statusPending": "pending"
|
"statusPending": "pending"
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"commands_one": "ran {{count}} command",
|
||||||
|
"commands_other": "ran {{count}} commands",
|
||||||
|
"edits_one": "edited a file",
|
||||||
|
"edits_other": "made {{count}} edits",
|
||||||
|
"reads_one": "read a file",
|
||||||
|
"reads_other": "read {{count}} files",
|
||||||
|
"searches_one": "searched the code",
|
||||||
|
"searches_other": "ran {{count}} searches",
|
||||||
|
"web_one": "looked up the web",
|
||||||
|
"web_other": "made {{count}} web lookups",
|
||||||
|
"agents_one": "ran an agent",
|
||||||
|
"agents_other": "ran {{count}} agents",
|
||||||
|
"tools_one": "used a tool",
|
||||||
|
"tools_other": "used {{count}} tools"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
|
|||||||
@@ -137,7 +137,8 @@
|
|||||||
"incomplete": "Hooks incomplete",
|
"incomplete": "Hooks incomplete",
|
||||||
"reinstall": "Reinstall Hooks",
|
"reinstall": "Reinstall Hooks",
|
||||||
"success": "Hooks installed successfully",
|
"success": "Hooks installed successfully",
|
||||||
"failed": "Hook installation failed"
|
"failed": "Hook installation failed",
|
||||||
|
"plugin": "Provided by the agenthub plugin (its plugin.json), not settings.json"
|
||||||
},
|
},
|
||||||
"lanesSection": {
|
"lanesSection": {
|
||||||
"title": "Workspaces",
|
"title": "Workspaces",
|
||||||
@@ -311,16 +312,59 @@
|
|||||||
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
"unavailable": "Not available here: needs systemd --user on Linux, or a GUI login on macOS.",
|
||||||
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
"foreign": "A service named agenthub that AgentHub did not write already exists — AgentHub leaves it alone."
|
||||||
},
|
},
|
||||||
|
"tray": {
|
||||||
|
"label": "Show AgentHub icon in the status area",
|
||||||
|
"desc": "An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.",
|
||||||
|
"unavailable": "Not available here: needs a graphical desktop session, and is off in server mode."
|
||||||
|
},
|
||||||
|
"quit": {
|
||||||
|
"label": "Quit AgentHub",
|
||||||
|
"desc": "Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run `agenthub start` to bring it back.",
|
||||||
|
"button": "Quit",
|
||||||
|
"confirmTitle": "Quit AgentHub?",
|
||||||
|
"confirmMessage": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`.",
|
||||||
|
"confirmMessageAutostart": "Every workspace terminal is closed — running agents stop — and the dashboard shuts down. It stays off until you run `agenthub start`, or until the next boot/login (start automatically is on).",
|
||||||
|
"done": "AgentHub is stopping. Run `agenthub start` to start it again."
|
||||||
|
},
|
||||||
|
"uninstall": {
|
||||||
|
"label": "Uninstall AgentHub",
|
||||||
|
"desc": "Remove AgentHub from this computer: the dashboard, its database and settings, its hooks in Claude Code, Codex and Antigravity, and the plugin. Your conversations, workspace folders and repositories are kept.",
|
||||||
|
"button": "Uninstall",
|
||||||
|
"confirmTitle": "Uninstall AgentHub?",
|
||||||
|
"confirmMessage": "Every workspace terminal is closed and AgentHub is removed for good, its database (history, workspaces, settings, saved logins) included — this cannot be undone. Conversations of Claude Code, Codex and the other AI tools, workspace worktrees and your repositories are kept.",
|
||||||
|
"done": "AgentHub is being uninstalled; this page stops answering in a moment. Log: {{log}}"
|
||||||
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "System",
|
||||||
"description": "Server runtime information.",
|
|
||||||
"release": "Release",
|
"release": "Release",
|
||||||
"uiBuild": "UI build v{{version}} (rebuild client to match)",
|
"uiBuild": "UI build v{{version}} (rebuild client to match)",
|
||||||
"uptime": "Uptime",
|
"uptime": "Uptime",
|
||||||
"nodejs": "Node.js",
|
"nodejs": "Node.js",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
"wsClients": "WS Clients",
|
"wsClients": "Live connections",
|
||||||
"loadingInfo": "Loading server info..."
|
"wsClientsDesc": "Browser tabs and tools receiving live updates",
|
||||||
|
"loadingInfo": "Loading server info...",
|
||||||
|
"server": "Server",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"cpuValue": "{{count}} cores · load {{load}}",
|
||||||
|
"memory": "Process memory",
|
||||||
|
"memoryValue": "{{rss}} RSS · heap {{used}} of {{limit}}",
|
||||||
|
"systemMemory": "System memory",
|
||||||
|
"systemMemoryValue": "{{used}} used of {{total}}",
|
||||||
|
"install": "Installation",
|
||||||
|
"installKind": {
|
||||||
|
"plugin": "Claude Code plugin",
|
||||||
|
"checkout": "Git checkout"
|
||||||
|
},
|
||||||
|
"latest": "Latest release",
|
||||||
|
"upToDate": "Up to date",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"database": "Database",
|
||||||
|
"dbFile": "Database file",
|
||||||
|
"journal": "Journal mode",
|
||||||
|
"activity": "Events recorded",
|
||||||
|
"activityValue": "{{m5}} in 5 min · {{m15}} in 15 min · {{h1}} in 1 h"
|
||||||
},
|
},
|
||||||
"tables": {
|
"tables": {
|
||||||
"sessions": "sessions",
|
"sessions": "sessions",
|
||||||
@@ -634,6 +678,7 @@
|
|||||||
"enabledOf": "{{tool}} enabled",
|
"enabledOf": "{{tool}} enabled",
|
||||||
"homepage": "Homepage",
|
"homepage": "Homepage",
|
||||||
"homepageOf": "{{tool}} homepage",
|
"homepageOf": "{{tool}} homepage",
|
||||||
|
"openSettings": "Open {{tool}} settings",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateAll": "Update all ({{count}})",
|
"updateAll": "Update all ({{count}})",
|
||||||
@@ -760,13 +805,13 @@
|
|||||||
},
|
},
|
||||||
"settingsNav": {
|
"settingsNav": {
|
||||||
"label": "Settings sections",
|
"label": "Settings sections",
|
||||||
|
"toolsTab": "All tools",
|
||||||
|
"toolTabs": "AI tool settings",
|
||||||
"back": "Back to Workspace",
|
"back": "Back to Workspace",
|
||||||
"group": {
|
"group": {
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"agents": "Agents",
|
"agents": "Agents",
|
||||||
"routing": "Routing",
|
"data": "Data"
|
||||||
"git": "Git",
|
|
||||||
"cost": "Cost"
|
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
"appearance": {
|
"appearance": {
|
||||||
@@ -800,37 +845,25 @@
|
|||||||
"title": "Pricing",
|
"title": "Pricing",
|
||||||
"description": "Model prices for every vendor, used to estimate cost. Rates per million tokens (USD); % is a wildcard in a pattern; a model's introductory rate is shown in violet."
|
"description": "Model prices for every vendor, used to estimate cost. Rates per million tokens (USD); % is a wildcard in a pattern; a model's introductory rate is shown in violet."
|
||||||
},
|
},
|
||||||
"gitCredentials": {
|
|
||||||
"title": "Git credentials",
|
|
||||||
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push."
|
|
||||||
},
|
|
||||||
"worktrees": {
|
|
||||||
"title": "Worktrees",
|
|
||||||
"description": "Which untracked git worktrees a workspace's Add folder suggests."
|
|
||||||
},
|
|
||||||
"data": {
|
"data": {
|
||||||
"title": "Data",
|
"title": "Database",
|
||||||
"description": "Cost and database overview, import, remote sources, export, cleanup and the danger zone."
|
"description": "Total cost, database overview, import, remote sources, export, cleanup and the danger zone."
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"title": "About",
|
|
||||||
"description": "Server version, uptime and runtime."
|
|
||||||
},
|
|
||||||
"snapshots": {
|
|
||||||
"title": "Snapshots",
|
|
||||||
"description": "Frozen, read-only copies of sessions, each behind its own link."
|
|
||||||
},
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
"title": "Reports",
|
"title": "Snapshots & reports",
|
||||||
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
"description": "Read-only session snapshots behind their own links, and analytics summaries written on a schedule."
|
||||||
},
|
},
|
||||||
"users": {
|
"system": {
|
||||||
"title": "Users",
|
"title": "System",
|
||||||
"description": "Accounts, their usage and the audit log."
|
"description": "Version, install, start at login, server runtime and the database file."
|
||||||
|
},
|
||||||
|
"git": {
|
||||||
|
"title": "Git",
|
||||||
|
"description": "Logins AgentHub gives git for a dashboard fetch, pull or push, and which worktrees Add folder suggests."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"backPrevious": "Back",
|
"backPrevious": "Back",
|
||||||
"unknownSection": "There is no “{{section}}” settings page — showing General instead."
|
"unknownSection": "There is no “{{section}}” settings page — showing Appearance instead."
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"placeholder": "Search settings",
|
"placeholder": "Search settings",
|
||||||
@@ -856,6 +889,18 @@
|
|||||||
"vi": "Tiếng Việt"
|
"vi": "Tiếng Việt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"terminalFont": {
|
||||||
|
"title": "Terminal font",
|
||||||
|
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved in this browser only.",
|
||||||
|
"label": "Font",
|
||||||
|
"names": {
|
||||||
|
"jetbrains": "JetBrains Mono",
|
||||||
|
"maple": "Maple Mono (rounded)",
|
||||||
|
"system": "System font"
|
||||||
|
},
|
||||||
|
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
|
||||||
|
"preview": "Preview"
|
||||||
|
},
|
||||||
"rowMenu": {
|
"rowMenu": {
|
||||||
"copyHost": "Copy host",
|
"copyHost": "Copy host",
|
||||||
"copyUrl": "Copy endpoint",
|
"copyUrl": "Copy endpoint",
|
||||||
@@ -902,6 +947,23 @@
|
|||||||
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
||||||
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"title": "Daily backup",
|
||||||
|
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
|
||||||
|
"folder": "Backup folder",
|
||||||
|
"folderHint": "An absolute path to an existing folder the server can write to. Leave it empty to turn backups off. AgentHub only ever deletes its own agenthub-YYYYMMDD-HHMMSS.db files here, oldest first beyond the number kept.",
|
||||||
|
"hour": "Time (server)",
|
||||||
|
"keep": "Backups kept",
|
||||||
|
"runNow": "Back up now",
|
||||||
|
"running": "Backing up…",
|
||||||
|
"done": "Backup written: {{file}}",
|
||||||
|
"failed": "Backup failed: {{error}}",
|
||||||
|
"newest": "Newest: {{time}}, {{size}} · {{n}} kept, {{total}} in all",
|
||||||
|
"none": "No backup in this folder yet.",
|
||||||
|
"off": "Backups are off.",
|
||||||
|
"lastFailed": "The last run ({{time}}) failed: {{error}}",
|
||||||
|
"restoreHint": "To restore: agenthub stop, delete dashboard.db-wal and dashboard.db-shm, copy a backup over dashboard.db (its path is on the System page), then agenthub start."
|
||||||
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
"title": "Scheduled reports",
|
"title": "Scheduled reports",
|
||||||
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
|
"description": "A summary of the last days — sessions, events, tokens, cost by model and by AI tool, top tools, budgets — written as HTML or JSON on a schedule. Reports and their history survive Clear Data.",
|
||||||
@@ -947,20 +1009,16 @@
|
|||||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||||
"badFile": "That file is not an AgentHub preferences file."
|
"badFile": "That file is not an AgentHub preferences file."
|
||||||
},
|
},
|
||||||
"users": {
|
"pricingLink": {
|
||||||
"title": "Users",
|
"title": "Model pricing",
|
||||||
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
"description": "Prices for every vendor's models live on one page.",
|
||||||
"name": "User",
|
"open": "Open Pricing"
|
||||||
"role": "Role",
|
},
|
||||||
"workspaces": "Workspaces",
|
"chatUi": {
|
||||||
"sessions": "Sessions",
|
"title": "Chat UI",
|
||||||
"last7d": "7 days",
|
"description": "How a Claude or Codex session opens in its workspace. Saved for your account only.",
|
||||||
"last30d": "30 days",
|
"label": "Open sessions as a chat",
|
||||||
"disabled": "disabled",
|
"labelDesc": "Show every Claude or Codex session's conversation like Claude Desktop, with a message box, instead of its terminal. Permission prompts and menus still need the terminal: turn this off to answer them.",
|
||||||
"tokens": "{{n}} tokens",
|
"saveFailed": "Could not save: {{message}}"
|
||||||
"manageHint": "Add, disable or change a user on the host: agenthub users …",
|
|
||||||
"auditTitle": "Audit log",
|
|
||||||
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
|
|
||||||
"auditEmpty": "No reads recorded yet."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"title": "Users",
|
||||||
|
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||||
|
"name": "User",
|
||||||
|
"role": "Role",
|
||||||
|
"workspaces": "Workspaces",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"last7d": "7 days",
|
||||||
|
"last30d": "30 days",
|
||||||
|
"disabled": "disabled",
|
||||||
|
"you": "you",
|
||||||
|
"tokens": "{{n}} tokens",
|
||||||
|
"actions": "Actions",
|
||||||
|
"roles": {
|
||||||
|
"admin": "Admin",
|
||||||
|
"analyst": "Analyst",
|
||||||
|
"developer": "Developer"
|
||||||
|
},
|
||||||
|
"roleHint": {
|
||||||
|
"admin": "Everything, including this server's configuration and every user's data",
|
||||||
|
"analyst": "Sees every user's data; works only in their own workspaces, changes no settings",
|
||||||
|
"developer": "Only their own workspaces; changes no settings"
|
||||||
|
},
|
||||||
|
"add": "Add user",
|
||||||
|
"username": "Username",
|
||||||
|
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||||
|
"password": "Password",
|
||||||
|
"passwordHint": "At least 8 characters",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"changeRole": "Change role",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
|
"resetPassword": "Reset password",
|
||||||
|
"assignWorkspace": "Give a workspace",
|
||||||
|
"workspace": "Workspace",
|
||||||
|
"noOwner": "no owner",
|
||||||
|
"ownedBy": "owned by {{name}}",
|
||||||
|
"noWorkspaces": "There are no workspaces yet.",
|
||||||
|
"yourPassword": "Your password",
|
||||||
|
"yourPasswordHint": "Type your own password to confirm this change.",
|
||||||
|
"save": "Save",
|
||||||
|
"dialog": {
|
||||||
|
"add": "Add a user",
|
||||||
|
"role": "Change {{name}}'s role",
|
||||||
|
"disable": "Disable {{name}}?",
|
||||||
|
"disableBody": "{{name}} is signed out everywhere and cannot sign in until enabled again. Their workspaces and history stay.",
|
||||||
|
"enable": "Enable {{name}}?",
|
||||||
|
"password": "Reset {{name}}'s password",
|
||||||
|
"passwordBody": "{{name}} is signed out everywhere and signs in with the new password.",
|
||||||
|
"workspace": "Give {{name}} a workspace",
|
||||||
|
"workspaceBody": "The workspace's sessions and costs move to {{name}}. A developer sees only their own workspaces."
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"created": "{{name}} added",
|
||||||
|
"role": "{{name}} is now {{role}}",
|
||||||
|
"disabled": "{{name}} disabled",
|
||||||
|
"enabled": "{{name}} enabled",
|
||||||
|
"password": "{{name}}'s password reset",
|
||||||
|
"workspace": "Workspace given to {{name}}"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"EREAUTH": "Your password is wrong.",
|
||||||
|
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||||
|
"ESELF": "You cannot change your own role or disable yourself.",
|
||||||
|
"EEXISTS": "That username is taken.",
|
||||||
|
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||||
|
"EBADPASSWORD": "Password: at least 8 characters."
|
||||||
|
},
|
||||||
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
|
"auditTitle": "Audit log",
|
||||||
|
"auditDescription": "Account changes made here, and each time an admin or analyst opened a session a developer owns.",
|
||||||
|
"auditEmpty": "Nothing recorded yet."
|
||||||
|
}
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
"title": "Workflows",
|
"title": "Workflows",
|
||||||
"subtitle": "Agent orchestration intelligence",
|
"subtitle": "Agent orchestration intelligence",
|
||||||
"failedLoad": "Failed to load workflow data",
|
"failedLoad": "Failed to load workflow data",
|
||||||
"filteredBy": "Filtered by:",
|
|
||||||
"clearFilter": "Clear filter",
|
|
||||||
"refreshData": "Refresh data",
|
"refreshData": "Refresh data",
|
||||||
"exportJson": "Export as JSON",
|
"exportJson": "Export as JSON",
|
||||||
"allSessions": "All Sessions",
|
"allSessions": "All Sessions",
|
||||||
@@ -238,8 +236,6 @@
|
|||||||
"noToolEvents": "No tool events recorded.",
|
"noToolEvents": "No tool events recorded.",
|
||||||
"noEvents": "No events recorded for this session.",
|
"noEvents": "No events recorded for this session.",
|
||||||
"showingOf": "Showing 100 of {{total}} events",
|
"showingOf": "Showing 100 of {{total}} events",
|
||||||
"sessionInfo": " session(s) - active {{from}}%-{{to}}% of session",
|
|
||||||
"sortedByCompaction": " session(s) (sorted by compaction count)",
|
|
||||||
"openSession": "Open session",
|
"openSession": "Open session",
|
||||||
"openInWorkspace": "Open in workspace"
|
"openInWorkspace": "Open in workspace"
|
||||||
},
|
},
|
||||||
@@ -266,7 +262,6 @@
|
|||||||
"subtitle": "Where errors cluster in agent hierarchy depth",
|
"subtitle": "Where errors cluster in agent hierarchy depth",
|
||||||
"noErrors": "No errors recorded",
|
"noErrors": "No errors recorded",
|
||||||
"allSuccess": "All sessions completed successfully",
|
"allSuccess": "All sessions completed successfully",
|
||||||
"sessionsHadErrors": " sessions had errors",
|
|
||||||
"sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors",
|
"sessionsErrorSummary": "{{errorSessions}} of {{totalSessions}} sessions had errors",
|
||||||
"agentErrors": " agent-level error(s) across hierarchy",
|
"agentErrors": " agent-level error(s) across hierarchy",
|
||||||
"sessionErrorsOnly": "Session-level errors only",
|
"sessionErrorsOnly": "Session-level errors only",
|
||||||
@@ -306,7 +301,6 @@
|
|||||||
"ofTotal": "of {{total}}",
|
"ofTotal": "of {{total}}",
|
||||||
"avgPerSession": "Avg / affected session",
|
"avgPerSession": "Avg / affected session",
|
||||||
"peakSession": "Most in one session",
|
"peakSession": "Most in one session",
|
||||||
"tokensRecovered": "Tokens Recovered",
|
|
||||||
"distribution": "Sessions by compaction count",
|
"distribution": "Sessions by compaction count",
|
||||||
"xAxis": "Compactions per session",
|
"xAxis": "Compactions per session",
|
||||||
"yAxis": "Sessions",
|
"yAxis": "Sessions",
|
||||||
|
|||||||
@@ -4,12 +4,9 @@
|
|||||||
"export": "Xuất",
|
"export": "Xuất",
|
||||||
"totalSessions": "Tổng số phiên",
|
"totalSessions": "Tổng số phiên",
|
||||||
"totalAgents": "Tổng số Agent",
|
"totalAgents": "Tổng số Agent",
|
||||||
"totalTokens": "Tổng token",
|
|
||||||
"totalCost": "Tổng chi phí",
|
"totalCost": "Tổng chi phí",
|
||||||
"totalEvents": "Tổng sự kiện",
|
"totalEvents": "Tổng sự kiện",
|
||||||
"cacheHitRate": "% tỷ lệ trúng bộ nhớ đệm",
|
|
||||||
"perSession": " mỗi phiên",
|
"perSession": " mỗi phiên",
|
||||||
"noDataCost": "Không có dữ liệu",
|
|
||||||
"eventCountLabel_one": "{{count}} sự kiện",
|
"eventCountLabel_one": "{{count}} sự kiện",
|
||||||
"eventCountLabel_other": "{{count}} sự kiện",
|
"eventCountLabel_other": "{{count}} sự kiện",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
|||||||
@@ -30,14 +30,12 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
"claudeHome": "Claude home",
|
|
||||||
"projectClaudeDir": "Dự án .claude",
|
"projectClaudeDir": "Dự án .claude",
|
||||||
"projectRoot": "Thư mục dự án",
|
"projectRoot": "Thư mục dự án",
|
||||||
"claudeJson": "claude.json",
|
"claudeJson": "claude.json",
|
||||||
"summary": "Tóm tắt nhanh",
|
"summary": "Tóm tắt nhanh",
|
||||||
"user": "người dùng",
|
"user": "người dùng",
|
||||||
"project": "dự án",
|
"project": "dự án"
|
||||||
"items": "mục"
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"search": "Tìm theo tên…",
|
"search": "Tìm theo tên…",
|
||||||
@@ -47,32 +45,19 @@
|
|||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"copyPath": "Sao chép đường dẫn",
|
"copyPath": "Sao chép đường dẫn",
|
||||||
"copyName": "Sao chép tên",
|
"copyName": "Sao chép tên",
|
||||||
"copied": "Đã sao chép",
|
|
||||||
"truncated": "(đã cắt bớt để xem trước)",
|
"truncated": "(đã cắt bớt để xem trước)",
|
||||||
"size": "Kích thước",
|
|
||||||
"modified": "Đã sửa",
|
|
||||||
"scopeLabel": "Phạm vi",
|
"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",
|
"preview": "Xem trước",
|
||||||
"raw": "Mã nguồn"
|
"raw": "Mã nguồn"
|
||||||
},
|
},
|
||||||
"skills": {
|
|
||||||
"name": "Tên",
|
|
||||||
"description": "Mô tả"
|
|
||||||
},
|
|
||||||
"agents": {
|
"agents": {
|
||||||
"name": "Tên",
|
"name": "Tên",
|
||||||
"model": "Model",
|
|
||||||
"tools": "Công cụ"
|
"tools": "Công cụ"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"manifest": "Manifest",
|
|
||||||
"marketplace": "Marketplace",
|
|
||||||
"version": "Phiên bản",
|
|
||||||
"installedAt": "Cài đặt lúc",
|
"installedAt": "Cài đặt lúc",
|
||||||
"lastUpdated": "Cập nhật",
|
"lastUpdated": "Cập nhật",
|
||||||
"installPath": "Đường dẫn cài đặt",
|
|
||||||
"missing": "Thiếu đường dẫn cài đặt",
|
"missing": "Thiếu đường dẫn cài đặt",
|
||||||
"manifestMissing": "Không tìm thấy installed_plugins.json tại {{path}}",
|
"manifestMissing": "Không tìm thấy installed_plugins.json tại {{path}}",
|
||||||
"enabled": "Đang bật",
|
"enabled": "Đang bật",
|
||||||
@@ -85,8 +70,7 @@
|
|||||||
"hooks_other": "{{count}} hook script",
|
"hooks_other": "{{count}} hook script",
|
||||||
"author": "Tác giả",
|
"author": "Tác giả",
|
||||||
"homepage": "Trang chủ",
|
"homepage": "Trang chủ",
|
||||||
"license": "Giấy phép",
|
"license": "Giấy phép"
|
||||||
"description": "Mô tả"
|
|
||||||
},
|
},
|
||||||
"marketplaces": {
|
"marketplaces": {
|
||||||
"manifest": "Manifest marketplace đã biết",
|
"manifest": "Manifest marketplace đã biết",
|
||||||
@@ -95,11 +79,9 @@
|
|||||||
"lastUpdated": "Đồng bộ lần cuối",
|
"lastUpdated": "Đồng bộ lần cuối",
|
||||||
"pluginCount": "Plugin",
|
"pluginCount": "Plugin",
|
||||||
"owner": "Chủ sở hữu",
|
"owner": "Chủ sở hữu",
|
||||||
"installLocation": "Đường dẫn cài",
|
|
||||||
"addCmd": "claude plugin marketplace add <repo>"
|
"addCmd": "claude plugin marketplace add <repo>"
|
||||||
},
|
},
|
||||||
"keybindings": {
|
"keybindings": {
|
||||||
"file": "Tệp nguồn",
|
|
||||||
"missing": "Không tìm thấy keybindings.json tại {{path}}",
|
"missing": "Không tìm thấy keybindings.json tại {{path}}",
|
||||||
"context": "Ngữ cảnh",
|
"context": "Ngữ cảnh",
|
||||||
"key": "Phím",
|
"key": "Phím",
|
||||||
@@ -123,8 +105,7 @@
|
|||||||
},
|
},
|
||||||
"hookScripts": {
|
"hookScripts": {
|
||||||
"title": "Script ~/.claude/hooks/",
|
"title": "Script ~/.claude/hooks/",
|
||||||
"subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến.",
|
"subtitle": "Các script hỗ trợ được tham chiếu bởi lệnh hook của bạn. Chạy khi `hooks.<event>.hooks[].command` trong settings.json gọi đến."
|
||||||
"empty": "Không có script trong ~/.claude/hooks/."
|
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"userScope": "Máy chủ phạm vi người dùng",
|
"userScope": "Máy chủ phạm vi người dùng",
|
||||||
@@ -138,8 +119,6 @@
|
|||||||
"env": "Tên env",
|
"env": "Tên env",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"headers": "Tên Header",
|
"headers": "Tên Header",
|
||||||
"kind": "Loại",
|
|
||||||
"source": "Nguồn",
|
|
||||||
"noServers": "Không tìm thấy máy chủ MCP.",
|
"noServers": "Không tìm thấy máy chủ MCP.",
|
||||||
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
|
"notRemovable": "Được cấu hình trong settings.json — hãy xóa ở đó; không có phạm vi CLI nào áp dụng cho mục này."
|
||||||
},
|
},
|
||||||
@@ -157,8 +136,6 @@
|
|||||||
"rawJson": "JSON thô"
|
"rawJson": "JSON thô"
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
"userScope": "Bộ nhớ người dùng",
|
|
||||||
"projectScope": "Bộ nhớ dự án",
|
|
||||||
"missing": "Không tìm thấy CLAUDE.md.",
|
"missing": "Không tìm thấy CLAUDE.md.",
|
||||||
"autoTitle": "Kho bộ nhớ dự án",
|
"autoTitle": "Kho bộ nhớ dự án",
|
||||||
"autoSubtitle": "Bộ nhớ theo tệp cho từng dự án trong ~/.claude/projects/<dự-án>/memory/ — một chỉ mục MEMORY.md cùng một tệp cho mỗi sự kiện được ghi nhớ. Chỉ đọc tại đây.",
|
"autoSubtitle": "Bộ nhớ theo tệp cho từng dự án trong ~/.claude/projects/<dự-án>/memory/ — một chỉ mục MEMORY.md cùng một tệp cho mỗi sự kiện được ghi nhớ. Chỉ đọc tại đây.",
|
||||||
@@ -236,15 +213,9 @@
|
|||||||
"backups": {
|
"backups": {
|
||||||
"title": "Bản sao lưu",
|
"title": "Bản sao lưu",
|
||||||
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
|
"empty": "Chưa có bản sao lưu - mỗi lần sửa hoặc xoá sẽ tạo bản sao có dấu thời gian.",
|
||||||
"viewLink": "Xem lịch sử sao lưu",
|
|
||||||
"openButton": "Bản sao lưu",
|
"openButton": "Bản sao lưu",
|
||||||
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.",
|
"subtitle": "Mỗi thao tác lưu / xoá đều ghi bản sao tới đây trước. Thư mục sao lưu nằm ngoài các vị trí Claude Code quét, nên không xuất hiện như mục thừa trong CLI.",
|
||||||
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):",
|
"restoreHint": "Khôi phục bằng cách sao chép bản sao lưu về chỗ cũ (bản sao lưu vẫn được giữ):"
|
||||||
"size": "Kích thước",
|
|
||||||
"created": "Tạo lúc",
|
|
||||||
"type": "Loại",
|
|
||||||
"scopeLabel": "Phạm vi",
|
|
||||||
"filterAll": "Tất cả loại"
|
|
||||||
},
|
},
|
||||||
"profiles": {
|
"profiles": {
|
||||||
"usageCheck": "Kiểm tra quota",
|
"usageCheck": "Kiểm tra quota",
|
||||||
@@ -309,9 +280,7 @@
|
|||||||
"title": "Plugin chạy qua CLI `claude`",
|
"title": "Plugin chạy qua CLI `claude`",
|
||||||
"body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.",
|
"body": "Plugin là các gói có registry riêng — `installed_plugins.json` cùng đường dẫn cài trong `plugins/cache/`. Các nút ở đây chạy đúng lệnh `claude plugin …` trên máy này nên registry luôn nhất quán. Cài plugin nghĩa là chạy mã của bên thứ ba: chỉ thêm marketplace mà bạn tin tưởng.",
|
||||||
"install": "Cài plugin:",
|
"install": "Cài plugin:",
|
||||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
"installCmd": "claude plugin install <marketplace>/<plugin>"
|
||||||
"uninstall": "Gỡ plugin này:",
|
|
||||||
"uninstallButton": "Sao chép lệnh gỡ"
|
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"title": "MCP server được CLI quản lý",
|
"title": "MCP server được CLI quản lý",
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"rootsTitle": "Thư mục gốc",
|
"rootsTitle": "Thư mục gốc",
|
||||||
"codexHome": "Thư mục Codex",
|
|
||||||
"projectCodexDir": "Thư mục .codex của dự án",
|
"projectCodexDir": "Thư mục .codex của dự án",
|
||||||
"projectRoot": "Thư mục gốc dự án",
|
"projectRoot": "Thư mục gốc dự án",
|
||||||
"summary": "Tóm tắt nhanh",
|
"summary": "Tóm tắt nhanh",
|
||||||
@@ -72,9 +71,6 @@
|
|||||||
"saving": "Đang tạo…",
|
"saving": "Đang tạo…",
|
||||||
"close": "Đóng"
|
"close": "Đóng"
|
||||||
},
|
},
|
||||||
"config": {
|
|
||||||
"missing": "Không tìm thấy config.toml."
|
|
||||||
},
|
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"addTitle": "Thêm máy chủ MCP",
|
"addTitle": "Thêm máy chủ MCP",
|
||||||
"nameLabel": "Tên",
|
"nameLabel": "Tên",
|
||||||
|
|||||||
@@ -21,14 +21,11 @@
|
|||||||
"live": "Trực tiếp",
|
"live": "Trực tiếp",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"running": "đang chạy",
|
"running": "đang chạy",
|
||||||
"dashboardRun": "Chạy",
|
|
||||||
"days": "ngày",
|
"days": "ngày",
|
||||||
"hours": "giờ",
|
"hours": "giờ",
|
||||||
"updated": "Đã cập nhật ",
|
"updated": "Đã cập nhật ",
|
||||||
"lastUpdated": "Cập nhật lần cuối: ",
|
"lastUpdated": "Cập nhật lần cuối: ",
|
||||||
"showMore": "Hiển thị thêm (còn {{count}})",
|
|
||||||
"plusMore": "+{{count}} nữa",
|
"plusMore": "+{{count}} nữa",
|
||||||
"ofSessions": "% số phiên",
|
|
||||||
"save": "Lưu",
|
"save": "Lưu",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
@@ -89,14 +86,8 @@
|
|||||||
"cost": {
|
"cost": {
|
||||||
"model": "Mô hình",
|
"model": "Mô hình",
|
||||||
"cost": "Chi phí",
|
"cost": "Chi phí",
|
||||||
"totalCost": "Tổng chi phí",
|
|
||||||
"totalEstimatedCost": "Tổng chi phí ước tính"
|
"totalEstimatedCost": "Tổng chi phí ước tính"
|
||||||
},
|
},
|
||||||
"duration": {
|
|
||||||
"h_m": "{{h}}h {{m}}m",
|
|
||||||
"m_s": "{{m}}m {{s}}s",
|
|
||||||
"s": "{{s}}s"
|
|
||||||
},
|
|
||||||
"eventDetail": {
|
"eventDetail": {
|
||||||
"summary": "Tóm tắt",
|
"summary": "Tóm tắt",
|
||||||
"seeDetailsBelow": "Xem chi tiết đầy đủ bên dưới trong {{fields}}",
|
"seeDetailsBelow": "Xem chi tiết đầy đủ bên dưới trong {{fields}}",
|
||||||
@@ -137,8 +128,6 @@
|
|||||||
"rawPayload": "Dữ Liệu Thô",
|
"rawPayload": "Dữ Liệu Thô",
|
||||||
"copy": "Sao chép",
|
"copy": "Sao chép",
|
||||||
"copied": "Đã sao chép",
|
"copied": "Đã sao chép",
|
||||||
"noPayload": "Không có dữ liệu cho sự kiện này.",
|
|
||||||
"viewDetails": "Xem chi tiết",
|
|
||||||
"expand": "Mở rộng sự kiện",
|
"expand": "Mở rộng sự kiện",
|
||||||
"collapse": "Thu gọn sự kiện"
|
"collapse": "Thu gọn sự kiện"
|
||||||
},
|
},
|
||||||
@@ -157,12 +146,7 @@
|
|||||||
"loadMore": "Tải thêm",
|
"loadMore": "Tải thêm",
|
||||||
"loading": "Đang tải...",
|
"loading": "Đang tải...",
|
||||||
"noResults": "Không có sự kiện nào khớp với bộ lọc",
|
"noResults": "Không có sự kiện nào khớp với bộ lọc",
|
||||||
"grouped": "Nhóm theo lần gọi công cụ",
|
|
||||||
"flat": "Danh sách phẳng",
|
|
||||||
"groupEventCount_one": "{{count}} sự kiện",
|
|
||||||
"groupEventCount_other": "{{count}} sự kiện",
|
|
||||||
"help": {
|
"help": {
|
||||||
"toggle": "Trợ giúp",
|
|
||||||
"title": "Về sự kiện, bộ lọc và nhóm",
|
"title": "Về sự kiện, bộ lọc và nhóm",
|
||||||
"subtitle": "Nhấn để mở - trạng thái, vòng đời, ngữ nghĩa bộ lọc, giá trị bộ lọc",
|
"subtitle": "Nhấn để mở - trạng thái, vòng đời, ngữ nghĩa bộ lọc, giá trị bộ lọc",
|
||||||
"valuesTitle": "Mỗi bộ lọc chấp nhận gì",
|
"valuesTitle": "Mỗi bộ lọc chấp nhận gì",
|
||||||
@@ -176,10 +160,8 @@
|
|||||||
"statusesTitle": "Trạng thái",
|
"statusesTitle": "Trạng thái",
|
||||||
"statusesIntro": "Trạng thái được suy ra từ event_type.",
|
"statusesIntro": "Trạng thái được suy ra từ event_type.",
|
||||||
"statusWorkingDesc": "Agent bắt đầu gọi công cụ (PreToolUse).",
|
"statusWorkingDesc": "Agent bắt đầu gọi công cụ (PreToolUse).",
|
||||||
"statusConnectedDesc": "Công cụ hoàn tất và trả kết quả (PostToolUse).",
|
|
||||||
"statusCompletedDesc": "Mốc vòng đời (Stop, SubagentStop, Compaction).",
|
"statusCompletedDesc": "Mốc vòng đời (Stop, SubagentStop, Compaction).",
|
||||||
"statusErrorDesc": "Sự kiện lỗi (error, APIError).",
|
"statusErrorDesc": "Sự kiện lỗi (error, APIError).",
|
||||||
"statusIdleDesc": "Sự kiện vòng đời / siêu dữ liệu (Notification, TurnDuration, SessionStart, SessionEnd).",
|
|
||||||
"lifecycleTitle": "Vòng đời sự kiện",
|
"lifecycleTitle": "Vòng đời sự kiện",
|
||||||
"lifecycleDesc": "Một lần gọi công cụ thường phát ra hai sự kiện chia sẻ tool_use_id:",
|
"lifecycleDesc": "Một lần gọi công cụ thường phát ra hai sự kiện chia sẻ tool_use_id:",
|
||||||
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
"eventsToday": "Sự kiện hôm nay",
|
"eventsToday": "Sự kiện hôm nay",
|
||||||
"totalEvents": "Tổng sự kiện",
|
"totalEvents": "Tổng sự kiện",
|
||||||
"totalCost": "Tổng chi phí",
|
"totalCost": "Tổng chi phí",
|
||||||
"viewBoard": "Xem bảng",
|
|
||||||
"viewAll": "Xem tất cả",
|
"viewAll": "Xem tất cả",
|
||||||
"activeAgentsSection": "Agent đang hoạt động",
|
"activeAgentsSection": "Agent đang hoạt động",
|
||||||
"recentActivity": "Hoạt động gần đây",
|
"recentActivity": "Hoạt động gần đây",
|
||||||
@@ -15,7 +14,6 @@
|
|||||||
"noAgentsDesc": "Agent sẽ xuất hiện ở đây khi phiên Claude Code đang chạy.",
|
"noAgentsDesc": "Agent sẽ xuất hiện ở đây khi phiên Claude Code đang chạy.",
|
||||||
"noActivity": "Chưa có hoạt động",
|
"noActivity": "Chưa có hoạt động",
|
||||||
"noActivityDesc": "Sự kiện từ các phiên Claude Code sẽ được phát trực tiếp tại đây.",
|
"noActivityDesc": "Sự kiện từ các phiên Claude Code sẽ được phát trực tiếp tại đây.",
|
||||||
"failedConnect": "Không thể kết nối máy chủ",
|
|
||||||
"failedLoad": "Không thể tải dữ liệu",
|
"failedLoad": "Không thể tải dữ liệu",
|
||||||
"activeTrend": " đang hoạt động",
|
"activeTrend": " đang hoạt động",
|
||||||
"totalTrend": " trong phiên hoạt động",
|
"totalTrend": " trong phiên hoạt động",
|
||||||
|
|||||||
@@ -19,15 +19,6 @@
|
|||||||
"sessionError": "Lỗi phiên",
|
"sessionError": "Lỗi phiên",
|
||||||
"subagentSpawned": "Đã tạo Subagent"
|
"subagentSpawned": "Đã tạo Subagent"
|
||||||
},
|
},
|
||||||
"toolLegend": {
|
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát.",
|
||||||
"read": "Đọc",
|
"adminOnly": "Chỉ admin mới làm được thao tác này. Hãy nhờ admin của AgentHub nếu bạn cần."
|
||||||
"write": "Ghi",
|
|
||||||
"edit": "Sửa",
|
|
||||||
"bash": "Bash",
|
|
||||||
"grep": "Grep",
|
|
||||||
"glob": "Glob",
|
|
||||||
"agent": "Agent",
|
|
||||||
"other": "Khác"
|
|
||||||
},
|
|
||||||
"badResponse": "Máy chủ trả về phản hồi không mong đợi thay vì dữ liệu. Dashboard có thể đang khởi động lại, hoặc một proxy đang trả lời thay nó — hãy tải lại sau giây lát."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,18 @@
|
|||||||
{
|
{
|
||||||
"title": "Bảng Kanban",
|
"title": "Bảng Kanban",
|
||||||
"subtitle": "Góc nhìn kanban của mọi Agent theo trạng thái",
|
"subtitle": "Góc nhìn kanban của mọi Agent theo trạng thái",
|
||||||
"subtitleSessions": "Góc nhìn kanban của mọi phiên theo trạng thái",
|
|
||||||
"viewToggle": {
|
|
||||||
"agents": "Agent",
|
|
||||||
"sessions": "Phiên"
|
|
||||||
},
|
|
||||||
"noAgents": "Chưa có Agent được theo dõi",
|
"noAgents": "Chưa có Agent được theo dõi",
|
||||||
"noAgentsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy Agent xuất hiện tại đây.",
|
"noAgentsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy Agent xuất hiện tại đây.",
|
||||||
"agentCount": "Đã theo dõi {{count}} Agent",
|
"agentCount": "Đã theo dõi {{count}} Agent",
|
||||||
"agentCount_plural": "Đã theo dõi {{count}} Agent",
|
|
||||||
"noAgentsInColumn": "Không có Agent",
|
|
||||||
"noSessions": "Chưa có phiên được theo dõi",
|
"noSessions": "Chưa có phiên được theo dõi",
|
||||||
"noSessionsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy phiên xuất hiện tại đây.",
|
"noSessionsDesc": "Bắt đầu một phiên Claude Code với hooks đã cài để thấy phiên xuất hiện tại đây.",
|
||||||
"sessionCount": "Đã theo dõi {{count}} phiên",
|
"sessionCount": "Đã theo dõi {{count}} phiên",
|
||||||
"sessionCount_plural": "Đã theo dõi {{count}} phiên",
|
|
||||||
"noSessionsInColumn": "Không có phiên",
|
|
||||||
"ran": "đã chạy ",
|
"ran": "đã chạy ",
|
||||||
"running": "đang chạy ",
|
"running": "đang chạy ",
|
||||||
"session": {
|
"session": {
|
||||||
"anonymous": "Phiên chưa đặt tên",
|
|
||||||
"agentSummary_one": "{{count}} Agent",
|
|
||||||
"agentSummary_other": "{{count}} Agent",
|
|
||||||
"subagentSummary_one": "{{count}} Agent con",
|
"subagentSummary_one": "{{count}} Agent con",
|
||||||
"subagentSummary_other": "{{count}} Agent con",
|
"subagentSummary_other": "{{count}} Agent con",
|
||||||
"turnSummary_one": "{{count}} lượt",
|
"turnSummary_one": "{{count}} lượt",
|
||||||
"turnSummary_other": "{{count}} lượt"
|
"turnSummary_other": "{{count}} lượt"
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"agent": {
|
|
||||||
"working": "Agent đang chạy một tool call ngay lúc này.\n\nVào trạng thái khi PreToolUse hoặc UserPromptSubmit, thoát khi Stop (hết lượt).",
|
|
||||||
"waiting": "Agent đã kết thúc lượt và đang chờ người dùng - Claude chờ tin nhắn tiếp theo hoặc đã hỏi xin quyền sử dụng tool.\n\nXóa khi PreToolUse / PostToolUse tiếp theo, hoặc khi phiên kết thúc.",
|
|
||||||
"completed": "Agent đã kết thúc hoàn toàn cho phiên này - thường do SessionEnd kích hoạt, hoặc với subagent là do SubagentStop.\n\nKhông còn hoạt động nào được ghi nhận cho Agent này.",
|
|
||||||
"error": "Agent gặp lỗi và sẽ không tiếp tục. Với main agent là từ Stop với stop_reason=error; với subagent là do chạy thất bại."
|
|
||||||
},
|
|
||||||
"session": {
|
|
||||||
"active": "Phiên đang giữa lượt - Claude đang chạy và người dùng không bị chặn. Trạng thái này thoáng qua: phiên đi qua Active trong các tool call rồi quay lại Waiting khi hết lượt.",
|
|
||||||
"waiting": "Phiên đang chờ người dùng. Có thể Claude đã hết lượt (phổ biến nhất) hoặc đang hỏi quyền và bị chặn cho tới khi người dùng phản hồi.\n\nXem cột này để biết phiên nào bạn cần trả lời.",
|
|
||||||
"completed": "Phiên đã đóng hoàn toàn - SessionEnd đã kích hoạt (CLI thoát sạch) hoặc người dùng đã đóng thủ công.\n\nMọi Agent của phiên cũng đã hoàn tất.",
|
|
||||||
"error": "Phiên kết thúc với lỗi - Claude trả về stop_reason=error, có lỗi không thể khôi phục, hoặc lỗi API thượng nguồn đã chấm dứt lần chạy.",
|
|
||||||
"abandoned": "Phiên đã chết mà không có SessionEnd sạch. Bị đánh dấu bỏ dở sau 5+ phút không hoạt động (mồ côi vì /resume, mất mạng, hoặc CLI bị tắt). Khác với Completed: ta không thấy tín hiệu thoát thật sự."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
{
|
{
|
||||||
"action.clear": "Dọn trạng thái",
|
|
||||||
"action.forget": "Xóa workspace",
|
|
||||||
"action.purge": "Xóa lịch sử",
|
|
||||||
"action.reset": "Đặt lại worktree",
|
"action.reset": "Đặt lại worktree",
|
||||||
"action.start": "Bắt đầu",
|
|
||||||
"action.stop": "Dừng",
|
|
||||||
"actionError": "Thao tác workspace thất bại: {{message}}",
|
"actionError": "Thao tác workspace thất bại: {{message}}",
|
||||||
"actionErrorUnknown": "Lỗi không xác định",
|
"actionErrorUnknown": "Lỗi không xác định",
|
||||||
"add": "Thêm workspace",
|
"add": "Thêm workspace",
|
||||||
@@ -58,11 +53,7 @@
|
|||||||
"changes.collapseContext": "Bớt dòng xung quanh",
|
"changes.collapseContext": "Bớt dòng xung quanh",
|
||||||
"changes.collapseFile": "Thu gọn {{path}}",
|
"changes.collapseFile": "Thu gọn {{path}}",
|
||||||
"changes.commitPlaceholder": "Nội dung commit",
|
"changes.commitPlaceholder": "Nội dung commit",
|
||||||
"changes.commitStaged": "Commit {{count}} file đã stage",
|
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các file đã tick)",
|
||||||
"changes.suggestMessage": "Gợi ý nội dung commit bằng AI (Claude, các thay đổi đã stage)",
|
|
||||||
"changes.neverStage": "Không bao giờ stage — Stage all bỏ qua file này",
|
|
||||||
"changes.allowStage": "Cho Stage all stage lại file này",
|
|
||||||
"changes.keptHint": "Stage all không bao giờ stage file này (trên trình duyệt này)",
|
|
||||||
"changes.conflicted": "xung đột",
|
"changes.conflicted": "xung đột",
|
||||||
"changes.discard": "Bỏ thay đổi của file này",
|
"changes.discard": "Bỏ thay đổi của file này",
|
||||||
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
"changes.discardConfirm": "Bấm lần nữa để bỏ — không thể hoàn tác",
|
||||||
@@ -79,6 +70,7 @@
|
|||||||
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
"changes.commentLine": "Comment dòng {{n}} — dán vào pane của workspace này",
|
||||||
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
"changes.commentOn": "Comment {{at}} — dán vào pane bạn dùng gần nhất; chưa gửi gì cho tới khi bạn nhấn Enter ở đó",
|
||||||
"changes.commentSend": "Dán vào pane",
|
"changes.commentSend": "Dán vào pane",
|
||||||
|
"changes.addToChat": "Thêm vào chat",
|
||||||
"changes.commentCancel": "Huỷ",
|
"changes.commentCancel": "Huỷ",
|
||||||
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
|
"changes.commentNoPane": "Chưa có terminal nào của workspace này đang hiện — hãy mở session (hoặc thu hẹp khung Changes) trước",
|
||||||
"changes.layout.split": "Hai cột",
|
"changes.layout.split": "Hai cột",
|
||||||
@@ -93,8 +85,9 @@
|
|||||||
"changes.noMatches": "Không có file nào khớp.",
|
"changes.noMatches": "Không có file nào khớp.",
|
||||||
"changes.openFile": "Mở {{path}}",
|
"changes.openFile": "Mở {{path}}",
|
||||||
"changes.quickOpen": "Tìm file theo đường dẫn…",
|
"changes.quickOpen": "Tìm file theo đường dẫn…",
|
||||||
"changes.range.branch": "So nhánh gốc",
|
"changes.range.branch": "So với {{base}}",
|
||||||
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của workspace này thêm vào so với nhánh gốc",
|
"changes.range.baseBranch": "nhánh gốc",
|
||||||
|
"changes.range.branchHint": "{{base}}...HEAD — toàn bộ phần nhánh này thêm vào so với {{base}}",
|
||||||
"changes.range.work": "Chưa commit",
|
"changes.range.work": "Chưa commit",
|
||||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||||
"changes.refresh": "Tải lại",
|
"changes.refresh": "Tải lại",
|
||||||
@@ -104,13 +97,7 @@
|
|||||||
"changes.size.full": "Toàn màn hình",
|
"changes.size.full": "Toàn màn hình",
|
||||||
"changes.fileCount_one": "{{count}} tệp",
|
"changes.fileCount_one": "{{count}} tệp",
|
||||||
"changes.fileCount_other": "{{count}} tệp",
|
"changes.fileCount_other": "{{count}} tệp",
|
||||||
"changes.stageFolder": "Stage mọi tệp trong thư mục này",
|
|
||||||
"changes.unstageFolder": "Bỏ stage mọi tệp trong thư mục này",
|
|
||||||
"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.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.stage": "Đưa file này vào index",
|
||||||
"changes.title": "Thay đổi",
|
"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.",
|
"changes.truncated": "Một phần thay đổi đã bị lược bớt để bảng này còn mượt.",
|
||||||
@@ -118,14 +105,6 @@
|
|||||||
"changes.unstage": "Bỏ file này khỏi index",
|
"changes.unstage": "Bỏ file này khỏi index",
|
||||||
"changes.changesView": "Thay đổi",
|
"changes.changesView": "Thay đổi",
|
||||||
"changes.wholeFile": "Toàn bộ file",
|
"changes.wholeFile": "Toàn bộ file",
|
||||||
"confirmRemoveCancel": "Hủy",
|
|
||||||
"confirmRemoveConfirm": "Xóa",
|
|
||||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
|
||||||
"confirmRemoveTitle": "Xóa workspace?",
|
|
||||||
"countDead": "đã chết",
|
|
||||||
"countNeedsYou": "cần bạn",
|
|
||||||
"countRunning": "đang chạy",
|
|
||||||
"countTotal": "workspace",
|
|
||||||
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.",
|
"destructive.blocked.adopted": "Đây là thư mục đã nhận và không thể thay đổi bằng thao tác worktree.",
|
||||||
"destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.",
|
"destructive.blocked.missing": "Thư mục workspace không còn, nên không thể tiếp tục thao tác này.",
|
||||||
"destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.",
|
"destructive.blocked.unreadable": "Không thể đọc thư mục workspace như một Git worktree, nên không thể tiếp tục thao tác này.",
|
||||||
@@ -197,10 +176,6 @@
|
|||||||
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
"gitBar.switchHint": "Đổi worktree này sang nhánh local khác",
|
||||||
"gitBar.switchLabel": "Đổi nhánh…",
|
"gitBar.switchLabel": "Đổi nhánh…",
|
||||||
"gitBar.unbornHint": "Hãy commit trước, rồi publish repo này lên remote.",
|
"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",
|
|
||||||
"laneDetail.show": "Chi tiết workspace",
|
|
||||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||||
"mode.repo": "Repo",
|
"mode.repo": "Repo",
|
||||||
@@ -214,36 +189,28 @@
|
|||||||
"preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}",
|
"preflightErrorWithMessage": "Không thể tải trạng thái workspace hiện tại: {{message}}",
|
||||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||||
"renameLabel": "Đổi tên workspace này",
|
"renameLabel": "Đổi tên workspace này",
|
||||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
|
||||||
"proxy.native": "Native ({{tool}})",
|
"proxy.native": "Native ({{tool}})",
|
||||||
"proxy.label": "AI Proxy",
|
"proxy.label": "AI Proxy",
|
||||||
"runtime.boot": "▶ chạy",
|
|
||||||
"runtime.busy.down": "đang dừng…",
|
|
||||||
"runtime.busy.up": "đang khởi động…",
|
|
||||||
"runtime.down": "tắt",
|
"runtime.down": "tắt",
|
||||||
"runtime.healthy": "khoẻ",
|
"runtime.healthy": "khoẻ",
|
||||||
"runtime.partial": "một phần",
|
"runtime.partial": "một phần",
|
||||||
"runtime.slot": "slot {{slot}}",
|
"runtime.slot": "slot {{slot}}",
|
||||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||||
"runtime.stop": "■ dừng",
|
|
||||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của workspace này (khác với phiên Claude của nó)",
|
|
||||||
"status.failed": "thất bại",
|
"status.failed": "thất bại",
|
||||||
"status.idle": "rảnh",
|
"status.idle": "rảnh",
|
||||||
"status.provisioning": "đang khởi tạo",
|
"status.provisioning": "đang khởi tạo",
|
||||||
"status.running": "đang chạy",
|
"status.running": "đang chạy",
|
||||||
"title": "Workspace",
|
"title": "Workspace",
|
||||||
"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_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.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.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.undoHunkShort": "Hoàn tác",
|
||||||
"changes.undoHunkShortConfirm": "Xác nhận",
|
"changes.undoHunkShortConfirm": "Xác nhận",
|
||||||
"changes.edit": "Sửa",
|
"changes.edit": "Sửa",
|
||||||
"changes.save": "Lưu",
|
"changes.save": "Lưu",
|
||||||
"changes.revertBlock": "Hoàn tác",
|
"changes.revertBlock": "Hoàn tác",
|
||||||
|
"changes.blockNotInDiff": "Thay đổi này không còn trong diff của git; hãy làm mới rồi thử lại",
|
||||||
"changes.cancelEdit": "Huỷ",
|
"changes.cancelEdit": "Huỷ",
|
||||||
"changes.preview": "Xem trước",
|
"changes.preview": "Xem trước",
|
||||||
"changes.raw": "Mã nguồn",
|
"changes.raw": "Mã nguồn",
|
||||||
@@ -269,14 +236,35 @@
|
|||||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
"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.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 workspace sẽ thay đổi ngay - nếu có agent đang làm việc trong workspace này, hãy để nó xong trước.",
|
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong workspace sẽ thay đổi ngay - nếu có agent đang làm việc trong workspace này, hãy để nó xong trước.",
|
||||||
|
"gitBar.pushConfirm": "Push {{count}} commit từ {{branch}} lên {{upstream}}? Ai có quyền vào remote đều thấy được, và không thể rút lại push từ đây.",
|
||||||
|
"gitBar.publishBranchConfirm": "Publish nhánh {{branch}} lên remote? Ai có quyền vào remote đều thấy được.",
|
||||||
|
"gitBar.publishConfirm": "Thêm {{url}} làm remote của repo và push {{branch}} lên đó? Ai có quyền vào remote đó đều thấy được code.",
|
||||||
|
"gitBar.branchDeleteConfirm": "Xoá nhánh local {{branch}}? Git sẽ từ chối nếu nhánh còn commit chưa được merge vào đâu.",
|
||||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
"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",
|
"gitBar.merging": "Đang {{op}}: hãy hoàn tất hoặc huỷ trước",
|
||||||
"changes.conflictsGroup": "Conflict khi merge",
|
"changes.conflictsGroup": "Conflict khi merge",
|
||||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
"changes.mergeBanner": "Đang {{op}} - còn {{count}} file conflict",
|
||||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
"changes.mergeReady": "Đang {{op}} - đã giải quyết hết conflict",
|
||||||
"changes.mergeCommit": "Hoàn tất merge",
|
"changes.mergeCommit": "Hoàn tất {{op}}",
|
||||||
"changes.mergeAbort": "Huỷ merge",
|
"changes.mergeAbort": "Huỷ {{op}}",
|
||||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||||
|
"changes.discardShortConfirm": "Xoá?",
|
||||||
|
"changes.amend": "Amend",
|
||||||
|
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
|
||||||
|
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||||
|
"changes.historyOf": "Lịch sử của nhánh",
|
||||||
|
"changes.cherryPick": "Cherry-pick vào {{branch}}",
|
||||||
|
"changes.revertCommit": "Revert commit này",
|
||||||
|
"changes.undoCommit": "Hoàn tác commit (giữ thay đổi)",
|
||||||
|
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||||
|
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||||
|
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||||
|
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||||
|
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||||
|
"changes.amendTitle": "Amend commit cuối?",
|
||||||
|
"changes.amendConfirm": "\"{{subject}}\" sẽ được thay bằng một commit mới gộp cả phần đã stage. Server sẽ từ chối nếu commit đã được push.",
|
||||||
|
"changes.opConflicts": "{{op}} dừng lại ở {{count}} file conflict — hãy giải quyết trong Changes",
|
||||||
|
"changes.opDone": "Đã {{op}} xong",
|
||||||
"changes.takeOurs": "Giữ bản của mình",
|
"changes.takeOurs": "Giữ bản của mình",
|
||||||
"changes.takeTheirs": "Lấy bản kia",
|
"changes.takeTheirs": "Lấy bản kia",
|
||||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||||
@@ -330,9 +318,6 @@
|
|||||||
"tree.allTools": "Mọi tool",
|
"tree.allTools": "Mọi tool",
|
||||||
"tree.newSession": "Session mới",
|
"tree.newSession": "Session mới",
|
||||||
"tree.noSessions": "Chưa có session",
|
"tree.noSessions": "Chưa có session",
|
||||||
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
|
|
||||||
"tree.reopenTerminal": "Mở terminal mới",
|
|
||||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
|
||||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||||
"tree.renameSession": "Đổi tên session",
|
"tree.renameSession": "Đổi tên session",
|
||||||
@@ -346,6 +331,7 @@
|
|||||||
"tree.unarchive": "Bỏ lưu trữ",
|
"tree.unarchive": "Bỏ lưu trữ",
|
||||||
"home.breadcrumb": "Không gian làm việc",
|
"home.breadcrumb": "Không gian làm việc",
|
||||||
"home.ai": "AI",
|
"home.ai": "AI",
|
||||||
|
"home.openTerminal": "Mở terminal",
|
||||||
"home.repoRoot": "Thư mục gốc của repo",
|
"home.repoRoot": "Thư mục gốc của repo",
|
||||||
"home.mainBranch": "Branch của worktree chính",
|
"home.mainBranch": "Branch của worktree chính",
|
||||||
"home.worktreeCount_one": "{{count}} worktree",
|
"home.worktreeCount_one": "{{count}} worktree",
|
||||||
@@ -376,6 +362,7 @@
|
|||||||
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
|
"home.memoryEmpty": "Bộ nhớ hiện ở đây khi thư mục có CLAUDE.md hoặc AGENTS.md, hoặc Claude lưu bộ nhớ cho nó.",
|
||||||
"home.context": "Ngữ cảnh",
|
"home.context": "Ngữ cảnh",
|
||||||
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
|
"home.contextEmpty": "Thêm PDF, tài liệu hoặc văn bản để tham chiếu trong workspace này.",
|
||||||
|
"home.contextSnapshot": "Mỗi file được sao chép lúc thêm — sửa file gốc sau đó sẽ không cập nhật theo. Hãy xóa rồi thêm lại, hoặc thêm thư mục chứa nó ở mục Thư mục để luôn đọc bản mới nhất.",
|
||||||
"home.addFile": "Thêm file",
|
"home.addFile": "Thêm file",
|
||||||
"home.folder": "Thư mục",
|
"home.folder": "Thư mục",
|
||||||
"home.ownFolder": "Thư mục của workspace",
|
"home.ownFolder": "Thư mục của workspace",
|
||||||
@@ -414,6 +401,7 @@
|
|||||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||||
"panes.closeShort": "Tách ra tab riêng",
|
"panes.closeShort": "Tách ra tab riêng",
|
||||||
|
"panes.stop": "Dừng phiên",
|
||||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||||
"changes.viewCommit": "Xem commit",
|
"changes.viewCommit": "Xem commit",
|
||||||
"changes.copySha": "Sao chép SHA của commit",
|
"changes.copySha": "Sao chép SHA của commit",
|
||||||
@@ -441,6 +429,7 @@
|
|||||||
"tree.showMore_one": "Hiện thêm {{count}}",
|
"tree.showMore_one": "Hiện thêm {{count}}",
|
||||||
"tree.showMore_other": "Hiện thêm {{count}}",
|
"tree.showMore_other": "Hiện thêm {{count}}",
|
||||||
"tree.seeAll": "Xem tất cả (thêm {{count}})",
|
"tree.seeAll": "Xem tất cả (thêm {{count}})",
|
||||||
|
"tree.showLess": "Thu gọn",
|
||||||
"home.contextPanel": "Ngữ cảnh workspace",
|
"home.contextPanel": "Ngữ cảnh workspace",
|
||||||
"row.state.needsYou": "cần bạn",
|
"row.state.needsYou": "cần bạn",
|
||||||
"row.state.error": "lỗi",
|
"row.state.error": "lỗi",
|
||||||
@@ -454,5 +443,30 @@
|
|||||||
"runtime.listening": "đang lắng nghe",
|
"runtime.listening": "đang lắng nghe",
|
||||||
"runtime.notListening": "không lắng nghe",
|
"runtime.notListening": "không lắng nghe",
|
||||||
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
|
"replay.pasted": "Session đó đã mở trong một tab — tin nhắn đã được dán vào đó; nhấn Enter để gửi.",
|
||||||
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}"
|
"replay.notPasted": "Session đó đang mở trong một tab nhưng không dán được tin nhắn vào đó: {{text}}",
|
||||||
|
"changes.changesGroup": "Thay đổi",
|
||||||
|
"changes.unversionedGroup": "File chưa theo dõi",
|
||||||
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
|
"changes.commitAndPush": "Commit và Push",
|
||||||
|
"changes.amendAndPush": "Amend và Push",
|
||||||
|
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||||
|
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||||
|
"changes.tickFolder": "Tick mọi file trong {{path}}",
|
||||||
|
"changes.commitFile": "Commit file…",
|
||||||
|
"changes.jumpToSource": "Mở mã nguồn",
|
||||||
|
"changes.copyPatch": "Sao chép dạng patch",
|
||||||
|
"changes.noPatch": "File này không có diff dạng văn bản để sao chép",
|
||||||
|
"changes.addToVcs": "Thêm vào VCS",
|
||||||
|
"changes.addToGitignore": "Thêm vào .gitignore",
|
||||||
|
"changes.includeBlock": "Đưa thay đổi này vào commit",
|
||||||
|
"changes.prevDiff": "Khác biệt trước (Shift+F7)",
|
||||||
|
"changes.nextDiff": "Khác biệt tiếp theo (F7)",
|
||||||
|
"changes.prevFile": "File trước",
|
||||||
|
"changes.nextFile": "File tiếp theo",
|
||||||
|
"changes.showHistory": "Xem lịch sử",
|
||||||
|
"changes.historyOfFile": "Lịch sử của {{path}}",
|
||||||
|
"changes.historyWholeBranch": "Cả nhánh",
|
||||||
|
"changes.annotate": "Annotate",
|
||||||
|
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
|
||||||
|
"changes.notCommitted": "Chưa commit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"activityFeed": "Luồng hoạt động",
|
"activityFeed": "Luồng hoạt động",
|
||||||
"agentBoard": "Bảng Kanban",
|
|
||||||
"agents": "Agent",
|
"agents": "Agent",
|
||||||
"analytics": "Phân tích",
|
"analytics": "Phân tích",
|
||||||
"avg": "TB",
|
"avg": "TB",
|
||||||
@@ -19,20 +18,11 @@
|
|||||||
"daysAgo": "{{count}} ngày trước",
|
"daysAgo": "{{count}} ngày trước",
|
||||||
"disconnected": "Mất kết nối",
|
"disconnected": "Mất kết nối",
|
||||||
"eventsLastMin": "60s qua",
|
"eventsLastMin": "60s qua",
|
||||||
"eventsReceived": "Sự kiện đã nhận",
|
|
||||||
"eventsTotal": "Tổng",
|
"eventsTotal": "Tổng",
|
||||||
"expand": "Mở rộng thanh bên",
|
"expand": "Mở rộng thanh bên",
|
||||||
"hoursAgo": "{{count}} giờ trước",
|
"hoursAgo": "{{count}} giờ trước",
|
||||||
"justNow": "vừa xong",
|
"justNow": "vừa xong",
|
||||||
"language": "Ngôn ngữ",
|
"language": "Ngôn ngữ",
|
||||||
"languageNames": {
|
|
||||||
"en": "English",
|
|
||||||
"vi": "Tiếng Việt"
|
|
||||||
},
|
|
||||||
"languageShort": {
|
|
||||||
"en": "EN",
|
|
||||||
"vi": "VI"
|
|
||||||
},
|
|
||||||
"lastEvent": "Sự kiện gần nhất",
|
"lastEvent": "Sự kiện gần nhất",
|
||||||
"live": "Trực tiếp",
|
"live": "Trực tiếp",
|
||||||
"minutesAgo": "{{count}} phút trước",
|
"minutesAgo": "{{count}} phút trước",
|
||||||
@@ -55,7 +45,6 @@
|
|||||||
"unitPerSec": "sự kiện/s",
|
"unitPerSec": "sự kiện/s",
|
||||||
"upToDate": "Đã là mới nhất",
|
"upToDate": "Đã là mới nhất",
|
||||||
"updateAvailable": "Có bản cập nhật",
|
"updateAvailable": "Có bản cập nhật",
|
||||||
"website": "Website",
|
|
||||||
"workspace": "Không gian làm việc",
|
"workspace": "Không gian làm việc",
|
||||||
"wsEndpoint": "WebSocket",
|
"wsEndpoint": "WebSocket",
|
||||||
"releaseAvailable": "Có {{version}}",
|
"releaseAvailable": "Có {{version}}",
|
||||||
@@ -81,6 +70,7 @@
|
|||||||
"quotaFailed": "Không đọc được quota",
|
"quotaFailed": "Không đọc được quota",
|
||||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||||
"quotaActive": "đang dùng",
|
"quotaActive": "đang dùng",
|
||||||
|
"quotaTokenExpired": "Đăng nhập đã hết hạn: dùng công cụ một lần để nó đăng nhập lại",
|
||||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||||
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
"quotaSignInExpired": "Đăng nhập đã hết hạn",
|
||||||
@@ -94,5 +84,6 @@
|
|||||||
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
|
"resizeSidebar": "Kéo để đổi độ rộng sidebar (nhấp đúp để đặt lại)",
|
||||||
"closeMenu": "Đóng menu",
|
"closeMenu": "Đóng menu",
|
||||||
"sidebar": "Thanh bên",
|
"sidebar": "Thanh bên",
|
||||||
"now": "bây giờ"
|
"now": "bây giờ",
|
||||||
|
"users": "Người dùng"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
{
|
{
|
||||||
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
|
"emptyPane": "Pane này chưa có phiên nào. Hãy tạo phiên từ ô prompt của workspace.",
|
||||||
"actions": {
|
"actions": {
|
||||||
"newSession": "Phiên mới",
|
"newSession": "Phiên mới"
|
||||||
"send": "Gửi",
|
|
||||||
"sending": "Đang gửi…",
|
|
||||||
"start": "Chạy",
|
|
||||||
"starting": "Đang khởi động…",
|
|
||||||
"stop": "Dừng",
|
|
||||||
"stopping": "Đang dừng…"
|
|
||||||
},
|
},
|
||||||
"autocomplete": {
|
"autocomplete": {
|
||||||
"fileHint": "Gõ @ để tham chiếu file",
|
"fileHint": "Gõ @ để tham chiếu file",
|
||||||
"groupBuiltin": "Built-in (chỉ CLI - không chạy ở đây)",
|
|
||||||
"groupFiles": "File trong cwd",
|
|
||||||
"groupProject": "Lệnh project",
|
|
||||||
"groupUser": "Lệnh user",
|
|
||||||
"noMatches": "Không có",
|
"noMatches": "Không có",
|
||||||
"slashHint": "Gõ / để tìm lệnh slash"
|
"slashHint": "Gõ / để tìm lệnh slash"
|
||||||
},
|
},
|
||||||
"binary": {
|
"binary": {
|
||||||
"found": "Đang dùng {{path}}",
|
|
||||||
"missing": "Không thấy CLI `{{cli}}` trong PATH. Cài đặt {{tool}} hoặc đặt PATH."
|
"missing": "Không thấy CLI `{{cli}}` trong PATH. Cài đặt {{tool}} hoặc đặt PATH."
|
||||||
},
|
},
|
||||||
"concurrency": {
|
|
||||||
"active_other": "Có {{count}} run đang chạy",
|
|
||||||
"atCap": "Đạt giới hạn concurrent ({{max}}). Dừng một run đang chạy để bắt đầu run mới.",
|
|
||||||
"viewActive": "Xem các run đang chạy"
|
|
||||||
},
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"body": "Gõ prompt và bấm Chạy. Output sẽ stream khi model suy nghĩ, gọi tool, và trả lời.",
|
"body": "Gõ prompt và bấm Chạy. Output sẽ stream khi model suy nghĩ, gọi tool, và trả lời.",
|
||||||
"title": "Chưa có gì"
|
"title": "Chưa có gì"
|
||||||
@@ -39,28 +23,16 @@
|
|||||||
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
|
"runStartedButNotAttached": "Run đã khởi động nhưng không thể gắn vào bảng điều khiển: {{message}}",
|
||||||
"sendFailed": "Không gửi được: {{message}}"
|
"sendFailed": "Không gửi được: {{message}}"
|
||||||
},
|
},
|
||||||
"events": {
|
|
||||||
"claude": "Claude",
|
|
||||||
"collapse": "Thu gọn",
|
|
||||||
"expand": "Mở rộng",
|
|
||||||
"system": "Hệ thống",
|
|
||||||
"thinking": "Đang suy nghĩ",
|
|
||||||
"tool": "Tool",
|
|
||||||
"toolResult": "Kết quả tool",
|
|
||||||
"you": "Bạn"
|
|
||||||
},
|
|
||||||
"fields": {
|
"fields": {
|
||||||
"approval": "Phê duyệt",
|
"approval": "Phê duyệt",
|
||||||
"approvalNever": "Không bao giờ",
|
"approvalNever": "Không bao giờ",
|
||||||
"approvalOnRequest": "Khi cần",
|
"approvalOnRequest": "Khi cần",
|
||||||
"cwd": "Thư mục làm việc",
|
|
||||||
"cwdGroups": {
|
"cwdGroups": {
|
||||||
"dashboard": "Máy chủ dashboard",
|
"dashboard": "Máy chủ dashboard",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"repo": "Repo nguồn",
|
"repo": "Repo nguồn",
|
||||||
"recent": "Gần đây - Claude Code đã dùng"
|
"recent": "Gần đây - Claude Code đã dùng"
|
||||||
},
|
},
|
||||||
"cwdHint": "Đường dẫn tuyệt đối. Mặc định là cwd của dashboard.",
|
|
||||||
"cwdNoMatches": "Không khớp. Gõ đường dẫn tuyệt đối để dùng trực tiếp.",
|
"cwdNoMatches": "Không khớp. Gõ đường dẫn tuyệt đối để dùng trực tiếp.",
|
||||||
"cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…",
|
"cwdPlaceholder": "Gõ để tìm hoặc dán đường dẫn tuyệt đối…",
|
||||||
"effort": "Mức độ suy nghĩ",
|
"effort": "Mức độ suy nghĩ",
|
||||||
@@ -81,64 +53,15 @@
|
|||||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||||
"permissionMode": "Chế độ quyền",
|
"permissionMode": "Chế độ quyền",
|
||||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||||
"prompt": "Prompt",
|
|
||||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
|
||||||
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
|
|
||||||
"sandbox": "Sandbox",
|
"sandbox": "Sandbox",
|
||||||
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
||||||
"sandboxReadOnly": "Chỉ đọc",
|
"sandboxReadOnly": "Chỉ đọc",
|
||||||
"sandboxWorkspaceWrite": "Ghi vào workspace"
|
"sandboxWorkspaceWrite": "Ghi vào workspace"
|
||||||
},
|
},
|
||||||
"footer": {
|
|
||||||
"cost": "Chi phí",
|
|
||||||
"duration": "Thời lượng",
|
|
||||||
"exitCode": "Mã thoát",
|
|
||||||
"sessionId": "Session ID",
|
|
||||||
"turns": "Lượt"
|
|
||||||
},
|
|
||||||
"hint": {
|
"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.",
|
"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.",
|
"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.",
|
"sandboxWarning": "Codex sẽ chạy lệnh mà không có sandbox."
|
||||||
"shortcut": "Enter để gửi · Shift+Enter xuống dòng"
|
|
||||||
},
|
|
||||||
"mode": {
|
|
||||||
"conversation": "Hội thoại",
|
|
||||||
"conversationHint": "Đa lượt - tiếp tục gõ trong khi agent xử lý.",
|
|
||||||
"headless": "Một lần",
|
|
||||||
"headlessHint": "Một prompt, một phản hồi. Stdin đóng ngay sau khi spawn.",
|
|
||||||
"label": "Chế độ"
|
|
||||||
},
|
|
||||||
"resume": {
|
|
||||||
"clear": "Xoá lựa chọn",
|
|
||||||
"freshHint": "Bắt đầu phiên Claude Code mới.",
|
|
||||||
"freshOption": "Phiên mới",
|
|
||||||
"label": "Phiên nguồn",
|
|
||||||
"lastOption": "Tiếp tục phiên trước",
|
|
||||||
"noSessions": "Không tìm thấy phiên.",
|
|
||||||
"originalCwd": "cwd gốc",
|
|
||||||
"pickSession": "Chọn phiên…",
|
|
||||||
"preview": "Hoạt động gần nhất",
|
|
||||||
"resumeHint": "Chọn một phiên từ lịch sử để tiếp tục. cwd được khoá theo phiên gốc.",
|
|
||||||
"search": "Tìm theo ID, cwd hoặc status…",
|
|
||||||
"selectedBadge": "Đang tiếp tục"
|
|
||||||
},
|
|
||||||
"statusHeader": {
|
|
||||||
"cwd": "Cwd",
|
|
||||||
"duration": "Đã chạy",
|
|
||||||
"effort": "Effort",
|
|
||||||
"envelopes": "Sự kiện",
|
|
||||||
"model": "Model",
|
|
||||||
"permissionMode": "Mode",
|
|
||||||
"session": "Session"
|
|
||||||
},
|
|
||||||
"tokens": {
|
|
||||||
"cacheRead": "Trúng cache",
|
|
||||||
"cost": "Chi phí",
|
|
||||||
"input": "Input",
|
|
||||||
"label": "Context",
|
|
||||||
"output": "Output"
|
|
||||||
},
|
},
|
||||||
"drop": {
|
"drop": {
|
||||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||||
@@ -158,5 +81,11 @@
|
|||||||
"findPrev": "Kết quả trước",
|
"findPrev": "Kết quả trước",
|
||||||
"findNext": "Kết quả sau",
|
"findNext": "Kết quả sau",
|
||||||
"findClose": "Đóng tìm kiếm"
|
"findClose": "Đóng tìm kiếm"
|
||||||
|
},
|
||||||
|
"chat": {
|
||||||
|
"placeholder": "Viết tin nhắn…",
|
||||||
|
"send": "Gửi",
|
||||||
|
"hint": "Enter để gửi · hỏi quyền và menu cần terminal (Settings → Appearance → Giao diện chat)",
|
||||||
|
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
"filterError": "Lỗi",
|
"filterError": "Lỗi",
|
||||||
"filterAbandoned": "Bị bỏ dở",
|
"filterAbandoned": "Bị bỏ dở",
|
||||||
"sessionCount": "Đã ghi nhận {{count}} phiên",
|
"sessionCount": "Đã ghi nhận {{count}} phiên",
|
||||||
"sessionCount_plural": "Đã ghi nhận {{count}} phiên",
|
|
||||||
"noSessions": "Không tìm thấy phiên",
|
"noSessions": "Không tìm thấy phiên",
|
||||||
"noSessionsDesc": "Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc.",
|
"noSessionsDesc": "Hãy thử điều chỉnh tìm kiếm hoặc bộ lọc.",
|
||||||
"noSessionsHint": "Bắt đầu một phiên Claude Code với hooks đã cài để theo dõi.",
|
"noSessionsHint": "Bắt đầu một phiên Claude Code với hooks đã cài để theo dõi.",
|
||||||
@@ -26,14 +25,12 @@
|
|||||||
"generic": "Claude Code đang tạm dừng trong phiên này cho đến khi bạn phản hồi trong terminal."
|
"generic": "Claude Code đang tạm dừng trong phiên này cho đến khi bạn phản hồi trong terminal."
|
||||||
},
|
},
|
||||||
"failedLoad": "Không thể tải phiên",
|
"failedLoad": "Không thể tải phiên",
|
||||||
"loading": "Đang tải phiên...",
|
|
||||||
"notFound": "Không tìm thấy phiên",
|
"notFound": "Không tìm thấy phiên",
|
||||||
"backToSessions": "Quay lại danh sách phiên",
|
"backToSessions": "Quay lại danh sách phiên",
|
||||||
"agents": "Agent",
|
"agents": "Agent",
|
||||||
"noAgents": "Chưa có Agent được ghi nhận.",
|
"noAgents": "Chưa có Agent được ghi nhận.",
|
||||||
"unparented": "Subagent không có cha",
|
"unparented": "Subagent không có cha",
|
||||||
"costBreakdown": "Phân rã chi phí",
|
"costBreakdown": "Phân rã chi phí",
|
||||||
"eventTimeline": "Dòng thời gian sự kiện",
|
|
||||||
"noEvents": "Chưa có sự kiện được ghi nhận.",
|
"noEvents": "Chưa có sự kiện được ghi nhận.",
|
||||||
"conversation": "Hội thoại",
|
"conversation": "Hội thoại",
|
||||||
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
"timeline": "Dòng thời gian ({{shown}}/{{total}})",
|
||||||
@@ -151,6 +148,8 @@
|
|||||||
"persistedOutput": "Đầu ra đã lưu",
|
"persistedOutput": "Đầu ra đã lưu",
|
||||||
"taskNotification": "Thông báo tác vụ",
|
"taskNotification": "Thông báo tác vụ",
|
||||||
"thinking": "Suy nghĩ",
|
"thinking": "Suy nghĩ",
|
||||||
|
"showMore": "Xem thêm",
|
||||||
|
"showLess": "Thu gọn",
|
||||||
"chars_one": "{{formatted}} ký tự",
|
"chars_one": "{{formatted}} ký tự",
|
||||||
"chars_other": "{{formatted}} ký tự",
|
"chars_other": "{{formatted}} ký tự",
|
||||||
"sender": {
|
"sender": {
|
||||||
@@ -181,6 +180,22 @@
|
|||||||
"statusError": "lỗi",
|
"statusError": "lỗi",
|
||||||
"statusOk": "ok",
|
"statusOk": "ok",
|
||||||
"statusPending": "đang chờ"
|
"statusPending": "đang chờ"
|
||||||
|
},
|
||||||
|
"work": {
|
||||||
|
"commands_one": "đã chạy {{count}} lệnh",
|
||||||
|
"commands_other": "đã chạy {{count}} lệnh",
|
||||||
|
"edits_one": "sửa 1 file",
|
||||||
|
"edits_other": "sửa {{count}} lần",
|
||||||
|
"reads_one": "đọc 1 file",
|
||||||
|
"reads_other": "đọc {{count}} file",
|
||||||
|
"searches_one": "tìm trong code",
|
||||||
|
"searches_other": "tìm {{count}} lần",
|
||||||
|
"web_one": "tra cứu web",
|
||||||
|
"web_other": "tra cứu web {{count}} lần",
|
||||||
|
"agents_one": "chạy 1 agent",
|
||||||
|
"agents_other": "chạy {{count}} agent",
|
||||||
|
"tools_one": "dùng 1 công cụ",
|
||||||
|
"tools_other": "dùng {{count}} công cụ"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user