Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f3735b29 | |||
| a9c71cd905 | |||
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 | |||
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 | |||
| be75572977 |
@@ -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
|
||||||
|
|||||||
+13
-8
@@ -333,7 +333,7 @@ graph TD
|
|||||||
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
||||||
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
|
| `routes/workflows.js` | Aggregate workflow visualization data (agent orchestration graphs, tool transition flows, collaboration networks, workflow pattern detection, model delegation, error propagation, concurrency timelines, session complexity metrics, compaction impact). Accepts `?status=active\|completed` query parameter to filter all data by session status and `?sections=a,b` to compute only those parts. The `GET /` bundle itself lives in `lib/workflow-stats.js` and runs in the read worker (`lib/read-pool.js`). `GET /api/workflows/skill-usage` reports per-skill and per-plugin call counts, counting `PreToolUse` rows only so a call is not double-counted against its `PostToolUse` twin. Per-session drill-in endpoint with agent tree, tool timeline, and event details |
|
||||||
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
|
| `lib/workflow-stats.js` | The eleven `GET /api/workflows` aggregations as pure reads over a passed-in connection (`computeWorkflowData(conn, status, sections)`), so they run in the read worker. `stats.topFlow` is the head of `toolFlow.transitions`: tool pairs come from one `LEAD() OVER (PARTITION BY session_id ORDER BY id)` pass — never a correlated `MIN(id) … id > ?` per event, which is quadratic in a session's size |
|
||||||
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there |
|
| `lib/read-pool.js` | Heavy read-only jobs off the event loop: one worker thread with its own `query_only` connection (same driver order as `db.js`), identical in-flight calls shared, a caller's `maxAgeMs` reusing a result that young (`/api/workflows` 30 s, `/api/analytics`'s events scans 10 s — args are the key, so a member's scoped result is never shared), a job past `AGENTHUB_READ_TIMEOUT_MS` (60 s) refused `EREADTIMEOUT` and its worker replaced, inline when `AGENTHUB_READ_WORKER=0` or a worker cannot start. Add a job to `JOBS` to move another aggregate there. The server's WAL checkpoint (`walCheckpoint`, every `DASHBOARD_WAL_CHECKPOINT_MS`, default 5 s) also runs here, on a second writable connection of the worker: the main connection sets `wal_autocheckpoint = 0`, so no hook's `COMMIT` pays for a checkpoint; a failed checkpoint turns the automatic one back on |
|
||||||
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
|
| `lib/transcript-cache.js` | Stat-based JSONL transcript cache with incremental byte-offset reads. Shared between `hooks.js` (token extraction on every event) and the periodic compaction scanner (`index.js`). Extracts tokens, compaction entries, API errors (`isApiErrorMessage` + raw error responses), turn durations (`system` subtype `turn_duration`), thinking block counts, usage extras (service_tier, speed, inference_geo), user-interrupt markers (the transcript `[Request interrupted by user]` entry / `interruptedMessageId` field — surfaced as `pendingInterrupt`, computed from transcript ordering: latest interrupt vs latest real turn activity, both on Claude Code's clock), and the latest session title — `custom-title` (`/rename`, `claude -n`, picker Ctrl+R) and `ai-title` (auto / plan-accept), append-only so the last value wins, carried through both full and incremental reads — plus the session's **first user prompt** (`firstUserMessage`: tool-result, meta/caveat, slash-command plumbing, compact-summary, and interrupt entries skipped; whitespace-collapsed, capped at 500 chars; first value wins across incremental reads), used as a fallback descriptor for placeholder-named sessions/agents. Uses `(path, mtime, size)` cache key — unchanged files return cached results instantly, grown files only parse new bytes, shrunk files (compaction) trigger full re-read. Each cache entry stores **only** `{mtimeMs, size, bytesRead, result}` — the previous shape that duplicated every growable array at both the top level and inside `result` is gone, halving steady-state memory per entry. Per-entry growable arrays (`turnDurations`, `errors`, `compaction.entries`, `usageExtras.*`) are bounded to `TRANSCRIPT_CACHE_MAX_ARRAY_LEN` (default `1000`, tail-kept) — older items remain in the `events` table thanks to hook dedup, so the cap only affects the in-memory view. Trimming runs both during parse (when an array reaches `2 * MAX_ARRAY_LEN`, amortized O(N)) and at finalize, so even a fresh full-file parse on a multi-day session cannot accumulate an unbounded transient before returning. **Chunked sync byte-stream reader** (`_streamRange`, 4 MiB chunks split on `0x0A` bytes — safe across UTF-8 multibyte sequences — with a growable per-line byte buffer capped at 64 MiB) replaces the previous `readFileSync("utf8")` so transcripts larger than V8's max JS string length (~512 MiB on 64-bit Node 20) parse without aborting Node with `FATAL ERROR: v8::ToLocalChecked Empty MaybeLocal`. Both full and incremental reads share the same line-level state machine (`_initParseState` / `_consumeLine` / `_finalizeState`). LRU eviction caps at 200 entries. Entries evicted on SessionEnd and abandoned session cleanup |
|
||||||
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
|
| `lib/session-liveness.js` | Process-liveness probe for the watchdog's dead-session reap. `probeLiveCwds()` enumerates running `claude` CLI processes (`ps -Ao pid=,args=`, then `lsof -a -p <pids> -d cwd -Fn` on macOS or `/proc/<pid>/cwd` on Linux) and returns the set of their working directories; `isClaudeCommand()` matches the bare binary and `node`/`bun`-launched shims while rejecting lookalikes (`claude-mem`, `Claude.app`). Fail-safe by contract: returns `available: false` (callers must change nothing) on Windows, inside containers (reuses `isInsideContainer` from `scripts/install-hooks.js` — host processes are invisible there, so an empty list would lie), on `ps`/`lsof` failure, or when `DASHBOARD_LIVENESS_PROBE=0` |
|
||||||
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
|
| `bin/agenthub.js` | Dependency-free umbrella CLI (`agenthub <command>`) exposing the full dashboard surface in the terminal: monitoring (health / stats / kanban / tail via short-interval event polling), data browsing (sessions, per-session detail with an indented agent tree + cost + recent events, agents, events), insights (analytics, workflow intelligence, dynamic Workflow-tool runs, per-model cost), alerts + rules + webhook test probes, pricing CRUD, imports (rescan / scan-path), and administration (doctor, info, export, cleanup, reinstall-hooks, clear-data). Linked globally by `npm run setup` via a fail-soft `npm link` (`link-cli` script). Discovers the live server through `server/lib/server-info.js` (`~/.claude/.agenthub.json`, PID-liveness-checked) with `CLAUDE_DASHBOARD_PORT` / `DASHBOARD_PORT` env overrides and a 4820 fallback; renders a full terminal UI — box-drawn width-fitted tables with right-aligned numeric columns, status icons, inline bar charts (stats / analytics / cost), `├─`/`└─` agent trees, and a TTY spinner for `start` — whose ANSI styling degrades to plain text when piped (`--no-color` / `NO_COLOR` / `FORCE_COLOR` / `AGENTHUB_COLOR` respected); the one destructive command (`clear-data`) refuses to run without `--yes`. Server lifecycle: `agenthub status` shows a ●/○ up/down indicator and `agenthub start` boots a detached production server (waits for /api/health, logs to `data/agenthub-server.log`). `agenthub repl` (aliases `shell` / `i`) opens an interactive shell — a readline prompt with tab-completion (commands / subcommands / flags), persisted arrow-key history (`data/.agenthub_repl_history`), and a live server-status prompt; each entered line runs as a short-lived child `agenthub` process (via `runCommand` dispatch), so an offline refusal or a blocking `tail` cannot take the shell down, and piped input runs each line in order then exits at EOF. When the server is down, read-only commands (sessions / session / agents / events / kanban / stats / pricing list / alerts list / rules / export / doctor) fall back to direct SQLite reads of `data/dashboard.db` under a ⚠ Offline-mode banner — the connection is opened without SQLite readonly mode so a live WAL stays visible, and is SELECT-only by construction — while server-only commands (tail, analytics, workflows, runs, cost, mutations) refuse with the specific reason |
|
||||||
@@ -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 |
|
||||||
|
|
||||||
@@ -487,7 +487,7 @@ graph TD
|
|||||||
### Splash & loading UX
|
### Splash & loading UX
|
||||||
|
|
||||||
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
- **`SplashScreen.tsx`** — rendered by `App.tsx` as a fixed full-screen overlay alongside the router. Shows once per browser session (`sessionStorage` gate, read synchronously so a repeat mount never flashes). Time-aware greeting + localized tagline/subtexts (`splash` i18n namespace, en/zh/vi/ko) + an animated node-graph brand mark on a dark backdrop (radial glow, drifting constellation, grain). The backdrop is **opaque from the first paint** (no entrance fade on the root) so the app rendered behind it never flashes through; only the inner content cascades in. Holds ~2.5 s, then fades out and unmounts; click-to-skip; honors `prefers-reduced-motion`. CSS-only keyframes, no added dependencies.
|
||||||
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, IntelliJ Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#e8e5da` and a `.card` is the brighter `surface-3` `#f5f3ec` (Claude's warm ivory family rather than Radix slate — a cool near-white page reads as glare at any lightness, while dark mode stays slate + blue), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is `#924224` — Claude's terracotta three steps down, since the brand `#cc785c` is 3.1:1 on ivory — not dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking the same hues four steps darker. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
- **Colour tokens (`client/src/index.css`, `tailwind.config.js`)** — one CSS-variable set per mode, plus named-theme override blocks keyed by `data-theme` on `<html>` (Darcula, Apple Dark, Apple Light, Canvas — picked in Settings → Appearance, `components/settings/AppearanceSettings.tsx`, state in `hooks/useTheme.ts`), consumed as `bg-surface-N` / `text-fg-*` / `border-border*` / `text-status-*` / `text-syntax-*`. The surface ramp encodes ELEVATION and rises in lightness with its index in BOTH themes: light mode's page is `surface-0` `#eceef2` and a `.card` is the brighter `surface-3` `#fdfdfe` (the light Default is IntelliJ Light's palette), so hovers lift instead of dimming. (Light mode previously used Radix slate in its native direction — index up meant darker — which put a near-white page under grey, recessed-looking cards: the "glaring and muddy at once" report.) Light `--accent` is IntelliJ's `#285ac0`, darker than dark's blue; `--accent` is a TEXT colour (dark Default `#70b8ff`), so white text sits on `--accent-solid` (`bg-accent-solid`, `.btn-primary`, Switch, Checkbox — dark Default `#0076d1`, light = `--accent`), never on `--accent`; `--fg-muted` is held at ≥4.5:1 because it carries timestamps and counts; `--shadow-card`, not the deliberately quiet `--border`, carries "raised" in light mode. Syntax colouring is tokenised too (`--syn-*`), dark keeping its 300-level pastels and light taking IntelliJ Light's editor scheme. A hardcoded near-black panel, a `hover:text-white`, or `filter: invert(1)` on a form glyph is by definition a light-mode bug — token it, or scope it under `.dark`. Status and accent **fills** are pre-mixed rather than translucent: `tint()` in `tailwind.config.js` compiles `bg-status-success/10` and `border-status-danger/40` to `color-mix(…, rgb(var(--tint-base)))`, because an alpha tint composites with what is behind it and made one chip render as five different colours over the five surface steps. It is deliberately scoped to `backgroundColor`/`borderColor` — `text-*/NN` and `ring-*/NN` must stay translucent, and a translucent `surface` is what every scrim is made of.
|
||||||
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
- **`Tooltip.tsx` (`GlobalTooltip`)** — mounted once by `App.tsx` beside the router. Every control in the app keeps writing the native `title="…"`; this listener upgrades it in place — on a 350 ms hover rest or immediately on keyboard focus it parks the attribute in `data-tip`, renders an anchored bubble (portal, arrow, flip above/below, viewport clamp, theme tokens so light mode is legible), and restores the attribute when it closes on pointer-out / pointer-down / scroll / resize / `Escape`. No wrapper at the call site, nothing to remember for the next `title=`, and the native tooltip plus the control's accessible name survive with JS off. `data-tip-off` opts an element out; `Tip.tsx` (cursor-following, for expanding a truncated number mid-sentence) draws the same `TOOLTIP_SURFACE`.
|
||||||
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
- **Loading skeletons** — the shared `Skeleton` primitive (`components/Skeleton.tsx`) uses Tailwind `animate-pulse`. `Analytics.tsx` now renders a pulsing `AnalyticsChartsSkeleton` for the whole chart region while `data` is null (previously it fell back to empty/zero charts).
|
||||||
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
- **`Workflows.tsx` `Section`** — the right-aligned section subtitle is clamped to a single line (`truncate` + `max-w` + hover `title`) so a long translation never wraps and unbalances the header; the full text stays in the section's `i` popover.
|
||||||
@@ -911,10 +911,10 @@ erDiagram
|
|||||||
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
| `idx_agents_status` | agents | `status` | Kanban board column queries |
|
||||||
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
|
| `idx_events_session_created` | events | `session_id, created_at` | A session's newest event / time order (watchdog every 15 s, every lane payload) |
|
||||||
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
|
| `idx_events_tool` | events | `tool_name` | Per-tool counts (analytics, workflows) and the events filter's tool list without a scan of every event row |
|
||||||
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
| `idx_events_session` | events | `session_id, id` | A session's events in insert order: every hook's duplicate guard reads the latest one. Not a prefix of `idx_events_session_type_tool` (that one is not id-ordered); dropping it once made every hook sort its whole session |
|
||||||
| `idx_events_type` | events | `event_type` | Filter events by type |
|
| `idx_events_type` | events | `event_type` | Filter events by type |
|
||||||
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
| `idx_events_created` | events | `created_at DESC` | Activity feed ordering |
|
||||||
| `idx_events_session_type` | events | `session_id, event_type` | Per-session event-type filters |
|
| `idx_events_session_type_tool` | events | `session_id, event_type, tool_name, created_at` | Per-session event-type filters, and a covering read for session stats (per-tool and per-type counts), the hook's TurnDuration/APIError dedupe and the import's newest-per-type resume point (1.2 s → 20 ms on a 29K-event session). Replaces `idx_events_session_type` (dropped at startup), so a hook writes no more index entries than before |
|
||||||
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
| `idx_events_agent_type` | events | `agent_id, event_type` | `importSubagentFromJsonl` reads the agent's existing `tool_use_id`s once through it (it used to run a `data LIKE` per tool event) |
|
||||||
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
| `idx_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
||||||
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
| `idx_sessions_started` | sessions | `started_at DESC` | Default sort order |
|
||||||
@@ -966,6 +966,11 @@ All messages are JSON with this envelope:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Frames a hook produces are sent only after its transaction committed and its
|
||||||
|
pane binding (`bindRunSession`) ran — in order, in the same tick — so no frame
|
||||||
|
describes a rolled-back row, and a server-mode member's first frames of a new
|
||||||
|
session reach them. `new_event` carries the event's row `id` (additive).
|
||||||
|
|
||||||
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
The `remote_source.status` message (emitted by the **Remote Data Sources**
|
||||||
sync poller and the `/api/remote-sources` routes) carries
|
sync poller and the `/api/remote-sources` routes) carries
|
||||||
`{ id, status, error?, last_sync_at? }`, where `status` is one of
|
`{ id, status, error?, last_sync_at? }`, where `status` is one of
|
||||||
@@ -2219,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. |
|
||||||
@@ -2296,8 +2301,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
|||||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
|
||||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
|
||||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||||
|
|||||||
+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,360 +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 and extra Folders, which Claude and Codex
|
|
||||||
sessions start with.
|
|
||||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
|
||||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
|
||||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
|
||||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
|
||||||
untracked worktrees a workspace's Add folder suggests.
|
|
||||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
|
||||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
|
||||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
|
||||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
|
||||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
|
||||||
`settings.json` stay read-only: they race with the live CLI.
|
|
||||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
|
||||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
|
||||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
|
||||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
|
||||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
|
||||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
|
||||||
Per-browser only.
|
|
||||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
|
||||||
Pop, Alert — off by default) played with every notification shown, and each
|
|
||||||
notification type can pick its own.
|
|
||||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
|
||||||
a UI in English, Vietnamese, Chinese and Korean.
|
|
||||||
- **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)).
|
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|||||||
+11
-11
File diff suppressed because one or more lines are too long
+404
-5
@@ -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
|
||||||
@@ -160,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
|
|||||||
const form = body instanceof FormData;
|
const form = body instanceof FormData;
|
||||||
res = await fetch(url, {
|
res = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
|
headers: {
|
||||||
|
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||||
|
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||||
|
...(process.env.AGENTHUB_PANE_TOKEN
|
||||||
|
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||||
});
|
});
|
||||||
@@ -1391,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."));
|
||||||
@@ -1441,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++;
|
||||||
@@ -1457,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
|
||||||
@@ -2303,6 +2404,17 @@ async function cmdLockAcquire(args) {
|
|||||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Only 409 means "held by someone else"; any other answer (a refused
|
||||||
|
// name, a server error) will not change by waiting.
|
||||||
|
if (result.status !== 409) {
|
||||||
|
console.error(
|
||||||
|
c.red(
|
||||||
|
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Date.now() - lastPrinted >= 60_000) {
|
if (Date.now() - lastPrinted >= 60_000) {
|
||||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -2714,11 +2826,36 @@ 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]",
|
||||||
"Auto-start on boot via systemd --user (Linux)",
|
"Auto-start on boot via systemd --user (Linux)",
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"server",
|
||||||
|
"setup|off|status [--hostname name] [--admin name]",
|
||||||
|
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"users",
|
||||||
|
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||||
|
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"accounts",
|
||||||
|
"list|add|remove [name]",
|
||||||
|
"Claude account pool: new sessions spread over these accounts",
|
||||||
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -2916,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",
|
||||||
@@ -3668,6 +3810,9 @@ const COMMANDS = (() => {
|
|||||||
const SUBCOMMANDS = {
|
const SUBCOMMANDS = {
|
||||||
alerts: ["ack", "ack-all"],
|
alerts: ["ack", "ack-all"],
|
||||||
service: ["install", "uninstall", "status"],
|
service: ["install", "uninstall", "status"],
|
||||||
|
server: ["setup", "off", "status"],
|
||||||
|
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||||
|
accounts: ["list", "add", "remove"],
|
||||||
pricing: ["set", "delete", "reset"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
import: ["rescan", "path"],
|
import: ["rescan", "path"],
|
||||||
@@ -3708,6 +3853,248 @@ function cmdStatusline(sub, flags) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Server mode (docs/SERVER-MODE.md) ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** The auth module over the SAME database and settings the server reads: the
|
||||||
|
* repo `.env`, then AgentHub's settings file (a pinned DASHBOARD_DATA_DIR). */
|
||||||
|
function serverAuth() {
|
||||||
|
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
|
||||||
|
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
|
||||||
|
return { settingsEnv, auth: require(path.join(REPO_ROOT, "server", "lib", "auth.js")) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One password: typed hidden on a terminal, else the first line of stdin. */
|
||||||
|
async function askPassword(prompt) {
|
||||||
|
const stdin = process.stdin;
|
||||||
|
if (!stdin.isTTY) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const chunk of stdin) chunks.push(chunk);
|
||||||
|
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/)[0];
|
||||||
|
}
|
||||||
|
process.stdout.write(prompt);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let typed = "";
|
||||||
|
const finish = () => {
|
||||||
|
stdin.setRawMode(false);
|
||||||
|
stdin.pause();
|
||||||
|
stdin.off("data", onData);
|
||||||
|
process.stdout.write("\n");
|
||||||
|
};
|
||||||
|
const onData = (text) => {
|
||||||
|
for (const ch of text) {
|
||||||
|
if (ch === "\r" || ch === "\n") return (finish(), resolve(typed));
|
||||||
|
if (ch === "\u0003") return (finish(), reject(new Error("cancelled")));
|
||||||
|
typed = ch === "\u007f" || ch === "\b" ? typed.slice(0, -1) : typed + ch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
stdin.setRawMode(true);
|
||||||
|
stdin.setEncoding("utf8");
|
||||||
|
stdin.on("data", onData);
|
||||||
|
stdin.resume();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new password, typed twice on a terminal. */
|
||||||
|
async function newPassword(label) {
|
||||||
|
const first = await askPassword(`${label}: `);
|
||||||
|
if (process.stdin.isTTY && (await askPassword("Again: ")) !== first) {
|
||||||
|
throw new Error("the two passwords differ");
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub server setup|off|status` — turn server mode on or off. */
|
||||||
|
async function cmdServer(sub, flags) {
|
||||||
|
const { settingsEnv, auth } = serverAuth();
|
||||||
|
const file = settingsEnv.settingsFilePath();
|
||||||
|
const envNote = (key) => {
|
||||||
|
if (settingsEnv.envOwned.has(key)) {
|
||||||
|
console.log(
|
||||||
|
c.yellow(`! ${key} is also set in the environment or .env, which wins over ${file}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (sub === "setup") {
|
||||||
|
if (flags.hostname) {
|
||||||
|
const hosts = String(flags.hostname);
|
||||||
|
if (!/^[a-z0-9.\-:[\],]+$/i.test(hosts)) throw new Error(`bad --hostname: ${hosts}`);
|
||||||
|
settingsEnv.writeSetting("DASHBOARD_ALLOWED_HOSTS", hosts);
|
||||||
|
envNote("DASHBOARD_ALLOWED_HOSTS");
|
||||||
|
}
|
||||||
|
if (!auth.hasAdmin()) {
|
||||||
|
const name = typeof flags.admin === "string" ? flags.admin : "admin";
|
||||||
|
const user = await auth.createUser(
|
||||||
|
name,
|
||||||
|
await newPassword(`Password for admin ${name}`),
|
||||||
|
"admin"
|
||||||
|
);
|
||||||
|
console.log(`${c.green("✔")} admin ${user.username} created`);
|
||||||
|
} else {
|
||||||
|
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||||
|
}
|
||||||
|
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||||
|
envNote("AGENTHUB_MODE");
|
||||||
|
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||||
|
console.log(" Next:");
|
||||||
|
console.log(
|
||||||
|
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||||
|
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||||
|
);
|
||||||
|
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||||
|
console.log(
|
||||||
|
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "off") {
|
||||||
|
settingsEnv.writeSetting("AGENTHUB_MODE", "local");
|
||||||
|
envNote("AGENTHUB_MODE");
|
||||||
|
console.log(`${c.green("✔")} server mode off — restart the dashboard. Users are kept.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "status" || sub === undefined) {
|
||||||
|
const users = auth.listUsers();
|
||||||
|
console.log(`mode: ${auth.isServerMode() ? "server" : "local"}`);
|
||||||
|
console.log(`settings: ${file}`);
|
||||||
|
console.log(
|
||||||
|
`users: ${users.length} (${users.filter((u) => u.role === "admin").length} admin)`
|
||||||
|
);
|
||||||
|
console.log(c.dim(" a change takes effect when the dashboard restarts"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
c.red("✖ Usage: agenthub server setup [--hostname name] [--admin name] | off | status")
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||||
|
async function cmdUsers(sub, positional, flags) {
|
||||||
|
const { auth } = serverAuth();
|
||||||
|
const name = positional[0];
|
||||||
|
const need = () => {
|
||||||
|
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
|
||||||
|
};
|
||||||
|
switch (sub) {
|
||||||
|
case undefined:
|
||||||
|
case "list":
|
||||||
|
table(
|
||||||
|
["id", "username", "role", "state"],
|
||||||
|
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
case "add": {
|
||||||
|
need();
|
||||||
|
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||||
|
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||||
|
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "passwd":
|
||||||
|
need();
|
||||||
|
await auth.setPassword(name, await newPassword(`New password for ${name}`));
|
||||||
|
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
|
||||||
|
return;
|
||||||
|
case "role":
|
||||||
|
need();
|
||||||
|
auth.setRole(name, positional[1]);
|
||||||
|
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
|
||||||
|
return;
|
||||||
|
case "assign":
|
||||||
|
if (!name || !positional[1]) {
|
||||||
|
throw new Error("usage: agenthub users assign <workspace id> <username>");
|
||||||
|
}
|
||||||
|
auth.assignWorkspace(name, positional[1]);
|
||||||
|
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
|
||||||
|
return;
|
||||||
|
case "audit": {
|
||||||
|
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
|
||||||
|
table(
|
||||||
|
["when", "reader", "owner", "path"],
|
||||||
|
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
|
||||||
|
a.at.replace("T", " ").slice(0, 19),
|
||||||
|
a.reader,
|
||||||
|
a.owner,
|
||||||
|
a.path,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "disable":
|
||||||
|
case "enable":
|
||||||
|
need();
|
||||||
|
auth.setDisabled(name, sub === "disable");
|
||||||
|
console.log(`${c.green("✔")} ${name} ${sub}d`);
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
console.error(
|
||||||
|
c.red(
|
||||||
|
"✖ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||||
|
async function cmdAccounts(sub, positional) {
|
||||||
|
serverAuth(); // the same settings + database the server reads
|
||||||
|
const pool = require(path.join(REPO_ROOT, "server", "lib", "claude-pool.js"));
|
||||||
|
const name = positional[0];
|
||||||
|
if (sub === "add" || sub === "remove") {
|
||||||
|
if (!name) throw new Error(`usage: agenthub accounts ${sub} <name>`);
|
||||||
|
}
|
||||||
|
if (sub === "add") {
|
||||||
|
console.log(
|
||||||
|
c.dim(" Run `claude setup-token` signed in as that account, then paste the token.")
|
||||||
|
);
|
||||||
|
const added = pool.addAccount(name, await askPassword(`Token for ${name}: `));
|
||||||
|
console.log(
|
||||||
|
`${c.green("✔")} ${added.name} added — valid until ${added.expiresAt.slice(0, 10)}`
|
||||||
|
);
|
||||||
|
console.log(c.dim(" New Claude sessions now spread over the pool's accounts."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === "remove") {
|
||||||
|
pool.removeAccount(name);
|
||||||
|
console.log(`${c.green("✔")} ${name} removed — running panes keep it until they exit`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sub === undefined || sub === "list") {
|
||||||
|
await pool.refreshQuotas({ force: true });
|
||||||
|
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||||
|
table(
|
||||||
|
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
|
||||||
|
pool
|
||||||
|
.poolStatus()
|
||||||
|
.map((a) => [
|
||||||
|
a.name,
|
||||||
|
a.livePanes,
|
||||||
|
a.spend5h.toFixed(2),
|
||||||
|
a.spend7d.toFixed(2),
|
||||||
|
pct(a.quota?.fiveHour),
|
||||||
|
pct(a.quota?.sevenDay),
|
||||||
|
a.expired
|
||||||
|
? "expired"
|
||||||
|
: a.full
|
||||||
|
? "full"
|
||||||
|
: a.quota?.error
|
||||||
|
? `no quota (${a.quota.error})`
|
||||||
|
: "ok",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(c.red("✖ Usage: agenthub accounts list | add <name> | remove <name>"));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
/** Run one parsed command. Returns the handler's promise; may throw
|
/** Run one parsed command. Returns the handler's promise; may throw
|
||||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||||
@@ -3723,8 +4110,18 @@ 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":
|
||||||
|
return cmdServer(positional[0], flags);
|
||||||
|
case "users":
|
||||||
|
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||||
|
case "accounts":
|
||||||
|
return cmdAccounts(positional[0], positional.slice(1));
|
||||||
case "stats":
|
case "stats":
|
||||||
return cmdStats();
|
return cmdStats();
|
||||||
case "kanban":
|
case "kanban":
|
||||||
@@ -3852,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":
|
||||||
|
|||||||
+17
-9
@@ -52,13 +52,13 @@ 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`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||||
|
|
||||||
Other rules that fall out of this:
|
Other rules that fall out of this:
|
||||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||||
@@ -199,13 +199,14 @@ 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)
|
||||||
│ │
|
│ │
|
||||||
│ ├── pages/ # Route pages
|
│ ├── pages/ # Route pages
|
||||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||||||
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
|
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||||||
|
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
||||||
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||||||
@@ -258,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 Router, 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
|
||||||
@@ -504,7 +505,8 @@ function App() {
|
|||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="sessions" element={<History />} />
|
<Route path="sessions" element={<History />} />
|
||||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||||
<Route path="query" element={<QueryExplorer />} />
|
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
|
||||||
|
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||||
@@ -523,7 +525,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||||
|
|
||||||
### Navigation Flow
|
### Navigation Flow
|
||||||
|
|
||||||
@@ -791,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
|||||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||||
the `.md-preview` rules in `index.css`, built from theme tokens.
|
the `.md-preview` rules in `index.css`, built from theme tokens.
|
||||||
|
|
||||||
|
#### Metric tooltips (`common:insights.help.*`)
|
||||||
|
|
||||||
|
The dotted-underline tooltips on jargon metrics (`StatCard`'s and
|
||||||
|
`SessionOverview`'s `help` prop) are worded in `common:insights.help.*`, `en` +
|
||||||
|
`vi`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Utilities
|
## Utilities
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
+9
-21
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @description Service Worker for caching static assets and handling push notifications.
|
* @description Service Worker: network-first navigations with an offline fallback, and push
|
||||||
|
* notifications. Hashed `/assets/` bundles are left to the HTTP cache (immutable).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Bump this any time the SW logic changes - old clients will install the new
|
// Bump this any time the SW logic changes - old clients will install the new
|
||||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||||
// freshly-built bundle starts being served on the very next request.
|
// freshly-built bundle starts being served on the very next request.
|
||||||
const CACHE_NAME = "dashboard-v2";
|
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
|
||||||
|
// and were never pruned.
|
||||||
|
const CACHE_NAME = "dashboard-v3";
|
||||||
|
|
||||||
self.addEventListener("install", () => {
|
self.addEventListener("install", () => {
|
||||||
// No pre-cache: network-first below means the cache fills lazily, and
|
// No pre-cache: network-first below means the cache fills lazily, and
|
||||||
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
// Hashed bundles under /assets/ are immutable and the server marks them so:
|
||||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
// the browser's HTTP cache keeps them. Caching them here too only grew a
|
||||||
// don't get re-requested.
|
// second copy of every build, forever.
|
||||||
if (url.pathname.startsWith("/assets/")) {
|
if (url.pathname.startsWith("/assets/")) return;
|
||||||
event.respondWith(
|
|
||||||
caches.match(request).then(
|
|
||||||
(cached) =>
|
|
||||||
cached ||
|
|
||||||
fetch(request).then((response) => {
|
|
||||||
if (response.ok && response.type === "basic") {
|
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||||
// with cache fallback. The user always gets the freshest UI while online and
|
// with cache fallback. The user always gets the freshest UI while online and
|
||||||
|
|||||||
+37
-18
@@ -38,8 +38,8 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./components/Layout`
|
* - `./components/Layout`
|
||||||
* - `./components/SplashScreen`
|
* - `./components/SplashScreen`
|
||||||
* - `./pages/Dashboard`
|
* - `./pages/Dashboard` (lazy)
|
||||||
* - `./pages/History`
|
* - `./pages/History` (lazy)
|
||||||
* - `./pages/SessionDetail` (lazy)
|
* - `./pages/SessionDetail` (lazy)
|
||||||
* - `./pages/SettingsHub` (lazy)
|
* - `./pages/SettingsHub` (lazy)
|
||||||
* - `./pages/Workspace` (lazy)
|
* - `./pages/Workspace` (lazy)
|
||||||
@@ -64,17 +64,19 @@ import { GlobalTooltip } from "./components/Tooltip";
|
|||||||
import { Toaster } from "./components/Toaster";
|
import { Toaster } from "./components/Toaster";
|
||||||
import { ConfirmHost } from "./components/ConfirmHost";
|
import { ConfirmHost } from "./components/ConfirmHost";
|
||||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
|
||||||
import { History } from "./pages/History";
|
|
||||||
import { NotFound } from "./pages/NotFound";
|
import { NotFound } from "./pages/NotFound";
|
||||||
import { useWebSocket } from "./hooks/useWebSocket";
|
import { useWebSocket } from "./hooks/useWebSocket";
|
||||||
import { useNotifications } from "./hooks/useNotifications";
|
import { useNotifications } from "./hooks/useNotifications";
|
||||||
import { eventBus } from "./lib/eventBus";
|
import { eventBus } from "./lib/eventBus";
|
||||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||||
|
import { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||||
import type { WSMessage } from "./lib/types";
|
import type { WSMessage } from "./lib/types";
|
||||||
|
|
||||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||||
// visit instead of in the initial bundle.
|
// visit instead of in the initial bundle — the Dashboard and History too, so the
|
||||||
|
// entry chunk holds only the shell.
|
||||||
|
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||||
|
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
|
||||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||||
const SettingsHub = lazy(() =>
|
const SettingsHub = lazy(() =>
|
||||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||||
@@ -85,9 +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 QueryExplorer = lazy(() =>
|
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||||
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
|
|
||||||
);
|
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -135,6 +135,10 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The server came back as a newer version (any tab, any way it was updated):
|
||||||
|
// load its UI build instead of leaving the user to press F5.
|
||||||
|
useEffect(() => reloadOnNewBuild(), []);
|
||||||
|
|
||||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||||
@@ -154,8 +158,22 @@ export default function App() {
|
|||||||
<DocumentTitle />
|
<DocumentTitle />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout wsConnected={connected} />}>
|
<Route element={<Layout wsConnected={connected} />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route
|
||||||
<Route path="sessions" element={<History />} />
|
index
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<Dashboard />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="sessions"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<History />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="sessions/:id"
|
path="sessions/:id"
|
||||||
element={
|
element={
|
||||||
@@ -172,14 +190,7 @@ export default function App() {
|
|||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||||
path="query"
|
|
||||||
element={
|
|
||||||
<Page>
|
|
||||||
<QueryExplorer />
|
|
||||||
</Page>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="workflows"
|
path="workflows"
|
||||||
@@ -199,6 +210,14 @@ export default function App() {
|
|||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<Page>
|
||||||
|
<Users />
|
||||||
|
</Page>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="settings/*"
|
path="settings/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
|
|||||||
import { fmtCost, timeAgo } from "../lib/format";
|
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 { 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;
|
||||||
@@ -283,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[]>([]);
|
||||||
@@ -342,6 +346,7 @@ export function AlertsNotifications() {
|
|||||||
setLoadingAlerts(false);
|
setLoadingAlerts(false);
|
||||||
}
|
}
|
||||||
}, [unackedOnly, t]);
|
}, [unackedOnly, t]);
|
||||||
|
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -359,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();
|
||||||
@@ -502,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"),
|
||||||
@@ -517,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">
|
||||||
@@ -1017,11 +1023,13 @@ export function AlertsNotifications() {
|
|||||||
label={t("feed.unackedOnly")}
|
label={t("feed.unackedOnly")}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => loadAlerts()}
|
onClick={refreshAlerts}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||||
title={t("refresh")}
|
title={t("refresh")}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-3.5 h-3.5" />
|
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||||
</button>
|
</button>
|
||||||
{unacked > 0 && (
|
{unacked > 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -1082,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"
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* @file AuthGate.tsx
|
||||||
|
* @description Server mode's login gate around the whole app (docs/SERVER-MODE.md).
|
||||||
|
* Asks `GET /api/auth/me` (again every 2 s while it gets no answer): in local
|
||||||
|
* mode — the default — it renders the app and nothing else changes, at once
|
||||||
|
* when this browser last saw local mode (`agenthub.authMode`); in server mode it renders the login page until
|
||||||
|
* someone logs in, and again whenever a request answers 401 `EAUTH`
|
||||||
|
* (`AUTH_REQUIRED_EVENT`). Logging out unmounts the app, so no page keeps the
|
||||||
|
* previous user's data. `useAuth()` gives the sidebar the user 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>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||||
|
|
||||||
|
type AuthValue = { state: AuthState; logout: () => Promise<void> };
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthValue>({ state: { mode: "local" }, logout: async () => {} });
|
||||||
|
|
||||||
|
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
||||||
|
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
||||||
|
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
||||||
|
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
||||||
|
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
||||||
|
* only their own — the one that matters is an analyst looking at another
|
||||||
|
* user's.
|
||||||
|
*/
|
||||||
|
export function usePermissions() {
|
||||||
|
const { state } = useAuth();
|
||||||
|
const user = state.mode === "server" ? state.user : null;
|
||||||
|
const local = state.mode !== "server";
|
||||||
|
const isAdmin = local || user?.role === "admin";
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
seesAll: local || (!!user && user.role !== "developer"),
|
||||||
|
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||||
|
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||||
|
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||||
|
const MODE_KEY = "agenthub.authMode";
|
||||||
|
const RETRY_MS = 2000;
|
||||||
|
|
||||||
|
function cachedState(): AuthState | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthGate({ children }: { children: ReactNode }) {
|
||||||
|
// null = still asking the server. A browser that last saw local mode starts
|
||||||
|
// there and is corrected by the answer (or by a 401) if that changed.
|
||||||
|
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: number | undefined;
|
||||||
|
let alive = true;
|
||||||
|
const ask = () => {
|
||||||
|
api.auth
|
||||||
|
.me()
|
||||||
|
.then((next) => {
|
||||||
|
if (!alive) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MODE_KEY, next.mode);
|
||||||
|
} catch {
|
||||||
|
/* storage blocked */
|
||||||
|
}
|
||||||
|
setState(next);
|
||||||
|
})
|
||||||
|
.catch((err: { status?: number; code?: string }) => {
|
||||||
|
if (!alive) return;
|
||||||
|
// An answer that is not this build's: an older server still running
|
||||||
|
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||||
|
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||||
|
// app as before and let it show its own state.
|
||||||
|
if (
|
||||||
|
err?.status === 404 ||
|
||||||
|
err?.code === "EBADRESPONSE" ||
|
||||||
|
(err?.status === 401 && err?.code !== "EAUTH")
|
||||||
|
) {
|
||||||
|
setState({ mode: "local" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No answer at all (a restart, a proxy's 502): ask again rather than
|
||||||
|
// guess — "local" would hide a server-mode dashboard's login page.
|
||||||
|
timer = window.setTimeout(ask, RETRY_MS);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
ask();
|
||||||
|
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||||
|
const onRequired = () => setState({ mode: "server", user: null });
|
||||||
|
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!state) return null;
|
||||||
|
if (state.mode === "server" && !state.user) {
|
||||||
|
return <LoginPage onLoggedIn={(user) => setState({ mode: "server", user })} />;
|
||||||
|
}
|
||||||
|
const logout = async () => {
|
||||||
|
await api.auth.logout().catch(() => {});
|
||||||
|
setState({ mode: "server", user: null });
|
||||||
|
};
|
||||||
|
return <AuthContext.Provider value={{ state, logout }}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onLoggedIn((await api.auth.login(username, password)).user);
|
||||||
|
} catch (err) {
|
||||||
|
const code = (err as { code?: string }).code;
|
||||||
|
setError(code === "ETOOMANY" ? t("auth.tooMany") : t("auth.failed"));
|
||||||
|
setPassword("");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-surface-0 p-4">
|
||||||
|
<form onSubmit={submit} className="card w-full max-w-sm space-y-4 p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-fg-primary">{t("auth.title")}</h1>
|
||||||
|
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||||
|
<span>{t("auth.username")}</span>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block space-y-1 text-sm text-fg-secondary">
|
||||||
|
<span>{t("auth.password")}</span>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" className="btn-primary w-full" disabled={busy}>
|
||||||
|
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
aria-describedby={message ? messageId : undefined}
|
aria-describedby={message ? messageId : undefined}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3 p-5">
|
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||||
{destructive && (
|
{destructive && (
|
||||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export function DocumentTitle() {
|
|||||||
title = t("workspace");
|
title = t("workspace");
|
||||||
} else if (path.startsWith("/settings")) {
|
} else if (path.startsWith("/settings")) {
|
||||||
title = t("settings");
|
title = t("settings");
|
||||||
} else if (path.startsWith("/query")) {
|
|
||||||
title = t("query");
|
|
||||||
} else if (path !== "/") {
|
} else if (path !== "/") {
|
||||||
title = t("notFound");
|
title = t("notFound");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./Sidebar`
|
* - `./Sidebar`
|
||||||
* - `./UpdateNotifier`
|
* - `./UpdateNotifier`
|
||||||
* - `./Sagi/Sagi`
|
* - `./Sagi/SagiMount`
|
||||||
*
|
*
|
||||||
* ## Public surface
|
* ## Public surface
|
||||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||||
@@ -81,7 +81,7 @@ import { Menu } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
import { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||||
import { UpdateNotifier } from "./UpdateNotifier";
|
import { UpdateNotifier } from "./UpdateNotifier";
|
||||||
import { Sagi } from "./Sagi/Sagi";
|
import { SagiMount } from "./Sagi/SagiMount";
|
||||||
import { StatusBar } from "./StatusBar";
|
import { StatusBar } from "./StatusBar";
|
||||||
|
|
||||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||||
@@ -171,7 +171,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
{t("skipToContent")}
|
{t("skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
<UpdateNotifier />
|
<UpdateNotifier />
|
||||||
<Sagi />
|
<SagiMount />
|
||||||
{drawer && drawerOpen && (
|
{drawer && drawerOpen && (
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
|
|||||||
import { api, type RunHandle } from "../lib/api";
|
import { api, type RunHandle } from "../lib/api";
|
||||||
import { timeAgo } from "../lib/format";
|
import { timeAgo } from "../lib/format";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||||
import { workspaceHref } from "./SessionRowMenu";
|
import { workspaceHref } from "./SessionRowMenu";
|
||||||
import type { Session } from "../lib/types";
|
import type { Session } from "../lib/types";
|
||||||
|
|
||||||
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
setTotal(r.total);
|
setTotal(r.total);
|
||||||
})
|
})
|
||||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||||
|
}, []);
|
||||||
|
// The live panes (for the links) move only when a lane's run columns do.
|
||||||
|
const loadRuns = useCallback(() => {
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
.then((r) => setRuns(r.items))
|
.then((r) => setRuns(r.items))
|
||||||
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, [load]);
|
loadRuns();
|
||||||
|
}, [load, loadRuns]);
|
||||||
useLiveRefresh(load, {
|
useLiveRefresh(load, {
|
||||||
intervalMs: 15000,
|
intervalMs: 15000,
|
||||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||||
});
|
});
|
||||||
|
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||||
|
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||||
|
|
||||||
if (sessions.length === 0) return null;
|
if (sessions.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.tsx
|
* @file Sagi.tsx
|
||||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||||
|
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||||
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
|
* shares the single WebSocket.
|
||||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
* 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
|
||||||
@@ -81,6 +85,7 @@ import { SpeechBubble } from "./SpeechBubble";
|
|||||||
import { SagiPanel } from "./SagiPanel";
|
import { SagiPanel } from "./SagiPanel";
|
||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
import { matchIntent } from "./intents";
|
import { matchIntent } from "./intents";
|
||||||
import { sagiPrefs } from "./prefs";
|
import { sagiPrefs } from "./prefs";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
@@ -195,9 +200,13 @@ function SagiFlyout({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Renders nothing — and runs no hook below — while the mascot is disabled. */
|
||||||
export function Sagi() {
|
export function Sagi() {
|
||||||
|
return useSagiEnabled() ? <SagiBody /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SagiBody() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
|
||||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -209,11 +218,11 @@ export function Sagi() {
|
|||||||
const brain = useSagiBrain();
|
const brain = useSagiBrain();
|
||||||
const place = useSagiPosition(size);
|
const place = useSagiPosition(size);
|
||||||
|
|
||||||
// Keep enabled, the pet and its size in sync with Settings / other tabs.
|
// Keep the pet and its size in sync with Settings / other tabs (enabled is
|
||||||
|
// the `Sagi` gate's: it unmounts this body when the mascot is turned off).
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
sagiPrefs.subscribe(() => {
|
sagiPrefs.subscribe(() => {
|
||||||
setEnabled(sagiPrefs.getEnabled());
|
|
||||||
setPet(sagiPrefs.getPet());
|
setPet(sagiPrefs.getPet());
|
||||||
setSize(sagiPrefs.getSize());
|
setSize(sagiPrefs.getSize());
|
||||||
}),
|
}),
|
||||||
@@ -223,7 +232,6 @@ export function Sagi() {
|
|||||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||||
@@ -235,7 +243,33 @@ export function Sagi() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [enabled]);
|
}, []);
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
@@ -279,8 +313,6 @@ export function Sagi() {
|
|||||||
[brain, navigate, t]
|
[brain, navigate, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!enabled) return null;
|
|
||||||
|
|
||||||
const anchor: Anchor = {
|
const anchor: Anchor = {
|
||||||
left: place.left,
|
left: place.left,
|
||||||
top: place.top,
|
top: place.top,
|
||||||
@@ -329,6 +361,7 @@ export function Sagi() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<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 }}
|
||||||
@@ -363,6 +396,7 @@ export function Sagi() {
|
|||||||
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.
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||||
* ============================================================================= */
|
* ============================================================================= */
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { memo, useEffect, useRef } from "react";
|
||||||
import type { Mood } from "./brain";
|
import type { Mood } from "./brain";
|
||||||
|
|
||||||
interface SagiAvatarProps {
|
interface SagiAvatarProps {
|
||||||
@@ -76,10 +76,21 @@ if (typeof window !== "undefined") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps) {
|
// The pupil offset is written as CSS variables on the root, never as state, so
|
||||||
|
// following the cursor costs no React render.
|
||||||
|
function setPupil(el: SVGSVGElement | null, x: number, y: number) {
|
||||||
|
el?.style.setProperty("--sagi-px", `${x}px`);
|
||||||
|
el?.style.setProperty("--sagi-py", `${y}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// memo: Sagi re-renders on every brain tick; the avatar only on mood/size/motion.
|
||||||
|
export const SagiAvatar = memo(function SagiAvatar({
|
||||||
|
mood,
|
||||||
|
reducedMotion,
|
||||||
|
size = 68,
|
||||||
|
}: SagiAvatarProps) {
|
||||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||||
const rafRef = useRef<number>();
|
const rafRef = useRef<number>();
|
||||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
|
||||||
|
|
||||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||||
@@ -89,7 +100,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tracking) {
|
if (!tracking) {
|
||||||
setPupil({ x: 0, y: 0 });
|
setPupil(rootRef.current, 0, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +116,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
// Normalize then clamp to the eye socket range.
|
// Normalize then clamp to the eye socket range.
|
||||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||||
@@ -202,7 +213,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
<g className="sagi-eye sagi-eye-left">
|
<g className="sagi-eye sagi-eye-left">
|
||||||
<g
|
<g
|
||||||
className="sagi-eye-track"
|
className="sagi-eye-track"
|
||||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||||
>
|
>
|
||||||
<g className="sagi-eye-blink">
|
<g className="sagi-eye-blink">
|
||||||
<path
|
<path
|
||||||
@@ -226,7 +237,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
<g className="sagi-eye sagi-eye-right">
|
<g className="sagi-eye sagi-eye-right">
|
||||||
<g
|
<g
|
||||||
className="sagi-eye-track"
|
className="sagi-eye-track"
|
||||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||||
>
|
>
|
||||||
<g className="sagi-eye-blink">
|
<g className="sagi-eye-blink">
|
||||||
<path
|
<path
|
||||||
@@ -792,4 +803,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file SagiMount.tsx
|
||||||
|
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||||
|
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||||
|
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||||
|
* WebSocket reducer.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy, Suspense, useEffect, useState } from "react";
|
||||||
|
import { sagiPrefs } from "./prefs";
|
||||||
|
|
||||||
|
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||||
|
|
||||||
|
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||||
|
export function useSagiEnabled(): boolean {
|
||||||
|
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||||
|
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SagiMount() {
|
||||||
|
if (!useSagiEnabled()) return null;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<Sagi />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -168,7 +168,10 @@ export function useSagiBrain(): SagiBrain {
|
|||||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||||
// happy/worried) re-evaluate without needing a new event.
|
// happy/worried) re-evaluate without needing a new event.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") setTick(Date.now());
|
||||||
|
}, 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -81,19 +81,22 @@ interface SessionOverviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
|
||||||
const REFRESH_THROTTLE_MS = 1000;
|
const REFRESH_THROTTLE_MS = 5000;
|
||||||
|
|
||||||
/** Compact tile used in the top stat row. */
|
/** Compact tile used in the top stat row. */
|
||||||
function StatTile({
|
function StatTile({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
hint,
|
hint,
|
||||||
|
help,
|
||||||
icon,
|
icon,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** Plain-language explanation, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||||
}) {
|
}) {
|
||||||
@@ -120,9 +123,14 @@ function StatTile({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
<div
|
||||||
|
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
||||||
|
title={help}
|
||||||
|
>
|
||||||
<span className={iconTone}>{icon}</span>
|
<span className={iconTone}>{icon}</span>
|
||||||
{label}
|
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||||
{value}
|
{value}
|
||||||
@@ -310,6 +318,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5">
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.events")}
|
label={t("overview.events")}
|
||||||
|
help={t("common:insights.help.events")}
|
||||||
value={stats.total_events.toLocaleString()}
|
value={stats.total_events.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
eventRate !== null && eventRate > 0
|
eventRate !== null && eventRate > 0
|
||||||
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.toolCalls")}
|
label={t("overview.toolCalls")}
|
||||||
|
help={t("common:insights.help.toolCalls")}
|
||||||
value={toolCallCount.toLocaleString()}
|
value={toolCallCount.toLocaleString()}
|
||||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||||
icon={<Wrench className="w-3 h-3" />}
|
icon={<Wrench className="w-3 h-3" />}
|
||||||
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.subagents")}
|
label={t("overview.subagents")}
|
||||||
|
help={t("common:insights.help.subagents")}
|
||||||
value={stats.agents.subagent.toLocaleString()}
|
value={stats.agents.subagent.toLocaleString()}
|
||||||
hint={
|
hint={
|
||||||
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
stats.agents.main > 0 ? t("overview.plusMain", { count: stats.agents.main }) : undefined
|
||||||
@@ -338,12 +349,14 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.compactions")}
|
label={t("overview.compactions")}
|
||||||
|
help={t("common:insights.help.compactions")}
|
||||||
value={stats.agents.compaction.toLocaleString()}
|
value={stats.agents.compaction.toLocaleString()}
|
||||||
icon={<Layers className="w-3 h-3" />}
|
icon={<Layers className="w-3 h-3" />}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatTile
|
<StatTile
|
||||||
label={t("overview.errors")}
|
label={t("overview.errors")}
|
||||||
|
help={t("common:insights.help.errors")}
|
||||||
value={stats.error_count.toLocaleString()}
|
value={stats.error_count.toLocaleString()}
|
||||||
icon={<AlertTriangle className="w-3 h-3" />}
|
icon={<AlertTriangle className="w-3 h-3" />}
|
||||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||||
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
|||||||
{totalTokens > 0 && (
|
{totalTokens > 0 && (
|
||||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||||
<div className="flex items-center justify-between mb-2.5">
|
<div className="flex items-center justify-between mb-2.5">
|
||||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
<h3
|
||||||
|
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
|
||||||
|
title={t("common:insights.help.tokenFlow")}
|
||||||
|
>
|
||||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||||
{t("overview.tokenFlow")}
|
{t("overview.tokenFlow")}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
TableProperties,
|
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, 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";
|
||||||
@@ -84,7 +86,6 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
|||||||
const NAV_KEYS = [
|
const NAV_KEYS = [
|
||||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||||
{ to: "/query", icon: TableProperties, key: "nav:query" },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||||
@@ -190,6 +191,9 @@ export function Sidebar({
|
|||||||
resizing = false,
|
resizing = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
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);
|
||||||
@@ -463,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
|
||||||
@@ -533,6 +545,22 @@ export function Sidebar({
|
|||||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
{auth.state.mode === "server" && auth.state.user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void auth.logout()}
|
||||||
|
title={`${t("common:auth.signedInAs", {
|
||||||
|
name: auth.state.user.username,
|
||||||
|
role: auth.state.user.role,
|
||||||
|
})} — ${t("common:auth.logout")}`}
|
||||||
|
aria-label={t("common:auth.logout")}
|
||||||
|
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||||
|
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
@@ -567,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;
|
||||||
@@ -609,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;
|
||||||
}
|
}
|
||||||
@@ -751,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.
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||||
to?: string;
|
to?: string;
|
||||||
|
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||||
|
help?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +94,15 @@ export function StatCard({
|
|||||||
raw,
|
raw,
|
||||||
loading = false,
|
loading = false,
|
||||||
to,
|
to,
|
||||||
|
help,
|
||||||
}: StatCardProps) {
|
}: StatCardProps) {
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-3 mb-3">
|
<div className="flex items-center justify-between gap-3 mb-3">
|
||||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
<span
|
||||||
|
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||||
|
title={help}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||||
|
|||||||
@@ -24,17 +24,26 @@ 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";
|
||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
|
import { useSpin } from "../hooks/useSpin";
|
||||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
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");
|
||||||
@@ -121,6 +130,8 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
|||||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||||
|
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. */
|
||||||
@@ -131,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")}
|
||||||
@@ -143,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>
|
||||||
@@ -159,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>
|
||||||
);
|
);
|
||||||
@@ -208,7 +225,7 @@ function QuotaPanel({
|
|||||||
onMouseLeave={onLeave}
|
onMouseLeave={onLeave}
|
||||||
onFocus={onEnter}
|
onFocus={onEnter}
|
||||||
onBlur={onLeave}
|
onBlur={onLeave}
|
||||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||||
>
|
>
|
||||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||||
{groups.map(([id, list]) => (
|
{groups.map(([id, list]) => (
|
||||||
@@ -264,15 +281,15 @@ function QuotaChips() {
|
|||||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||||
};
|
};
|
||||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const load = useCallback(
|
||||||
const load = useCallback((force = false) => {
|
(force = false) =>
|
||||||
if (force) setRefreshing(true);
|
api.pricing
|
||||||
api.pricing
|
.quota(force)
|
||||||
.quota(force)
|
.then((r) => setTools(r.tools))
|
||||||
.then((r) => setTools(r.tools))
|
.catch(() => undefined),
|
||||||
.catch(() => undefined)
|
[]
|
||||||
.finally(() => force && setRefreshing(false));
|
);
|
||||||
}, []);
|
const [refreshing, refresh] = useSpin(() => load(true));
|
||||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||||
if (!tools.length) return null;
|
if (!tools.length) return null;
|
||||||
return (
|
return (
|
||||||
@@ -281,7 +298,7 @@ function QuotaChips() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="status-bar-quota"
|
data-testid="status-bar-quota"
|
||||||
data-tip-off
|
data-tip-off
|
||||||
onClick={() => load(true)}
|
onClick={refresh}
|
||||||
onMouseEnter={show}
|
onMouseEnter={show}
|
||||||
onMouseLeave={hide}
|
onMouseLeave={hide}
|
||||||
onFocus={show}
|
onFocus={show}
|
||||||
@@ -313,7 +330,7 @@ function QuotaChips() {
|
|||||||
tools={tools}
|
tools={tools}
|
||||||
onEnter={show}
|
onEnter={show}
|
||||||
onLeave={hide}
|
onLeave={hide}
|
||||||
onRefresh={() => load(true)}
|
onRefresh={refresh}
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
*
|
*
|
||||||
* ## State sources
|
* ## State sources
|
||||||
* - Initial fetch via `api.updates.status()` on mount.
|
* - Initial fetch via `api.updates.status()` on mount.
|
||||||
* - Background re-check via `api.updates.check()` every hour
|
* - No client-side background re-check: the server checks on its own
|
||||||
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
* schedule and broadcasts `update_status`; "Check now" stays manual.
|
||||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||||
*
|
*
|
||||||
* ## Dismissal persistence
|
* ## Dismissal persistence
|
||||||
@@ -85,15 +85,14 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
|
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";
|
||||||
|
|
||||||
/** How often to silently re-check for updates in the background. */
|
|
||||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||||
@@ -127,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);
|
||||||
@@ -171,19 +171,6 @@ export function UpdateNotifier() {
|
|||||||
});
|
});
|
||||||
}, [syncFromPayload]);
|
}, [syncFromPayload]);
|
||||||
|
|
||||||
// Background re-check every hour, on top of the initial mount fetch and the
|
|
||||||
// manual "Check now" button — so a long-lived tab notices an update without
|
|
||||||
// the user having to click anything.
|
|
||||||
useEffect(() => {
|
|
||||||
const id = setInterval(() => {
|
|
||||||
api.updates
|
|
||||||
.check()
|
|
||||||
.then(syncFromPayload)
|
|
||||||
.catch(() => {});
|
|
||||||
}, AUTO_CHECK_INTERVAL_MS);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}, [syncFromPayload]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// An explicit "Check for updates" undoes both kinds of close.
|
// An explicit "Check for updates" undoes both kinds of close.
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
@@ -205,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 &&
|
||||||
@@ -264,22 +253,41 @@ export function UpdateNotifier() {
|
|||||||
* stretch, so polling naively would get a 200 from the process we are
|
* stretch, so polling naively would get a 200 from the process we are
|
||||||
* waiting to replace and reload onto the same old bundle.
|
* waiting to replace and reload onto the same old bundle.
|
||||||
*/
|
*/
|
||||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
const pollUntilBack = useCallback(
|
||||||
let sawDown = !requireDownFirst;
|
(requireDownFirst = false, fromVersion: string | null = null) => {
|
||||||
const attempt = () => {
|
let sawDown = !requireDownFirst;
|
||||||
api.updates
|
// Its own verdicts only: an older plugin-update.json is still on disk
|
||||||
.status()
|
// until this chain writes one (5 s slack for a clock a little behind).
|
||||||
.then(() => {
|
const since = Date.now() - 5000;
|
||||||
if (sawDown) window.location.reload();
|
const attempt = () => {
|
||||||
else setTimeout(attempt, 1500);
|
api.updates
|
||||||
})
|
.status()
|
||||||
.catch(() => {
|
.then((s) => {
|
||||||
sawDown = true;
|
const v = s.last_plugin_update;
|
||||||
setTimeout(attempt, 1500);
|
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||||
});
|
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||||
};
|
// never goes down, so waiting for that spun "Restarting…" forever.
|
||||||
setTimeout(attempt, 1500);
|
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
|
||||||
}, []);
|
syncFromPayload(s);
|
||||||
|
setRestarting(false);
|
||||||
|
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A new version answering is back too, even if a poll never
|
||||||
|
// landed in the short gap between the old server and the new one.
|
||||||
|
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
|
||||||
|
if (sawDown || moved) window.location.reload();
|
||||||
|
else setTimeout(attempt, 1500);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
sawDown = true;
|
||||||
|
setTimeout(attempt, 1500);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setTimeout(attempt, 1500);
|
||||||
|
},
|
||||||
|
[syncFromPayload, t]
|
||||||
|
);
|
||||||
|
|
||||||
const applyNow = async () => {
|
const applyNow = async () => {
|
||||||
if (applying || restarting) return;
|
if (applying || restarting) return;
|
||||||
@@ -292,7 +300,7 @@ export function UpdateNotifier() {
|
|||||||
setRestarting(true);
|
setRestarting(true);
|
||||||
// mode "plugin": the old server lingers while the chain installs, so
|
// mode "plugin": the old server lingers while the chain installs, so
|
||||||
// only treat a reachable server as "back" after it has gone away once.
|
// only treat a reachable server as "back" after it has gone away once.
|
||||||
pollUntilBack(result.mode === "plugin");
|
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(
|
setError(
|
||||||
@@ -330,7 +338,7 @@ export function UpdateNotifier() {
|
|||||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||||
@@ -370,7 +378,7 @@ export function UpdateNotifier() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-5 py-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -5,22 +5,32 @@
|
|||||||
* (`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 and handed over by the Workspace page's own
|
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||||
* 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 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
|
||||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type DragEvent,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation, useNavigate } from "react-router-dom";
|
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 {
|
import {
|
||||||
insertionIndex,
|
insertionIndex,
|
||||||
moveBefore,
|
moveBefore,
|
||||||
@@ -33,8 +43,15 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
|||||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
|
||||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
// The sidebar is in the entry chunk; its two modals (and the run form the add
|
||||||
|
// modal pulls in) load when first opened.
|
||||||
|
const AddLaneModal = lazy(() =>
|
||||||
|
import("./lanes/AddLaneModal").then((m) => ({ default: m.AddLaneModal }))
|
||||||
|
);
|
||||||
|
const DestructiveLaneModal = lazy(() =>
|
||||||
|
import("./lanes/DestructiveLaneModal").then((m) => ({ default: m.DestructiveLaneModal }))
|
||||||
|
);
|
||||||
|
|
||||||
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
/** The workspace (and attached run) the Workspace page shows, from its URL. */
|
||||||
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
/** The workspace, pane run and replayed session the URL names (Workspace page only). */
|
||||||
@@ -53,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("");
|
||||||
@@ -72,8 +89,12 @@ export function WorkspaceTree() {
|
|||||||
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
// A slot-0 pane starts, ends or learns its session id exactly when a lane's
|
||||||
// status or session moves, so that is when the live-pane list is re-read;
|
// status or session moves, so that is when the live-pane list is re-read;
|
||||||
// the Workspace page also hands over every run list it polls (a tab in
|
// the Workspace page also hands over every run list it polls (a tab in
|
||||||
// slots 1–3 moves no lane column).
|
// slots 1–3 moves no lane column). Throttled: with two sessions in one
|
||||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
// workspace these columns flip on every hook, and each flip was a GET.
|
||||||
|
const runKey = useThrottledValue(
|
||||||
|
lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(","),
|
||||||
|
3000
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let live = true;
|
let live = true;
|
||||||
api.run
|
api.run
|
||||||
@@ -349,28 +370,32 @@ export function WorkspaceTree() {
|
|||||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{destructive && (
|
<Suspense fallback={null}>
|
||||||
<DestructiveLaneModal
|
{destructive && (
|
||||||
lane={destructive.lane}
|
<DestructiveLaneModal
|
||||||
action={destructive.action}
|
lane={destructive.lane}
|
||||||
open
|
action={destructive.action}
|
||||||
onClose={() => setDestructive(null)}
|
open
|
||||||
onConfirm={(body) => void confirmDestructive(body)}
|
onClose={() => setDestructive(null)}
|
||||||
/>
|
onConfirm={(body) => void confirmDestructive(body)}
|
||||||
)}
|
/>
|
||||||
<AddLaneModal
|
)}
|
||||||
open={addOpen}
|
{addOpen && (
|
||||||
cwdSuggestions={cwdSuggestions}
|
<AddLaneModal
|
||||||
prefill={addPrefill}
|
open
|
||||||
onClose={() => {
|
cwdSuggestions={cwdSuggestions}
|
||||||
setAddOpen(false);
|
prefill={addPrefill}
|
||||||
setAddPrefill(null);
|
onClose={() => {
|
||||||
}}
|
setAddOpen(false);
|
||||||
onAdded={(lane) => {
|
setAddPrefill(null);
|
||||||
void refreshLanes();
|
}}
|
||||||
select(lane.id);
|
onAdded={(lane) => {
|
||||||
}}
|
void refreshLanes();
|
||||||
/>
|
select(lane.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/**
|
||||||
|
* @file AuthGate.test.tsx
|
||||||
|
* @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,
|
||||||
|
* 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>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import "../../i18n";
|
||||||
|
|
||||||
|
const me = vi.fn();
|
||||||
|
const login = vi.fn();
|
||||||
|
const LANES = [
|
||||||
|
{ id: 1, owner_id: 5 },
|
||||||
|
{ id: 2, owner_id: 6 },
|
||||||
|
];
|
||||||
|
vi.mock("../../lib/api", () => ({
|
||||||
|
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||||
|
api: {
|
||||||
|
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||||
|
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { AuthGate, usePermissions } from "../AuthGate";
|
||||||
|
import { useWorkLanes } from "../../hooks/useLanes";
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
me.mockReset();
|
||||||
|
login.mockReset();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AuthGate", () => {
|
||||||
|
it("renders the app in local mode", async () => {
|
||||||
|
me.mockResolvedValue({ mode: "local" });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("paints at once in a browser that last saw local mode", () => {
|
||||||
|
localStorage.setItem("agenthub.authMode", "local");
|
||||||
|
me.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(screen.getByText("app")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks again instead of guessing when the server does not answer", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
try {
|
||||||
|
me.mockRejectedValueOnce(new Error("502"));
|
||||||
|
me.mockResolvedValue({ mode: "server", user: null });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2100);
|
||||||
|
});
|
||||||
|
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app when an older server or a dashboard token answers instead", async () => {
|
||||||
|
for (const err of [
|
||||||
|
{ status: 404 },
|
||||||
|
{ status: 200, code: "EBADRESPONSE" },
|
||||||
|
{ status: 401, code: "EUNAUTHORIZED" },
|
||||||
|
]) {
|
||||||
|
me.mockReset();
|
||||||
|
me.mockRejectedValue(Object.assign(new Error("x"), err));
|
||||||
|
const { unmount } = render(<AuthGate>app</AuthGate>);
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login in server mode and opens the app after one", async () => {
|
||||||
|
me.mockResolvedValue({ mode: "server", user: null });
|
||||||
|
login.mockRejectedValueOnce(Object.assign(new Error("x"), { code: "EBADLOGIN" }));
|
||||||
|
login.mockResolvedValueOnce({ user });
|
||||||
|
render(<AuthGate>app</AuthGate>);
|
||||||
|
|
||||||
|
const name = await screen.findByLabelText(/username/i);
|
||||||
|
const pass = screen.getByLabelText(/password/i);
|
||||||
|
await userEvent.type(name, "mia");
|
||||||
|
await userEvent.type(pass, "nope");
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("app")).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.type(pass, "member-pass-1");
|
||||||
|
await userEvent.click(screen.getByRole("button"));
|
||||||
|
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||||
|
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new Event("agenthub:auth-required"));
|
||||||
|
});
|
||||||
|
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);
|
||||||
@@ -244,7 +267,11 @@ describe("StatusBar", () => {
|
|||||||
await user.click(refresh);
|
await user.click(refresh);
|
||||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => answer(same));
|
await act(async () => answer(same));
|
||||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
// The spin outlasts an instant answer (a throttled refresh is served from
|
||||||
|
// cache), so the label returns after the minimum spin.
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
|
||||||
|
);
|
||||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||||
await user.unhover(panel);
|
await user.unhover(panel);
|
||||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||||
|
|||||||
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — plugin chain verdict", () => {
|
||||||
|
it("stops Restarting when the chain fails and the old server stays up", async () => {
|
||||||
|
const PLUGIN: UpdateStatusPayload = {
|
||||||
|
...BASE_STATUS,
|
||||||
|
situation: "plugin_managed",
|
||||||
|
install_kind: "plugin",
|
||||||
|
plugin_marketplace: "sg",
|
||||||
|
plugin_name: "agenthub",
|
||||||
|
commits_behind: 0,
|
||||||
|
local_version: "1.5.0",
|
||||||
|
release_check: "ok",
|
||||||
|
latest_release: "v1.6.0",
|
||||||
|
};
|
||||||
|
statusMock.mockResolvedValue(PLUGIN);
|
||||||
|
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||||
|
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||||
|
|
||||||
|
// The old server answers again, now carrying this chain's failed verdict.
|
||||||
|
statusMock.mockResolvedValue({
|
||||||
|
...PLUGIN,
|
||||||
|
last_plugin_update: {
|
||||||
|
ok: false,
|
||||||
|
step: "plugin_update",
|
||||||
|
error: "boom",
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("UpdateNotifier — closing vs skipping", () => {
|
describe("UpdateNotifier — closing vs skipping", () => {
|
||||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||||
const { unmount } = render(<UpdateNotifier />);
|
const { unmount } = render(<UpdateNotifier />);
|
||||||
|
|||||||
@@ -81,14 +81,12 @@ 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" })),
|
||||||
iconUrl: () => "",
|
iconUrl: () => "",
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="cc-confirm-delete-title"
|
aria-labelledby="cc-confirm-delete-title"
|
||||||
aria-describedby="cc-confirm-delete-body"
|
aria-describedby="cc-confirm-delete-body"
|
||||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
|||||||
{t("edit.confirmDelete")}
|
{t("edit.confirmDelete")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-3">
|
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||||
{t("edit.confirmDeleteBody")}
|
{t("edit.confirmDeleteBody")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ 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";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||||
|
|
||||||
@@ -75,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);
|
||||||
@@ -90,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);
|
||||||
@@ -106,7 +116,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
const genRef = useRef(0);
|
const genRef = useRef(0);
|
||||||
// Refresh-button spinner state - separate from initial `loading` so the
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// existing skeleton doesn't blink during a manual refresh.
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
|
|
||||||
// Load available transcript list (also rescanned on a short interval so
|
// Load available transcript list (also rescanned on a short interval so
|
||||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||||
@@ -242,6 +251,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
// also poll below to catch what WS misses.
|
// also poll below to catch what WS misses.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||||
|
// Hidden tab: the visibility handler below catches up once on show.
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
if (isRemoteDataRefreshMessage(msg)) {
|
if (isRemoteDataRefreshMessage(msg)) {
|
||||||
fetchNewMessages();
|
fetchNewMessages();
|
||||||
return;
|
return;
|
||||||
@@ -303,14 +314,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
|||||||
|
|
||||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
const refresh = useCallback(async () => {
|
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||||
setRefreshing(true);
|
|
||||||
try {
|
|
||||||
await fetchNewMessages();
|
|
||||||
} finally {
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, [fetchNewMessages]);
|
|
||||||
|
|
||||||
// Scroll-up to load history
|
// Scroll-up to load history
|
||||||
const loadHistory = useCallback(async () => {
|
const loadHistory = useCallback(async () => {
|
||||||
@@ -375,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) {
|
||||||
@@ -395,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">
|
||||||
@@ -446,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 && (
|
||||||
@@ -479,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"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
RUNTIME_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
LOCKS_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
@@ -98,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export function useLaneGitFacts(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
read();
|
read();
|
||||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
const timer = setInterval(
|
||||||
|
() => document.visibilityState === "visible" && read(),
|
||||||
|
GIT_REFRESH_MS
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function SessionReplay({
|
|||||||
? { runId: holder.id, laneId: lane.id, held: true }
|
? { runId: holder.id, laneId: lane.id, held: true }
|
||||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
onResumed(r.runId, r.laneId);
|
onResumed(r.runId, r.laneId);
|
||||||
// Held already: the prompt reached nothing yet — paste it there.
|
// Held already: the prompt reached nothing yet — paste it there.
|
||||||
@@ -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,16 +25,22 @@ 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 `\`. */
|
||||||
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
|
|
||||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||||
* same path-boundary rule the server binds hook sessions with. */
|
* same path-boundary rule the server binds hook sessions with. */
|
||||||
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
||||||
let best: Lane | null = null;
|
let best: Lane | null = null;
|
||||||
|
const target = strip(cwd);
|
||||||
for (const l of lanes) {
|
for (const l of lanes) {
|
||||||
if (l.tool !== tool) continue;
|
if (l.tool !== tool) continue;
|
||||||
const root = l.cwd.replace(/\/+$/, "");
|
const root = strip(l.cwd);
|
||||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
|
||||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
continue;
|
||||||
|
if (!best || root.length > strip(best.cwd).length) best = l;
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
@@ -47,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);
|
||||||
@@ -72,9 +79,10 @@ 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) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
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
|
||||||
// --worktree` sessions), and refuses one whose worktree is gone.
|
// --worktree` sessions), and refuses one whose worktree is gone.
|
||||||
const resumable =
|
const resumable =
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
* 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
|
||||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
* 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
|
||||||
* (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
|
||||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Plus,
|
Plus,
|
||||||
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +60,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
|||||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
import { MarkdownPreview } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
@@ -143,25 +148,25 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
}, [draftKey, prompt]);
|
}, [draftKey, prompt]);
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
const [router, setRouter] = useSessionRouter();
|
const [proxy, setProxy] = useSessionProxy();
|
||||||
const routers = useAiRouters();
|
const proxies = useAiProxies();
|
||||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||||
const profile = routerCapable
|
const profile = proxyCapable
|
||||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const routerModels: ModelChoice[] | undefined = profile
|
const proxyModels: ModelChoice[] | undefined = profile
|
||||||
? [
|
? [
|
||||||
...new Set(
|
...new Set(
|
||||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||||
Boolean
|
(m): m is string => !!m
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
].map((id) => ({ id, label: id }))
|
].map((id) => ({ id, label: id }))
|
||||||
: undefined;
|
: undefined;
|
||||||
// The tool's default model (also when the catalog arrives after mount); on
|
// The tool's default model (also when the catalog arrives after mount); on
|
||||||
// a router, the router's own default. A new tool or router drops the pick.
|
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
|
||||||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||||
const [model, setModel] = useState(defaultModel);
|
const [model, setModel] = useState(defaultModel);
|
||||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||||||
@@ -202,6 +207,12 @@ function HomeBody({
|
|||||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [title, setTitle] = useState<string | null>(null);
|
const [title, setTitle] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
|
const [ctxDrag, setCtxDrag] = useState(false);
|
||||||
|
const addFiles = async (picked: File[]) => {
|
||||||
|
for (const f of picked) {
|
||||||
|
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||||
|
}
|
||||||
|
};
|
||||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||||
const recentCtx = useContextMenu<Session>();
|
const recentCtx = useContextMenu<Session>();
|
||||||
@@ -305,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.
|
||||||
@@ -319,7 +348,7 @@ function HomeBody({
|
|||||||
// Only a different tool is named: the workspace's own keeps starting
|
// Only a different tool is named: the workspace's own keeps starting
|
||||||
// even after it was disabled in Settings.
|
// even after it was disabled in Settings.
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
...routerArg(routerCapable, router),
|
...proxyArg(proxyCapable, proxy),
|
||||||
...(hasModel && model ? { model } : {}),
|
...(hasModel && model ? { model } : {}),
|
||||||
...(claude ? { permissionMode: mode } : {}),
|
...(claude ? { permissionMode: mode } : {}),
|
||||||
...(codex ? { sandbox, approval } : {}),
|
...(codex ? { sandbox, approval } : {}),
|
||||||
@@ -328,7 +357,7 @@ function HomeBody({
|
|||||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||||
});
|
});
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
setImages([]);
|
setImages([]);
|
||||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -512,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">
|
||||||
@@ -533,7 +572,7 @@ function HomeBody({
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={setModel}
|
onChange={setModel}
|
||||||
tool={tool}
|
tool={tool}
|
||||||
choices={routerModels}
|
choices={proxyModels}
|
||||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -556,12 +595,12 @@ function HomeBody({
|
|||||||
data-testid="workspace-home-options"
|
data-testid="workspace-home-options"
|
||||||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||||||
>
|
>
|
||||||
<RouterPicker
|
<ProxyPicker
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
tool={tool}
|
tool={tool}
|
||||||
routers={routers}
|
proxies={proxies}
|
||||||
value={router}
|
value={proxy}
|
||||||
onChange={setRouter}
|
onChange={setProxy}
|
||||||
/>
|
/>
|
||||||
{claude && (
|
{claude && (
|
||||||
<Select<PermissionMode>
|
<Select<PermissionMode>
|
||||||
@@ -737,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}`}
|
||||||
@@ -839,7 +888,26 @@ function HomeBody({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={card}>
|
<section
|
||||||
|
data-testid="workspace-context-files"
|
||||||
|
className={`${card} ${ctxDrag ? "ring-2 ring-accent/50" : ""}`}
|
||||||
|
// Files dragged in from the OS are added like the + button's pick.
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!view || !e.dataTransfer.types.includes("Files")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
setCtxDrag(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setCtxDrag(false);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
setCtxDrag(false);
|
||||||
|
if (!view || e.dataTransfer.files.length === 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void addFiles([...e.dataTransfer.files]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||||
<button
|
<button
|
||||||
@@ -858,15 +926,14 @@ function HomeBody({
|
|||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
hidden
|
hidden
|
||||||
onChange={async (e) => {
|
onChange={(e) => {
|
||||||
const picked = [...(e.target.files ?? [])];
|
const picked = [...(e.target.files ?? [])];
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
for (const f of picked) {
|
void addFiles(picked);
|
||||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</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,23 +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 { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { useThrottledValue } from "../../hooks/useThrottledValue";
|
||||||
|
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
|
||||||
@@ -66,9 +62,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
|||||||
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
||||||
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
||||||
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||||
const strip = (p: string) => p.replace(/\/+$/, "");
|
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
|
||||||
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
||||||
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
|
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||||
@@ -266,56 +263,27 @@ export function WorkspaceSessionList({
|
|||||||
);
|
);
|
||||||
// The lane's own session/status/needs-you move on every hook, which is when
|
// The lane's own session/status/needs-you move on every hook, which is when
|
||||||
// a session can appear or change state, and a tab opening or getting its
|
// a session can appear or change state, and a tab opening or getting its
|
||||||
// session is too — re-read then, not on a timer.
|
// session is too — re-read then, not on a timer. Throttled to one re-read per
|
||||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
// 3 s: two sessions in one workspace flip these on every hook. A rename or an
|
||||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
// archive (`version`) re-reads at once.
|
||||||
.join(",")}`;
|
const hookKey = useThrottledValue(
|
||||||
// A Terminal tab closing is when one joins the history.
|
`${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns
|
||||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
.join(",")}`,
|
||||||
useEffect(() => {
|
3000
|
||||||
if (hidden) return;
|
);
|
||||||
let live = true;
|
const refreshKey = `${hookKey}:${version}`;
|
||||||
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.
|
||||||
@@ -432,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>
|
||||||
@@ -591,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
|
||||||
@@ -638,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 && (
|
||||||
@@ -650,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";
|
||||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn() },
|
aiProxies: { list: vi.fn() },
|
||||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||||
run: { start: vi.fn() },
|
run: { start: vi.fn() },
|
||||||
},
|
},
|
||||||
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
prompt: { mode: "argv" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
const TOOLS: AiToolsView = {
|
const TOOLS: AiToolsView = {
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
|
||||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||||
tool("gemini", "Gemini"),
|
tool("gemini", "Gemini"),
|
||||||
tool("aider", "Aider", { installed: false, path: null }),
|
tool("aider", "Aider", { installed: false, path: null }),
|
||||||
tool("grok", "Grok", { enabled: false }),
|
tool("grok", "Grok", { enabled: false }),
|
||||||
@@ -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>,
|
||||||
@@ -158,7 +165,7 @@ beforeEach(() => {
|
|||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||||
vi.mocked(api.aiRouters.list).mockReset();
|
vi.mocked(api.aiProxies.list).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||||
@@ -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();
|
||||||
@@ -639,12 +652,12 @@ describe("AddLaneModal — create/update readme", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("AddLaneModal — no router here", () => {
|
describe("AddLaneModal — no proxy here", () => {
|
||||||
it("asks for no AI Router: a new session picks its own", async () => {
|
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||||
renderModal({});
|
renderModal({});
|
||||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||||
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||||
expect(api.aiRouters.list).not.toHaveBeenCalled();
|
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
locks: {
|
locks: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: {
|
aiProxies: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
@@ -45,10 +45,10 @@ beforeEach(() => {
|
|||||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||||
vi.mocked(api.lanes.runtime).mockReset();
|
vi.mocked(api.lanes.runtime).mockReset();
|
||||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||||
// Only the built-in profile by default: a user who never opened the AI Router
|
// Only the built-in profile by default: a user who never opened the AI Proxy
|
||||||
// tab has nothing to switch between, so the row stays out of every other test.
|
// tab has nothing to switch between, so the row stays out of every other test.
|
||||||
vi.mocked(api.aiRouters.list).mockReset();
|
vi.mocked(api.aiProxies.list).mockReset();
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
|
||||||
});
|
});
|
||||||
vi.mocked(api.locks.list).mockReset();
|
vi.mocked(api.locks.list).mockReset();
|
||||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* @file WorkspaceHome.test.tsx
|
* @file WorkspaceHome.test.tsx
|
||||||
* @description The workspace home: a new session starts in the first free
|
* @description The workspace home: a new session starts in the first free
|
||||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||||
* default), AI and Model in the box and AI Router / Permission / Worktree
|
* default), AI and Model in the box and AI Proxy / Permission / Worktree
|
||||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
|
||||||
* transcript (never a resume), instructions save, context files upload and
|
* transcript (never a resume), instructions save, context files upload and
|
||||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } 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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import type { Lane, Session } from "../../../lib/types";
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -241,7 +241,7 @@ describe("WorkspaceHome", () => {
|
|||||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
await screen.findByTestId("workspace-recent-s-live");
|
await screen.findByTestId("workspace-recent-s-live");
|
||||||
const labels = (id: string) =>
|
const labels = (id: string) =>
|
||||||
@@ -252,7 +252,7 @@ describe("WorkspaceHome", () => {
|
|||||||
].map((x) => x.getAttribute("aria-label"));
|
].map((x) => x.getAttribute("aria-label"));
|
||||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||||
expect(labels("workspace-home-options")).toEqual([
|
expect(labels("workspace-home-options")).toEqual([
|
||||||
"AI Router",
|
"AI Proxy",
|
||||||
"Permission mode",
|
"Permission mode",
|
||||||
expect.stringContaining("own git worktree"),
|
expect.stringContaining("own git worktree"),
|
||||||
]);
|
]);
|
||||||
@@ -263,8 +263,8 @@ describe("WorkspaceHome", () => {
|
|||||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
|
||||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
|
||||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||||
} as never);
|
} as never);
|
||||||
@@ -289,9 +289,9 @@ describe("WorkspaceHome", () => {
|
|||||||
__resetAiTools();
|
__resetAiTools();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers the router's models on a router, and sends the router", async () => {
|
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
|
||||||
localStorage.setItem("agenthub.session.router", "glm");
|
localStorage.setItem("agenthub.session.proxy", "glm");
|
||||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
|
||||||
items: [
|
items: [
|
||||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||||
{
|
{
|
||||||
@@ -316,7 +316,7 @@ describe("WorkspaceHome", () => {
|
|||||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
"start",
|
"start",
|
||||||
expect.objectContaining({ router: "glm" })
|
expect.objectContaining({ proxy: "glm" })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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();
|
||||||
@@ -454,6 +487,13 @@ describe("WorkspaceHome", () => {
|
|||||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||||
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
await user.upload(screen.getByTestId("workspace-file-input"), file);
|
||||||
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
expect(api.lanes.addFile).toHaveBeenCalledWith(1, file);
|
||||||
|
const a = new File(["a"], "a.csv", { type: "text/csv" });
|
||||||
|
const b = new File(["b"], "b.log", { type: "" });
|
||||||
|
fireEvent.drop(screen.getByTestId("workspace-context-files"), {
|
||||||
|
dataTransfer: { types: ["Files"], files: [a, b] },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(api.lanes.addFile).toHaveBeenCalledWith(1, b));
|
||||||
|
expect(api.lanes.addFile).toHaveBeenCalledWith(1, a);
|
||||||
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
await user.click(screen.getByRole("button", { name: "Remove spec.pdf" }));
|
||||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests that a session finds its workspace and its `claude --worktree`
|
||||||
|
* name by folder on Windows paths (`D:\…`) as well as POSIX ones — the
|
||||||
|
* transcript page's Resume/Fork/Open pane hid on Windows when they matched
|
||||||
|
* on "/" only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { Lane, Session } from "../../../lib/types";
|
||||||
|
import { owningLane } from "../SessionResumeButton";
|
||||||
|
import { sessionWorktreeName } from "../WorkspaceSessionList";
|
||||||
|
|
||||||
|
const lane = (id: number, cwd: string) => ({ id, cwd, tool: "claude" }) as Lane;
|
||||||
|
|
||||||
|
describe("session paths", () => {
|
||||||
|
it("finds the deepest owning workspace with either separator", () => {
|
||||||
|
const lanes = [lane(1, "D:\\Project"), lane(2, "D:\\Project\\Hobby\\"), lane(3, "/home/u/app")];
|
||||||
|
expect(owningLane("D:\\Project\\Hobby\\tri-vi-portfolio", "claude", lanes)?.id).toBe(2);
|
||||||
|
expect(owningLane("D:\\Project\\Hobby", "claude", lanes)?.id).toBe(2);
|
||||||
|
expect(owningLane("D:\\ProjectX", "claude", lanes)).toBeNull();
|
||||||
|
expect(owningLane("/home/u/app/src", "claude", lanes)?.id).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a worktree name from a Windows or POSIX session folder", () => {
|
||||||
|
const at = (cwd: string) => ({ cwd }) as Session;
|
||||||
|
expect(
|
||||||
|
sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\.claude\\worktrees\\fix-a"))
|
||||||
|
).toBe("fix-a");
|
||||||
|
expect(sessionWorktreeName(lane(1, "/r"), at("/r/.claude/worktrees/fix-b/"))).toBe("fix-b");
|
||||||
|
expect(sessionWorktreeName(lane(1, "D:\\repo"), at("D:\\repo\\"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,15 +14,18 @@
|
|||||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||||
* or an installed, enabled AI tool started bare on its default model, no
|
* or an installed, enabled AI tool started bare on its default model, no
|
||||||
* prompt (a prompt, router or worktree is still the workspace home's prompt
|
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||||
* (`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. A right-click on a tab
|
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||||
|
* of another tab, a tab (or a pane header) joins that tab instead
|
||||||
|
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||||
|
* one, up to four panes). A right-click on a tab
|
||||||
* opens the same actions as a menu (`ContextMenu`).
|
* opens the same actions as a menu (`ContextMenu`).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -53,8 +56,10 @@ import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./Pan
|
|||||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||||
import {
|
import {
|
||||||
type PaneGroups,
|
type PaneGroups,
|
||||||
|
MAX_PANES,
|
||||||
detachSlot,
|
detachSlot,
|
||||||
groupOf,
|
groupOf,
|
||||||
|
joinGroup,
|
||||||
leaves,
|
leaves,
|
||||||
reorderGroups,
|
reorderGroups,
|
||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
@@ -207,6 +212,10 @@ export function ConsoleTabs({
|
|||||||
// point among the drawn tabs, from the pointer against their middles.
|
// point among the drawn tabs, from the pointer against their middles.
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const [dropAt, setDropAt] = useState<number | null>(null);
|
const [dropAt, setDropAt] = useState<number | null>(null);
|
||||||
|
// The drawn tab a drop would join (the pointer on its middle half), and the
|
||||||
|
// slots a drag from this bar carries — unknown for a pane header (one slot).
|
||||||
|
const [joinAt, setJoinAt] = useState<number | null>(null);
|
||||||
|
const dragged = useRef<number[]>([]);
|
||||||
const isBarDrag = (e: DragEvent) => {
|
const isBarDrag = (e: DragEvent) => {
|
||||||
const types = e.dataTransfer?.types;
|
const types = e.dataTransfer?.types;
|
||||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||||
@@ -221,31 +230,70 @@ export function ConsoleTabs({
|
|||||||
x
|
x
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
const joinIndexAt = (x: number) => {
|
||||||
|
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||||
|
const i = els.findIndex((el) => {
|
||||||
|
const b = el.getBoundingClientRect();
|
||||||
|
return x >= b.left + b.width / 4 && x <= b.right - b.width / 4;
|
||||||
|
});
|
||||||
|
const tab = tabs[i];
|
||||||
|
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||||
|
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||||
|
};
|
||||||
|
const endDrag = () => {
|
||||||
|
dragged.current = [];
|
||||||
|
setDropAt(null);
|
||||||
|
setJoinAt(null);
|
||||||
|
};
|
||||||
const dragHandlers = {
|
const dragHandlers = {
|
||||||
onDragOver: (e: DragEvent) => {
|
onDragOver: (e: DragEvent) => {
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "move";
|
e.dataTransfer.dropEffect = "move";
|
||||||
const at = dropIndexAt(e.clientX);
|
const join = joinIndexAt(e.clientX);
|
||||||
|
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||||
|
setJoinAt((d) => (d === join ? d : join));
|
||||||
setDropAt((d) => (d === at ? d : at));
|
setDropAt((d) => (d === at ? d : at));
|
||||||
},
|
},
|
||||||
onDragLeave: (e: DragEvent) => {
|
onDragLeave: (e: DragEvent) => {
|
||||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||||
},
|
|
||||||
onDragEnd: () => setDropAt(null),
|
|
||||||
onDrop: (e: DragEvent) => {
|
|
||||||
setDropAt(null);
|
setDropAt(null);
|
||||||
|
setJoinAt(null);
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
onDrop: (e: DragEvent) => {
|
||||||
|
const join = joinIndexAt(e.clientX);
|
||||||
|
endDrag();
|
||||||
if (!isBarDrag(e)) return;
|
if (!isBarDrag(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||||
|
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||||
|
const target = join === null ? undefined : tabs[join];
|
||||||
|
// A pane header dropped on its OWN tab is a detach, not a join: a pane
|
||||||
|
// drag carries no slots during dragover, so joinIndexAt cannot skip it.
|
||||||
|
const ownTab =
|
||||||
|
tab === "" && raw !== "" && target && groupOf(groups, Number(raw)) === target.index;
|
||||||
|
if (target && !ownTab) {
|
||||||
|
onGroupsChange((g) =>
|
||||||
|
joinGroup(
|
||||||
|
g,
|
||||||
|
tab !== ""
|
||||||
|
? leaves(g.groups[Number(tab)]?.root ?? null)
|
||||||
|
: raw !== ""
|
||||||
|
? [Number(raw)]
|
||||||
|
: [],
|
||||||
|
target.index
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// The drawn position, as an index into the groups.
|
// The drawn position, as an index into the groups.
|
||||||
const drawn = dropIndexAt(e.clientX);
|
const drawn = dropIndexAt(e.clientX);
|
||||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
|
||||||
if (tab !== "") {
|
if (tab !== "") {
|
||||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
|
||||||
if (raw === "") return;
|
if (raw === "") return;
|
||||||
const slot = Number(raw);
|
const slot = Number(raw);
|
||||||
onGroupsChange((g) => {
|
onGroupsChange((g) => {
|
||||||
@@ -432,6 +480,7 @@ export function ConsoleTabs({
|
|||||||
data-slots={tab.key}
|
data-slots={tab.key}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
|
dragged.current = tab.slots;
|
||||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||||
else {
|
else {
|
||||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||||
@@ -445,7 +494,7 @@ export function ConsoleTabs({
|
|||||||
active
|
active
|
||||||
? "bg-surface-0 text-fg-primary"
|
? "bg-surface-0 text-fg-primary"
|
||||||
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
: "text-fg-muted hover:bg-surface-4 hover:text-fg-secondary"
|
||||||
}`}
|
} ${joinAt === i ? "ring-2 ring-inset ring-accent/60 bg-accent/15" : ""}`}
|
||||||
>
|
>
|
||||||
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
{(dropAt === i || (dropAt === tabs.length && i === tabs.length - 1)) && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -106,20 +108,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
|
|||||||
return [current.groups, update];
|
return [current.groups, update];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Zone = Edge | "center";
|
export type Zone = Edge | "center";
|
||||||
const pct = (r: Rect) => ({
|
const pct = (r: Rect) => ({
|
||||||
left: `${r.x * 100}%`,
|
left: `${r.x * 100}%`,
|
||||||
top: `${r.y * 100}%`,
|
top: `${r.y * 100}%`,
|
||||||
width: `${r.w * 100}%`,
|
width: `${r.w * 100}%`,
|
||||||
height: `${r.h * 100}%`,
|
height: `${r.h * 100}%`,
|
||||||
});
|
});
|
||||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
||||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
* splits. The drop applies it and the preview draws it, so they agree. */
|
||||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
||||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
||||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
}
|
||||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
|
||||||
return r;
|
/** Where `src` would land, or null for a drop that changes nothing. A pane
|
||||||
|
* already shown leaves its place first, so its target grows before the
|
||||||
|
* split — the target's own half is not where it lands. */
|
||||||
|
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
|
||||||
|
if (src === slot) return null;
|
||||||
|
const next = applyDrop(l, src, slot, zone);
|
||||||
|
if (next === l) return null;
|
||||||
|
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||||
@@ -302,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. */
|
||||||
@@ -323,7 +335,7 @@ export function PaneGrid({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
|
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||||
const { panes, dividers } = layoutRects(layout.root);
|
const { panes, dividers } = layoutRects(layout.root);
|
||||||
const shown = leaves(layout.root);
|
const shown = leaves(layout.root);
|
||||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||||
@@ -391,6 +403,8 @@ export function PaneGrid({
|
|||||||
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
|
||||||
|
|
||||||
const isPaneDrag = (e: React.DragEvent) =>
|
const isPaneDrag = (e: React.DragEvent) =>
|
||||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||||
|
|
||||||
@@ -404,7 +418,10 @@ export function PaneGrid({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
const z = zoneAt(e);
|
const z = zoneAt(e);
|
||||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||||
|
setDrop((d) =>
|
||||||
|
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onDragLeave={(e) => {
|
onDragLeave={(e) => {
|
||||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||||
@@ -417,11 +434,7 @@ export function PaneGrid({
|
|||||||
dragSlot = null;
|
dragSlot = null;
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||||
onChange((l) =>
|
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||||
z.zone === "center"
|
|
||||||
? swapInLayout(l, src, z.slot)
|
|
||||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mounted.map((slot) => {
|
{mounted.map((slot) => {
|
||||||
@@ -506,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>
|
||||||
)}
|
)}
|
||||||
@@ -549,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) => (
|
||||||
@@ -561,11 +591,11 @@ export function PaneGrid({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{drop && rectOf.has(drop.slot) && (
|
{preview && (
|
||||||
<div
|
<div
|
||||||
data-testid="pane-drop-preview"
|
data-testid="pane-drop-preview"
|
||||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||||
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
style={pct(preview)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* @file ProxyPicker.tsx
|
||||||
|
* @description The AI Proxy a NEW session starts on — a session's choice, not
|
||||||
|
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
|
||||||
|
* Native or one of the saved profiles (`useAiProxies`, which the caller also
|
||||||
|
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||||
|
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||||
|
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||||
|
* `agenthub.session.router` is still read when that is unset).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type AiProxy } from "../../lib/api";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { Select } from "../Select";
|
||||||
|
|
||||||
|
const KEY = "agenthub.session.proxy";
|
||||||
|
const LEGACY_KEY = "agenthub.session.router";
|
||||||
|
|
||||||
|
/** The remembered pick, `"native"` by default. */
|
||||||
|
export function useSessionProxy(): [string, (v: string) => void] {
|
||||||
|
const [value, setValue] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
|
||||||
|
} catch {
|
||||||
|
return "native";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const set = (v: string) => {
|
||||||
|
setValue(v);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, v);
|
||||||
|
} catch {
|
||||||
|
/* storage off — the pick still holds for this page */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return [value, set];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The saved profiles (Native included), null until they arrive. */
|
||||||
|
export function useAiProxies(): AiProxy[] | null {
|
||||||
|
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
api.aiProxies
|
||||||
|
.list()
|
||||||
|
.then((r) => alive && setProxies(r.items))
|
||||||
|
.catch(() => alive && setProxies([]));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
return proxies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
|
||||||
|
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
|
||||||
|
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProxyPicker({
|
||||||
|
tool,
|
||||||
|
proxies,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
variant?: "field" | "ghost";
|
||||||
|
tool: string;
|
||||||
|
/** From `useAiProxies`. */
|
||||||
|
proxies: AiProxy[] | null;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
|
||||||
|
const native = t("proxy.native", { tool: aiTools.label(tool) });
|
||||||
|
|
||||||
|
// A remembered profile that has since been deleted falls back to native,
|
||||||
|
// or the start would be refused for a proxy the user cannot see.
|
||||||
|
useEffect(() => {
|
||||||
|
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||||
|
}, [proxies, value, onChange]);
|
||||||
|
|
||||||
|
const options =
|
||||||
|
capable && proxies?.length
|
||||||
|
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||||
|
: [{ value: "native", label: native }];
|
||||||
|
return (
|
||||||
|
<div data-testid="proxy-picker">
|
||||||
|
<Select<string>
|
||||||
|
value={capable ? value : "native"}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options}
|
||||||
|
disabled={!capable}
|
||||||
|
variant={variant}
|
||||||
|
label={t("proxy.label")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file RouterPicker.tsx
|
|
||||||
* @description The AI Router a NEW session starts on — a session's choice, not
|
|
||||||
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
|
|
||||||
* Native or one of the saved profiles (`useAiRouters`, which the caller also
|
|
||||||
* reads to list the router's models). A tool that cannot use a router (only
|
|
||||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
|
||||||
* for the next session (`agenthub.session.router`).
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { api, type AiRouter } from "../../lib/api";
|
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
|
||||||
import { Select } from "../Select";
|
|
||||||
|
|
||||||
const KEY = "agenthub.session.router";
|
|
||||||
|
|
||||||
/** The remembered pick, `"native"` by default. */
|
|
||||||
export function useSessionRouter(): [string, (v: string) => void] {
|
|
||||||
const [value, setValue] = useState(() => {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(KEY) || "native";
|
|
||||||
} catch {
|
|
||||||
return "native";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const set = (v: string) => {
|
|
||||||
setValue(v);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(KEY, v);
|
|
||||||
} catch {
|
|
||||||
/* storage off — the pick still holds for this page */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return [value, set];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The saved profiles (Native included), null until they arrive. */
|
|
||||||
export function useAiRouters(): AiRouter[] | null {
|
|
||||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
api.aiRouters
|
|
||||||
.list()
|
|
||||||
.then((r) => alive && setRouters(r.items))
|
|
||||||
.catch(() => alive && setRouters([]));
|
|
||||||
return () => {
|
|
||||||
alive = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
return routers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
|
|
||||||
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
|
|
||||||
return routerCapable && router && router !== "native" ? { router } : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RouterPicker({
|
|
||||||
tool,
|
|
||||||
routers,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
variant?: "field" | "ghost";
|
|
||||||
tool: string;
|
|
||||||
/** From `useAiRouters`. */
|
|
||||||
routers: AiRouter[] | null;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation(["lanes"]);
|
|
||||||
const aiTools = useAiTools();
|
|
||||||
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
|
||||||
const native = t("router.native", { tool: aiTools.label(tool) });
|
|
||||||
|
|
||||||
// A remembered profile that has since been deleted falls back to native,
|
|
||||||
// or the start would be refused for a router the user cannot see.
|
|
||||||
useEffect(() => {
|
|
||||||
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
|
|
||||||
}, [routers, value, onChange]);
|
|
||||||
|
|
||||||
const options =
|
|
||||||
capable && routers?.length
|
|
||||||
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
|
||||||
: [{ value: "native", label: native }];
|
|
||||||
return (
|
|
||||||
<div data-testid="router-picker">
|
|
||||||
<Select<string>
|
|
||||||
value={capable ? value : "native"}
|
|
||||||
onChange={onChange}
|
|
||||||
options={options}
|
|
||||||
disabled={!capable}
|
|
||||||
variant={variant}
|
|
||||||
label={t("router.label")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@
|
|||||||
* box (`WorkspaceHome.tsx`):
|
* box (`WorkspaceHome.tsx`):
|
||||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||||
* newlines, and a pasted or dropped image attached by its saved path
|
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||||
* (`POST /api/run/drop-image`);
|
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||||
* or a fixed one (an AI Router's models), plus Custom…;
|
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||||
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
||||||
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
||||||
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Home,
|
Home,
|
||||||
@@ -47,12 +48,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
|||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
source: "project" | "user" | "builtin";
|
source: "project" | "user" | "plugin" | "builtin";
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commands the CLI answers itself. They are typed into the pane like any
|
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||||
// other text, so they do run — they are just not files anyone can list.
|
// typed into the pane like any other text, so they do run — they are just not
|
||||||
|
// files anyone can list.
|
||||||
|
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
||||||
|
// API lists the built-ins, add names here as they ship.
|
||||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||||
"help",
|
"help",
|
||||||
"clear",
|
"clear",
|
||||||
@@ -62,6 +66,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
"memory",
|
"memory",
|
||||||
"hooks",
|
"hooks",
|
||||||
"cost",
|
"cost",
|
||||||
|
"usage",
|
||||||
|
"context",
|
||||||
"agents",
|
"agents",
|
||||||
"review",
|
"review",
|
||||||
"permissions",
|
"permissions",
|
||||||
@@ -69,14 +75,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
"init",
|
"init",
|
||||||
"mcp",
|
"mcp",
|
||||||
"plugin",
|
"plugin",
|
||||||
|
"reload-plugins",
|
||||||
|
"skills",
|
||||||
"output-style",
|
"output-style",
|
||||||
|
"resume",
|
||||||
|
"rewind",
|
||||||
|
"add-dir",
|
||||||
|
"doctor",
|
||||||
|
"export",
|
||||||
|
"login",
|
||||||
|
"logout",
|
||||||
|
"statusline",
|
||||||
|
"theme",
|
||||||
|
"fast",
|
||||||
"exit",
|
"exit",
|
||||||
|
"code-review",
|
||||||
|
"security-review",
|
||||||
|
"simplify",
|
||||||
|
"loop",
|
||||||
|
"schedule",
|
||||||
|
"fewer-permission-prompts",
|
||||||
|
"update-config",
|
||||||
|
"claude-api",
|
||||||
|
"run",
|
||||||
].map((name) => ({ name, source: "builtin" }));
|
].map((name) => ({ name, source: "builtin" }));
|
||||||
|
|
||||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
||||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||||
project: "text-status-success border-status-success/40",
|
project: "text-status-success border-status-success/40",
|
||||||
user: "text-accent border-accent/40",
|
user: "text-accent border-accent/40",
|
||||||
|
plugin: "text-accent border-accent/40",
|
||||||
builtin: "text-fg-muted border-border",
|
builtin: "text-fg-muted border-border",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,8 +154,8 @@ interface PromptEditorProps {
|
|||||||
slash: boolean;
|
slash: boolean;
|
||||||
/** Where `@` looks for files and project commands/skills are read from. */
|
/** Where `@` looks for files and project commands/skills are read from. */
|
||||||
fileCwd: string;
|
fileCwd: string;
|
||||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||||
* them with the text through `promptWithImages`. */
|
* sends them with the text through `promptWithImages`. */
|
||||||
images: PromptImage[];
|
images: PromptImage[];
|
||||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||||
/** Borderless, for a composer box that draws its own frame. */
|
/** Borderless, for a composer box that draws its own frame. */
|
||||||
@@ -137,11 +165,12 @@ interface PromptEditorProps {
|
|||||||
onBusyChange?: (busy: boolean) => void;
|
onBusyChange?: (busy: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** An attached image: its path on the server's machine and a local preview. */
|
/** An attached file: its path on the server's machine, and a local preview
|
||||||
export type PromptImage = { path: string; url: string; name: string };
|
* `url` when it is an image. */
|
||||||
|
export type PromptImage = { path: string; url?: string; name: string };
|
||||||
|
|
||||||
/** The prompt as sent: the text, then each image's saved path — which is how
|
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||||
* the CLI is handed an image. */
|
* the CLI is handed an image or a file. */
|
||||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
@@ -185,8 +214,26 @@ export function PromptEditor({
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
]).then(([c, sk]) => {
|
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
]).then(([c, sk, pl]) => {
|
||||||
|
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||||
|
// installed at both user and project scope is listed once.
|
||||||
|
const fromPlugins = pl.plugins
|
||||||
|
.filter((p) => p.enabled !== false && p.contributes)
|
||||||
|
.flatMap((p) =>
|
||||||
|
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
||||||
|
(it): SlashCommand => ({
|
||||||
|
name: `${p.name}:${it.name}`,
|
||||||
|
source: "plugin",
|
||||||
|
description: it.description || undefined,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
||||||
|
if (live)
|
||||||
|
setCommands(
|
||||||
|
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
live = false;
|
live = false;
|
||||||
@@ -264,9 +311,9 @@ export function PromptEditor({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pasted or dropped image is saved on the server's machine (the same
|
// A pasted or dropped file is saved on the server's machine (the same
|
||||||
// route the terminal's drop uses) and shown as a thumbnail; its path is
|
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||||
// added to the prompt only when it is sent (promptWithImages).
|
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||||
@@ -275,15 +322,15 @@ export function PromptEditor({
|
|||||||
busyRef.current = onBusyChange;
|
busyRef.current = onBusyChange;
|
||||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||||
const attachImages = (list: FileList | null | undefined) => {
|
const attachImages = (list: FileList | null | undefined) => {
|
||||||
const picked = Array.from(list ?? []).filter((f) => f.type.startsWith("image/"));
|
const picked = Array.from(list ?? []);
|
||||||
if (picked.length === 0) return false;
|
if (picked.length === 0) return false;
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||||
.then((saved) => {
|
.then((saved) => {
|
||||||
setUpload(null);
|
setUpload(null);
|
||||||
const added = picked.map((f, i) => ({
|
const added = picked.map((f, i) => ({
|
||||||
path: saved[i]!.path,
|
path: saved[i]!.path,
|
||||||
url: URL.createObjectURL(f),
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
}));
|
||||||
onImagesChange((prev) => [...prev, ...added]);
|
onImagesChange((prev) => [...prev, ...added]);
|
||||||
@@ -292,7 +339,7 @@ export function PromptEditor({
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
const removeImage = (img: PromptImage) => {
|
const removeImage = (img: PromptImage) => {
|
||||||
URL.revokeObjectURL(img.url);
|
if (img.url) URL.revokeObjectURL(img.url);
|
||||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||||
};
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -349,17 +396,27 @@ export function PromptEditor({
|
|||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||||
{images.map((img) => (
|
{images.map((img) => (
|
||||||
<div key={img.url} className="group relative">
|
<div key={img.path} className="group relative">
|
||||||
<button
|
{img.url ? (
|
||||||
type="button"
|
<button
|
||||||
aria-label={img.name}
|
type="button"
|
||||||
title={img.name}
|
aria-label={img.name}
|
||||||
onClick={() => setViewing(img)}
|
title={img.name}
|
||||||
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
onClick={() => setViewing(img)}
|
||||||
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
onContextMenu={(e) => setImageMenu({ ...menuPoint(e), img })}
|
||||||
>
|
className="block h-14 w-14 overflow-hidden rounded-lg border border-border bg-surface-3 hover:border-accent/50"
|
||||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
>
|
||||||
</button>
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title={img.path}
|
||||||
|
className="flex h-14 max-w-48 items-center gap-1.5 rounded-lg border border-border bg-surface-3 px-2 text-xs text-fg-secondary"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-fg-muted" />
|
||||||
|
<span className="truncate">{img.name}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t("drop.remove")}
|
aria-label={t("drop.remove")}
|
||||||
@@ -671,7 +728,7 @@ const MODEL_CUSTOM = "__custom__";
|
|||||||
/**
|
/**
|
||||||
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
||||||
* own list (Codex's models cache, Claude's aliases named after the newest
|
* own list (Codex's models cache, Claude's aliases named after the newest
|
||||||
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
|
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
|
||||||
* Claude's static aliases only when the server cannot answer. `loaded` once
|
* Claude's static aliases only when the server cannot answer. `loaded` once
|
||||||
* the list is final.
|
* the list is final.
|
||||||
*/
|
*/
|
||||||
@@ -707,7 +764,7 @@ export function ModelPicker({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||||
choices?: ModelChoice[];
|
choices?: ModelChoice[];
|
||||||
variant?: "field" | "ghost";
|
variant?: "field" | "ghost";
|
||||||
}) {
|
}) {
|
||||||
@@ -781,7 +838,7 @@ export function ModelMenu({
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (s: string) => void;
|
onChange: (s: string) => void;
|
||||||
tool: LaneTool;
|
tool: LaneTool;
|
||||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||||
choices?: ModelChoice[];
|
choices?: ModelChoice[];
|
||||||
/** Omitted for a tool that takes no effort. */
|
/** Omitted for a tool that takes no effort. */
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
|||||||
@@ -9,11 +9,15 @@
|
|||||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||||
* without a retry the pane silently freezes on its last painted frame — it
|
* without a retry the pane silently freezes on its last painted frame — it
|
||||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||||
|
* Retries back off from 1.5 s to 30 s (reset once a socket opens) and stop on
|
||||||
|
* close code 1008. xterm and its addons are loaded on the first terminal
|
||||||
|
* (`loadXterm`), not bundled with the Workspace chunk.
|
||||||
* Always painted dark, in the palette of the user's chosen DARK theme
|
* Always painted dark, in the palette of the user's chosen DARK theme
|
||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
* Files (images or any other) dropped on it are saved on the server
|
||||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* and how `claude` attaches an image. The pane also registers with
|
* local terminal does on a drop, and how `claude` attaches an image or reads
|
||||||
|
* a file. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
* pasted into whichever of the workspace's panes was focused last.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||||
@@ -27,25 +31,27 @@
|
|||||||
* 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";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
import type { SearchAddon } from "@xterm/addon-search";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
||||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTheme } from "../../hooks/useTheme";
|
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" | "imagesOnly";
|
type DropState = "hint" | "uploading" | "failed";
|
||||||
|
|
||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -72,11 +78,49 @@ const clipboardProvider: IClipboardProvider = {
|
|||||||
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
if (text) void navigator.clipboard?.writeText(text).catch(() => undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
|
||||||
const ClipboardWithProvider = ClipboardAddon as unknown as new (
|
/** xterm and its addons (~400 KB) load with the first terminal, not with the
|
||||||
base64: undefined,
|
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||||
provider: IClipboardProvider
|
* awaits `loadXterm()` first) sets up synchronously. */
|
||||||
) => ClipboardAddon;
|
const importXterm = async () => {
|
||||||
|
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
||||||
|
import("@xterm/xterm"),
|
||||||
|
import("@xterm/addon-fit"),
|
||||||
|
import("@xterm/addon-canvas"),
|
||||||
|
import("@xterm/addon-clipboard"),
|
||||||
|
import("@xterm/addon-search"),
|
||||||
|
import("@xterm/addon-web-links"),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
Terminal: xterm.Terminal,
|
||||||
|
FitAddon: fit.FitAddon,
|
||||||
|
CanvasAddon: canvas.CanvasAddon,
|
||||||
|
SearchAddon: search.SearchAddon,
|
||||||
|
WebLinksAddon: links.WebLinksAddon,
|
||||||
|
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||||
|
ClipboardWithProvider: clipboard.ClipboardAddon as unknown as new (
|
||||||
|
base64: undefined,
|
||||||
|
provider: IClipboardProvider
|
||||||
|
) => ClipboardAddon,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
type Xterm = Awaited<ReturnType<typeof importXterm>>;
|
||||||
|
let xtermLoaded: Xterm | null = null;
|
||||||
|
let xtermLoading: Promise<Xterm> | null = null;
|
||||||
|
export function loadXterm(): Promise<Xterm> {
|
||||||
|
xtermLoading ??= importXterm().then(
|
||||||
|
(x) => (xtermLoaded = x),
|
||||||
|
(err) => {
|
||||||
|
xtermLoading = null; // a failed chunk fetch may be retried by the next pane
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return xtermLoading;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||||
|
const RETRY_MIN_MS = 1500;
|
||||||
|
const RETRY_MAX_MS = 30_000;
|
||||||
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
@@ -144,13 +188,13 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
if (!hasFiles(e)) return; // dragged text keeps xterm's own handling
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
const files = Array.from(e.dataTransfer.files);
|
||||||
if (images.length === 0) return flash("imagesOnly");
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
try {
|
try {
|
||||||
const saved = await Promise.all(images.map((f) => api.run.dropImage(f)));
|
const saved = await Promise.all(files.map((f) => api.run.dropFile(f)));
|
||||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
||||||
// sees one pasted path rather than typed keystrokes.
|
// sees pasted paths rather than typed keystrokes.
|
||||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
@@ -160,265 +204,332 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
|
||||||
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
|
||||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
|
||||||
// substitutes those specific glyphs from a different face with a
|
|
||||||
// mismatched advance width, corrupting the cell it's drawn into even
|
|
||||||
// under the canvas renderer (which only stops a glyph from shoving its
|
|
||||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
|
||||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
|
||||||
// its `vietnamese` subset so this covers the diacritics too.
|
|
||||||
// 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
|
|
||||||
// it on a PTY, where termios already does that translation for the streams
|
|
||||||
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
|
||||||
// drawing at the column it was on; with this on, that draw landed at
|
|
||||||
// column 0 instead, stamping the first characters of the line into the
|
|
||||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
|
||||||
// and why it only showed up once content scrolled.
|
|
||||||
const term = new Terminal({
|
|
||||||
fontSize: 13,
|
|
||||||
cursorBlink: true,
|
|
||||||
fontFamily: '"JetBrains Mono", monospace',
|
|
||||||
theme: paletteRef.current,
|
|
||||||
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
|
||||||
});
|
|
||||||
termRef.current = term;
|
|
||||||
const fit = new FitAddon();
|
|
||||||
term.loadAddon(fit);
|
|
||||||
const search = new SearchAddon();
|
|
||||||
term.loadAddon(search);
|
|
||||||
searchRef.current = search;
|
|
||||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
|
||||||
term.loadAddon(
|
|
||||||
new WebLinksAddon((_e, uri) => {
|
|
||||||
window.open(uri, "_blank", "noopener,noreferrer");
|
|
||||||
})
|
|
||||||
);
|
|
||||||
if (containerRef.current) term.open(containerRef.current);
|
|
||||||
term.textarea?.setAttribute("aria-describedby", hintId);
|
|
||||||
|
|
||||||
// Draw each cell at a fixed position instead of letting the browser lay the
|
|
||||||
// row out as text. xterm's default DOM renderer aligns a row with a single
|
|
||||||
// `letter-spacing`, which only holds while every glyph comes from one
|
|
||||||
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
|
|
||||||
// face whose advance width differs, so glyphs shove their neighbours along
|
|
||||||
// and the row's tail spills onto the row below. The buffer is correct
|
|
||||||
// throughout (copying the line yields clean text); only the painting drifts,
|
|
||||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
|
||||||
// context keeps the DOM renderer rather than losing the terminal.
|
|
||||||
let canvas: CanvasAddon | null = null;
|
|
||||||
try {
|
|
||||||
canvas = new CanvasAddon();
|
|
||||||
term.loadAddon(canvas);
|
|
||||||
} catch {
|
|
||||||
canvas = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
fit.fit();
|
|
||||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
|
||||||
// page here, and having to click it first meant the first thing typed went
|
|
||||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
|
||||||
if (autoFocusRef.current) term.focus();
|
|
||||||
|
|
||||||
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
|
||||||
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
|
||||||
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
|
|
||||||
// the wheel silently walks the prompt history instead of scrolling. Kill
|
|
||||||
// ONLY that branch: xterm runs this same handler before sending a real SGR
|
|
||||||
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
|
|
||||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
|
||||||
// because that protocol reports button presses only, never the wheel — so
|
|
||||||
// xterm takes the emulation path for it as well.
|
|
||||||
term.attachCustomWheelEventHandler(() => {
|
|
||||||
const mode = term.modes.mouseTrackingMode;
|
|
||||||
const tracked = mode !== "none" && mode !== "x10";
|
|
||||||
return tracked || term.buffer.active.type !== "alternate";
|
|
||||||
});
|
|
||||||
|
|
||||||
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
|
|
||||||
// event cancelled, so the browser still runs its default action and moves
|
|
||||||
// focus out of the terminal. The pane then only ever sees the first press,
|
|
||||||
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
|
||||||
// here while working in a real terminal. Returning true keeps xterm's own
|
|
||||||
// handling; the preventDefault only stops focus from escaping.
|
|
||||||
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
|
|
||||||
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
|
|
||||||
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
|
|
||||||
// handling — and from cancelling — the key.
|
|
||||||
term.attachCustomKeyEventHandler((ev) => {
|
|
||||||
if (ev.type !== "keydown") return true;
|
|
||||||
const key = ev.key.toLowerCase();
|
|
||||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
|
|
||||||
ev.preventDefault();
|
|
||||||
tabMovesRef.current = !tabMovesRef.current;
|
|
||||||
setTabMoves(tabMovesRef.current);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
|
|
||||||
// which is readline's forward-char (and bound by many TUIs).
|
|
||||||
const findChord =
|
|
||||||
!ev.altKey &&
|
|
||||||
key === "f" &&
|
|
||||||
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) || (ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
|
||||||
if (findChord) {
|
|
||||||
ev.preventDefault();
|
|
||||||
setFind((f) => f ?? "");
|
|
||||||
requestAnimationFrame(() => findInput.current?.focus());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
|
||||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tell the server this terminal's size. tmux draws the pane at whatever
|
|
||||||
* width it last heard, and a pane WIDER than xterm is silently corrupting:
|
|
||||||
* every over-long row autowraps its tail onto the next row's left margin,
|
|
||||||
* and tmux — believing that row unchanged — never repaints over it. The
|
|
||||||
* result is stray characters glued to the left edge of blank lines, which
|
|
||||||
* only becomes visible once a scroll repaints history. Reproduced with a
|
|
||||||
* one-column difference; two columns leaves two characters per line.
|
|
||||||
*
|
|
||||||
* A zero-size fit is never reported: the console collapses with CSS
|
|
||||||
* (`display:none` leaves the container 0×0), and fitting to that would
|
|
||||||
* resize the real tmux pane down to nothing.
|
|
||||||
*/
|
|
||||||
const sendSizeNow = () => {
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
if (term.cols < 2 || term.rows < 2) return;
|
|
||||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
|
||||||
};
|
|
||||||
// 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
|
|
||||||
// settle (trailing, 150 ms).
|
|
||||||
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const sendSize = () => {
|
|
||||||
if (sizeTimer) clearTimeout(sizeTimer);
|
|
||||||
sizeTimer = setTimeout(() => {
|
|
||||||
sizeTimer = null;
|
|
||||||
sendSizeNow();
|
|
||||||
}, 150);
|
|
||||||
};
|
|
||||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let ended = false; // server said the pane exited — nothing left to attach to
|
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
const attach = ({
|
||||||
|
Terminal,
|
||||||
|
FitAddon,
|
||||||
|
CanvasAddon,
|
||||||
|
SearchAddon,
|
||||||
|
WebLinksAddon,
|
||||||
|
ClipboardWithProvider,
|
||||||
|
}: Xterm) => {
|
||||||
|
// An explicit fontFamily pins every glyph to one bundled webfont. Left
|
||||||
|
// unset, xterm falls back to the browser/OS default (`courier-new`),
|
||||||
|
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||||
|
// substitutes those specific glyphs from a different face with a
|
||||||
|
// mismatched advance width, corrupting the cell it's drawn into even
|
||||||
|
// under the canvas renderer (which only stops a glyph from shoving its
|
||||||
|
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||||
|
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||||
|
// 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
|
||||||
|
// 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
|
||||||
|
// that want it. tmux scrolls a pane by emitting a bare LF and then keeps
|
||||||
|
// drawing at the column it was on; with this on, that draw landed at
|
||||||
|
// column 0 instead, stamping the first characters of the line into the
|
||||||
|
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||||
|
// and why it only showed up once content scrolled.
|
||||||
|
const term = new Terminal({
|
||||||
|
fontSize: 13,
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||||
|
theme: paletteRef.current,
|
||||||
|
screenReaderMode: readFlag(SCREEN_READER_KEY),
|
||||||
|
});
|
||||||
|
termRef.current = term;
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
const search = new SearchAddon();
|
||||||
|
term.loadAddon(search);
|
||||||
|
searchRef.current = search;
|
||||||
|
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||||
|
term.loadAddon(
|
||||||
|
new WebLinksAddon((_e, uri) => {
|
||||||
|
window.open(uri, "_blank", "noopener,noreferrer");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (containerRef.current) term.open(containerRef.current);
|
||||||
|
term.textarea?.setAttribute("aria-describedby", hintId);
|
||||||
|
|
||||||
const connect = () => {
|
// Draw each cell at a fixed position instead of letting the browser lay the
|
||||||
// The attach itself carries this terminal's size. tmux sizes the window
|
// row out as text. xterm's default DOM renderer aligns a row with a single
|
||||||
// to the client that attached most recently, so spawning the attach at a
|
// `letter-spacing`, which only holds while every glyph comes from one
|
||||||
// default 80×24 and correcting it from the first `resize` message a beat
|
// monospace font — Vietnamese diacritics and CJK fall through to a fallback
|
||||||
// later makes the pane's program (claude) redraw its whole UI at 80
|
// face whose advance width differs, so glyphs shove their neighbours along
|
||||||
// columns first — and everything painted at that width stays in the
|
// and the row's tail spills onto the row below. The buffer is correct
|
||||||
// scrollback as torn fragments along the left margin, visible as soon as
|
// throughout (copying the line yields clean text); only the painting drifts,
|
||||||
// you scroll back (issue #33). Happens on every attach: page load,
|
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||||
// lane switch, and each 1.5 s reconnect.
|
// context keeps the DOM renderer rather than losing the terminal.
|
||||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
let canvas: CanvasAddon | null = null;
|
||||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
try {
|
||||||
ws = sock;
|
canvas = new CanvasAddon();
|
||||||
wsRef.current = sock;
|
term.loadAddon(canvas);
|
||||||
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
} catch {
|
||||||
// would hand onmessage a Blob that the string checks below never match,
|
canvas = null;
|
||||||
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
}
|
||||||
sock.binaryType = "arraybuffer";
|
|
||||||
|
|
||||||
sock.onopen = () => {
|
fit.fit();
|
||||||
setReconnecting(false);
|
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||||
sendSizeNow();
|
// page here, and having to click it first meant the first thing typed went
|
||||||
};
|
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||||
sock.onmessage = (event) => {
|
if (autoFocusRef.current) term.focus();
|
||||||
const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
|
||||||
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
// When the pane's program has NOT enabled wheel reporting, xterm falls back
|
||||||
// to the next decode() call when a PTY chunk splits mid-character —
|
// to converting each wheel notch on an alt-screen buffer into a cursor-key
|
||||||
// without it, a Vietnamese/accented char split across two WS frames
|
// press (ESC[A / ESC[B). In a `claude` pane that reads as arrow up/down —
|
||||||
// decodes to garbage on both sides (U+FFFD or stray bytes).
|
// the wheel silently walks the prompt history instead of scrolling. Kill
|
||||||
const data = isArrayBuffer
|
// ONLY that branch: xterm runs this same handler before sending a real SGR
|
||||||
? decoder.decode(event.data as ArrayBuffer, { stream: true })
|
// mouse report too (`bindMouse`'s wheel case consults it), so a blanket
|
||||||
: event.data;
|
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||||
if (typeof data === "string") {
|
// because that protocol reports button presses only, never the wheel — so
|
||||||
// A JSON control frame is the only thing that starts with `{"type"`.
|
// xterm takes the emulation path for it as well.
|
||||||
if (data.startsWith('{"type"')) {
|
term.attachCustomWheelEventHandler(() => {
|
||||||
try {
|
const mode = term.modes.mouseTrackingMode;
|
||||||
const msg = JSON.parse(data);
|
const tracked = mode !== "none" && mode !== "x10";
|
||||||
if (msg.type === "exit") {
|
return tracked || term.buffer.active.type !== "alternate";
|
||||||
ended = true;
|
});
|
||||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
|
||||||
onExitRef.current?.();
|
// xterm sends Shift+Tab as ESC[Z but — unlike plain Tab — never marks the
|
||||||
}
|
// event cancelled, so the browser still runs its default action and moves
|
||||||
return;
|
// focus out of the terminal. The pane then only ever sees the first press,
|
||||||
} catch {
|
// which is why `shift+tab to cycle` (permission mode in claude) looked dead
|
||||||
/* not JSON — fall through and render as PTY output */
|
// here while working in a real terminal. Returning true keeps xterm's own
|
||||||
}
|
// handling; the preventDefault only stops focus from escaping.
|
||||||
}
|
// Ctrl+M toggles "Tab moves focus" (VS Code's way out of its terminal);
|
||||||
term.write(data);
|
// in that mode Tab and Shift+Tab are not the pane's, so the browser moves
|
||||||
|
// focus. Cmd+F / Ctrl+Shift+F open the find bar. Returning false keeps xterm from
|
||||||
|
// handling — and from cancelling — the key.
|
||||||
|
term.attachCustomKeyEventHandler((ev) => {
|
||||||
|
if (ev.type !== "keydown") return true;
|
||||||
|
const key = ev.key.toLowerCase();
|
||||||
|
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "m") {
|
||||||
|
ev.preventDefault();
|
||||||
|
tabMovesRef.current = !tabMovesRef.current;
|
||||||
|
setTabMoves(tabMovesRef.current);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
// Find: Cmd+F on macOS, Ctrl+Shift+F elsewhere — never plain Ctrl+F,
|
||||||
|
// which is readline's forward-char (and bound by many TUIs).
|
||||||
|
const findChord =
|
||||||
|
!ev.altKey &&
|
||||||
|
key === "f" &&
|
||||||
|
((ev.metaKey && !ev.ctrlKey && !ev.shiftKey) ||
|
||||||
|
(ev.ctrlKey && ev.shiftKey && !ev.metaKey));
|
||||||
|
if (findChord) {
|
||||||
|
ev.preventDefault();
|
||||||
|
setFind((f) => f ?? "");
|
||||||
|
requestAnimationFrame(() => findInput.current?.focus());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||||
|
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
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})`;
|
||||||
};
|
};
|
||||||
sock.onclose = () => {
|
|
||||||
if (disposed || ended) return;
|
/**
|
||||||
// tmux keeps the session alive across a server restart, so re-attaching
|
* Tell the server this terminal's size. tmux draws the pane at whatever
|
||||||
// repaints the pane in full — no output is lost by retrying. Input is
|
* width it last heard, and a pane WIDER than xterm is silently corrupting:
|
||||||
// not: the overlay says so rather than letting keystrokes vanish.
|
* every over-long row autowraps its tail onto the next row's left margin,
|
||||||
setReconnecting(true);
|
* and tmux — believing that row unchanged — never repaints over it. The
|
||||||
retryTimer = setTimeout(connect, 1500);
|
* result is stray characters glued to the left edge of blank lines, which
|
||||||
|
* only becomes visible once a scroll repaints history. Reproduced with a
|
||||||
|
* one-column difference; two columns leaves two characters per line.
|
||||||
|
*
|
||||||
|
* A zero-size fit is never reported: the console collapses with CSS
|
||||||
|
* (`display:none` leaves the container 0×0), and fitting to that would
|
||||||
|
* resize the real tmux pane down to nothing.
|
||||||
|
*/
|
||||||
|
const sendSizeNow = (drive = false) => {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
if (term.cols < 2 || term.rows < 2) return;
|
||||||
|
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
|
||||||
|
// program in the pane redraw on each, so the LAST size is sent once things
|
||||||
|
// settle (trailing, 150 ms).
|
||||||
|
let sizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const sendSize = () => {
|
||||||
|
if (sizeTimer) clearTimeout(sizeTimer);
|
||||||
|
sizeTimer = setTimeout(() => {
|
||||||
|
sizeTimer = null;
|
||||||
|
sendSizeNow();
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let ended = false; // server said the pane exited — nothing left to attach to
|
||||||
|
let retryMs = RETRY_MIN_MS;
|
||||||
|
|
||||||
|
const connect = () => {
|
||||||
|
// The attach itself carries this terminal's size. tmux sizes the window
|
||||||
|
// to the client that attached most recently, so spawning the attach at a
|
||||||
|
// default 80×24 and correcting it from the first `resize` message a beat
|
||||||
|
// later makes the pane's program (claude) redraw its whole UI at 80
|
||||||
|
// columns first — and everything painted at that width stays in the
|
||||||
|
// scrollback as torn fragments along the left margin, visible as soon as
|
||||||
|
// you scroll back (issue #33). Happens on every attach: page load,
|
||||||
|
// lane switch, and each reconnect.
|
||||||
|
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||||
|
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||||
|
ws = sock;
|
||||||
|
wsRef.current = sock;
|
||||||
|
// Server sends PTY bytes as binary frames — default binaryType ("blob")
|
||||||
|
// would hand onmessage a Blob that the string checks below never match,
|
||||||
|
// silently dropping all terminal output. "arraybuffer" keeps it sync.
|
||||||
|
sock.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
sock.onopen = () => {
|
||||||
|
retryMs = RETRY_MIN_MS;
|
||||||
|
setReconnecting(false);
|
||||||
|
sendSizeNow();
|
||||||
|
};
|
||||||
|
sock.onmessage = (event) => {
|
||||||
|
const isArrayBuffer =
|
||||||
|
Object.prototype.toString.call(event.data) === "[object ArrayBuffer]";
|
||||||
|
// `stream: true` carries a UTF-8 multi-byte char's trailing bytes over
|
||||||
|
// to the next decode() call when a PTY chunk splits mid-character —
|
||||||
|
// without it, a Vietnamese/accented char split across two WS frames
|
||||||
|
// decodes to garbage on both sides (U+FFFD or stray bytes).
|
||||||
|
const data = isArrayBuffer
|
||||||
|
? decoder.decode(event.data as ArrayBuffer, { stream: true })
|
||||||
|
: event.data;
|
||||||
|
if (typeof data === "string") {
|
||||||
|
// A JSON control frame is the only thing that starts with `{"type"`.
|
||||||
|
if (data.startsWith('{"type"')) {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(data);
|
||||||
|
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;
|
||||||
|
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||||
|
onExitRef.current?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
/* not JSON — fall through and render as PTY output */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
term.write(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sock.onclose = (e?: CloseEvent) => {
|
||||||
|
// 1008: the server refused this attach (policy) — retrying cannot help.
|
||||||
|
if (disposed || ended || e?.code === 1008) return;
|
||||||
|
// tmux keeps the session alive across a server restart, so re-attaching
|
||||||
|
// repaints the pane in full — no output is lost by retrying. Input is
|
||||||
|
// not: the overlay says so rather than letting keystrokes vanish.
|
||||||
|
// Backoff (1.5 s → 30 s, reset on open) so a server that stays down is
|
||||||
|
// not hammered by every open pane.
|
||||||
|
setReconnecting(true);
|
||||||
|
retryTimer = setTimeout(connect, retryMs);
|
||||||
|
retryMs = Math.min(retryMs * 2, RETRY_MAX_MS);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
connect();
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// xterm's own size change is the event worth reporting, not the
|
||||||
|
// container's: cols also move when the webfont finishes loading or the
|
||||||
|
// page zooms, neither of which resizes the element ResizeObserver watches.
|
||||||
|
const resizeDisposable = term.onResize(sendSize);
|
||||||
|
|
||||||
|
// Re-assert on focus. Several attach clients can share one tmux session
|
||||||
|
// (a second tab, another browser): coming back to this one makes it the
|
||||||
|
// driver again, and the others follow its size.
|
||||||
|
const reassert = takeLead;
|
||||||
|
term.textarea?.addEventListener("focus", reassert);
|
||||||
|
|
||||||
|
const refit = () => {
|
||||||
|
// Never fit a collapsed console — see sendSize.
|
||||||
|
const el = containerRef.current;
|
||||||
|
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
|
||||||
|
};
|
||||||
|
const resizeObserver = new ResizeObserver(refit);
|
||||||
|
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||||
|
// A webfont landing after open changes the cell, not the container, so the
|
||||||
|
// observer never fires — and xterm re-measures its cell only on a font
|
||||||
|
// option change or a resize, so the fit kept the fallback font's cell and
|
||||||
|
// the rows/cols overflowed the pane. Re-setting the family (a trailing
|
||||||
|
// space keeps it a change) makes xterm measure again before the fit.
|
||||||
|
const onFontLoaded = () => {
|
||||||
|
const family = term.options.fontFamily ?? "";
|
||||||
|
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
||||||
|
refit();
|
||||||
|
};
|
||||||
|
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (retryTimer) clearTimeout(retryTimer);
|
||||||
|
if (sizeTimer) clearTimeout(sizeTimer);
|
||||||
|
searchRef.current = null;
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||||
|
term.textarea?.removeEventListener("focus", reassert);
|
||||||
|
resizeDisposable.dispose();
|
||||||
|
dataDisposable.dispose();
|
||||||
|
ws?.close();
|
||||||
|
canvas?.dispose();
|
||||||
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
connect();
|
// Synchronous once xterm is loaded; the first pane waits for the chunk, and
|
||||||
|
// `disposed` drops a setup that lands after this effect was cleaned up.
|
||||||
const dataDisposable = term.onData((data) => {
|
let teardown: (() => void) | undefined;
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(data);
|
const start = (x: Xterm) => {
|
||||||
});
|
if (!disposed) teardown = attach(x);
|
||||||
|
|
||||||
// xterm's own size change is the event worth reporting, not the
|
|
||||||
// container's: cols also move when the webfont finishes loading or the
|
|
||||||
// page zooms, neither of which resizes the element ResizeObserver watches.
|
|
||||||
const resizeDisposable = term.onResize(sendSize);
|
|
||||||
|
|
||||||
// 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
|
|
||||||
// the window to whichever client was last active — resizing this pane out
|
|
||||||
// from under a terminal that is never told. Coming back to it says so.
|
|
||||||
const reassert = () => sendSizeNow();
|
|
||||||
term.textarea?.addEventListener("focus", reassert);
|
|
||||||
|
|
||||||
const refit = () => {
|
|
||||||
// Never fit a collapsed console — see sendSize.
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
|
||||||
fit.fit(); // a real change fires onResize, which reports it
|
|
||||||
};
|
};
|
||||||
const resizeObserver = new ResizeObserver(refit);
|
if (xtermLoaded) start(xtermLoaded);
|
||||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
else loadXterm().then(start, () => undefined);
|
||||||
// A webfont landing after open changes the cell, not the container, so the
|
|
||||||
// observer never fires — and xterm re-measures its cell only on a font
|
|
||||||
// option change or a resize, so the fit kept the fallback font's cell and
|
|
||||||
// the rows/cols overflowed the pane. Re-setting the family (a trailing
|
|
||||||
// space keeps it a change) makes xterm measure again before the fit.
|
|
||||||
const onFontLoaded = () => {
|
|
||||||
const family = term.options.fontFamily ?? "";
|
|
||||||
term.options.fontFamily = family.endsWith(" ") ? family.trimEnd() : `${family} `;
|
|
||||||
refit();
|
|
||||||
};
|
|
||||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
if (retryTimer) clearTimeout(retryTimer);
|
teardown?.();
|
||||||
if (sizeTimer) clearTimeout(sizeTimer);
|
|
||||||
searchRef.current = null;
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
|
||||||
term.textarea?.removeEventListener("focus", reassert);
|
|
||||||
resizeDisposable.dispose();
|
|
||||||
dataDisposable.dispose();
|
|
||||||
ws?.close();
|
|
||||||
canvas?.dispose();
|
|
||||||
term.dispose();
|
|
||||||
termRef.current = null;
|
|
||||||
};
|
};
|
||||||
}, [runId, wsBaseUrl]);
|
}, [runId, wsBaseUrl]);
|
||||||
|
|
||||||
@@ -431,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,
|
||||||
@@ -442,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 ?? "";
|
||||||
@@ -583,9 +706,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
data-testid="terminal-drop"
|
||||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||||
drop === "failed" || drop === "imagesOnly"
|
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||||
? "border-red-400 text-red-300"
|
|
||||||
: "border-sky-400 text-sky-200"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`drop.${drop}`)}
|
{t(`drop.${drop}`)}
|
||||||
|
|||||||
@@ -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,14 +25,21 @@ 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(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||||
aiTools: {
|
aiTools: {
|
||||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* @file PaneGrid.test.ts
|
||||||
|
* @description The pane drop preview: it draws where the dragged pane will
|
||||||
|
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||||
|
* current box, and nothing for a drop that changes nothing.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { dropPreview } from "../PaneGrid";
|
||||||
|
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||||
|
|
||||||
|
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||||
|
|
||||||
|
describe("dropPreview", () => {
|
||||||
|
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
|
||||||
|
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pane from another tab splits the target's current box", () => {
|
||||||
|
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the center swaps into the target's box", () => {
|
||||||
|
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing on its own pane or on a refused move", () => {
|
||||||
|
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
|
||||||
|
const full: PaneLayout = {
|
||||||
|
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
|
||||||
|
focus: 0,
|
||||||
|
};
|
||||||
|
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-28
@@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* @file RouterPicker.test.tsx
|
* @file ProxyPicker.test.tsx
|
||||||
* @description The per-session AI Router picker: Native plus the saved
|
* @description The per-session AI Proxy picker: Native plus the saved
|
||||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||||
* remembering the pick, and falling back to Native when the remembered
|
* remembering the pick, and falling back to Native when the remembered
|
||||||
* profile is gone; plus `routerArg`, which never sends native.
|
* profile is gone; plus `proxyArg`, which never sends native.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||||
import type { AiRouter } from "../../../lib/api";
|
import type { AiProxy } from "../../../lib/api";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
list: vi.fn().mockResolvedValue({
|
list: vi.fn().mockResolvedValue({
|
||||||
default: "claude",
|
default: "claude",
|
||||||
tools: [
|
tools: [
|
||||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
].map((x) => ({ ...x, installed: true, enabled: true })),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
aiRouters: { list: vi.fn() },
|
aiProxies: { list: vi.fn() },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
vi.mocked(api.aiRouters.list)
|
vi.mocked(api.aiProxies.list)
|
||||||
.mockReset()
|
.mockReset()
|
||||||
.mockResolvedValue({ items: [native, nine] } as never);
|
.mockResolvedValue({ items: [native, nine] } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RouterPicker", () => {
|
describe("ProxyPicker", () => {
|
||||||
it("lists the saved profiles for a tool that can use one", async () => {
|
it("lists the saved profiles for a tool that can use one", async () => {
|
||||||
render(
|
render(
|
||||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||||
);
|
);
|
||||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
|
||||||
render(
|
render(
|
||||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||||
);
|
);
|
||||||
const picker = screen.getByTestId("router-picker");
|
const picker = screen.getByTestId("proxy-picker");
|
||||||
expect(picker.querySelector("button")).toBeDisabled();
|
expect(picker.querySelector("button")).toBeDisabled();
|
||||||
expect(picker).not.toHaveTextContent("9Router");
|
expect(picker).not.toHaveTextContent("9Router");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to Native when the remembered profile is gone", async () => {
|
it("falls back to Native when the remembered profile is gone", async () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
|
||||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("useAiRouters reads the saved profiles once", async () => {
|
it("useAiProxies reads the saved profiles once", async () => {
|
||||||
const { result } = renderHook(() => useAiRouters());
|
const { result } = renderHook(() => useAiProxies());
|
||||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useSessionRouter / routerArg", () => {
|
describe("useSessionProxy / proxyArg", () => {
|
||||||
it("remembers the last pick for the next session", () => {
|
it("remembers the last pick for the next session", () => {
|
||||||
const first = renderHook(() => useSessionRouter());
|
const first = renderHook(() => useSessionProxy());
|
||||||
expect(first.result.current[0]).toBe("native");
|
expect(first.result.current[0]).toBe("native");
|
||||||
act(() => first.result.current[1]("9router"));
|
act(() => first.result.current[1]("9router"));
|
||||||
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
|
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends a router only for a capable tool and never native", () => {
|
it("still reads the pick remembered before the rename", () => {
|
||||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
localStorage.setItem("agenthub.session.router", "9router");
|
||||||
expect(routerArg(true, "native")).toEqual({});
|
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||||
expect(routerArg(false, "9router")).toEqual({});
|
});
|
||||||
|
|
||||||
|
it("sends a proxy only for a capable tool and never native", () => {
|
||||||
|
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
|
||||||
|
expect(proxyArg(true, "native")).toEqual({});
|
||||||
|
expect(proxyArg(false, "9router")).toEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||||
* same list as one chip: aliases up front, pinned versions under More models,
|
* same list as one chip: aliases up front, pinned versions under More models,
|
||||||
* plus the effort slider.
|
* plus the effort slider.
|
||||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
run: { files: r({ items: [] }), dropImage: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
items: [
|
items: [
|
||||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
skills: r({
|
skills: r({
|
||||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||||
}),
|
}),
|
||||||
|
plugins: r({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: "vercel",
|
||||||
|
enabled: true,
|
||||||
|
contributes: {
|
||||||
|
items: {
|
||||||
|
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
|
||||||
|
commands: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "off",
|
||||||
|
enabled: false,
|
||||||
|
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
|
|||||||
expect(options.map((o) => o.textContent)).toEqual([
|
expect(options.map((o) => o.textContent)).toEqual([
|
||||||
expect.stringContaining("/review"),
|
expect.stringContaining("/review"),
|
||||||
expect.stringContaining("/review-pr"),
|
expect.stringContaining("/review-pr"),
|
||||||
|
// Claude Code's bundled skills, matched on a word of their name.
|
||||||
|
expect.stringContaining("/code-review"),
|
||||||
|
expect.stringContaining("/security-review"),
|
||||||
]);
|
]);
|
||||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||||
// Read for the directory the session will start in, not the server's own.
|
// Read for the directory the session will start in, not the server's own.
|
||||||
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
|
|||||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers enabled plugins' skills under their plugin's name", async () => {
|
||||||
|
renderEditor("");
|
||||||
|
fireEvent.change(box(), { target: { value: "/deplo" } });
|
||||||
|
const options = await screen.findAllByRole("option");
|
||||||
|
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
|
||||||
|
});
|
||||||
|
|
||||||
it("suggests files under the folder on @", async () => {
|
it("suggests files under the folder on @", async () => {
|
||||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||||
@@ -166,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||||
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
const thumb = await screen.findByRole("button", { name: "shot.png" });
|
||||||
expect(api.run.dropImage).toHaveBeenCalledWith(img);
|
expect(api.run.dropFile).toHaveBeenCalledWith(img);
|
||||||
// The path is not typed into the prompt; it goes with it on send.
|
// The path is not typed into the prompt; it goes with it on send.
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
expect(screen.getByTestId("sent").textContent).toBe("see /tmp/agenthub-drops-1/a.png");
|
||||||
|
|
||||||
@@ -175,16 +204,43 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
fireEvent.keyDown(document, { key: "Escape" });
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Remove image" }));
|
fireEvent.click(screen.getByRole("button", { name: "Remove attachment" }));
|
||||||
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "shot.png" })).toBeNull();
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||||
expect(screen.getByTestId("sent").textContent).toBe("see");
|
expect(screen.getByTestId("sent").textContent).toBe("see");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a dropped non-image file as a name chip sent by its path", async () => {
|
||||||
|
vi.mocked(api.run.dropFile).mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/b-spec.pdf" });
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value="read"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash
|
||||||
|
fileCwd="/Users/tester"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const pdf = new File(["%PDF"], "spec.pdf", { type: "application/pdf" });
|
||||||
|
fireEvent.drop(box(), { dataTransfer: { files: [pdf] } });
|
||||||
|
expect(await screen.findByText("spec.pdf")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("read /tmp/agenthub-drops-1/b-spec.pdf");
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves a text paste to the textarea", () => {
|
it("leaves a text paste to the textarea", () => {
|
||||||
renderEditor("");
|
renderEditor("");
|
||||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||||
expect(api.run.dropImage).not.toHaveBeenCalled();
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -211,7 +267,7 @@ describe("ModelPicker", () => {
|
|||||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
|
||||||
render(
|
render(
|
||||||
<ModelPicker
|
<ModelPicker
|
||||||
value=""
|
value=""
|
||||||
|
|||||||
@@ -3,14 +3,15 @@
|
|||||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||||
* a WS connection to the right URL, writes incoming binary frames to the
|
* a WS connection to the right URL, writes incoming binary frames to the
|
||||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||||
|
* after `exit` or a policy close,
|
||||||
* and recolours live on a dark-theme change without re-attaching.
|
* and recolours live on a dark-theme change without re-attaching.
|
||||||
* @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, 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 } 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> = [];
|
||||||
@@ -20,9 +21,9 @@ const wheelHandlerMock = vi.fn();
|
|||||||
const keyHandlerMock = vi.fn();
|
const keyHandlerMock = vi.fn();
|
||||||
const focusMock = vi.fn();
|
const focusMock = vi.fn();
|
||||||
const pasteMock = vi.fn();
|
const pasteMock = vi.fn();
|
||||||
const dropImageMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||||
}));
|
}));
|
||||||
const loadAddonMock = vi.fn();
|
const loadAddonMock = vi.fn();
|
||||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||||
@@ -68,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.
|
||||||
@@ -87,7 +89,7 @@ class MockWebSocket {
|
|||||||
sent: unknown[] = [];
|
sent: unknown[] = [];
|
||||||
onopen: (() => void) | null = null;
|
onopen: (() => void) | null = null;
|
||||||
onmessage: ((e: { data: unknown }) => void) | null = null;
|
onmessage: ((e: { data: unknown }) => void) | null = null;
|
||||||
onclose: (() => void) | null = null;
|
onclose: ((e?: { code: number }) => void) | null = null;
|
||||||
readyState = 1; // OPEN — the component only sends on an open socket
|
readyState = 1; // OPEN — the component only sends on an open socket
|
||||||
static OPEN = 1;
|
static OPEN = 1;
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
@@ -105,6 +107,9 @@ class MockWebSocket {
|
|||||||
global.WebSocket = MockWebSocket;
|
global.WebSocket = MockWebSocket;
|
||||||
|
|
||||||
describe("TerminalView", () => {
|
describe("TerminalView", () => {
|
||||||
|
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||||
|
beforeAll(() => loadXterm());
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
termState.modes.mouseTrackingMode = "none";
|
termState.modes.mouseTrackingMode = "none";
|
||||||
termState.buffer.active.type = "alternate";
|
termState.buffer.active.type = "alternate";
|
||||||
@@ -307,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.
|
||||||
@@ -345,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 () => {
|
||||||
@@ -362,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", () => {
|
||||||
@@ -388,6 +441,23 @@ describe("TerminalView", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("backs off between failed re-attaches, and stops on a policy close (1008)", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
MockWebSocket.instances[0]!.close();
|
||||||
|
vi.advanceTimersByTime(1600);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(2);
|
||||||
|
MockWebSocket.instances[1]!.close(); // failed again: now 3 s
|
||||||
|
vi.advanceTimersByTime(1600);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(2);
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
act(() => MockWebSocket.instances[2]!.onclose?.({ code: 1008 }));
|
||||||
|
vi.advanceTimersByTime(60_000);
|
||||||
|
expect(MockWebSocket.instances).toHaveLength(3);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("does not re-attach once the server reported the pane exited", () => {
|
it("does not re-attach once the server reported the pane exited", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
@@ -400,29 +470,36 @@ describe("TerminalView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("pastes the server-side path of a dropped image into the pane", async () => {
|
it("pastes the server-side path of a dropped image into the pane", async () => {
|
||||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
|
||||||
pasteMock.mockClear();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||||
const target = screen.getByTestId("terminal-view");
|
const target = screen.getByTestId("terminal-view");
|
||||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
|
||||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
await waitFor(() =>
|
||||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||||
|
);
|
||||||
|
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
it("pastes every path of several dropped files, images or not", async () => {
|
||||||
dropImageMock.mockReset();
|
dropFileMock
|
||||||
|
.mockReset()
|
||||||
|
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||||
pasteMock.mockClear();
|
pasteMock.mockClear();
|
||||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||||||
|
const img = new File(["x"], "b.png", { type: "image/png" });
|
||||||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
dataTransfer: { types: ["Files"], files: [txt] },
|
dataTransfer: { types: ["Files"], files: [txt, img] },
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
await waitFor(() =>
|
||||||
expect(dropImageMock).not.toHaveBeenCalled();
|
expect(pasteMock).toHaveBeenCalledWith(
|
||||||
expect(pasteMock).not.toHaveBeenCalled();
|
"/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png "
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,19 +13,26 @@
|
|||||||
* @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",
|
||||||
"apple-dark": "Apple Dark",
|
"apple-dark": "Apple Dark",
|
||||||
"apple-light": "Apple Light",
|
"apple-light": "Apple Light",
|
||||||
"intellij-light": "IntelliJ Light",
|
|
||||||
canvas: "Canvas",
|
canvas: "Canvas",
|
||||||
"orca-dark": "Orca Dark",
|
"orca-dark": "Orca Dark",
|
||||||
"orca-light": "Orca Light",
|
"orca-light": "Orca Light",
|
||||||
@@ -66,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({
|
||||||
@@ -103,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.
|
||||||
@@ -56,6 +62,7 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
|||||||
// badge current the way the old Settings page did, every 10 s.
|
// badge current the way the old Settings page did, every 10 s.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => {
|
const id = setInterval(() => {
|
||||||
|
if (!(document.visibilityState === "visible")) return;
|
||||||
api.settings
|
api.settings
|
||||||
.info()
|
.info()
|
||||||
.then((r) => setHooks(r.hooks))
|
.then((r) => setHooks(r.hooks))
|
||||||
@@ -204,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}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||||
* and Codex, the Antigravity tab itself): Claude
|
* and Codex, the Antigravity tab itself): Claude
|
||||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
|
||||||
* **Show all models** switch lists every row, and a row added outside the
|
* **Show all models** switch lists every row, and a row added outside the
|
||||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -49,7 +51,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
|||||||
prompt: { mode: "argv" },
|
prompt: { mode: "argv" },
|
||||||
resume: false,
|
resume: false,
|
||||||
bypass: false,
|
bypass: false,
|
||||||
routerCapable: false,
|
proxyCapable: false,
|
||||||
install: null,
|
install: null,
|
||||||
update: null,
|
update: null,
|
||||||
modelFlag: false,
|
modelFlag: false,
|
||||||
@@ -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>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
|
|||||||
"Apple Dark",
|
"Apple Dark",
|
||||||
"Orca Dark",
|
"Orca Dark",
|
||||||
"Apple Light",
|
"Apple Light",
|
||||||
"IntelliJ Light",
|
|
||||||
"Canvas",
|
"Canvas",
|
||||||
"Orca Light",
|
"Orca Light",
|
||||||
]) {
|
]) {
|
||||||
@@ -74,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");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||||
|
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||||
import type { PipelineStats } from "../../lib/types";
|
import type { PipelineStats } from "../../lib/types";
|
||||||
|
|
||||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||||
@@ -52,8 +53,12 @@ export function LanePipelinePanel({
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Stages move as agents declare them; throttled (not debounced) so a burst
|
// Stages move as agents declare them; throttled (not debounced) so a burst
|
||||||
// costs one fetch and a steady stream still refreshes every 3 s.
|
// costs one fetch and a steady stream still refreshes every 3 s. A lane's
|
||||||
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
|
// re-broadcast that moved no stage column is ignored.
|
||||||
|
const [stageChanged] = useState(() =>
|
||||||
|
laneChangeMatcher((l) => `${l.pipeline}:${l.stage}:${l.active_feature_id}`)
|
||||||
|
);
|
||||||
|
useLiveRefresh(() => void refresh(), { match: stageChanged });
|
||||||
|
|
||||||
const hasPipelines = !!data && data.pipelines.length > 0;
|
const hasPipelines = !!data && data.pipelines.length > 0;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
|||||||
void refresh();
|
void refresh();
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// Throttled (not debounced): a steady event stream still refreshes every 3 s.
|
// Throttled (not debounced): a steady event stream still refreshes, every
|
||||||
|
// 15 s — it is an aggregate over every event.
|
||||||
useLiveRefresh(() => void refresh(), {
|
useLiveRefresh(() => void refresh(), {
|
||||||
|
throttleMs: 15000,
|
||||||
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
match: (msg) => msg.type === "new_event" || msg.type === "session_updated",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* @file useSpin.test.ts
|
||||||
|
* @description Tests the Refresh-button hook: the reload runs with no
|
||||||
|
* arguments, a click while it is in flight is dropped, and the spin lasts at
|
||||||
|
* least the floor even when the reload answers at once — also under StrictMode's
|
||||||
|
* mount → unmount → mount, which once left it spinning for good.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { useSpin } from "../useSpin";
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("useSpin", () => {
|
||||||
|
it("drops a click while the reload is in flight, and spins for the floor after it answers", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let answer!: () => void;
|
||||||
|
const load = vi.fn(() => new Promise<void>((r) => (answer = r)));
|
||||||
|
const { result } = renderHook(() => useSpin(load, 600));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
expect(load).toHaveBeenCalledWith();
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Answered at once: still spinning until the floor, but a new click reloads.
|
||||||
|
await act(async () => answer());
|
||||||
|
expect(result.current[0]).toBe(true);
|
||||||
|
act(() => {
|
||||||
|
void result.current[1]();
|
||||||
|
});
|
||||||
|
expect(load).toHaveBeenCalledTimes(2);
|
||||||
|
await act(async () => {
|
||||||
|
answer();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops spinning under StrictMode's double mount", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result } = renderHook(() => useSpin(() => undefined, 600), { wrapper: StrictMode });
|
||||||
|
await act(async () => {
|
||||||
|
void result.current[1]();
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
});
|
||||||
|
expect(result.current[0]).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file useThrottledValue.test.ts
|
||||||
|
* @description Tests for `useThrottledValue`: the first change lands at once, a
|
||||||
|
* burst inside the window collapses into one trailing update with the last value.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { useThrottledValue } from "../useThrottledValue";
|
||||||
|
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
describe("useThrottledValue", () => {
|
||||||
|
it("is leading + trailing", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result, rerender } = renderHook(({ v }) => useThrottledValue(v, 3000), {
|
||||||
|
initialProps: { v: "a" },
|
||||||
|
});
|
||||||
|
expect(result.current).toBe("a");
|
||||||
|
act(() => vi.advanceTimersByTime(5000));
|
||||||
|
rerender({ v: "b" });
|
||||||
|
expect(result.current).toBe("b"); // leading
|
||||||
|
rerender({ v: "c" });
|
||||||
|
rerender({ v: "d" });
|
||||||
|
expect(result.current).toBe("b"); // inside the window
|
||||||
|
act(() => vi.advanceTimersByTime(3000));
|
||||||
|
expect(result.current).toBe("d"); // trailing, last value
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
|||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const live = throttle(() => loadRef.current(), throttleMs);
|
const live = throttle(() => loadRef.current(), throttleMs);
|
||||||
const visible = () => document.visibilityState === "visible";
|
const visible = () => document.visibilityState === "visible";
|
||||||
|
// A hidden tab ignores events: onVis reloads once when it is shown again.
|
||||||
const offMsg = eventBus.subscribe((msg) => {
|
const offMsg = eventBus.subscribe((msg) => {
|
||||||
if (matchRef.current?.(msg)) live();
|
if (visible() && matchRef.current?.(msg)) live();
|
||||||
});
|
});
|
||||||
let wasDown = !eventBus.connected;
|
let wasDown = !eventBus.connected;
|
||||||
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
// (Guarded: test doubles of the bus often stub `subscribe` only.)
|
||||||
@@ -47,7 +48,7 @@ export function useLiveRefresh(load: () => void, opts: LiveRefreshOptions = {}):
|
|||||||
if (!up) wasDown = true;
|
if (!up) wasDown = true;
|
||||||
else if (wasDown) {
|
else if (wasDown) {
|
||||||
wasDown = false;
|
wasDown = false;
|
||||||
live();
|
if (visible()) live();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: () => undefined;
|
: () => undefined;
|
||||||
|
|||||||
@@ -121,9 +121,11 @@ export function useNotifications() {
|
|||||||
type: NotifType,
|
type: NotifType,
|
||||||
title: string,
|
title: string,
|
||||||
body: string,
|
body: string,
|
||||||
url?: Promise<string | undefined>
|
// A thunk: the pane lookup (GET /api/run) runs only for a
|
||||||
|
// notification that is actually shown, never per incoming event.
|
||||||
|
url?: () => Promise<string | undefined>
|
||||||
) =>
|
) =>
|
||||||
void Promise.resolve(url)
|
void Promise.resolve(url?.())
|
||||||
.then((u) => notify(title, body, u))
|
.then((u) => notify(title, body, u))
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||||
@@ -138,7 +140,7 @@ export function useNotifications() {
|
|||||||
"onNewSession",
|
"onNewSession",
|
||||||
i18n.t("errors:notifications.newSession"),
|
i18n.t("errors:notifications.newSession"),
|
||||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||||
laneUrlForSession(s.id)
|
() => laneUrlForSession(s.id)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -149,7 +151,7 @@ export function useNotifications() {
|
|||||||
"onSessionError",
|
"onSessionError",
|
||||||
i18n.t("errors:notifications.sessionError"),
|
i18n.t("errors:notifications.sessionError"),
|
||||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||||
laneUrlForSession(s.id)
|
() => laneUrlForSession(s.id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -158,10 +160,7 @@ export function useNotifications() {
|
|||||||
if (!prefs.onSubagentSpawn) return;
|
if (!prefs.onSubagentSpawn) return;
|
||||||
const a = msg.data as Agent;
|
const a = msg.data as Agent;
|
||||||
if (a.type === "subagent") {
|
if (a.type === "subagent") {
|
||||||
show(
|
show("onSubagentSpawn", i18n.t("errors:notifications.subagentSpawned"), a.name, () =>
|
||||||
"onSubagentSpawn",
|
|
||||||
i18n.t("errors:notifications.subagentSpawned"),
|
|
||||||
a.name,
|
|
||||||
laneUrlForSession(a.session_id)
|
laneUrlForSession(a.session_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -169,7 +168,7 @@ export function useNotifications() {
|
|||||||
}
|
}
|
||||||
case "new_event": {
|
case "new_event": {
|
||||||
const ev = msg.data as DashboardEvent;
|
const ev = msg.data as DashboardEvent;
|
||||||
const url = laneUrlForSession(ev.session_id);
|
const url = () => laneUrlForSession(ev.session_id);
|
||||||
// The two states where Claude is BLOCKED on the user get their own
|
// The two states where Claude is BLOCKED on the user get their own
|
||||||
// flags (issue #29): `Notification` is a permission prompt or a
|
// flags (issue #29): `Notification` is a permission prompt or a
|
||||||
// question, `Stop` is the end of a turn. Both used to hang off
|
// question, `Stop` is the end of a turn. Both used to hang off
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user