Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7f3735b29 | |||
| a9c71cd905 | |||
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 | |||
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 |
@@ -91,6 +91,11 @@
|
||||
# (default: enabled).
|
||||
# AGENTHUB_PRICING_SYNC=0
|
||||
|
||||
# The status bar's Usage panel reads Antigravity's quota by running
|
||||
# `agy --print /usage --output-format json` (no model call), which may let agy
|
||||
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
|
||||
# AGENTHUB_AGY_USAGE=0
|
||||
|
||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||
# their own connection so they never stall other requests. 0 runs them inline
|
||||
# on the event loop instead (default: worker). A job past the timeout answers
|
||||
|
||||
@@ -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.
|
||||
- Small, scoped, reversible diffs.
|
||||
- Preserve existing behavior unless change is requested.
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||
|
||||
## Workspace stage
|
||||
|
||||
+11
-6
@@ -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 |
|
||||
| `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/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/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 |
|
||||
@@ -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-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-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/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
|
||||
|
||||
- **`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`.
|
||||
- **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.
|
||||
@@ -911,10 +911,10 @@ erDiagram
|
||||
| `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_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_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_sessions_status` | sessions | `status` | Status filter on Sessions page and Kanban Sessions view |
|
||||
| `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**
|
||||
sync poller and the `/api/remote-sources` routes) carries
|
||||
`{ 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/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. |
|
||||
| **`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. |
|
||||
|
||||
+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
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
Follow the steps below instead when you want a checkout — to develop the
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
@@ -323,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
|
||||
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
|
||||
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# AgentHub
|
||||
|
||||
[Tiếng Việt](README.md) · **English**
|
||||
|
||||
A dashboard that runs on your machine to watch and drive your Claude Code (and
|
||||
Codex) sessions from the browser.
|
||||
|
||||
| Section | Tiếng Việt |
|
||||
| --- | --- |
|
||||
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
|
||||
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
|
||||
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
|
||||
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
|
||||
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
To install them:
|
||||
|
||||
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
## Option 1 — Install through Claude Code (recommended)
|
||||
|
||||
Send **each line on its own** straight into the Claude Code chat:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
```
|
||||
|
||||
```
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
The last one prints the dashboard's address (by default <http://localhost:4820>) —
|
||||
open it in the browser and you are done.
|
||||
|
||||
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||
To update to a new version: `/agenthub:agenthub-update`.
|
||||
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
|
||||
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
|
||||
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
|
||||
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Option 2 — Install from a terminal (without Claude Code)
|
||||
|
||||
Run these in a terminal, one after another:
|
||||
|
||||
```bash
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # install the dependencies + the agenthub command
|
||||
npm run build # build the UI
|
||||
npm start # run the dashboard
|
||||
```
|
||||
|
||||
Open <http://localhost:4820> in the browser.
|
||||
|
||||
`npm start` stops when the terminal closes. To have the dashboard start every time
|
||||
the machine boots:
|
||||
|
||||
```bash
|
||||
agenthub service install
|
||||
```
|
||||
|
||||
> ⚠️ Pick **only one** of the two options. With both installed, every event is
|
||||
> counted twice.
|
||||
|
||||
<a id="more-docs"></a>
|
||||
|
||||
## More docs
|
||||
|
||||
- Vietnamese README: [README.md](README.md)
|
||||
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
|
||||
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
|
||||
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
Internal build — all rights reserved.
|
||||
@@ -1,360 +1,94 @@
|
||||
# AgentHub
|
||||
|
||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||
Express + SQLite server and a React UI updates over WebSocket; **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.
|
||||
**Tiếng Việt** · [English](README.en.md)
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
every decision.
|
||||
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
|
||||
(và Codex) ngay trên trình duyệt.
|
||||
|
||||
Internal build — all rights reserved.
|
||||
| Phần | English |
|
||||
| --- | --- |
|
||||
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
|
||||
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
|
||||
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
|
||||
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Main features
|
||||
## Yêu cầu
|
||||
|
||||
**Observe**
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||
targets that fire on them.
|
||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||
startup; archives from another machine come in through Settings → Import
|
||||
History (idempotent, never double-counts tokens).
|
||||
- **Remote sources.** Pull session history from other machines over SSH
|
||||
(`agenthub remote-sources`).
|
||||
Cài:
|
||||
|
||||
**Drive**
|
||||
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a workspace moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a 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`).
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
**Configure**
|
||||
## Cách 1 — Cài qua Claude Code (khuyên dùng)
|
||||
|
||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
||||
other its conversation with a prompt box — sending a message resumes it,
|
||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||
prompt box that starts a new session, Recents, and the workspace's own
|
||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
||||
sessions start with.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||
untracked worktrees a workspace's Add folder suggests.
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
(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`:
|
||||
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
```
|
||||
|
||||
```
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
||||
PATH; it runs detached, so the session never waits on
|
||||
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
||||
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||
Details, including what to delete on uninstall:
|
||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root and client dependencies
|
||||
npm run build # builds the client into client/dist
|
||||
npm start # serves the built client and the API on :4820
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
Open <http://localhost:4820>.
|
||||
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
|
||||
trên trình duyệt là xong.
|
||||
|
||||
Development, with hot reload:
|
||||
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
|
||||
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
|
||||
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
|
||||
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
|
||||
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
|
||||
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
|
||||
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Cách 2 — Cài bằng terminal (không qua Claude Code)
|
||||
|
||||
Chạy lần lượt trong terminal:
|
||||
|
||||
```bash
|
||||
npm run dev # server on :4820, Vite client on :5173
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # cài thư viện + lệnh agenthub
|
||||
npm run build # build giao diện
|
||||
npm start # chạy dashboard
|
||||
```
|
||||
|
||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
||||
every event is counted twice.
|
||||
Mở <http://localhost:4820> trên trình duyệt.
|
||||
|
||||
### Run as a service (auto-start on boot)
|
||||
|
||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||
to trigger it:
|
||||
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
agenthub service install
|
||||
```
|
||||
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. 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`.
|
||||
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
|
||||
|
||||
## The CLI
|
||||
<a id="more-docs"></a>
|
||||
|
||||
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
||||
## Tài liệu thêm
|
||||
|
||||
```bash
|
||||
agenthub status # is the dashboard up
|
||||
agenthub start # start it in the background and wait for healthy
|
||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
||||
agenthub sessions # recent sessions
|
||||
agenthub workspaces # workspaces with stage and progress
|
||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
- README tiếng Anh: [README.en.md](README.en.md)
|
||||
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
|
||||
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
|
||||
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
`agenthub --help` lists the rest.
|
||||
|
||||
## 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
|
||||
Internal build — all rights reserved.
|
||||
|
||||
+404
-5
@@ -40,7 +40,7 @@
|
||||
|
||||
const path = require("node:path");
|
||||
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
|
||||
// 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;
|
||||
res = await fetch(url, {
|
||||
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,
|
||||
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
|
||||
* 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
|
||||
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
|
||||
* printed and registered in ~/.claude/.agenthub.json).
|
||||
* not stop the dashboard; stop it later with `agenthub stop`.
|
||||
*/
|
||||
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()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
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");
|
||||
if (!fs.existsSync(clientDist)) {
|
||||
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(
|
||||
`${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;
|
||||
}
|
||||
tick++;
|
||||
@@ -1457,6 +1485,79 @@ async function cmdStart(flags) {
|
||||
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
|
||||
* (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}`);
|
||||
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) {
|
||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
@@ -2714,11 +2826,36 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["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",
|
||||
"install|uninstall|status [--port N]",
|
||||
"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)"],
|
||||
],
|
||||
],
|
||||
@@ -2916,6 +3053,11 @@ const COMMAND_GROUPS = [
|
||||
"",
|
||||
"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 install|uninstall",
|
||||
@@ -3668,6 +3810,9 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
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"],
|
||||
webhooks: ["test"],
|
||||
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
|
||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
@@ -3723,8 +4110,18 @@ async function runCommand(argv) {
|
||||
return cmdStatus();
|
||||
case "start":
|
||||
return cmdStart(flags);
|
||||
case "stop":
|
||||
return cmdStop();
|
||||
case "uninstall":
|
||||
return cmdUninstall(flags);
|
||||
case "service":
|
||||
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":
|
||||
return cmdStats();
|
||||
case "kanban":
|
||||
@@ -3852,6 +4249,8 @@ async function runCommand(argv) {
|
||||
return;
|
||||
}
|
||||
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||
case "tray":
|
||||
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
|
||||
case "open":
|
||||
return cmdOpen();
|
||||
case "version":
|
||||
|
||||
+13
-7
@@ -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
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (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:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
||||
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
@@ -199,7 +199,7 @@ client/
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → 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
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
@@ -259,7 +259,7 @@ graph TB
|
||||
Detail[SessionDetail<br/>agent hierarchy + timeline]
|
||||
Feed[ActivityFeed<br/>streaming event log]
|
||||
Workflows[Workflows<br/>Analytics → Workflow sub-tab:<br/>lane pipelines + D3 analytics]
|
||||
Settings[SettingsHub /settings/:section<br/>General · Appearance · Notifications<br/>Agents: AI tools, Claude Code, Codex, each catalog tool<br/>Routing: AI Proxy · Cost: Pricing · Git: credentials, worktree suggestions]
|
||||
Settings[SettingsHub /settings/:section<br/>General: Appearance · Notifications · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
@@ -525,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
|
||||
|
||||
@@ -793,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
|
||||
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
|
||||
|
||||
Generated
+629
-548
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
||||
+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>
|
||||
*/
|
||||
|
||||
// Bump this any time the SW logic changes - old clients will install the new
|
||||
// SW, drop their existing caches in `activate`, and `skipWaiting` so the
|
||||
// freshly-built bundle starts being served on the very next request.
|
||||
const CACHE_NAME = "dashboard-v2";
|
||||
// v3: drops v2's cache-first `/assets/` entries, which grew with every build
|
||||
// and were never pruned.
|
||||
const CACHE_NAME = "dashboard-v3";
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
// No pre-cache: network-first below means the cache fills lazily, and
|
||||
@@ -39,25 +42,10 @@ self.addEventListener("fetch", (event) => {
|
||||
)
|
||||
return;
|
||||
|
||||
// Hashed bundles under /assets/ are immutable for a given URL - cache-first
|
||||
// is safe and fast. A new build emits new filenames, so stale entries simply
|
||||
// don't get re-requested.
|
||||
if (url.pathname.startsWith("/assets/")) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(
|
||||
(cached) =>
|
||||
cached ||
|
||||
fetch(request).then((response) => {
|
||||
if (response.ok && response.type === "basic") {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Hashed bundles under /assets/ are immutable and the server marks them so:
|
||||
// the browser's HTTP cache keeps them. Caching them here too only grew a
|
||||
// second copy of every build, forever.
|
||||
if (url.pathname.startsWith("/assets/")) return;
|
||||
|
||||
// Everything else (navigations, sw.js, manifest, icons, root /): network-first
|
||||
// with cache fallback. The user always gets the freshest UI while online and
|
||||
|
||||
+31
-7
@@ -38,8 +38,8 @@
|
||||
* ## Internal dependencies
|
||||
* - `./components/Layout`
|
||||
* - `./components/SplashScreen`
|
||||
* - `./pages/Dashboard`
|
||||
* - `./pages/History`
|
||||
* - `./pages/Dashboard` (lazy)
|
||||
* - `./pages/History` (lazy)
|
||||
* - `./pages/SessionDetail` (lazy)
|
||||
* - `./pages/SettingsHub` (lazy)
|
||||
* - `./pages/Workspace` (lazy)
|
||||
@@ -64,8 +64,6 @@ import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { History } from "./pages/History";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
@@ -75,7 +73,10 @@ import { reloadOnNewBuild } from "./lib/newBuildReload";
|
||||
import type { WSMessage } from "./lib/types";
|
||||
|
||||
// The heavy pages (xterm, the diff viewer, the settings editors) load on first
|
||||
// visit instead of in the initial bundle.
|
||||
// visit instead of in the initial bundle — the Dashboard and History too, so the
|
||||
// entry chunk holds only the shell.
|
||||
const Dashboard = lazy(() => import("./pages/Dashboard").then((m) => ({ default: m.Dashboard })));
|
||||
const History = lazy(() => import("./pages/History").then((m) => ({ default: m.History })));
|
||||
const Workspace = lazy(() => import("./pages/Workspace").then((m) => ({ default: m.Workspace })));
|
||||
const SettingsHub = lazy(() =>
|
||||
import("./pages/SettingsHub").then((m) => ({ default: m.SettingsHub }))
|
||||
@@ -86,6 +87,7 @@ const SessionDetail = lazy(() =>
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -156,8 +158,22 @@ export default function App() {
|
||||
<DocumentTitle />
|
||||
<Routes>
|
||||
<Route element={<Layout wsConnected={connected} />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Page>
|
||||
<Dashboard />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions"
|
||||
element={
|
||||
<Page>
|
||||
<History />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="sessions/:id"
|
||||
element={
|
||||
@@ -194,6 +210,14 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -92,6 +92,8 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
|
||||
import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -283,7 +285,9 @@ export function AlertsNotifications() {
|
||||
const { t } = useTranslation("alerts");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("rules");
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
@@ -342,6 +346,7 @@ export function AlertsNotifications() {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly, t]);
|
||||
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -359,8 +364,8 @@ export function AlertsNotifications() {
|
||||
}, [unackedOnly, alerts.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
if (isAdmin) loadRules();
|
||||
}, [loadRules, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
@@ -502,7 +507,7 @@ export function AlertsNotifications() {
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
key: "rules",
|
||||
label: ts("alertsHub.tabRules"),
|
||||
@@ -517,6 +522,7 @@ export function AlertsNotifications() {
|
||||
badge: unacked || undefined,
|
||||
},
|
||||
];
|
||||
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1017,11 +1023,13 @@ export function AlertsNotifications() {
|
||||
label={t("feed.unackedOnly")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => loadAlerts()}
|
||||
onClick={refreshAlerts}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{unacked > 0 && (
|
||||
<button
|
||||
@@ -1082,7 +1090,7 @@ export function AlertsNotifications() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.acknowledged_at && (
|
||||
{!alert.acknowledged_at && alert.can_ack !== false && (
|
||||
<button
|
||||
onClick={() => onAck(alert.id)}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./Sagi/Sagi`
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `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 { Sidebar, SIDEBAR_STORAGE_KEY, loadCollapsed } from "./Sidebar";
|
||||
import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { Sagi } from "./Sagi/Sagi";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
@@ -171,7 +171,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<SagiMount />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BellRing } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import { timeAgo } from "../lib/format";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher, runKeyOf } from "../lib/laneChange";
|
||||
import { workspaceHref } from "./SessionRowMenu";
|
||||
import type { Session } from "../lib/types";
|
||||
|
||||
@@ -32,6 +33,9 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch(() => undefined); // the strip is a shortcut; the pages show errors
|
||||
}, []);
|
||||
// The live panes (for the links) move only when a lane's run columns do.
|
||||
const loadRuns = useCallback(() => {
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => setRuns(r.items))
|
||||
@@ -40,11 +44,14 @@ export function NeedsYouStrip({ compact = false }: { compact?: boolean }) {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
loadRuns();
|
||||
}, [load, loadRuns]);
|
||||
useLiveRefresh(load, {
|
||||
intervalMs: 15000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
const [runChanged] = useState(() => laneChangeMatcher(runKeyOf));
|
||||
useLiveRefresh(loadRuns, { intervalMs: 15000, match: runChanged });
|
||||
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const ITEM =
|
||||
@@ -50,6 +51,8 @@ export function PetMenu({
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
// Adding or deleting a pet changes the whole install: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
@@ -133,6 +136,7 @@ export function PetMenu({
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
if (!have && !isAdmin) return null;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -173,48 +177,54 @@ export function PetMenu({
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet, 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
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* 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
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -19,9 +21,29 @@ import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
const COLS = 8;
|
||||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||
const frames = (n: number, step: number, last: number) =>
|
||||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||
* how a sleeping pet breathes here. */
|
||||
const DURATIONS = [
|
||||
[280, 110, 110, 140, 140, 320], // idle
|
||||
frames(8, 120, 220), // run right
|
||||
frames(8, 120, 220), // run left
|
||||
frames(4, 140, 280), // waving
|
||||
frames(5, 140, 280), // jumping
|
||||
frames(8, 140, 240), // failed
|
||||
frames(6, 150, 260), // waiting
|
||||
frames(6, 120, 220), // running (working)
|
||||
frames(6, 150, 280), // review
|
||||
];
|
||||
const SLEEP_SLOWDOWN = 6;
|
||||
/** Rows that play three times and settle into idle; the others loop. */
|
||||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||
const GAZE_ROW = 9;
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
@@ -36,25 +58,13 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** 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({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -63,6 +73,8 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
@@ -70,24 +82,30 @@ export function PetSprite({
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
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(() => {
|
||||
setFrame(0);
|
||||
setCell({ of: row, row, col: 0 });
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
const play = (r: number, slow = 1) =>
|
||||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
}, list[i]!.ms);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
@@ -96,10 +114,13 @@ export function PetSprite({
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||
const probe = new Image();
|
||||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onload = null;
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -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;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
data-row={shown.row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
@@ -132,8 +160,8 @@ export function PetSprite({
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @file Sagi.tsx
|
||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
@@ -81,6 +85,7 @@ import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
import { matchIntent } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
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() {
|
||||
return useSagiEnabled() ? <SagiBody /> : null;
|
||||
}
|
||||
|
||||
function SagiBody() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
const [pet, setPet] = useState(() => sagiPrefs.getPet());
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -209,11 +218,11 @@ export function Sagi() {
|
||||
const brain = useSagiBrain();
|
||||
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(
|
||||
() =>
|
||||
sagiPrefs.subscribe(() => {
|
||||
setEnabled(sagiPrefs.getEnabled());
|
||||
setPet(sagiPrefs.getPet());
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
@@ -223,7 +232,6 @@ export function Sagi() {
|
||||
// ⌘B / Ctrl+B toggles the panel; Esc closes it. Only while the mascot is
|
||||
// shown, and never from an editable field, where Ctrl+B belongs to the field.
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "b") {
|
||||
if (isEditable(e.target) || isEditable(document.activeElement)) return;
|
||||
@@ -235,7 +243,33 @@ export function Sagi() {
|
||||
};
|
||||
window.addEventListener("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(
|
||||
(route: string) => {
|
||||
@@ -279,8 +313,6 @@ export function Sagi() {
|
||||
[brain, navigate, t]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
@@ -329,6 +361,7 @@ export function Sagi() {
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={btn}
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
@@ -363,6 +396,7 @@ export function Sagi() {
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
|
||||
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 rafRef = useRef<number>();
|
||||
const [pupil, setPupil] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
@@ -89,7 +100,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tracking) {
|
||||
setPupil({ x: 0, y: 0 });
|
||||
setPupil(rootRef.current, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +116,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
// Normalize then clamp to the eye socket range.
|
||||
const nx = (dx / dist) * Math.min(1, dist / 240);
|
||||
const ny = (dy / dist) * Math.min(1, dist / 240);
|
||||
setPupil({ x: nx * MAX_PUPIL_SHIFT, y: ny * MAX_PUPIL_SHIFT });
|
||||
setPupil(el, nx * MAX_PUPIL_SHIFT, ny * MAX_PUPIL_SHIFT);
|
||||
};
|
||||
|
||||
// Immediately aim at the last-known cursor (next frame, so layout is ready)
|
||||
@@ -202,7 +213,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-left">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -226,7 +237,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
<g className="sagi-eye sagi-eye-right">
|
||||
<g
|
||||
className="sagi-eye-track"
|
||||
style={{ transform: `translate(${pupil.x}px, ${pupil.y}px)` }}
|
||||
style={{ transform: "translate(var(--sagi-px, 0px), var(--sagi-py, 0px))" }}
|
||||
>
|
||||
<g className="sagi-eye-blink">
|
||||
<path
|
||||
@@ -792,4 +803,4 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @file SagiMount.tsx
|
||||
* @description Gate for the Sagi mascot: follows the enabled preference
|
||||
* (`useSagiEnabled`) and only while it is on lazy-loads and mounts `Sagi` — so
|
||||
* a disabled mascot costs no chunk download, no brain, no 1 Hz tick and no
|
||||
* WebSocket reducer.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { lazy, Suspense, useEffect, useState } from "react";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
const Sagi = lazy(() => import("./Sagi").then((m) => ({ default: m.Sagi })));
|
||||
|
||||
/** The mascot's enabled preference, followed live (Settings, other tabs). */
|
||||
export function useSagiEnabled(): boolean {
|
||||
const [enabled, setEnabled] = useState(() => sagiPrefs.getEnabled());
|
||||
useEffect(() => sagiPrefs.subscribe(() => setEnabled(sagiPrefs.getEnabled())), []);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function SagiMount() {
|
||||
if (!useSagiEnabled()) return null;
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Sagi />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
act(() => vi.advanceTimersByTime(110));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
|
||||
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(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
|
||||
/>
|
||||
);
|
||||
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");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => vi.advanceTimersByTime(310));
|
||||
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");
|
||||
});
|
||||
|
||||
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||
expect(sprite.dataset.row).toBe("4");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(sprite.dataset.row).toBe("0");
|
||||
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalHeight = 2288;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// Pose 12 (270°, left) = row 10, column 4.
|
||||
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
gaze={4}
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
|
||||
@@ -168,7 +168,10 @@ export function useSagiBrain(): SagiBrain {
|
||||
// Advance the clock so timed moods (stuck/sleeping, and exit from
|
||||
// happy/worried) re-evaluate without needing a new event.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), 1000);
|
||||
// Hidden tab: no re-render; the first visible tick catches the mood up.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") setTick(Date.now());
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -84,6 +84,8 @@ export interface SelectOption<T extends string> {
|
||||
hint?: string;
|
||||
/** Optional leading icon, shown in the trigger and the list. */
|
||||
icon?: ReactNode;
|
||||
/** Draw a rule above this option: it starts a group of its own. */
|
||||
divider?: boolean;
|
||||
}
|
||||
|
||||
/** Props for {@link Select}. */
|
||||
@@ -272,6 +274,8 @@ export function Select<T extends string>({
|
||||
onClick={() => choose(opt)}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -81,19 +81,22 @@ interface SessionOverviewProps {
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
help,
|
||||
icon,
|
||||
tone = "default",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
hint?: string;
|
||||
/** Plain-language explanation, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
icon: React.ReactNode;
|
||||
tone?: "default" | "violet" | "emerald" | "amber" | "rose" | "cyan" | "blue";
|
||||
}) {
|
||||
@@ -120,9 +123,14 @@ function StatTile({
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border px-3 py-2.5 ${palette}`}>
|
||||
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<div
|
||||
className={`flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-fg-muted${help ? " cursor-help" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
<span className={iconTone}>{icon}</span>
|
||||
{label}
|
||||
<span className={help ? "underline decoration-dotted underline-offset-2" : undefined}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-lg font-semibold text-fg-primary leading-tight">
|
||||
{value}
|
||||
@@ -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">
|
||||
<StatTile
|
||||
label={t("overview.events")}
|
||||
help={t("common:insights.help.events")}
|
||||
value={stats.total_events.toLocaleString()}
|
||||
hint={
|
||||
eventRate !== null && eventRate > 0
|
||||
@@ -322,6 +331,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.toolCalls")}
|
||||
help={t("common:insights.help.toolCalls")}
|
||||
value={toolCallCount.toLocaleString()}
|
||||
hint={distinctTools > 0 ? t("overview.unique", { count: distinctTools }) : undefined}
|
||||
icon={<Wrench className="w-3 h-3" />}
|
||||
@@ -329,6 +339,7 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.subagents")}
|
||||
help={t("common:insights.help.subagents")}
|
||||
value={stats.agents.subagent.toLocaleString()}
|
||||
hint={
|
||||
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
|
||||
label={t("overview.compactions")}
|
||||
help={t("common:insights.help.compactions")}
|
||||
value={stats.agents.compaction.toLocaleString()}
|
||||
icon={<Layers className="w-3 h-3" />}
|
||||
tone="blue"
|
||||
/>
|
||||
<StatTile
|
||||
label={t("overview.errors")}
|
||||
help={t("common:insights.help.errors")}
|
||||
value={stats.error_count.toLocaleString()}
|
||||
icon={<AlertTriangle className="w-3 h-3" />}
|
||||
tone={stats.error_count > 0 ? "rose" : "default"}
|
||||
@@ -478,7 +491,10 @@ export function SessionOverview({ session, agents, stats: frozen }: SessionOverv
|
||||
{totalTokens > 0 && (
|
||||
<div className="rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
|
||||
<div className="flex items-center justify-between mb-2.5">
|
||||
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
|
||||
<h3
|
||||
className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5 cursor-help"
|
||||
title={t("common:insights.help.tokenFlow")}
|
||||
>
|
||||
<Coins className="w-3.5 h-3.5 text-status-warning" />
|
||||
{t("overview.tokenFlow")}
|
||||
</h3>
|
||||
|
||||
@@ -69,9 +69,12 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -188,6 +191,9 @@ export function Sidebar({
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
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 drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -461,7 +467,15 @@ export function Sidebar({
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-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);
|
||||
return (
|
||||
<NavLink
|
||||
@@ -531,6 +545,22 @@ export function Sidebar({
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</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
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
@@ -565,7 +595,7 @@ export function Sidebar({
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: onCheckUpdates,
|
||||
onCheck: canUpdate ? onCheckUpdates : undefined,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
@@ -607,7 +637,8 @@ interface ConnectionStatusModalProps {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
failed: boolean;
|
||||
onCheck: () => void;
|
||||
/** Absent for a user who may not update (server mode, not an admin). */
|
||||
onCheck?: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
@@ -749,18 +780,20 @@ function ConnectionStatusModal({
|
||||
{update.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
{update.onCheck && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
* ## Public surface
|
||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -52,11 +51,6 @@
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TextSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TableRowSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text placeholder. Good for trend/sub labels.
|
||||
*/
|
||||
export function TextSkeleton({
|
||||
className = "",
|
||||
width = "w-16",
|
||||
}: {
|
||||
className?: string;
|
||||
width?: string;
|
||||
}) {
|
||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||
* the skeleton lines up under existing headers.
|
||||
|
||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
||||
loading?: boolean;
|
||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||
to?: string;
|
||||
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,11 +94,15 @@ export function StatCard({
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
help,
|
||||
}: StatCardProps) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
<span
|
||||
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||
|
||||
@@ -24,17 +24,26 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -121,6 +130,8 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -131,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{q.plan && (
|
||||
<span
|
||||
data-testid="status-bar-quota-plan"
|
||||
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||
>
|
||||
{q.plan}
|
||||
</span>
|
||||
)}
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
@@ -143,12 +162,17 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</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">
|
||||
{q.windows.map((w) => {
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w, i) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.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">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
@@ -159,16 +183,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
||||
<span className="text-right text-fg-muted">
|
||||
{resets && t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -264,15 +281,15 @@ function QuotaChips() {
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const load = useCallback((force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined)
|
||||
.finally(() => force && setRefreshing(false));
|
||||
}, []);
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined),
|
||||
[]
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
@@ -281,7 +298,7 @@ function QuotaChips() {
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
onClick={refresh}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
@@ -313,7 +330,7 @@ function QuotaChips() {
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
* - Background re-check via `api.updates.check()` every hour
|
||||
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
||||
* - No client-side background re-check: the server checks on its own
|
||||
* schedule and broadcasts `update_status`; "Check now" stays manual.
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
@@ -88,13 +88,11 @@ import { copyText } from "../lib/clipboard";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream 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
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
@@ -128,6 +126,7 @@ function loadDismissedSha(): string | null {
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { isAdmin } = usePermissions();
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
@@ -172,19 +171,6 @@ export function UpdateNotifier() {
|
||||
});
|
||||
}, [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(() => {
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
@@ -206,7 +192,9 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
|
||||
@@ -5,22 +5,32 @@
|
||||
* (`WorkspaceSessionList.tsx`) — no repo grouping, no worktree rows. Picking a
|
||||
* workspace — or `+` on its row — opens its home (`/workspace?lane=<id>`); a
|
||||
* live tab is opened from its session row. Live status comes from the shared
|
||||
* lane list (`useLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves and handed over by the Workspace page's own
|
||||
* poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals so the row menu works from any page. Which rows are
|
||||
* lane list (`useWorkLanes`), live panes from `GET /api/run`, re-read whenever a
|
||||
* lane's status or session moves (at most every 3 s) and handed over by the
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
insertionIndex,
|
||||
moveBefore,
|
||||
@@ -33,8 +43,15 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { AddLaneModal } from "./lanes/AddLaneModal";
|
||||
import { DestructiveLaneModal } from "./lanes/DestructiveLaneModal";
|
||||
|
||||
// The 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, pane run and replayed session the URL names (Workspace page only). */
|
||||
@@ -53,7 +70,7 @@ export function WorkspaceTree() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { lanes, refresh: refreshLanes } = useWorkLanes();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
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
|
||||
// status or session moves, so that is when the live-pane list is re-read;
|
||||
// the Workspace page also hands over every run list it polls (a tab in
|
||||
// slots 1–3 moves no lane column).
|
||||
const runKey = lanes.map((l) => `${l.id}:${l.status}:${l.session_id}:${l.run_id}`).join(",");
|
||||
// slots 1–3 moves no lane column). Throttled: with two sessions in one
|
||||
// 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(() => {
|
||||
let live = true;
|
||||
api.run
|
||||
@@ -349,28 +370,32 @@ export function WorkspaceTree() {
|
||||
onAction={(a) => onMenuAction(menu.lane, a)}
|
||||
/>
|
||||
)}
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
<AddLaneModal
|
||||
open={addOpen}
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
{destructive && (
|
||||
<DestructiveLaneModal
|
||||
lane={destructive.lane}
|
||||
action={destructive.action}
|
||||
open
|
||||
onClose={() => setDestructive(null)}
|
||||
onConfirm={(body) => void confirmDestructive(body)}
|
||||
/>
|
||||
)}
|
||||
{addOpen && (
|
||||
<AddLaneModal
|
||||
open
|
||||
cwdSuggestions={cwdSuggestions}
|
||||
prefill={addPrefill}
|
||||
onClose={() => {
|
||||
setAddOpen(false);
|
||||
setAddPrefill(null);
|
||||
}}
|
||||
onAdded={(lane) => {
|
||||
void refreshLanes();
|
||||
select(lane.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</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", () => ({
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
sessions: {
|
||||
// 3 active sessions, 1 of them waiting on the user.
|
||||
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
||||
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
plan: "max",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
|
||||
windows: [],
|
||||
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: "",
|
||||
}),
|
||||
@@ -214,10 +232,10 @@ describe("StatusBar", () => {
|
||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||
await user.hover(chips);
|
||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workmaxin use");
|
||||
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(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
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.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
@@ -244,7 +267,11 @@ describe("StatusBar", () => {
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||
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();
|
||||
await user.unhover(panel);
|
||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||
|
||||
@@ -81,8 +81,6 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
cwds: vi.fn(async () => ({ items: [] })),
|
||||
kill: vi.fn(async () => ({ ok: true })),
|
||||
history: vi.fn(async () => ({ items: [] })),
|
||||
forgetHistory: vi.fn(async () => ({ removed: "" })),
|
||||
},
|
||||
aiTools: {
|
||||
list: vi.fn(async () => ({ tools: [], default: "claude" })),
|
||||
@@ -180,6 +178,10 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
await screen.findByText("Session 8");
|
||||
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 () => {
|
||||
@@ -309,6 +311,23 @@ describe("WorkspaceTree", () => {
|
||||
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 () => {
|
||||
vi.mocked(api.run.list).mockImplementation(
|
||||
async () =>
|
||||
@@ -351,97 +370,6 @@ describe("WorkspaceTree", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree("/workspace?lane=2");
|
||||
|
||||
@@ -59,6 +59,7 @@ import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
@@ -75,9 +76,16 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
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 [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -90,6 +98,8 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
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
|
||||
const lastLineRef = useRef(0);
|
||||
@@ -106,7 +116,6 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const genRef = useRef(0);
|
||||
// Refresh-button spinner state - separate from initial `loading` so the
|
||||
// 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
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
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)) {
|
||||
fetchNewMessages();
|
||||
return;
|
||||
@@ -303,14 +314,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetchNewMessages();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fetchNewMessages]);
|
||||
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||
|
||||
// Scroll-up to load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
@@ -375,6 +379,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Detect if at bottom
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setAwayFromEnd(!atBottom);
|
||||
|
||||
// Hide "new messages" indicator when scrolled to bottom
|
||||
if (atBottom) {
|
||||
@@ -395,10 +400,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && (
|
||||
{!loading && !fill && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -446,7 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
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 */}
|
||||
{loadingHistory && (
|
||||
@@ -479,8 +484,28 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
)}
|
||||
</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 */}
|
||||
{showNewMsg && (
|
||||
{showNewMsg && !fill && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
|
||||
@@ -381,6 +381,8 @@ interface MarkdownContentProps {
|
||||
text: string;
|
||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||
dense?: boolean;
|
||||
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
|
||||
prose?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_STYLES = [
|
||||
@@ -392,12 +394,16 @@ const HEADING_STYLES = [
|
||||
"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 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 (
|
||||
<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) => {
|
||||
switch (b.kind) {
|
||||
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"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</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"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -503,7 +509,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
|
||||
case "para":
|
||||
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}`)}
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -111,7 +111,7 @@ const SENDER_STYLES: Record<
|
||||
headerText: "text-status-warning",
|
||||
},
|
||||
};
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ToolCallBlock, buildSummary, renderInput, renderResult } from "./ToolCallBlock";
|
||||
import { MarkdownContent } from "./MarkdownContent";
|
||||
import { fmt, formatModelName } from "../../lib/format";
|
||||
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 */
|
||||
function CollapsibleBlock({
|
||||
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) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const toolResultMap = useMemo(() => buildToolResultMap(messages), [messages]);
|
||||
const keys = useMemo(() => messageKeys(messages), [messages]);
|
||||
const items = useMemo(() => chatItems(messages, keys), [messages, keys]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -344,23 +434,280 @@ export function MessageList({ messages, loading }: MessageListProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{messages.map((msg, idx) => {
|
||||
// Session lifecycle markers (e.g. /rename) render as a centered chip,
|
||||
// not as a user/assistant row.
|
||||
if (msg.type === "session_event") {
|
||||
return <SessionEventRow key={keys[idx]} title={msg.title} timestamp={msg.timestamp} />;
|
||||
<div className="mx-auto w-full max-w-3xl space-y-4 px-4 py-4">
|
||||
{items.map((item) => {
|
||||
switch (item.kind) {
|
||||
case "event":
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
* new rows; its thinking-block expansion is its own state, kept by key. */
|
||||
const MessageRow = memo(function MessageRow({
|
||||
@@ -419,59 +766,7 @@ const MessageRow = memo(function MessageRow({
|
||||
|
||||
{/* Content blocks */}
|
||||
{msg.content.map((block, bIdx) => {
|
||||
if (block.type === "text" && block.text) {
|
||||
// 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 === "text" && block.text) return renderTextBlock(block.text, bIdx);
|
||||
|
||||
if (block.type === "thinking" && block.text) {
|
||||
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. */
|
||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
export function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const input = toolUse.input;
|
||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||
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}`);
|
||||
|
||||
/** Render the input pane with tool-aware formatting. */
|
||||
function renderInput(toolUse: TranscriptContent) {
|
||||
export function renderInput(toolUse: TranscriptContent) {
|
||||
const input = toolUse.input;
|
||||
if (!input) return null;
|
||||
|
||||
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
}
|
||||
|
||||
/** 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 ?? "";
|
||||
if (text.length === 0)
|
||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
/**
|
||||
* @file MessageList.sender.test.tsx
|
||||
* @description Verifies the transcript renders each message under its TRUE
|
||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
||||
* @description Verifies the transcript renders each message by its TRUE
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* 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>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, workLabel } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -36,8 +38,8 @@ describe("MessageList — sender attribution", () => {
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
|
||||
expect(screen.getByText("System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||
});
|
||||
@@ -48,7 +50,31 @@ describe("MessageList — sender attribution", () => {
|
||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
|
||||
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
|
||||
* @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
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
@@ -94,7 +95,7 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -130,7 +131,7 @@ export function AddLaneModal({
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setMode("worktree");
|
||||
setMode("repo");
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -151,6 +152,8 @@ export function AddLaneModal({
|
||||
if (!open || !prefill) return;
|
||||
if (prefill.sourceRepo) setSourceRepo(prefill.sourceRepo);
|
||||
if (prefill.branch) {
|
||||
// A branch only means something to a worktree.
|
||||
setMode("worktree");
|
||||
setBranch(prefill.branch);
|
||||
setBranchMode("existing");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* 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
|
||||
* 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 —
|
||||
* 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
|
||||
@@ -11,17 +13,39 @@
|
||||
* 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
|
||||
* 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:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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 {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
gutter,
|
||||
GutterMarker,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
@@ -30,8 +54,12 @@ import {
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
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 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
|
||||
@@ -60,14 +88,14 @@ const mark = (cls: string) => {
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const info = chunksOf(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
for (let pos = from; pos <= to;) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
@@ -139,6 +167,166 @@ const diffColours = (lang: string) =>
|
||||
{ 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 RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
@@ -192,14 +380,21 @@ const theme = EditorView.theme({
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".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.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||
* the click. The split layout draws its own » in the gutter. */
|
||||
function revertButton(label: string) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.textContent = label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
@@ -217,6 +412,12 @@ export default function DiffEditor({
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
blockIncluded,
|
||||
blame,
|
||||
onBlameClick,
|
||||
navRef,
|
||||
onAddToChat,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -231,15 +432,85 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => 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 view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
||||
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(() => {
|
||||
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 = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
@@ -261,25 +532,41 @@ export default function DiffEditor({
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
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 destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
const sd = splitDiff({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
original,
|
||||
value,
|
||||
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
extB: editing,
|
||||
collapse: folded,
|
||||
revertLabel,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
v = sd.b;
|
||||
destroy = sd.destroy;
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
@@ -306,15 +593,56 @@ export default function DiffEditor({
|
||||
}
|
||||
v.focus();
|
||||
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.
|
||||
// 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).
|
||||
useEffect(() => {
|
||||
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 } });
|
||||
}
|
||||
}, [value]);
|
||||
@@ -324,7 +652,9 @@ export default function DiffEditor({
|
||||
ref={host}
|
||||
data-testid="lane-file-editor"
|
||||
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();
|
||||
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
RUNTIME_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
LOCKS_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
@@ -98,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
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 {
|
||||
if (sec === null) return "—";
|
||||
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
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* - Side by side is JetBrains-shaped and laid out here, on the rows the
|
||||
* server already numbered: within a hunk each column lists only its own
|
||||
* 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
|
||||
* 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
|
||||
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
import { bandPath } from "../../lib/diffConnectors";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
@@ -220,29 +227,28 @@ function emphasis(
|
||||
};
|
||||
}
|
||||
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
|
||||
* 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
|
||||
* 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
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
/** Pair each run of removed lines with the run of added lines following it
|
||||
* 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 {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
for (let i = 0; i < lines.length;) {
|
||||
if (lines[i]!.type === "ctx") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
emph.set(delStart + k, e.del);
|
||||
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 };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
|
||||
/** First/last index in `hunk.lines` (unified). */
|
||||
firstLine: number;
|
||||
lastLine: number;
|
||||
/** First/last split-view row. */
|
||||
/** First split-view row (either side): the bar's arrows scroll here. */
|
||||
firstRow: number;
|
||||
/** The right column's row its review bar hangs under. */
|
||||
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[]>();
|
||||
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
||||
const hit = blocksOf.get(hunk);
|
||||
if (hit) return hit;
|
||||
const out: LaneDiffChange[] = [];
|
||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (line.type === "ctx") return;
|
||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||
if (!c) {
|
||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
||||
c = {
|
||||
hunk,
|
||||
block: out.length,
|
||||
firstLine: i,
|
||||
lastLine: i,
|
||||
firstRow: -1,
|
||||
lastRow: -1,
|
||||
left: [0, 0],
|
||||
right: [0, 0],
|
||||
};
|
||||
out.push(c);
|
||||
}
|
||||
c.lastLine = i;
|
||||
owner.set(line, c);
|
||||
});
|
||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||||
if (!c) return;
|
||||
if (c.firstRow < 0) c.firstRow = i;
|
||||
c.lastRow = i;
|
||||
});
|
||||
// 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);
|
||||
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
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
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[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
@@ -414,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
if (!ctx?.actions) return null;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
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({
|
||||
row,
|
||||
hunk,
|
||||
i,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
row: PairRow;
|
||||
hunk: LaneDiffHunk;
|
||||
i: number;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: 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);
|
||||
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
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -598,11 +641,12 @@ function SplitColumn({
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<HunkHeader hunk={hunk} fixed />
|
||||
{analyseHunk(hunk).rows.map((_, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per change block: on the right (new) column only.
|
||||
@@ -638,10 +682,10 @@ function SplitSlice({
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
@@ -657,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["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 }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const mid = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const bands = useMemo(() => bandsOf(items), [items]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
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;
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
if (mid.current) mid.current.scrollTop = src.scrollTop;
|
||||
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") =>
|
||||
win.on ? (
|
||||
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
>
|
||||
{column("left")}
|
||||
</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
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-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")}
|
||||
</div>
|
||||
@@ -826,7 +931,9 @@ export function LaneDiffView({
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
// 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)
|
||||
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
|
||||
* 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.
|
||||
* 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.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -234,6 +234,45 @@ export function LaneGitBar({
|
||||
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 ok = await confirmDialog({
|
||||
title: t("gitBar.merge"),
|
||||
@@ -322,7 +361,11 @@ export function LaneGitBar({
|
||||
icon={<ArrowDown className="h-3.5 w-3.5" />}
|
||||
busy={busy === "pull"}
|
||||
disabled={!git.hasRemote || !!busy || merging}
|
||||
title={merging ? t("gitBar.merging") : t("gitBar.pullHint")}
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: t("gitBar.pullHint")
|
||||
}
|
||||
onClick={() => run("pull")}
|
||||
/>
|
||||
<Btn
|
||||
@@ -338,7 +381,7 @@ export function LaneGitBar({
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
onClick={() => void push()}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
@@ -362,7 +405,7 @@ export function LaneGitBar({
|
||||
// beats a red box after it.
|
||||
title={
|
||||
merging
|
||||
? t("gitBar.merging")
|
||||
? t("gitBar.merging", { op: git.operation ?? "merge" })
|
||||
: dirty > 0
|
||||
? t("gitBar.switchDirty")
|
||||
: t("gitBar.switchHint")
|
||||
@@ -434,7 +477,17 @@ export function LaneGitBar({
|
||||
busy={busy === "branch-delete"}
|
||||
disabled={!picked || picked === git.branch || !!heldBy.get(picked)}
|
||||
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 && (
|
||||
<span className="text-[10px] text-status-warning">{t("gitBar.switchDirty")}</span>
|
||||
@@ -474,7 +527,7 @@ export function LaneGitBar({
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
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
|
||||
// token, ssh, user@host:path); this only names the two usual ones.
|
||||
@@ -488,7 +541,7 @@ export function LaneGitBar({
|
||||
busy={busy === "publish"}
|
||||
disabled={!url.trim()}
|
||||
title={t("gitBar.publishHint")}
|
||||
onClick={() => void run("publish", { url: url.trim() })}
|
||||
onClick={() => void publish()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,10 @@ export function useLaneGitFacts(
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, GIT_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
GIT_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
|
||||
@@ -108,7 +108,7 @@ export function SessionReplay({
|
||||
? { runId: holder.id, laneId: lane.id, held: true }
|
||||
: await startInFreeTabDetailed(lane.id, { resumeSessionId: sessionId, prompt: text });
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
onResumed(r.runId, r.laneId);
|
||||
// Held already: the prompt reached nothing yet — paste it there.
|
||||
@@ -128,12 +128,12 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
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>
|
||||
<button
|
||||
@@ -148,7 +148,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
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>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</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
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useLanes } from "../../hooks/useLanes";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
@@ -52,6 +53,7 @@ export function SessionResumeButton({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -77,7 +79,8 @@ export function SessionResumeButton({
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
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 same = (a: string, b: string) => strip(a) === strip(b);
|
||||
// Its own worktree counts too: the server resumes it there (`claude
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* prompt (the session view's tab bar `+` only starts a bare CLI or a shell,
|
||||
* `ConsoleTabs.tsx`). Inside
|
||||
* the box, like Claude Desktop's: AI (the session's own tool, the workspace's
|
||||
* by default) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* by default; its last row, **Open terminal**, is no AI — it starts a plain
|
||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||
* (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
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -101,6 +103,9 @@ type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
/** 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
|
||||
* 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) {
|
||||
return <HomeBody key={props.lane.id} {...props} />;
|
||||
}
|
||||
@@ -202,6 +207,12 @@ function HomeBody({
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(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);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
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 start = async () => {
|
||||
// Enter reaches here past the disabled button: the same guards again.
|
||||
@@ -328,7 +357,7 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
@@ -512,19 +541,29 @@ function HomeBody({
|
||||
label={t("home.ai")}
|
||||
value={tool}
|
||||
onChange={(v) => {
|
||||
// Not a tool: an action. The AI chosen for the prompt stays.
|
||||
if (v === OPEN_TERMINAL) return void openTerminal();
|
||||
setTool(v);
|
||||
setBypass(false);
|
||||
}}
|
||||
options={toolOptions.map((id) => ({
|
||||
value: id,
|
||||
label: aiTools.label(id),
|
||||
icon: (
|
||||
<AiToolIcon
|
||||
tool={{ id, label: aiTools.label(id) }}
|
||||
className="h-4 w-4 !p-0.5"
|
||||
/>
|
||||
),
|
||||
}))}
|
||||
options={[
|
||||
...toolOptions.map((id) => ({
|
||||
value: id,
|
||||
label: aiTools.label(id),
|
||||
icon: (
|
||||
<AiToolIcon
|
||||
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 className="ml-auto flex items-center gap-1">
|
||||
@@ -737,6 +776,16 @@ function HomeBody({
|
||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||
</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">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
@@ -839,7 +888,26 @@ function HomeBody({
|
||||
</p>
|
||||
</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">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||
<button
|
||||
@@ -858,15 +926,14 @@ function HomeBody({
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={async (e) => {
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* 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
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* 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
|
||||
* 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"`,
|
||||
* which never has a session) as **Terminal** — and under the sessions the last
|
||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X 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
|
||||
* which never has a session) as **Terminal** while it is open — a closed one
|
||||
* leaves no row; Ctrl/Cmd+click opens
|
||||
* it in a split in the tab on screen) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* 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
|
||||
* 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,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
@@ -32,23 +31,20 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
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 { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle, type RunHistoryItem } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
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";
|
||||
|
||||
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
|
||||
@@ -267,56 +263,27 @@ export function WorkspaceSessionList({
|
||||
);
|
||||
// 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
|
||||
// session is too — re-read then, not on a timer.
|
||||
const refreshKey = `${lane.session_id}:${lane.status}:${lane.needs_action}:${version}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`;
|
||||
// A Terminal tab closing is when one joins the history.
|
||||
const shellKey = liveShells.map((r) => r.id).join(",");
|
||||
const [closedShells, setClosedShells] = useState<RunHistoryItem[]>([]);
|
||||
useEffect(() => {
|
||||
if (hidden) return;
|
||||
let live = true;
|
||||
api.run
|
||||
// Live ones come back too (isLive): ask for enough to fill the list anyway.
|
||||
.history({ laneId: lane.id, tool: "shell", limit: CLOSED_TERMINALS + 10 })
|
||||
.then(
|
||||
(r) => live && setClosedShells(r.items.filter((x) => !x.isLive).slice(0, CLOSED_TERMINALS))
|
||||
)
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id, shellKey, hidden]);
|
||||
const reopenShell = async (split: boolean) => {
|
||||
try {
|
||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||
navigate(
|
||||
`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`,
|
||||
split ? { state: { split: true } } : undefined
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
const closedCtx = useContextMenu<RunHistoryItem>();
|
||||
const forgetShell = async (r: RunHistoryItem) => {
|
||||
try {
|
||||
await api.run.forgetHistory(r.id);
|
||||
setClosedShells((all) => all.filter((x) => x.id !== r.id));
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
// session is too — re-read then, not on a timer. Throttled to one re-read per
|
||||
// 3 s: two sessions in one workspace flip these on every hook. A rename or an
|
||||
// archive (`version`) re-reads at once.
|
||||
const hookKey = useThrottledValue(
|
||||
`${lane.session_id}:${lane.status}:${lane.needs_action}:${laneRuns
|
||||
.map((r) => `${r.id}=${r.sessionId ?? ""}`)
|
||||
.join(",")}`,
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
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);
|
||||
// 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
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
@@ -433,9 +400,7 @@ export function WorkspaceSessionList({
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
// Not during a search, like the live ones: they have no name to match.
|
||||
const closed = q ? [] : closedShells;
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0 && closed.length === 0) {
|
||||
if (sessions !== null && shown.length === 0 && fresh.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className="py-1 pl-[42px] text-[11px] text-fg-muted">{t("tree.noSessions")}</p>
|
||||
@@ -592,42 +557,6 @@ export function WorkspaceSessionList({
|
||||
</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>
|
||||
{more > 0 && !q && (
|
||||
<button
|
||||
@@ -639,6 +568,16 @@ export function WorkspaceSessionList({
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</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}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
@@ -651,42 +590,6 @@ export function WorkspaceSessionList({
|
||||
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 && (
|
||||
<SessionMenu
|
||||
x={sessionCtx.menu.x}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
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 { AddLaneModal } from "../AddLaneModal";
|
||||
import { api } from "../../../lib/api";
|
||||
@@ -119,6 +119,13 @@ function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> =
|
||||
);
|
||||
}
|
||||
|
||||
/** The modal opens on Repo; worktree tests switch to Worktree first. */
|
||||
function renderWorktree(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||
const r = renderModal(over);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Worktree" }));
|
||||
return r;
|
||||
}
|
||||
|
||||
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
||||
* races userEvent.type() and can eat the first keystroke. Let that timer fire,
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
async function fillWorktreeForm(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
@@ -173,7 +180,7 @@ describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
|
||||
it("lists the repo's existing worktrees once the path resolves", async () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.branches).mockResolvedValue(withWorktrees);
|
||||
vi.mocked(api.lanes.worktree).mockResolvedValue({ lane: laneFixture({ id: 9 }) });
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 server name it" (issue #34).
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -267,7 +274,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.branches).mockRejectedValue(new Error("EBADSOURCEREPO"));
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -303,7 +310,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
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 }) });
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose, onAdded });
|
||||
renderWorktree({ onClose, onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
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"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
renderWorktree({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
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,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
renderWorktree({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user);
|
||||
@@ -410,7 +417,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("boom"));
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
renderWorktree({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo2" });
|
||||
@@ -431,7 +438,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
vi.mocked(api.lanes.mcpSync).mockRejectedValue(new Error("no mcp"));
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onClose });
|
||||
renderWorktree({ onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
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: "failed" }) });
|
||||
const onAdded = vi.fn();
|
||||
renderModal({ onAdded });
|
||||
renderWorktree({ onAdded });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await fillWorktreeForm(user, { title: "demo4" });
|
||||
@@ -477,6 +484,12 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
@@ -727,7 +740,7 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
await focusField(user, repoField);
|
||||
await user.type(repoField, "/Users/tester/projects/repo");
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* @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
|
||||
* 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>
|
||||
*/
|
||||
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";
|
||||
|
||||
// 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 () => {
|
||||
const same = Array.from({ length: 30 }, (_, i) => `line ${i}`).join("\n");
|
||||
const { container } = render(
|
||||
@@ -90,4 +167,161 @@ describe("DiffEditor", () => {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
|
||||
it("annotates each line in a gutter, and hands a click its commit", async () => {
|
||||
const onBlameClick = vi.fn();
|
||||
const props = {
|
||||
value: "a\nnew",
|
||||
original: "a",
|
||||
lang: "plain",
|
||||
label: "Edit",
|
||||
revertLabel: "Revert",
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onBlameClick,
|
||||
};
|
||||
const sha = "f".repeat(40);
|
||||
const blame = {
|
||||
available: true as const,
|
||||
path: "x",
|
||||
commits: {
|
||||
[sha]: { short: "fffffff", author: "Vi", date: "2026-10-01T00:00:00Z", summary: "s" },
|
||||
},
|
||||
lines: [sha, null],
|
||||
};
|
||||
const { container, rerender } = render(<DiffEditor {...props} />);
|
||||
expect(container.querySelector(".cm-ah-blame")).toBeNull();
|
||||
rerender(<DiffEditor {...props} blame={blame} />);
|
||||
await waitFor(() => expect(container.querySelector(".cm-ah-blame")).not.toBeNull());
|
||||
// The first element is CodeMirror's invisible width spacer.
|
||||
const cells = [...container.querySelectorAll(".cm-ah-blame .cm-gutterElement span")].slice(1);
|
||||
expect(cells.map((c) => c.textContent)).toEqual(["2026-10-01 Vi", "Not committed yet"]);
|
||||
fireEvent.mouseDown(cells[0]!.parentElement!);
|
||||
expect(onBlameClick).toHaveBeenCalledWith(sha);
|
||||
});
|
||||
|
||||
it("puts an Undo + commit-checkbox bar under each block, by line, in place of Revert", async () => {
|
||||
const onBlock = vi.fn();
|
||||
const props = {
|
||||
value: "a\nB\nc\nnew\ne",
|
||||
original: "a\nb\nc\nd\ne",
|
||||
lang: "plain",
|
||||
label: "Edit",
|
||||
revertLabel: "Revert",
|
||||
onChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<DiffEditor {...props} onBlock={onBlock} blockIncluded={(b) => b.oldFrom === 2} />
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(2)
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||
"data-bars",
|
||||
"true"
|
||||
);
|
||||
const [first, second] = [
|
||||
...container.querySelectorAll<HTMLElement>('[data-testid="lane-editor-block-bar"]'),
|
||||
];
|
||||
const boxes = [first, second].map((b) => b!.querySelector<HTMLInputElement>("input")!);
|
||||
expect(boxes.map((b) => b.checked)).toEqual([true, false]);
|
||||
fireEvent.click(boxes[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("include", {
|
||||
oldFrom: 4,
|
||||
oldCount: 1,
|
||||
newFrom: 4,
|
||||
newCount: 1,
|
||||
});
|
||||
// Undo asks twice.
|
||||
const firstUndo = first!.querySelector("button")!;
|
||||
fireEvent.click(firstUndo);
|
||||
expect(onBlock).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(firstUndo);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("undo", {
|
||||
oldFrom: 2,
|
||||
oldCount: 1,
|
||||
newFrom: 2,
|
||||
newCount: 1,
|
||||
});
|
||||
// Unsaved text (no onBlock): no bars, Revert back.
|
||||
rerender(<DiffEditor {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="lane-editor-block-bar"]')).toBeNull()
|
||||
);
|
||||
expect(container.querySelector('[data-testid="lane-file-editor"]')).toHaveAttribute(
|
||||
"data-bars",
|
||||
"false"
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["unified", "split"] as const)(
|
||||
"keeps a CRLF file's text as is (%s): no edit at mount, so the bars stay",
|
||||
async (layout) => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
layout={layout}
|
||||
value={"a\r\nB\r\nc"}
|
||||
original={"a\r\nb\r\nc"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={onChange}
|
||||
onSave={vi.fn()}
|
||||
onBlock={vi.fn()}
|
||||
/>
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelectorAll('[data-testid="lane-editor-block-bar"]')).toHaveLength(1)
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
const eds = container.querySelectorAll<HTMLElement>(".cm-editor");
|
||||
const view = EditorView.findFromDOM(eds[eds.length - 1]!)!;
|
||||
expect(view.state.sliceDoc()).toBe("a\r\nB\r\nc");
|
||||
// Only B changed: the CRs are not read as an edit on every line.
|
||||
expect(
|
||||
container
|
||||
.querySelectorAll(".cm-editor")
|
||||
[eds.length - 1]!.querySelectorAll(".cm-changedLine")
|
||||
).toHaveLength(1);
|
||||
}
|
||||
);
|
||||
|
||||
it("offers Add to Chat on a selection, with its 1-based line range", async () => {
|
||||
const onAddToChat = vi.fn();
|
||||
const { container } = render(
|
||||
<DiffEditor
|
||||
value={"a\nb\nc\nd"}
|
||||
original={"a\nb\nc\nd"}
|
||||
lang="plain"
|
||||
label="Edit"
|
||||
revertLabel="Revert"
|
||||
onChange={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
);
|
||||
const view = EditorView.findFromDOM(container.querySelector<HTMLElement>(".cm-editor")!)!;
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull();
|
||||
// b..c, ending at the start of d: d is not taken.
|
||||
view.dispatch({ selection: { anchor: 2, head: 6 } });
|
||||
const chip = await waitFor(() => {
|
||||
const b = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="lane-editor-add-to-chat"]'
|
||||
);
|
||||
expect(b).not.toBeNull();
|
||||
return b!;
|
||||
});
|
||||
// Inline at the end of the first selected line, never a tooltip over the
|
||||
// line above it.
|
||||
expect(chip.closest(".cm-tooltip")).toBeNull();
|
||||
expect(chip.closest(".cm-line")?.textContent).toMatch(/^b/);
|
||||
fireEvent.click(chip);
|
||||
expect(onAddToChat).toHaveBeenCalledWith(2, 3);
|
||||
view.dispatch({ selection: { anchor: 6 } });
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector('[data-testid="lane-editor-add-to-chat"]')).toBeNull()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,19 @@
|
||||
* once, the range toggle changes which comparison is requested, History opens
|
||||
* 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
|
||||
* `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>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel } from "../LaneChangesPanel";
|
||||
import { confirmDiscard } from "../../../lib/confirm";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
@@ -70,6 +74,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitLs: vi.fn(),
|
||||
gitTree: 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 }));
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function discardOf(path: string) {
|
||||
return within(screen.getByTitle(path).closest("li")!).getByRole("button", {
|
||||
@@ -133,7 +143,6 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem("agenthub.changes.neverStage.7");
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
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)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -255,7 +272,8 @@ describe("LaneChangesPanel", () => {
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
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" }));
|
||||
});
|
||||
|
||||
@@ -271,60 +289,36 @@ describe("LaneChangesPanel", () => {
|
||||
).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();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// Both rows offer it (one modified, one untracked); the first is the
|
||||
// modified file the assertion below names.
|
||||
const [stage] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stage!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
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)" }));
|
||||
// Only the untracked row offers it: a tracked file is already in VCS.
|
||||
const [add, ...more] = screen.getAllByRole("button", { name: "Add to VCS" });
|
||||
expect(more).toHaveLength(0);
|
||||
expect(tickOf("notes.txt")).not.toBeChecked();
|
||||
await user.click(add!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", { paths: ["notes.txt"] });
|
||||
// And back: the row's button now offers the way out.
|
||||
await user.click(screen.getByRole("button", { name: "Include in Stage all again" }));
|
||||
expect(JSON.parse(window.localStorage.getItem("agenthub.changes.neverStage.7")!)).toEqual([]);
|
||||
await waitFor(() => expect(tickOf("notes.txt")).toBeChecked());
|
||||
});
|
||||
|
||||
it("draws a never-stage file orange in the Files tree", 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,
|
||||
});
|
||||
it("a group's box unticks, then ticks, every file in it, without telling git", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Tabbed laneId={7} />);
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await user.click(screen.getByRole("button", { name: "Files" }));
|
||||
const row = async (name: string) => (await screen.findByText(name)).closest("button")!;
|
||||
expect(await row("notes.txt")).toHaveAttribute("data-tree-kept", "true");
|
||||
expect(await row("notes.txt")).toHaveClass("text-kept");
|
||||
expect(await row("a.txt")).not.toHaveAttribute("data-tree-kept");
|
||||
expect(await row("a.txt")).toHaveClass("text-status-warning");
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
const group = screen.getByRole("checkbox", { name: "Tick every file in Changes" });
|
||||
expect(group).toBePartiallyChecked();
|
||||
await user.click(group); // some ticked -> untick all
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
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();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
@@ -332,16 +326,15 @@ describe("LaneChangesPanel", () => {
|
||||
const folder = within(list).getByTitle("server/lib");
|
||||
expect(folder).toHaveTextContent("1 file");
|
||||
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);
|
||||
expect(within(list).queryByTitle("server/lib/git-ops.js")).toBeNull();
|
||||
await user.click(
|
||||
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"],
|
||||
});
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(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!);
|
||||
// The header's counts moved with the index: waiting out its 30s poll
|
||||
// would leave them wrong beside the list that just changed.
|
||||
@@ -411,43 +404,61 @@ describe("LaneChangesPanel", () => {
|
||||
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();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
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(screen.getByTestId("lane-changes-commit")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Stage all (2)" }));
|
||||
// The already-staged docs/API.md is not sent again.
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
await user.click(tickOf("notes.txt"));
|
||||
await user.click(tickOf("docs/API.md"));
|
||||
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"],
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
vi.mocked(api.lanes.suggestCommitMessage).mockResolvedValue({ message: "docs: update API" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
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();
|
||||
});
|
||||
|
||||
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} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const staged = list.querySelector('[data-group="staged"]') as HTMLElement;
|
||||
const unstaged = list.querySelector('[data-group="unstaged"]') as HTMLElement;
|
||||
expect(within(staged).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(staged).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unstaged).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(unstaged).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(within(unstaged).queryByTitle("docs/API.md")).toBeNull();
|
||||
expect(screen.getByText("Staged Changes")).toBeInTheDocument();
|
||||
const tracked = list.querySelector('[data-group="changes"]') as HTMLElement;
|
||||
const unversioned = list.querySelector('[data-group="unversioned"]') as HTMLElement;
|
||||
expect(within(tracked).getByTitle("docs/API.md")).toBeInTheDocument();
|
||||
expect(within(tracked).getByTitle("server/lib/git-ops.js")).toBeInTheDocument();
|
||||
expect(within(tracked).queryByText("notes.txt")).toBeNull();
|
||||
expect(within(unversioned).getByText("notes.txt")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unversioned Files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
const list = await screen.findByTestId("lane-changes-overview");
|
||||
const row = within(list).getByTitle("server/lib/git-ops.js");
|
||||
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 user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Stage this file")
|
||||
);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
vi.mocked(api.lanes.gitAction).mockClear();
|
||||
await user.click(menu().getByText("Commit File…"));
|
||||
expect(tickOf("server/lib/git-ops.js")).toBeChecked();
|
||||
expect(tickOf("docs/API.md")).not.toBeChecked();
|
||||
expect(screen.getByLabelText("Commit message")).toHaveFocus();
|
||||
expect(screen.getByRole("button", { name: "Commit (1)" })).toBeInTheDocument();
|
||||
|
||||
await right();
|
||||
await user.click(screen.getByTestId("lane-changes-menu-discard"));
|
||||
@@ -645,10 +656,123 @@ describe("LaneChangesPanel", () => {
|
||||
});
|
||||
|
||||
await right();
|
||||
await user.click(
|
||||
within(screen.getByTestId("lane-changes-file-menu")).getByText("Copy relative path")
|
||||
);
|
||||
await user.click(menu().getByText("Copy relative path"));
|
||||
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 () => {
|
||||
@@ -739,6 +863,132 @@ describe("LaneChangesPanel", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_id, path) =>
|
||||
@@ -806,7 +1056,7 @@ describe("LaneChangesPanel", () => {
|
||||
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({
|
||||
available: true,
|
||||
path: "",
|
||||
@@ -816,6 +1066,8 @@ describe("LaneChangesPanel", () => {
|
||||
{ name: "a.txt", path: "a.txt", dir: false, status: " M" },
|
||||
{ name: "new.txt", path: "new.txt", dir: false, status: "??" },
|
||||
{ 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,
|
||||
});
|
||||
@@ -826,9 +1078,12 @@ describe("LaneChangesPanel", () => {
|
||||
|
||||
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")).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")).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("keep.txt")).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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
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();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
|
||||
// The box exists only because a file is staged — with a clean index there
|
||||
// is nothing this button could mean.
|
||||
const box = await screen.findByTestId("lane-changes-commit");
|
||||
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",
|
||||
});
|
||||
await screen.findByTestId("lane-changes-commit");
|
||||
await user.type(screen.getByLabelText("Commit message"), "fix: nothing");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Tick every file in Changes" }));
|
||||
expect(screen.getByRole("button", { name: "Commit (0)" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Commit and Push" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("drops a half-typed commit message on a lane switch", async () => {
|
||||
@@ -1053,24 +1288,10 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByLabelText("Commit message")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hides the commit box when nothing is staged", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(
|
||||
changes({
|
||||
files: [
|
||||
{
|
||||
path: "server/lib/git-ops.js",
|
||||
status: " M",
|
||||
untracked: false,
|
||||
unstaged: true,
|
||||
added: 1,
|
||||
deleted: 0,
|
||||
binary: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
it("hides the commit box when there is nothing to commit", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes({ files: [] }));
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
await screen.findByText("Nothing uncommitted.");
|
||||
expect(screen.queryByTestId("lane-changes-commit")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1160,22 +1381,7 @@ describe("LaneChangesPanel", () => {
|
||||
await screen.findByTestId("lane-changes-review");
|
||||
};
|
||||
|
||||
it("stages one hunk by its @@ line, at the context the diff was read with", 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 () => {
|
||||
it("gives each +/- block inside ONE hunk its own bar and checkbox (#50)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
@@ -1218,13 +1424,35 @@ describe("LaneChangesPanel", () => {
|
||||
expect.stringContaining("1 of 2"),
|
||||
expect.stringContaining("2 of 2"),
|
||||
]);
|
||||
await user.click(screen.getAllByRole("button", { name: "Stage this hunk" })[1]!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage-hunk", {
|
||||
path: "server/lib/git-ops.js",
|
||||
hunk: "@@ -1,4 +1,5 @@",
|
||||
block: 1,
|
||||
context: 3,
|
||||
});
|
||||
const boxes = screen.getAllByRole("checkbox", { name: "Include this change in the commit" });
|
||||
expect(boxes).toHaveLength(2);
|
||||
await user.click(boxes[1]!);
|
||||
expect(boxes[0]).toBeChecked();
|
||||
expect(boxes[1]).not.toBeChecked();
|
||||
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 () => {
|
||||
@@ -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();
|
||||
await openReview(user);
|
||||
const [stageFile] = screen.getAllByRole("button", { name: "Stage this file" });
|
||||
await user.click(stageFile!);
|
||||
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "stage", {
|
||||
paths: ["server/lib/git-ops.js"],
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "Stage this file" })).toBeNull();
|
||||
await user.click(tickOf("server/lib/git-ops.js"));
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
// 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(
|
||||
screen.getByRole("button", { name: "Collapse server/lib/git-ops.js" })
|
||||
).toBeInTheDocument();
|
||||
@@ -1483,7 +1715,7 @@ describe("LaneChangesPanel", () => {
|
||||
);
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} />);
|
||||
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!);
|
||||
|
||||
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.
|
||||
expect(screen.getAllByText("a.txt")).toHaveLength(1);
|
||||
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 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,
|
||||
} from "../LaneDiffView";
|
||||
import type { LaneChangeFile } from "../../../lib/types";
|
||||
import { bandPath } from "../../../lib/diffConnectors";
|
||||
|
||||
const FILE: LaneChangeFile = {
|
||||
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");
|
||||
});
|
||||
|
||||
it("keeps an empty filler row one line tall, so the columns stay in step", () => {
|
||||
// Two lines removed, none added: the right column's two rows are fillers.
|
||||
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 padding.
|
||||
const file = fileWith([
|
||||
{ type: "del", oldLine: 1, newLine: null, text: "gone one" },
|
||||
{ type: "del", oldLine: 2, newLine: null, text: "gone two" },
|
||||
@@ -72,7 +73,39 @@ describe("LaneDiffView — side by side", () => {
|
||||
.querySelectorAll<HTMLElement>("div.flex.w-max"),
|
||||
];
|
||||
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", () => {
|
||||
@@ -414,8 +447,8 @@ describe("LaneDiffView — windowing", () => {
|
||||
expect(l.length).toBeLessThan(300);
|
||||
expect(l).toHaveLength(r.length);
|
||||
expect(right.textContent).toContain("line 2001;");
|
||||
// same rows: the right column's add has its filler row on the left
|
||||
expect(left.textContent).toContain("line 2000;");
|
||||
// Packed: the left holds only the context lines, so it runs twice as far.
|
||||
expect(left.textContent).toContain("line 4000;");
|
||||
});
|
||||
|
||||
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 { LaneGitBar } from "../LaneGitBar";
|
||||
import { api } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import type { LaneGitFacts } from "../../../lib/types";
|
||||
|
||||
// A merge asks through the themed confirm (its host lives in App): yes.
|
||||
@@ -129,6 +130,40 @@ describe("LaneGitBar", () => {
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
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 { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { Lane, Session } from "../../../lib/types";
|
||||
@@ -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 () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
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 });
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
@@ -454,6 +487,13 @@ describe("WorkspaceHome", () => {
|
||||
const file = new File(["# notes"], "notes.md", { type: "text/markdown" });
|
||||
await user.upload(screen.getByTestId("workspace-file-input"), 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" }));
|
||||
expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* @file The file editor's side-by-side layout, JetBrains-shaped: the original
|
||||
* (read-only, left) and the working file (editable, right) are two
|
||||
* CodeMirror views in their OWN scrollers, with no padding to line them up —
|
||||
* each shows its real lines — and the gutter between them draws, for every
|
||||
* changed block, a curved band joining its lines on the left to its lines on
|
||||
* the right (`lib/diffConnectors.ts`), with a » that reverts the block.
|
||||
* Scrolling either side scrolls the other to the matching line (`mapLine`:
|
||||
* 1:1 outside a block, proportional inside one).
|
||||
*
|
||||
* What @codemirror/merge's MergeView gave the old layout is kept, by its own
|
||||
* public pieces: the chunks are `Chunk.build` at mount and `Chunk.updateB` on
|
||||
* every edit of the right side; the changed-line, changed-text and gutter
|
||||
* classes, the editor-root `cm-merge-a`/`cm-merge-b` classes, the folded
|
||||
* unchanged stretches (opened on both sides at once) and the revert's text
|
||||
* rule are ported from it, so DiffEditor's theme and colouring apply as before.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Text,
|
||||
type Extension,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
WidgetType,
|
||||
gutterLineClass,
|
||||
type DecorationSet,
|
||||
} from "@codemirror/view";
|
||||
import { Chunk, getChunks } from "@codemirror/merge";
|
||||
import { bandPath, mapLine, type Span } from "../../lib/diffConnectors";
|
||||
|
||||
/** The chunks a side carries, the side it is, and (right side) the original. */
|
||||
type SplitState = { chunks: readonly Chunk[]; side: "a" | "b"; orig: Text | null };
|
||||
|
||||
const setChunks = StateEffect.define<readonly Chunk[]>();
|
||||
const uncollapse = StateEffect.define<number>();
|
||||
|
||||
/** Each side's chunks. The right side recomputes them as it is edited; the
|
||||
* left, read-only, is handed the new list (`setChunks`). */
|
||||
const splitField = StateField.define<SplitState>({
|
||||
create: () => ({ chunks: [], side: "b", orig: null }),
|
||||
update(v, tr) {
|
||||
for (const e of tr.effects) if (e.is(setChunks)) return { ...v, chunks: e.value };
|
||||
if (v.orig && tr.docChanged) {
|
||||
return { ...v, chunks: Chunk.updateB(v.chunks, v.orig, tr.state.doc, tr.changes) };
|
||||
}
|
||||
return v;
|
||||
},
|
||||
});
|
||||
|
||||
/** The chunks and side of an editor in either merge layout — unified (the
|
||||
* merge package's own state) or this split. */
|
||||
export function chunksOf(
|
||||
state: EditorState
|
||||
): { chunks: readonly Chunk[]; side: "a" | "b" | null } | null {
|
||||
return getChunks(state) ?? state.field(splitField, false) ?? null;
|
||||
}
|
||||
|
||||
const changedLine = Decoration.line({ class: "cm-changedLine" });
|
||||
const changedText = Decoration.mark({ class: "cm-changedText" });
|
||||
const changedGutter = new (class extends GutterMarker {
|
||||
override elementClass = "cm-changedLineGutter";
|
||||
})();
|
||||
|
||||
/** The lines (and, inside them, the changed spans) of this side's chunks. */
|
||||
function changedRanges(state: EditorState) {
|
||||
const { chunks, side } = state.field(splitField);
|
||||
const doc = state.doc;
|
||||
const lines: number[] = [];
|
||||
const spans: [number, number][] = [];
|
||||
for (const c of chunks) {
|
||||
const [from, to] = side === "a" ? [c.fromA, c.toA] : [c.fromB, c.toB];
|
||||
if (from >= to) continue;
|
||||
const last = doc.lineAt(Math.min(to - 1, doc.length)).number;
|
||||
for (let n = doc.lineAt(from).number; n <= last; n += 1) {
|
||||
const line = doc.line(n);
|
||||
lines.push(line.from);
|
||||
for (const ch of c.changes) {
|
||||
const s = Math.max(line.from, from + (side === "a" ? ch.fromA : ch.fromB));
|
||||
const e = Math.min(line.to, from + (side === "a" ? ch.toA : ch.toB));
|
||||
if (s < e) spans.push([s, e]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lines, spans };
|
||||
}
|
||||
|
||||
const chunkDecorations = EditorView.decorations.compute([splitField], (state) => {
|
||||
const { lines, spans } = changedRanges(state);
|
||||
return Decoration.set(
|
||||
[...lines.map((p) => changedLine.range(p)), ...spans.map(([s, e]) => changedText.range(s, e))],
|
||||
true
|
||||
);
|
||||
});
|
||||
const chunkGutter = gutterLineClass.compute([splitField], (state) =>
|
||||
RangeSet.of(
|
||||
changedRanges(state).lines.map((p) => changedGutter.range(p)),
|
||||
true
|
||||
)
|
||||
);
|
||||
|
||||
/** The other side's editor, for a fold opened on one side to open on both. */
|
||||
const siblings = new WeakMap<EditorView, EditorView>();
|
||||
|
||||
/** `pos` on one side, moved past the chunks before it to the other (ported). */
|
||||
function mapPos(pos: number, chunks: readonly Chunk[], isA: boolean) {
|
||||
let ours = 0;
|
||||
let theirs = 0;
|
||||
for (const c of chunks) {
|
||||
if ((isA ? c.fromA : c.fromB) >= pos) break;
|
||||
[ours, theirs] = isA ? [c.toA, c.toB] : [c.toB, c.toA];
|
||||
}
|
||||
return theirs + (pos - ours);
|
||||
}
|
||||
|
||||
class CollapseWidget extends WidgetType {
|
||||
constructor(readonly lines: number) {
|
||||
super();
|
||||
}
|
||||
override eq(o: CollapseWidget) {
|
||||
return this.lines === o.lines;
|
||||
}
|
||||
toDOM(view: EditorView) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "cm-collapsedLines";
|
||||
el.textContent = view.state.phrase("$ unchanged lines", this.lines);
|
||||
el.addEventListener("click", (e) => {
|
||||
const pos = view.posAtDOM(e.target as Node);
|
||||
view.dispatch({ effects: uncollapse.of(pos) });
|
||||
const { chunks, side } = view.state.field(splitField);
|
||||
siblings.get(view)?.dispatch({ effects: uncollapse.of(mapPos(pos, chunks, side === "a")) });
|
||||
});
|
||||
return el;
|
||||
}
|
||||
override ignoreEvent(e: Event) {
|
||||
return e instanceof MouseEvent;
|
||||
}
|
||||
override get estimatedHeight() {
|
||||
return 27;
|
||||
}
|
||||
}
|
||||
|
||||
/** The folded unchanged stretches: built once at mount, then only mapped,
|
||||
* opened by a click, and dropped where an edit grows a chunk into them. */
|
||||
const collapsedField = StateField.define<DecorationSet>({
|
||||
create: () => Decoration.none,
|
||||
update(deco, tr) {
|
||||
deco = deco.map(tr.changes);
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(uncollapse)) deco = deco.update({ filter: (from) => from !== e.value });
|
||||
}
|
||||
const { chunks, side } = tr.state.field(splitField);
|
||||
if (deco.size && chunks !== tr.startState.field(splitField).chunks) {
|
||||
const touching: number[] = [];
|
||||
for (const c of chunks) {
|
||||
deco.between(side === "a" ? c.fromA : c.fromB, side === "a" ? c.toA : c.toB, (from) => {
|
||||
touching.push(from);
|
||||
});
|
||||
}
|
||||
if (touching.length) deco = deco.update({ filter: (from) => !touching.includes(from) });
|
||||
}
|
||||
return deco;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f),
|
||||
});
|
||||
|
||||
function buildCollapsed(state: EditorState, margin: number, minLines: number) {
|
||||
const { chunks, side } = state.field(splitField);
|
||||
const doc = state.doc;
|
||||
const out = [];
|
||||
let prev = 1;
|
||||
for (let i = 0; ; i += 1) {
|
||||
const c = i < chunks.length ? chunks[i]! : null;
|
||||
const from = i ? prev + margin : 1;
|
||||
const to = c ? doc.lineAt(side === "a" ? c.fromA : c.fromB).number - 1 - margin : doc.lines;
|
||||
const lines = to - from + 1;
|
||||
if (lines >= minLines) {
|
||||
out.push(
|
||||
Decoration.replace({ widget: new CollapseWidget(lines), block: true }).range(
|
||||
doc.line(from).from,
|
||||
doc.line(to).to
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!c) break;
|
||||
prev = doc.lineAt(Math.min(doc.length, side === "a" ? c.toA : c.toB)).number;
|
||||
}
|
||||
return Decoration.set(out);
|
||||
}
|
||||
|
||||
/** A chunk side as 0-based half-open lines; an empty side is the point
|
||||
* before the line it sits above (past the end: after the last line). */
|
||||
function sideLines(doc: Text, from: number, to: number): [number, number] {
|
||||
const first = from > doc.length ? doc.lines : doc.lineAt(from).number - 1;
|
||||
if (from >= to) return [first, first];
|
||||
return [first, doc.lineAt(Math.min(to - 1, doc.length)).number];
|
||||
}
|
||||
|
||||
function spansOf(chunks: readonly Chunk[], a: Text, b: Text): Span[] {
|
||||
return chunks.map((c) => {
|
||||
const [a0, a1] = sideLines(a, c.fromA, c.toA);
|
||||
const [b0, b1] = sideLines(b, c.fromB, c.toB);
|
||||
return { a0, a1, b0, b1 };
|
||||
});
|
||||
}
|
||||
|
||||
/** Where the document starts inside the view's scroll content. */
|
||||
const docOffset = (v: EditorView) =>
|
||||
v.documentTop - v.scrollDOM.getBoundingClientRect().top + v.scrollDOM.scrollTop;
|
||||
|
||||
/** The fractional line at the top of the view (a folded stretch spreads its
|
||||
* height over the lines it hides). */
|
||||
function topLine(v: EditorView) {
|
||||
const h = Math.max(0, v.scrollDOM.scrollTop - docOffset(v));
|
||||
const blk = v.lineBlockAtHeight(h);
|
||||
const first = v.state.doc.lineAt(blk.from).number - 1;
|
||||
const n = v.state.doc.lineAt(blk.to).number - first;
|
||||
const frac = blk.height > 0 ? Math.min(1, Math.max(0, (h - blk.top) / blk.height)) : 0;
|
||||
return first + frac * n;
|
||||
}
|
||||
|
||||
/** Document y of a fractional line (0-based). */
|
||||
function yOf(v: EditorView, line: number) {
|
||||
const doc = v.state.doc;
|
||||
if (line >= doc.lines) return v.lineBlockAt(doc.length).bottom;
|
||||
const blk = v.lineBlockAt(doc.line(Math.max(0, Math.floor(line)) + 1).from);
|
||||
const first = doc.lineAt(blk.from).number - 1;
|
||||
const n = doc.lineAt(blk.to).number - first;
|
||||
return blk.top + ((line - first) / n) * blk.height;
|
||||
}
|
||||
|
||||
/** The gutter's width: room for an S-curve and the » under it. */
|
||||
const GUTTER_PX = 40;
|
||||
const BAND: Record<"add" | "del" | "mod", string> = {
|
||||
add: "fill-status-success/20 stroke-status-success/60",
|
||||
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||
};
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
export function splitDiff({
|
||||
parent,
|
||||
original,
|
||||
value,
|
||||
extA,
|
||||
extB,
|
||||
collapse,
|
||||
revertLabel,
|
||||
}: {
|
||||
parent: HTMLElement;
|
||||
original: string;
|
||||
value: string;
|
||||
/** Each side's own extensions (DiffEditor's colours, keymaps, …). */
|
||||
extA: Extension[];
|
||||
extB: Extension[];
|
||||
collapse?: { margin: number; minSize: number };
|
||||
revertLabel: string;
|
||||
}): { a: EditorView; b: EditorView; destroy: () => void } {
|
||||
const origDoc = Text.of(original.split(/\r?\n/));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split(/\r?\n/)));
|
||||
|
||||
const root = parent.appendChild(document.createElement("div"));
|
||||
root.className = "flex h-full min-h-0";
|
||||
const pane = () => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "h-full min-w-0 flex-1";
|
||||
return el;
|
||||
};
|
||||
const paneA = root.appendChild(pane());
|
||||
const gutter = root.appendChild(document.createElement("div"));
|
||||
gutter.className =
|
||||
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
||||
gutter.style.width = `${GUTTER_PX}px`;
|
||||
gutter.dataset.testid = "lane-editor-connectors";
|
||||
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
||||
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
||||
const buttons = gutter.appendChild(document.createElement("div"));
|
||||
const paneB = root.appendChild(pane());
|
||||
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
if (frame) return;
|
||||
const run = () => {
|
||||
frame = 0;
|
||||
paint();
|
||||
};
|
||||
frame =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame(run)
|
||||
: window.setTimeout(run);
|
||||
};
|
||||
|
||||
const side = (s: "a" | "b"): Extension[] => [
|
||||
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
||||
chunkDecorations,
|
||||
chunkGutter,
|
||||
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
||||
...(collapse
|
||||
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
||||
: []),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
||||
}),
|
||||
];
|
||||
|
||||
const a = new EditorView({
|
||||
parent: paneA,
|
||||
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
||||
});
|
||||
const b = new EditorView({
|
||||
parent: paneB,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...side("b"),
|
||||
...extB,
|
||||
// The left side is told the chunks the right side's edit produced.
|
||||
EditorView.updateListener.of((u) => {
|
||||
const now = u.state.field(splitField).chunks;
|
||||
if (now !== u.startState.field(splitField).chunks)
|
||||
a.dispatch({ effects: setChunks.of(now) });
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
siblings.set(a, b);
|
||||
siblings.set(b, a);
|
||||
|
||||
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
||||
|
||||
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
||||
// caused comes back as the other side's own event: `echo` holds where we
|
||||
// put it, so that event is recognised and not mirrored back.
|
||||
const echo = { a: -1, b: -1 };
|
||||
const follow = (from: "a" | "b") => () => {
|
||||
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
||||
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
||||
echo[from] = -1;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
const line = mapLine(topLine(src), spans(), from === "a");
|
||||
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
||||
const before = dst.scrollDOM.scrollTop;
|
||||
dst.scrollDOM.scrollTop = target;
|
||||
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
||||
draw();
|
||||
};
|
||||
const onA = follow("a");
|
||||
const onB = follow("b");
|
||||
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
||||
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
||||
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||
resize?.observe(root);
|
||||
|
||||
// » puts the original block back on the right (the merge package's rule:
|
||||
// a non-empty source brings its line break unless it ends the document).
|
||||
buttons.addEventListener("mousedown", (e) => {
|
||||
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
||||
const c = b.state.field(splitField).chunks[i];
|
||||
if (!c) return;
|
||||
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
||||
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
||||
b.dispatch({
|
||||
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
||||
userEvent: "revert",
|
||||
});
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
function paint() {
|
||||
const g = gutter.getBoundingClientRect();
|
||||
const height = gutter.clientHeight;
|
||||
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
||||
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
||||
const paths: SVGPathElement[] = [];
|
||||
const btns: HTMLButtonElement[] = [];
|
||||
spans().forEach((s, i) => {
|
||||
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
||||
// Off screen on both sides: nothing to draw (no layout = draw all).
|
||||
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
||||
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
||||
const p = document.createElementNS(SVG, "path");
|
||||
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
||||
p.setAttribute("class", BAND[kind]);
|
||||
p.setAttribute("vector-effect", "non-scaling-stroke");
|
||||
p.dataset.kind = kind;
|
||||
paths.push(p);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "»";
|
||||
btn.title = revertLabel;
|
||||
btn.setAttribute("aria-label", revertLabel);
|
||||
btn.dataset.chunk = String(i);
|
||||
btn.className =
|
||||
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
||||
btns.push(btn);
|
||||
});
|
||||
svg.replaceChildren(...paths);
|
||||
buttons.replaceChildren(...btns);
|
||||
}
|
||||
draw();
|
||||
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
destroy: () => {
|
||||
if (frame)
|
||||
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
||||
resize?.disconnect();
|
||||
a.scrollDOM.removeEventListener("scroll", onA);
|
||||
b.scrollDOM.removeEventListener("scroll", onB);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,10 +19,13 @@
|
||||
* (`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)
|
||||
* 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
|
||||
* 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`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -51,7 +54,15 @@ import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { type PaneGroups, detachSlot, groupOf, leaves, reorderGroups } from "../../lib/paneLayout";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
detachSlot,
|
||||
groupOf,
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
} from "../../lib/paneLayout";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
|
||||
type AiToolsState = ReturnType<typeof useAiTools>;
|
||||
@@ -201,6 +212,10 @@ export function ConsoleTabs({
|
||||
// point among the drawn tabs, from the pointer against their middles.
|
||||
const listRef = useRef<HTMLDivElement>(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 types = e.dataTransfer?.types;
|
||||
return !!types && (types.includes(PANE_DRAG_TYPE) || types.includes(TAB_DRAG_TYPE));
|
||||
@@ -215,31 +230,70 @@ export function ConsoleTabs({
|
||||
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 = {
|
||||
onDragOver: (e: DragEvent) => {
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
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));
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDropAt(null);
|
||||
},
|
||||
onDragEnd: () => setDropAt(null),
|
||||
onDrop: (e: DragEvent) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
},
|
||||
onDragEnd: endDrag,
|
||||
onDrop: (e: DragEvent) => {
|
||||
const join = joinIndexAt(e.clientX);
|
||||
endDrag();
|
||||
if (!isBarDrag(e)) return;
|
||||
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.
|
||||
const drawn = dropIndexAt(e.clientX);
|
||||
const at = tabs[drawn]?.index ?? groups.groups.length;
|
||||
const tab = e.dataTransfer.getData(TAB_DRAG_TYPE);
|
||||
if (tab !== "") {
|
||||
onGroupsChange((g) => reorderGroups(g, Number(tab), at));
|
||||
return;
|
||||
}
|
||||
const raw = e.dataTransfer.getData(PANE_DRAG_TYPE);
|
||||
if (raw === "") return;
|
||||
const slot = Number(raw);
|
||||
onGroupsChange((g) => {
|
||||
@@ -426,6 +480,7 @@ export function ConsoleTabs({
|
||||
data-slots={tab.key}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragged.current = tab.slots;
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
@@ -439,7 +494,7 @@ export function ConsoleTabs({
|
||||
active
|
||||
? "bg-surface-0 text-fg-primary"
|
||||
: "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)) && (
|
||||
<span
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -23,6 +27,10 @@ import type { RunHandle, RunListResponse } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { PaneChat } from "./PaneChat";
|
||||
import { useChatUi } from "../../lib/chatUi";
|
||||
|
||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
|
||||
export interface LaneConsolePaneProps {
|
||||
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
|
||||
// workspace's; the workspace's with no run.
|
||||
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(() => {
|
||||
onHasActiveRunChange?.(handle !== null);
|
||||
@@ -173,11 +187,22 @@ export function LaneConsolePane({
|
||||
)}
|
||||
</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
|
||||
runId={handle.id}
|
||||
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 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.
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
Minimize2,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -309,8 +311,11 @@ export function PaneGrid({
|
||||
toolLabel,
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
}: {
|
||||
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). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -514,6 +519,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
)}
|
||||
@@ -557,6 +574,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
{dividers.map((d) => (
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `PromptEditor` — the prompt textarea with `/` (commands and skills,
|
||||
* Claude only) and `@` (`GET /api/run/files`) autocomplete, Shift/Alt+Enter
|
||||
* newlines, and a pasted or dropped image attached by its saved path
|
||||
* (`POST /api/run/drop-image`);
|
||||
* newlines, and pasted or dropped files (images as thumbnails, any other
|
||||
* as a name chip) attached by their saved paths (`POST /api/run/drop-file`);
|
||||
* - `CwdAutocomplete` — the folder field the Add-workspace modal uses;
|
||||
* - `ModelPicker` — a tool's own model list (`GET /api/ai-tools/:id/models`)
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
Home,
|
||||
@@ -47,12 +48,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "builtin";
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||
// 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[] = [
|
||||
"help",
|
||||
"clear",
|
||||
@@ -62,6 +66,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"usage",
|
||||
"context",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
@@ -69,14 +75,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"reload-plugins",
|
||||
"skills",
|
||||
"output-style",
|
||||
"resume",
|
||||
"rewind",
|
||||
"add-dir",
|
||||
"doctor",
|
||||
"export",
|
||||
"login",
|
||||
"logout",
|
||||
"statusline",
|
||||
"theme",
|
||||
"fast",
|
||||
"exit",
|
||||
"code-review",
|
||||
"security-review",
|
||||
"simplify",
|
||||
"loop",
|
||||
"schedule",
|
||||
"fewer-permission-prompts",
|
||||
"update-config",
|
||||
"claude-api",
|
||||
"run",
|
||||
].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> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
plugin: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
@@ -126,8 +154,8 @@ interface PromptEditorProps {
|
||||
slash: boolean;
|
||||
/** Where `@` looks for files and project commands/skills are read from. */
|
||||
fileCwd: string;
|
||||
/** Images pasted or dropped so far, shown as thumbnails; the caller sends
|
||||
* them with the text through `promptWithImages`. */
|
||||
/** Files pasted or dropped so far (images shown as thumbnails); the caller
|
||||
* sends them with the text through `promptWithImages`. */
|
||||
images: PromptImage[];
|
||||
onImagesChange: Dispatch<SetStateAction<PromptImage[]>>;
|
||||
/** Borderless, for a composer box that draws its own frame. */
|
||||
@@ -137,11 +165,12 @@ interface PromptEditorProps {
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
/** An attached image: its path on the server's machine and a local preview. */
|
||||
export type PromptImage = { path: string; url: string; name: string };
|
||||
/** An attached file: its path on the server's machine, and a local preview
|
||||
* `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 CLI is handed an image. */
|
||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||
* the CLI is handed an image or a file. */
|
||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
@@ -185,8 +214,26 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).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 () => {
|
||||
live = false;
|
||||
@@ -264,9 +311,9 @@ export function PromptEditor({
|
||||
});
|
||||
};
|
||||
|
||||
// A pasted or dropped image is saved on the server's machine (the same
|
||||
// route the terminal's drop uses) and shown as a thumbnail; its path is
|
||||
// added to the prompt only when it is sent (promptWithImages).
|
||||
// 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 or a name chip;
|
||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||
@@ -275,15 +322,15 @@ export function PromptEditor({
|
||||
busyRef.current = onBusyChange;
|
||||
useEffect(() => busyRef.current?.(uploading), [uploading]);
|
||||
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;
|
||||
setUpload("uploading");
|
||||
Promise.all(picked.map((f) => api.run.dropImage(f)))
|
||||
Promise.all(picked.map((f) => api.run.dropFile(f)))
|
||||
.then((saved) => {
|
||||
setUpload(null);
|
||||
const added = picked.map((f, i) => ({
|
||||
path: saved[i]!.path,
|
||||
url: URL.createObjectURL(f),
|
||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||
name: f.name,
|
||||
}));
|
||||
onImagesChange((prev) => [...prev, ...added]);
|
||||
@@ -292,7 +339,7 @@ export function PromptEditor({
|
||||
return true;
|
||||
};
|
||||
const removeImage = (img: PromptImage) => {
|
||||
URL.revokeObjectURL(img.url);
|
||||
if (img.url) URL.revokeObjectURL(img.url);
|
||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||
};
|
||||
useEffect(() => {
|
||||
@@ -349,17 +396,27 @@ export function PromptEditor({
|
||||
{images.length > 0 && (
|
||||
<div data-testid="prompt-images" className="mb-2 flex flex-wrap gap-2 px-1 pt-1">
|
||||
{images.map((img) => (
|
||||
<div key={img.url} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
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>
|
||||
<div key={img.path} className="group relative">
|
||||
{img.url ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={img.name}
|
||||
title={img.name}
|
||||
onClick={() => setViewing(img)}
|
||||
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>
|
||||
) : (
|
||||
<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
|
||||
type="button"
|
||||
aria-label={t("drop.remove")}
|
||||
|
||||
@@ -9,11 +9,15 @@
|
||||
* The socket auto-reconnects: a server restart kills the attach PTY, and
|
||||
* without a retry the pane silently freezes on its last painted frame — it
|
||||
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
|
||||
* 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
|
||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||
* An image dropped on it is saved on the server (`POST /api/run/drop-image`)
|
||||
* and its PATH pasted into the pane — what a local terminal does on a drop,
|
||||
* and how `claude` attaches an image. The pane also registers with
|
||||
* Files (images or any other) dropped on it are saved on the server
|
||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||
* 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
|
||||
* pasted into whichever of the workspace's panes was focused last.
|
||||
* 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
|
||||
* READ the user's clipboard). While the socket is down a "Reconnecting…"
|
||||
* 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>
|
||||
*/
|
||||
import { useEffect, useId, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import { ClipboardAddon, type IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
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). */
|
||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||
@@ -72,11 +78,49 @@ const clipboardProvider: IClipboardProvider = {
|
||||
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 (
|
||||
base64: undefined,
|
||||
provider: IClipboardProvider
|
||||
) => ClipboardAddon;
|
||||
|
||||
/** xterm and its addons (~400 KB) load with the first terminal, not with the
|
||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||
* awaits `loadXterm()` first) sets up synchronously. */
|
||||
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");
|
||||
|
||||
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
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
const images = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (images.length === 0) return flash("imagesOnly");
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
setDrop("uploading");
|
||||
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
|
||||
// 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?.focus();
|
||||
setDrop(null);
|
||||
@@ -160,265 +204,332 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
};
|
||||
|
||||
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;
|
||||
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 = () => {
|
||||
// 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 1.5 s 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";
|
||||
// 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;
|
||||
}
|
||||
|
||||
sock.onopen = () => {
|
||||
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 === "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);
|
||||
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;
|
||||
// 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
|
||||
// repaints the pane in full — no output is lost by retrying. Input is
|
||||
// not: the overlay says so rather than letting keystrokes vanish.
|
||||
setReconnecting(true);
|
||||
retryTimer = setTimeout(connect, 1500);
|
||||
|
||||
/**
|
||||
* 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 = (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();
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
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 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
|
||||
// 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.
|
||||
let teardown: (() => void) | undefined;
|
||||
const start = (x: Xterm) => {
|
||||
if (!disposed) teardown = attach(x);
|
||||
};
|
||||
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);
|
||||
|
||||
if (xtermLoaded) start(xtermLoaded);
|
||||
else loadXterm().then(start, () => undefined);
|
||||
return () => {
|
||||
disposed = true;
|
||||
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;
|
||||
teardown?.();
|
||||
};
|
||||
}, [runId, wsBaseUrl]);
|
||||
|
||||
@@ -431,9 +542,16 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
() =>
|
||||
registerPane(
|
||||
runId,
|
||||
(text) => {
|
||||
(text, submit) => {
|
||||
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:
|
||||
// `onData` drops input while the socket is connecting or reconnecting,
|
||||
@@ -442,6 +560,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
),
|
||||
[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 q = find ?? "";
|
||||
@@ -583,9 +706,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
<div
|
||||
data-testid="terminal-drop"
|
||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||
drop === "failed" || drop === "imagesOnly"
|
||||
? "border-red-400 text-red-300"
|
||||
: "border-sky-400 text-sky-200"
|
||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
||||
}`}
|
||||
>
|
||||
{t(`drop.${drop}`)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LaneConsolePane } from "../LaneConsolePane";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
import { __resetChatUi } from "../../../lib/chatUi";
|
||||
|
||||
vi.mock("../TerminalView", () => ({
|
||||
// The exit button stands in for the server's `exit` frame (a double Ctrl+C
|
||||
@@ -24,8 +25,15 @@ vi.mock("../TerminalView", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../PaneChat", () => ({
|
||||
PaneChat: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="pane-chat" data-session-id={sessionId} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
me: { prefs: vi.fn().mockResolvedValue({ prefs: {}, onboardedAt: null }) },
|
||||
lanes: {
|
||||
ensure: vi.fn(),
|
||||
action: vi.fn(),
|
||||
@@ -139,6 +147,34 @@ describe("LaneConsolePane", () => {
|
||||
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 () => {
|
||||
const LANE2: Lane = { ...LANE, id: 2, title: "other", cwd: "/workspace/b" };
|
||||
const run = (id: string, laneId: number) => ({
|
||||
|
||||
@@ -44,7 +44,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
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: {
|
||||
commands: r({
|
||||
items: [
|
||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: r({
|
||||
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.stringContaining("/review"),
|
||||
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" });
|
||||
// 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();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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.
|
||||
@@ -166,7 +195,7 @@ describe("PromptEditor — newlines and images", () => {
|
||||
const img = new File(["x"], "shot.png", { type: "image/png" });
|
||||
fireEvent.paste(box(), { clipboardData: { files: [img] } });
|
||||
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.
|
||||
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" });
|
||||
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(URL.revokeObjectURL).toHaveBeenCalledWith("blob:shot");
|
||||
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", () => {
|
||||
renderEditor("");
|
||||
fireEvent.paste(box(), { clipboardData: { files: [] } });
|
||||
expect(api.run.dropImage).not.toHaveBeenCalled();
|
||||
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
* @description Tests for the xterm.js-backed terminal view: verifies it opens
|
||||
* a WS connection to the right URL, writes incoming binary frames to the
|
||||
* mocked terminal, forwards typed input as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { TerminalView } from "../TerminalView";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
const onDataHandlers: Array<(d: string) => void> = [];
|
||||
@@ -20,9 +21,9 @@ const wheelHandlerMock = vi.fn();
|
||||
const keyHandlerMock = vi.fn();
|
||||
const focusMock = vi.fn();
|
||||
const pasteMock = vi.fn();
|
||||
const dropImageMock = vi.fn();
|
||||
const dropFileMock = vi.fn();
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||||
api: { run: { dropFile: (f: File) => dropFileMock(f) } },
|
||||
}));
|
||||
const loadAddonMock = vi.fn();
|
||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||
@@ -68,6 +69,7 @@ vi.mock("@xterm/xterm", () => ({
|
||||
attachCustomKeyEventHandler: keyHandlerMock,
|
||||
focus: focusMock,
|
||||
paste: pasteMock,
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
|
||||
@@ -87,7 +89,7 @@ class MockWebSocket {
|
||||
sent: unknown[] = [];
|
||||
onopen: (() => 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
|
||||
static OPEN = 1;
|
||||
constructor(url: string) {
|
||||
@@ -105,6 +107,9 @@ class MockWebSocket {
|
||||
global.WebSocket = MockWebSocket;
|
||||
|
||||
describe("TerminalView", () => {
|
||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||
beforeAll(() => loadXterm());
|
||||
|
||||
beforeEach(() => {
|
||||
termState.modes.mouseTrackingMode = "none";
|
||||
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", () => {
|
||||
// The terminal IS the page here — without this the first thing typed after
|
||||
// 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" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
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 () => {
|
||||
@@ -362,7 +388,34 @@ describe("TerminalView", () => {
|
||||
onResizeHandlers.forEach((fn) => fn());
|
||||
expect(ws.sent).toEqual([]);
|
||||
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", () => {
|
||||
@@ -388,6 +441,23 @@ describe("TerminalView", () => {
|
||||
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", () => {
|
||||
vi.useFakeTimers();
|
||||
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 () => {
|
||||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.png" });
|
||||
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" });
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" });
|
||||
const target = screen.getByTestId("terminal-view");
|
||||
fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop image to attach");
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach");
|
||||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ")
|
||||
);
|
||||
expect(dropFileMock).toHaveBeenCalledWith(img);
|
||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||||
dropImageMock.mockReset();
|
||||
it("pastes every path of several dropped files, images or not", async () => {
|
||||
dropFileMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` }));
|
||||
pasteMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
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"), {
|
||||
dataTransfer: { types: ["Files"], files: [txt] },
|
||||
dataTransfer: { types: ["Files"], files: [txt, img] },
|
||||
});
|
||||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||||
expect(dropImageMock).not.toHaveBeenCalled();
|
||||
expect(pasteMock).not.toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(pasteMock).toHaveBeenCalledWith(
|
||||
"/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).
|
||||
* A right-click on an installed row opens its Update / Set default /
|
||||
* 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
|
||||
* `AiToolIcon`. A disabled tool is hidden from Add lane; existing lanes keep
|
||||
* working.
|
||||
@@ -19,12 +21,14 @@
|
||||
import { Switch } from "../Switch";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, Download, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { api, type AiTool, type AiToolJob } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
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 navigate = useNavigate();
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [job, setJob] = useState<AiToolJob | null>(null);
|
||||
@@ -218,7 +230,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.installed", { count: installed.length })}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateAllTools.length > 0 && (
|
||||
{isAdmin && updateAllTools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm({ kind: "update-all" })}
|
||||
@@ -229,15 +241,17 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.updateAll", { count: updateAllTools.length })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void run("refresh", refresh)}
|
||||
disabled={busy !== null}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${busy === "refresh" ? "animate-spin" : ""}`} />
|
||||
{t("aiTools.refresh")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="mb-2 text-xs text-status-danger">{error}</p>}
|
||||
@@ -248,12 +262,18 @@ export function AiToolsSettings() {
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-lg border border-border bg-surface-2">
|
||||
{installed.map((tool) => {
|
||||
const isDefault = tool.id === defaultId;
|
||||
const href = settingsHref?.(tool.id);
|
||||
return (
|
||||
<li
|
||||
key={tool.id}
|
||||
data-testid={`ai-tool-${tool.id}`}
|
||||
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
|
||||
@@ -262,7 +282,17 @@ export function AiToolsSettings() {
|
||||
<AiToolIcon tool={tool} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm font-medium text-fg-primary">
|
||||
<span className="truncate">{tool.label}</span>
|
||||
{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} />
|
||||
{isDefault && (
|
||||
<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>
|
||||
{/* Fixed slots, so the buttons line up from row to row. */}
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch tool={tool} disabled={busy !== null} onToggle={() => toggle(tool)} />
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="flex min-w-20 shrink-0 justify-end">
|
||||
{tool.update && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
onClick={() => setConfirm({ kind: "update", tool })}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.update")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-28 shrink-0 justify-end">
|
||||
{!isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!tool.enabled || busy !== null}
|
||||
onClick={() => setDefault(tool)}
|
||||
className={GHOST_BTN}
|
||||
>
|
||||
{t("aiTools.setDefault")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ToolSwitch
|
||||
tool={tool}
|
||||
disabled={busy !== null}
|
||||
onToggle={() => toggle(tool)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{logFor(tool.id)}
|
||||
</li>
|
||||
@@ -312,7 +350,7 @@ export function AiToolsSettings() {
|
||||
</ul>
|
||||
{ctx.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
|
||||
disabled={jobBusy}
|
||||
onSelect={pick((tool) => setConfirm({ kind: "update", tool }))}
|
||||
@@ -320,7 +358,7 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.update")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.id !== defaultId && (
|
||||
{isAdmin && ctx.menu.item.id !== defaultId && (
|
||||
<MenuItem
|
||||
disabled={!ctx.menu.item.enabled || busy !== null}
|
||||
onSelect={pick(setDefault)}
|
||||
@@ -328,12 +366,14 @@ export function AiToolsSettings() {
|
||||
{t("aiTools.setDefault")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<MenuItem disabled={busy !== null} onSelect={pick(toggle)}>
|
||||
{ctx.menu.item.enabled ? t("rowMenu.disable") : t("rowMenu.enable")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{ctx.menu.item.path && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem
|
||||
onSelect={pick((tool) => void navigator.clipboard.writeText(tool.path ?? ""))}
|
||||
>
|
||||
@@ -376,7 +416,7 @@ export function AiToolsSettings() {
|
||||
</span>
|
||||
</span>
|
||||
<Homepage tool={tool} />
|
||||
{tool.install && (
|
||||
{isAdmin && tool.install && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={jobBusy}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @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
|
||||
* per-user Chat UI switch (`ChatUiSettings`, `#chat-ui`), the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* 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
|
||||
@@ -11,19 +13,26 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Monitor, Moon, RotateCcw, Sun } from "lucide-react";
|
||||
import { SettingsRow, SettingsSection, SettingsSegmented } from "./SettingsRow";
|
||||
import { PetSettings } from "./PetSettings";
|
||||
import { ChatUiSettings } from "./ChatUiSettings";
|
||||
import { THEMES, useTheme, type Theme, type ThemeMode } from "../../hooks/useTheme";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import {
|
||||
TERMINAL_FONTS,
|
||||
readTerminalFont,
|
||||
writeTerminalFont,
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
|
||||
const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
"apple-dark": "Apple Dark",
|
||||
"apple-light": "Apple Light",
|
||||
"intellij-light": "IntelliJ Light",
|
||||
canvas: "Canvas",
|
||||
"orca-dark": "Orca Dark",
|
||||
"orca-light": "Orca Light",
|
||||
@@ -66,6 +75,11 @@ function Swatch({ mode, name }: { mode: Theme; name: string }) {
|
||||
export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
setTermFont(font);
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
const ok = await confirmDialog({
|
||||
@@ -103,6 +117,39 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</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">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
* whether the dashboard starts at boot/login through the per-user service
|
||||
* (systemd `--user` unit, or a LaunchAgent on macOS), over
|
||||
* `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>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
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 { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function AutostartSettings() {
|
||||
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
|
||||
? undefined
|
||||
: !status.available
|
||||
@@ -53,6 +111,43 @@ export function AutostartSettings() {
|
||||
disabled={!status?.available || status.foreign || saving}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* under Settings → Claude Code → Overview: the CLAUDE_HOME the server reads
|
||||
* transcripts and config from, the AgentHub hooks installed into Claude Code
|
||||
* with a Reinstall button, and Model Pricing for Claude models (the shared
|
||||
* `PricingSettings` table, with Reset Defaults). Changing CLAUDE_HOME asks first.
|
||||
* with a Reinstall button (named as the plugin's when the enabled `agenthub`
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -14,12 +15,17 @@ import { RefreshCw, Plug, AlertTriangle, RotateCcw, CheckCircle, XCircle } from
|
||||
import { api } from "../../lib/api";
|
||||
import { Skeleton } from "../Skeleton";
|
||||
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
|
||||
* the config it lists, which lives under that home).
|
||||
* @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.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
if (!(document.visibilityState === "visible")) return;
|
||||
api.settings
|
||||
.info()
|
||||
.then((r) => setHooks(r.hooks))
|
||||
@@ -204,13 +211,17 @@ export function ClaudeAgentHubSettings({ onHomeChanged }: { onHomeChanged?: () =
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<PricingSettings vendor="claude" allowReset />
|
||||
<PricingLink vendor="claude" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { usePets, useWornPet } from "../Sagi/usePets";
|
||||
import type { PetView } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
const PREVIEW = 72;
|
||||
|
||||
@@ -121,6 +122,8 @@ export function PetSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const worn = useWornPet();
|
||||
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 bundleInput = useRef<HTMLInputElement>(null);
|
||||
const wornPet = list?.pets.find((p) => p.id === worn.pet.id);
|
||||
@@ -219,6 +222,7 @@ export function PetSettings() {
|
||||
/>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
if (!have && !isAdmin) return null;
|
||||
return (
|
||||
<PetCard
|
||||
key={f.id}
|
||||
@@ -252,50 +256,54 @@ export function PetSettings() {
|
||||
disabled={busy !== null}
|
||||
preview={<Preview pet={p} size={PREVIEW} />}
|
||||
onPick={() => wear(p)}
|
||||
onDelete={() => void remove(p)}
|
||||
onDelete={isAdmin ? () => void remove(p) : undefined}
|
||||
deleteLabel={t("sagi.pet.delete", { name: p.displayName })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy !== null}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
{t("sagi.pet.browse")}
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-fg-muted">{t("sagi.uploadHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="mt-2 text-xs text-status-danger">
|
||||
{error}
|
||||
|
||||
@@ -19,7 +19,18 @@
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { useEffect, useState, useCallback, useRef, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DollarSign, Plus, Pencil, Trash2, Check, X, RotateCcw, Info } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
ArrowRight,
|
||||
DollarSign,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
RotateCcw,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
@@ -191,6 +202,29 @@ function PricingInfoTooltip({ vendor }: { vendor: PricingVendor }) {
|
||||
|
||||
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
|
||||
// fired together share one request.
|
||||
const tables = new Set<() => void>();
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -13,6 +14,7 @@ import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { AiToolsSettings } from "../AiToolsSettings";
|
||||
import { api, type AiTool, type AiToolJob, type AiToolsView } from "../../../lib/api";
|
||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||
@@ -92,9 +94,48 @@ const job = (over: Partial<AiToolJob> = {}): AiToolJob => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname}</span>;
|
||||
}
|
||||
|
||||
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 () => {
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
expect(await screen.findByText("Installed (3 detected)")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Available to install (1)" }));
|
||||
const claude = screen.getByTestId("ai-tool-claude");
|
||||
@@ -107,7 +148,7 @@ describe("AiToolsSettings", () => {
|
||||
|
||||
it("toggles a tool and sets the default through the API", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.click(within(gemini).getByRole("switch", { name: "Gemini enabled" }));
|
||||
expect(api.aiTools.update).toHaveBeenCalledWith("gemini", { enabled: false });
|
||||
@@ -121,7 +162,7 @@ describe("AiToolsSettings", () => {
|
||||
|
||||
it("refreshes detection", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
await user.click(screen.getByRole("button", { name: "Refresh" }));
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
const gemini = await screen.findByTestId("ai-tool-gemini");
|
||||
await user.pointer({ keys: "[MouseRight]", target: gemini });
|
||||
await user.click(screen.getByRole("menuitem", { name: "Set default" }));
|
||||
@@ -182,7 +223,7 @@ describe("AiToolsSettings — install and update", () => {
|
||||
vi.mocked(api.aiTools.job)
|
||||
.mockResolvedValueOnce(null)
|
||||
.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/ }));
|
||||
const qwen = await screen.findByTestId("ai-tool-qwen");
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await openAvailable();
|
||||
const cursor = await screen.findByTestId("ai-tool-cursor");
|
||||
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 () => {
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
for (const id of ["claude", "codex", "gemini"]) {
|
||||
const row = await screen.findByTestId(`ai-tool-${id}`);
|
||||
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 () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await user.click(await screen.findByRole("button", { name: "Update all (3)" }));
|
||||
const cmd = screen.getByTestId("ai-tool-confirm-command");
|
||||
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 () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue(VIEW);
|
||||
render(<AiToolsSettings />);
|
||||
render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await screen.findByTestId("ai-tool-claude");
|
||||
expect(screen.queryByRole("button", { name: /Update all/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the letter when the icon fails", async () => {
|
||||
const { container } = render(<AiToolsSettings />);
|
||||
const { container } = render(<AiToolsSettings />, { wrapper: MemoryRouter });
|
||||
await openAvailable();
|
||||
await screen.findByTestId("ai-tool-qwen");
|
||||
const img = container.querySelector('img[src="/api/ai-tools/qwen/icon"]')!;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @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>
|
||||
*/
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Dark",
|
||||
"Orca Dark",
|
||||
"Apple Light",
|
||||
"IntelliJ Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
]) {
|
||||
@@ -74,8 +73,8 @@ describe("AppearanceSettings", () => {
|
||||
it("the System mode is checked after choosing it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
await user.click(screen.getByRole("radio", { name: /System/ }));
|
||||
expect(screen.getByRole("radio", { name: /System/ })).toHaveAttribute("aria-checked", "true");
|
||||
await user.click(screen.getByRole("radio", { name: "System" }));
|
||||
expect(screen.getByRole("radio", { name: "System" })).toHaveAttribute("aria-checked", "true");
|
||||
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"]: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
|
||||
* @description Settings → Claude Code → AgentHub: the pricing table, the hook
|
||||
* status with Reinstall, and CLAUDE_HOME — the three sections moved out of
|
||||
* Settings → Dashboard (#40 part B) — still load and act as they did there.
|
||||
* @description Settings → Claude Code → AgentHub: the hook status with
|
||||
* Reinstall (named as the plugin's when the plugin provides the hooks),
|
||||
* 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>
|
||||
*/
|
||||
|
||||
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { ClaudeAgentHubSettings } from "../ClaudeAgentHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { ModelPricing } from "../../../lib/types";
|
||||
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). */
|
||||
function confirmYes() {
|
||||
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(() => {
|
||||
i18n.changeLanguage("en");
|
||||
vi.mocked(api.pricing.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ pricing: [RULE] });
|
||||
vi.mocked(api.pricing.list).mockReset().mockResolvedValue({ pricing: [] });
|
||||
vi.mocked(api.settings.info)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
@@ -76,10 +65,15 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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 />);
|
||||
expect(await screen.findByText("claude-opus-5-5")).toBeInTheDocument();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
expect(await screen.findByRole("link", { name: /open pricing/i })).toHaveAttribute(
|
||||
"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();
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
it("resets pricing only on the second click", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.settings.resetPricing).mockResolvedValue({ ok: true, pricing: [RULE] });
|
||||
it("names the plugin, not settings.json, when the plugin provides the hooks", async () => {
|
||||
vi.mocked(api.settings.info).mockResolvedValue({
|
||||
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 />);
|
||||
await user.click(await screen.findByRole("button", { name: "Reset Defaults" }));
|
||||
expect(api.settings.resetPricing).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Confirm Reset" }));
|
||||
expect(api.settings.resetPricing).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("Reset to 1 default rules")).toBeInTheDocument();
|
||||
const note = await screen.findByText(/provided by the agenthub plugin/i);
|
||||
const hooks = note.closest("section#hooks") as HTMLElement;
|
||||
expect(within(hooks).queryByText("/home/u/.claude/settings.json")).toBeNull();
|
||||
expect(within(hooks).getByText("UserPromptSubmit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api } from "../../lib/api";
|
||||
import { useLiveRefresh } from "../../hooks/useLiveRefresh";
|
||||
import { laneChangeMatcher } from "../../lib/laneChange";
|
||||
import type { PipelineStats } from "../../lib/types";
|
||||
|
||||
/** Percentage of `n` out of `total`, 0 when nothing was recorded. */
|
||||
@@ -52,8 +53,12 @@ export function LanePipelinePanel({
|
||||
}, [refresh]);
|
||||
|
||||
// Stages move as agents declare them; throttled (not debounced) so a burst
|
||||
// costs one fetch and a steady stream still refreshes every 3 s.
|
||||
useLiveRefresh(() => void refresh(), { match: (msg) => msg.type === "lane_update" });
|
||||
// costs one fetch and a steady stream still refreshes every 3 s. A lane's
|
||||
// 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;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,8 +33,10 @@ export function SkillUsagePanel({ statusFilter }: { statusFilter: string }) {
|
||||
void 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(), {
|
||||
throttleMs: 15000,
|
||||
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
|
||||
* frames missed while disconnected are never replayed. The copy is dropped
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import type { Lane, WSMessage } from "../lib/types";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
|
||||
let lanes: Lane[] | null = null;
|
||||
// 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
const live = throttle(() => loadRef.current(), throttleMs);
|
||||
const visible = () => document.visibilityState === "visible";
|
||||
// A hidden tab ignores events: onVis reloads once when it is shown again.
|
||||
const offMsg = eventBus.subscribe((msg) => {
|
||||
if (matchRef.current?.(msg)) live();
|
||||
if (visible() && matchRef.current?.(msg)) live();
|
||||
});
|
||||
let wasDown = !eventBus.connected;
|
||||
// (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;
|
||||
else if (wasDown) {
|
||||
wasDown = false;
|
||||
live();
|
||||
if (visible()) live();
|
||||
}
|
||||
})
|
||||
: () => undefined;
|
||||
|
||||
@@ -121,9 +121,11 @@ export function useNotifications() {
|
||||
type: NotifType,
|
||||
title: 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((r) => {
|
||||
if (r.via === "native" || r.via === "push" || r.via === "local")
|
||||
@@ -138,7 +140,7 @@ export function useNotifications() {
|
||||
"onNewSession",
|
||||
i18n.t("errors:notifications.newSession"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export function useNotifications() {
|
||||
"onSessionError",
|
||||
i18n.t("errors:notifications.sessionError"),
|
||||
s.name || `${i18n.t("errors:notifications.sessionDefault")}${s.id.slice(0, 8)}`,
|
||||
laneUrlForSession(s.id)
|
||||
() => laneUrlForSession(s.id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
@@ -158,10 +160,7 @@ export function useNotifications() {
|
||||
if (!prefs.onSubagentSpawn) return;
|
||||
const a = msg.data as Agent;
|
||||
if (a.type === "subagent") {
|
||||
show(
|
||||
"onSubagentSpawn",
|
||||
i18n.t("errors:notifications.subagentSpawned"),
|
||||
a.name,
|
||||
show("onSubagentSpawn", i18n.t("errors:notifications.subagentSpawned"), a.name, () =>
|
||||
laneUrlForSession(a.session_id)
|
||||
);
|
||||
}
|
||||
@@ -169,7 +168,7 @@ export function useNotifications() {
|
||||
}
|
||||
case "new_event": {
|
||||
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
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* @file useSpin.ts
|
||||
* @description A Refresh button's busy state. `run` calls the reload with no
|
||||
* arguments (an `onClick={load}` handed the click event to it) and keeps
|
||||
* `spinning` true for at least `minMs`, so a reload answered from a cache or
|
||||
* a throttle in a few milliseconds still shows the user that it ran.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* @returns `[spinning, run]`. A click while the reload is still in flight is
|
||||
* dropped; once it has answered, a click reloads again even if the icon is
|
||||
* still finishing its minimum spin.
|
||||
*/
|
||||
export function useSpin(fn: () => unknown, minMs = 600): [boolean, () => Promise<void>] {
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const inFlight = useRef(false);
|
||||
const lastRun = useRef(0);
|
||||
const fnRef = useRef(fn);
|
||||
fnRef.current = fn;
|
||||
const alive = useRef(true);
|
||||
// Set on mount too: StrictMode mounts, unmounts and mounts again, and a
|
||||
// cleanup-only effect left `alive` false — the spin never stopped and the
|
||||
// button stayed busy for good.
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const id = ++lastRun.current;
|
||||
const started = Date.now();
|
||||
setSpinning(true);
|
||||
try {
|
||||
await fnRef.current();
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
const rest = minMs - (Date.now() - started);
|
||||
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
|
||||
// Only the newest run stops the spin: an older one's floor ending must
|
||||
// not cut short a reload clicked after it.
|
||||
if (alive.current && id === lastRun.current) setSpinning(false);
|
||||
}
|
||||
}, [minMs]);
|
||||
|
||||
return [spinning, run];
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export type ThemeMode = Theme | "system";
|
||||
/** Named themes per mode, in picker order. `default` is the stock palette. */
|
||||
export const THEMES = {
|
||||
dark: ["default", "darcula", "apple-dark", "orca-dark"],
|
||||
light: ["default", "apple-light", "intellij-light", "canvas", "orca-light"],
|
||||
light: ["default", "apple-light", "canvas", "orca-light"],
|
||||
} as const satisfies Record<Theme, readonly string[]>;
|
||||
|
||||
const MODE_KEY = "theme";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file useThrottledValue.ts
|
||||
* @description Returns `value`, but lets it change at most once per `ms`
|
||||
* (leading + trailing, via `lib/throttle.ts`): the first change shows at once,
|
||||
* a burst collapses into one more update when it settles, and the last value
|
||||
* always lands. For effect keys that move on every hook (a lane's status or
|
||||
* session flipping between two sessions) so the fetch they drive cannot storm.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { throttle } from "../lib/throttle";
|
||||
|
||||
export function useThrottledValue<T>(value: T, ms: number): T {
|
||||
const [shown, setShown] = useState(value);
|
||||
const latest = useRef(value);
|
||||
latest.current = value;
|
||||
const push = useMemo(() => throttle(() => setShown(latest.current), ms), [ms]);
|
||||
useEffect(() => () => push.cancel(), [push]);
|
||||
useEffect(() => push(), [value, push]);
|
||||
return shown;
|
||||
}
|
||||
@@ -128,7 +128,10 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
// restart is picked up within a few seconds rather than after a long
|
||||
// idle wait. The focus/online/visibility listeners below reconnect
|
||||
// instantly on top of this for the common "user comes back" case.
|
||||
const delay = Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000);
|
||||
// Jittered (×0.5–1.5) so a server restart is not met by every
|
||||
// browser reconnecting in the same instant.
|
||||
const delay =
|
||||
Math.min(500 * Math.pow(2, reconnectAttempts.current), 3000) * (0.5 + Math.random());
|
||||
reconnectAttempts.current++;
|
||||
reconnectTimer.current = setTimeout(connect, delay);
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ describe("i18n resources", () => {
|
||||
await i18n.changeLanguage("vi");
|
||||
|
||||
expect(i18n.t("nav:dashboard")).toBe("Tổng quan");
|
||||
expect(i18n.t("nav:agentBoard")).toBe("Bảng Kanban");
|
||||
expect(i18n.t("nav:languageShort.vi")).toBe("VI");
|
||||
expect(i18n.t("nav:settings")).toBe("Cài đặt");
|
||||
});
|
||||
|
||||
it("should keep Agent terminology untranslated in the vi locale", async () => {
|
||||
|
||||
+33
-88
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description i18next bootstrap for the dashboard client. Registers bundled JSON
|
||||
* locale files for English (`en`) and Vietnamese (`vi`), wires browser language detection, and exports the configured
|
||||
* singleton consumed by `react-i18next`.
|
||||
* @description i18next bootstrap for the dashboard client. Loads the JSON locale
|
||||
* files for English (`en`) and Vietnamese (`vi`) lazily — only the active language
|
||||
* (and the `en` fallback) — wires browser language detection, and exports the
|
||||
* configured singleton consumed by `react-i18next` plus `i18nReady`, the promise
|
||||
* the entry point awaits before the first render.
|
||||
*
|
||||
* ## Namespaces
|
||||
* Translations are split by feature area (`dashboard`, `sessions`, `settings`,
|
||||
@@ -38,14 +40,7 @@
|
||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./locales/en/common.json`
|
||||
* - `./locales/vi/common.json`
|
||||
* - `./locales/en/nav.json`
|
||||
* - `./locales/vi/nav.json`
|
||||
* - `./locales/en/dashboard.json`
|
||||
* - `./locales/vi/dashboard.json`
|
||||
* - `./locales/en/sessions.json`
|
||||
* - `./locales/vi/sessions.json`
|
||||
* - `./locales/<lng>/<ns>.json` (lazy, via `import.meta.glob`)
|
||||
*
|
||||
* ## Testing pointers
|
||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||||
@@ -57,44 +52,30 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import i18n from "i18next";
|
||||
import i18n, { type BackendModule, type ResourceKey } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import common_en from "./locales/en/common.json";
|
||||
import common_vi from "./locales/vi/common.json";
|
||||
import nav_en from "./locales/en/nav.json";
|
||||
import nav_vi from "./locales/vi/nav.json";
|
||||
import dashboard_en from "./locales/en/dashboard.json";
|
||||
import dashboard_vi from "./locales/vi/dashboard.json";
|
||||
import sessions_en from "./locales/en/sessions.json";
|
||||
import sessions_vi from "./locales/vi/sessions.json";
|
||||
import activity_en from "./locales/en/activity.json";
|
||||
import activity_vi from "./locales/vi/activity.json";
|
||||
import analytics_en from "./locales/en/analytics.json";
|
||||
import analytics_vi from "./locales/vi/analytics.json";
|
||||
import workflows_en from "./locales/en/workflows.json";
|
||||
import workflows_vi from "./locales/vi/workflows.json";
|
||||
import settings_en from "./locales/en/settings.json";
|
||||
import settings_vi from "./locales/vi/settings.json";
|
||||
import kanban_en from "./locales/en/kanban.json";
|
||||
import kanban_vi from "./locales/vi/kanban.json";
|
||||
import errors_en from "./locales/en/errors.json";
|
||||
import errors_vi from "./locales/vi/errors.json";
|
||||
import updates_en from "./locales/en/updates.json";
|
||||
import updates_vi from "./locales/vi/updates.json";
|
||||
import ccConfig_en from "./locales/en/ccConfig.json";
|
||||
import ccConfig_vi from "./locales/vi/ccConfig.json";
|
||||
import codexConfig_en from "./locales/en/codexConfig.json";
|
||||
import codexConfig_vi from "./locales/vi/codexConfig.json";
|
||||
import run_en from "./locales/en/run.json";
|
||||
import run_vi from "./locales/vi/run.json";
|
||||
import alerts_en from "./locales/en/alerts.json";
|
||||
import alerts_vi from "./locales/vi/alerts.json";
|
||||
import splash_en from "./locales/en/splash.json";
|
||||
import splash_vi from "./locales/vi/splash.json";
|
||||
import lanes_en from "./locales/en/lanes.json";
|
||||
import lanes_vi from "./locales/vi/lanes.json";
|
||||
// Every locale file is its own lazy chunk (vite.config.ts groups them into one
|
||||
// chunk per language), so a page loads only the language it shows. i18next
|
||||
// reads them through this inline backend: `init` fetches the detected language
|
||||
// (plus the `en` fallback), and `changeLanguage` fetches a new one BEFORE it
|
||||
// switches, so no raw key is ever painted.
|
||||
const localeFiles = import.meta.glob<Record<string, unknown>>("./locales/*/*.json", {
|
||||
import: "default",
|
||||
});
|
||||
const lazyLocales: BackendModule = {
|
||||
type: "backend",
|
||||
init() {},
|
||||
read(lng, ns, cb) {
|
||||
const load = localeFiles[`./locales/${lng}/${ns}.json`];
|
||||
if (!load) return cb(null, {});
|
||||
load().then(
|
||||
(data) => cb(null, data as ResourceKey),
|
||||
(err) => cb(err, false)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/** Keep `<html lang>` on the active language, so a screen reader speaks the
|
||||
* page with the right voice and the browser hyphenates/translates correctly.
|
||||
@@ -105,50 +86,13 @@ export function syncDocumentLang(): void {
|
||||
}
|
||||
i18n.on("languageChanged", syncDocumentLang);
|
||||
|
||||
i18n
|
||||
/** Resolves once the detected language's namespaces are loaded; `main.tsx`
|
||||
* awaits it before the first render. */
|
||||
export const i18nReady = i18n
|
||||
.use(lazyLocales)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: {
|
||||
common: common_en,
|
||||
nav: nav_en,
|
||||
dashboard: dashboard_en,
|
||||
sessions: sessions_en,
|
||||
activity: activity_en,
|
||||
analytics: analytics_en,
|
||||
workflows: workflows_en,
|
||||
settings: settings_en,
|
||||
kanban: kanban_en,
|
||||
errors: errors_en,
|
||||
updates: updates_en,
|
||||
ccConfig: ccConfig_en,
|
||||
codexConfig: codexConfig_en,
|
||||
run: run_en,
|
||||
alerts: alerts_en,
|
||||
splash: splash_en,
|
||||
lanes: lanes_en,
|
||||
},
|
||||
vi: {
|
||||
common: common_vi,
|
||||
nav: nav_vi,
|
||||
dashboard: dashboard_vi,
|
||||
sessions: sessions_vi,
|
||||
activity: activity_vi,
|
||||
analytics: analytics_vi,
|
||||
workflows: workflows_vi,
|
||||
settings: settings_vi,
|
||||
kanban: kanban_vi,
|
||||
errors: errors_vi,
|
||||
updates: updates_vi,
|
||||
ccConfig: ccConfig_vi,
|
||||
codexConfig: codexConfig_vi,
|
||||
run: run_vi,
|
||||
alerts: alerts_vi,
|
||||
splash: splash_vi,
|
||||
lanes: lanes_vi,
|
||||
},
|
||||
},
|
||||
supportedLngs: ["en", "vi"],
|
||||
nonExplicitSupportedLngs: true,
|
||||
fallbackLng: "en",
|
||||
@@ -170,6 +114,7 @@ i18n
|
||||
"alerts",
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
@@ -182,5 +127,5 @@ i18n
|
||||
|
||||
syncDocumentLang();
|
||||
|
||||
/** Configured i18next instance — import side effects run {@link init}. */
|
||||
/** Configured i18next instance — import side effects start {@link init}. */
|
||||
export default i18n;
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
"export": "Export",
|
||||
"totalSessions": "Total Sessions",
|
||||
"totalAgents": "Total Agents",
|
||||
"totalTokens": "Total Tokens",
|
||||
"totalCost": "Total Cost",
|
||||
"totalEvents": "Total Events",
|
||||
"cacheHitRate": "% cache hit rate",
|
||||
"perSession": " per session",
|
||||
"noDataCost": "No data",
|
||||
"eventCountLabel_one": "{{count}} event",
|
||||
"eventCountLabel_other": "{{count}} events",
|
||||
"tabs": {
|
||||
|
||||
@@ -30,14 +30,12 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"claudeHome": "Claude home",
|
||||
"projectClaudeDir": "Project .claude",
|
||||
"projectRoot": "Project root",
|
||||
"claudeJson": "claude.json",
|
||||
"summary": "Quick summary",
|
||||
"user": "user",
|
||||
"project": "project",
|
||||
"items": "items"
|
||||
"project": "project"
|
||||
},
|
||||
"common": {
|
||||
"search": "Search by name…",
|
||||
@@ -47,32 +45,19 @@
|
||||
"close": "Close",
|
||||
"copyPath": "Copy path",
|
||||
"copyName": "Copy name",
|
||||
"copied": "Copied",
|
||||
"truncated": "(truncated for preview)",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"scopeLabel": "Scope",
|
||||
"noFrontmatter": "No frontmatter",
|
||||
"redactedNotice": "Values matching /token|secret|password|key|auth/i are redacted server-side.",
|
||||
"preview": "Preview",
|
||||
"raw": "Raw"
|
||||
},
|
||||
"skills": {
|
||||
"name": "Name",
|
||||
"description": "Description"
|
||||
},
|
||||
"agents": {
|
||||
"name": "Name",
|
||||
"model": "Model",
|
||||
"tools": "Tools"
|
||||
},
|
||||
"plugins": {
|
||||
"manifest": "Manifest",
|
||||
"marketplace": "Marketplace",
|
||||
"version": "Version",
|
||||
"installedAt": "Installed",
|
||||
"lastUpdated": "Updated",
|
||||
"installPath": "Install path",
|
||||
"missing": "Install path missing",
|
||||
"manifestMissing": "No installed_plugins.json found at {{path}}",
|
||||
"enabled": "Enabled",
|
||||
@@ -90,8 +75,7 @@
|
||||
"hooks_other": "{{count}} hook scripts",
|
||||
"author": "Author",
|
||||
"homepage": "Homepage",
|
||||
"license": "License",
|
||||
"description": "Description"
|
||||
"license": "License"
|
||||
},
|
||||
"marketplaces": {
|
||||
"manifest": "Known marketplaces manifest",
|
||||
@@ -100,11 +84,9 @@
|
||||
"lastUpdated": "Last synced",
|
||||
"pluginCount": "Plugins",
|
||||
"owner": "Owner",
|
||||
"installLocation": "Install path",
|
||||
"addCmd": "claude plugin marketplace add <repo>"
|
||||
},
|
||||
"keybindings": {
|
||||
"file": "Source file",
|
||||
"missing": "No keybindings.json found at {{path}}",
|
||||
"context": "Context",
|
||||
"key": "Key",
|
||||
@@ -128,8 +110,7 @@
|
||||
},
|
||||
"hookScripts": {
|
||||
"title": "~/.claude/hooks/ scripts",
|
||||
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them.",
|
||||
"empty": "No scripts in ~/.claude/hooks/."
|
||||
"subtitle": "Helper scripts referenced by your hook commands. These run when settings.json `hooks.<event>.hooks[].command` invokes them."
|
||||
},
|
||||
"mcp": {
|
||||
"userScope": "User-scoped servers",
|
||||
@@ -143,8 +124,6 @@
|
||||
"env": "Env names",
|
||||
"url": "URL",
|
||||
"headers": "Header names",
|
||||
"kind": "Kind",
|
||||
"source": "Source",
|
||||
"noServers": "No MCP servers found.",
|
||||
"notRemovable": "Configured in settings.json — remove it there; no CLI scope addresses this entry."
|
||||
},
|
||||
@@ -162,8 +141,6 @@
|
||||
"rawJson": "Raw JSON"
|
||||
},
|
||||
"memory": {
|
||||
"userScope": "User memory",
|
||||
"projectScope": "Project memory",
|
||||
"missing": "No CLAUDE.md found.",
|
||||
"autoTitle": "Project memory store",
|
||||
"autoSubtitle": "Per-project file-based memory under ~/.claude/projects/<project>/memory/ — a MEMORY.md index plus one file per remembered fact. Read-only here.",
|
||||
@@ -241,15 +218,9 @@
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"empty": "No backups yet - every edit or delete creates a timestamped backup that lands here.",
|
||||
"viewLink": "Show backup history",
|
||||
"openButton": "Backups",
|
||||
"subtitle": "Every save / delete in the editor writes a copy here first. Backup folders live outside the directories Claude Code scans, so they don't reappear as ghosts in your CLI.",
|
||||
"restoreHint": "Restore by copying the backup back into place (the backup is kept):",
|
||||
"size": "Size",
|
||||
"created": "Created",
|
||||
"type": "Type",
|
||||
"scopeLabel": "Scope",
|
||||
"filterAll": "All types"
|
||||
"restoreHint": "Restore by copying the backup back into place (the backup is kept):"
|
||||
},
|
||||
"profiles": {
|
||||
"usageCheck": "Check quota",
|
||||
@@ -263,7 +234,8 @@
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
||||
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
|
||||
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
|
||||
"activeLabel": "Logged in now",
|
||||
"noActive": "No Claude account is logged in on this machine.",
|
||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||
@@ -313,9 +285,7 @@
|
||||
"title": "Plugins run through the `claude` CLI",
|
||||
"body": "Plugins are bundles with their own registry — `installed_plugins.json` plus install paths under `plugins/cache/`. The buttons here run the matching `claude plugin …` command on this machine, so the registry stays consistent. Installing a plugin means running third-party code: only add marketplaces you trust.",
|
||||
"install": "Install a plugin:",
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>",
|
||||
"uninstall": "Uninstall this plugin:",
|
||||
"uninstallButton": "Copy uninstall command"
|
||||
"installCmd": "claude plugin install <marketplace>/<plugin>"
|
||||
},
|
||||
"mcp": {
|
||||
"title": "MCP servers are managed by the CLI",
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"codexHome": "Codex home",
|
||||
"projectCodexDir": "Project .codex",
|
||||
"projectRoot": "Project root",
|
||||
"summary": "Quick summary",
|
||||
@@ -72,9 +71,6 @@
|
||||
"saving": "Creating…",
|
||||
"close": "Close"
|
||||
},
|
||||
"config": {
|
||||
"missing": "No config.toml found."
|
||||
},
|
||||
"mcp": {
|
||||
"addTitle": "Add an MCP server",
|
||||
"nameLabel": "Name",
|
||||
|
||||
@@ -21,14 +21,11 @@
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"running": "running",
|
||||
"dashboardRun": "Run",
|
||||
"days": "days",
|
||||
"hours": "hours",
|
||||
"updated": "Updated ",
|
||||
"lastUpdated": "Last updated: ",
|
||||
"showMore": "Show more ({{count}} remaining)",
|
||||
"plusMore": "+{{count}} more",
|
||||
"ofSessions": "% of sessions",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -89,14 +86,8 @@
|
||||
"cost": {
|
||||
"model": "Model",
|
||||
"cost": "Cost",
|
||||
"totalCost": "Total Cost",
|
||||
"totalEstimatedCost": "Total Estimated Cost"
|
||||
},
|
||||
"duration": {
|
||||
"h_m": "{{h}}h {{m}}m",
|
||||
"m_s": "{{m}}m {{s}}s",
|
||||
"s": "{{s}}s"
|
||||
},
|
||||
"eventDetail": {
|
||||
"summary": "Summary",
|
||||
"seeDetailsBelow": "Check below for full details in {{fields}}",
|
||||
@@ -137,8 +128,6 @@
|
||||
"rawPayload": "Raw Payload",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"noPayload": "No payload captured for this event.",
|
||||
"viewDetails": "View details",
|
||||
"expand": "Expand event",
|
||||
"collapse": "Collapse event"
|
||||
},
|
||||
@@ -157,12 +146,7 @@
|
||||
"loadMore": "Load more",
|
||||
"loading": "Loading...",
|
||||
"noResults": "No events match the current filters",
|
||||
"grouped": "Group by tool call",
|
||||
"flat": "Flat list",
|
||||
"groupEventCount_one": "{{count}} event",
|
||||
"groupEventCount_other": "{{count}} events",
|
||||
"help": {
|
||||
"toggle": "Help",
|
||||
"title": "About events, filters, and grouping",
|
||||
"subtitle": "Unlock deeper insights: badges, lifecycles, and filter mechanics explained.",
|
||||
"valuesTitle": "What each filter accepts",
|
||||
@@ -176,10 +160,8 @@
|
||||
"statusesTitle": "Status badges",
|
||||
"statusesIntro": "Status is derived from event_type. It's what you see on each row.",
|
||||
"statusWorkingDesc": "Agent started a tool call (PreToolUse). The tool is about to run.",
|
||||
"statusConnectedDesc": "Tool call completed and returned a result (PostToolUse).",
|
||||
"statusCompletedDesc": "Turn or lifecycle boundary (Stop, SubagentStop, Compaction).",
|
||||
"statusErrorDesc": "An error was recorded (error, APIError).",
|
||||
"statusIdleDesc": "Lifecycle or metadata (Notification, TurnDuration, SessionStart, SessionEnd).",
|
||||
"lifecycleTitle": "Event lifecycle",
|
||||
"lifecycleDesc": "A typical tool call emits two events sharing the same tool_use_id:",
|
||||
"lifecycleFlow": "PreToolUse (Working) → PostToolUse (Connected) → ... → Stop (Completed)",
|
||||
@@ -202,5 +184,32 @@
|
||||
"message": "Your edits have not been saved and will be lost.",
|
||||
"discard": "Discard",
|
||||
"keep": "Keep editing"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Sign in to AgentHub",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"submit": "Sign in",
|
||||
"submitting": "Signing in…",
|
||||
"failed": "Wrong username or password",
|
||||
"tooMany": "Too many failed attempts — try again in a few minutes",
|
||||
"logout": "Sign out",
|
||||
"signedInAs": "Signed in as {{name}} ({{role}})"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
"sessions": "A session is one conversation with an AI agent, from start to finish.",
|
||||
"agents": "Agents are the AI workers: the main one in each session plus any helpers it started.",
|
||||
"tokens": "Tokens are the pieces of text the AI reads and writes. Roughly 1 token ≈ 4 characters. More tokens = more cost.",
|
||||
"cost": "Estimated money spent, calculated from token counts and each model's price.",
|
||||
"events": "Every step the AI takes (running a tool, finishing a reply…) is recorded as one event.",
|
||||
"tokenMix": "Input = what you and the files sent the AI. Output = what the AI wrote. Cache read = text reused from memory (about 10× cheaper). Cache write = text saved to memory for reuse.",
|
||||
"spendPace": "How fast money is being spent, and where this month will end up if that speed continues.",
|
||||
"toolCalls": "How many actions the AI took: reading a file, editing, running a command…",
|
||||
"subagents": "Helper agents the main AI started to work on part of the task in parallel.",
|
||||
"compactions": "When a conversation gets too long, it is summarized to make room. Early details can be lost.",
|
||||
"errors": "Steps that failed. A few are normal; many may mean the AI got stuck.",
|
||||
"tokenFlow": "Input = sent to the AI, Output = written by the AI, Cache = reused from memory (cheaper)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"eventsToday": "Events Today",
|
||||
"totalEvents": "Total Events",
|
||||
"totalCost": "Total Cost",
|
||||
"viewBoard": "View Board",
|
||||
"viewAll": "View All",
|
||||
"activeAgentsSection": "Active Agents",
|
||||
"recentActivity": "Recent Activity",
|
||||
@@ -15,7 +14,6 @@
|
||||
"noAgentsDesc": "Agents will appear here when a Claude Code session is running.",
|
||||
"noActivity": "No activity yet",
|
||||
"noActivityDesc": "Events from Claude Code sessions will stream here in real-time.",
|
||||
"failedConnect": "Failed to connect to server",
|
||||
"failedLoad": "Failed to load data",
|
||||
"activeTrend": " active",
|
||||
"totalTrend": " in active sessions",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user