Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 | |||
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b |
@@ -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
|
||||
|
||||
+13
-8
@@ -333,7 +333,7 @@ graph TD
|
||||
| `lib/webhooks.js` | Universal webhook delivery engine driven by the provider registry. `buildRequest()` resolves the URL, formats the provider-native payload, and assembles headers (provider auth headers + generic-family custom headers + optional HMAC-SHA256 signature via `X-Webhook-Signature` / `X-Webhook-Timestamp`). `dispatchAlert()` fans a fired alert out to every enabled, in-scope target (optional per-rule scoping via `rule_ids`); each `deliver()` POSTs with an `AbortController` timeout and bounded retry/backoff (retries transport errors / 429 / 5xx, never other 4xx) and records the attempt-chain outcome in `webhook_deliveries` (pruned to the newest 2000 rows). Delivery is detached and fully fail-safe — it never throws into the alert path. Enabled targets are cached like alert rules; tunables (`WEBHOOK_TIMEOUT_MS`, `WEBHOOK_MAX_ATTEMPTS`, `WEBHOOK_RETRY_BASE_MS`) are env-overridable. `sendTest()` awaits a synthetic delivery for the test endpoint |
|
||||
| `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. |
|
||||
@@ -2296,8 +2301,8 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
| Ahead of the newest release | `{update_available:false, dev_build:true, commits_ahead_of_release:N}` | Sidebar reads `Dev build · N ahead`; modal suppressed (a dev build is not an available update). |
|
||||
| Shallow clone (incl. the plugin cache) | `{shallow_clone:true, commits_behind_release:null, commits_ahead_of_release:null, dev_build:false}` | No commit badge is rendered at all — the numbers would be graft artefacts. Version compare still decides `update_available`. |
|
||||
| Newer release, ordinary checkout | `{update_available:true, situation:"release_available", latest_release:"vX.Y.Z", manual_command:"...git fetch origin --tags && git merge --ff-only vX.Y.Z..."}` | Modal opens titled with the release version, links its notes, and shows **Update now**. |
|
||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. |
|
||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner. |
|
||||
| Newer release, plugin-cache install | `{update_available:true, situation:"plugin_managed", install_kind:"plugin", plugin_marketplace:"sg", plugin_name:"agenthub", manual_command:"claude plugin marketplace update sg && ..."}` | Modal opens WITH **Update now**; `POST /apply` answers `200 {applied:true, mode:"plugin"}` and the detached chain pulls the new version and re-bootstraps. The modal notes that an already-open Claude Code session still needs `/reload-plugins` for new skills/commands. It then polls `status` and reloads once the old server went down and answered again, or as soon as a different `local_version` answers; a verdict of this chain (`finished_at` after the click) that is `ok:false` or `already_current` ends the wait and shows it, since the old server then never goes down. Independently, every tab reloads itself onto the new UI build when its WebSocket reconnects to a server whose `/api/health` `version` is not the bundle's (`client/src/lib/newBuildReload.ts`, once per version). |
|
||||
| Plugin chain ran but nothing was published | `{last_plugin_update:{ok:true, step:"already_current"}}` | The old server keeps running, untouched; no restart, no error banner — a tab waiting on **Update now** stops waiting and says nothing new was pulled. |
|
||||
| Plugin chain mid-handover | `{last_plugin_update:{ok:true, step:"bootstrap", note:"the new plugin version is installed; the server is being handed over"}}` | Written BEFORE the handover on purpose: that step retires the process tree the helper hangs off, and a helper killed there would otherwise leave `last_plugin_update: null` for an update that succeeded. Overwritten by `done` when the helper survives. |
|
||||
| Plugin chain failed (no `claude` on PATH, `npm ci` broke, bootstrap died) | `{last_plugin_update:{ok:false, step:"resolve_claude"\|"plugin_update"\|"bootstrap", error:"..."}}` | Danger banner in the modal naming the failed step. A `bootstrap` failure means the old server was already stopped, so the verdict says to run `/agenthub-update` to recover — and it is read back by whichever server comes up next, because it lives outside the cache. |
|
||||
| Plugin `/apply` with nothing newer published | 409 `{reason:"plugin_managed", release_check:"ok"}` | No chain is spawned. Message names the newest release and points at `/agenthub-update` to force a refresh. |
|
||||
|
||||
+1
-1
@@ -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,89 @@
|
||||
# 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`.
|
||||
|
||||
<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,87 @@
|
||||
# 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`.
|
||||
|
||||
<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.
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+284
-1
@@ -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),
|
||||
});
|
||||
@@ -2303,6 +2309,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(
|
||||
@@ -2719,6 +2736,21 @@ const COMMAND_GROUPS = [
|
||||
"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|member)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name]",
|
||||
"Claude account pool: new sessions spread over these accounts",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
],
|
||||
@@ -3668,6 +3700,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 +3743,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 : "member";
|
||||
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|member] | 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
|
||||
@@ -3725,6 +4002,12 @@ async function runCommand(argv) {
|
||||
return cmdStart(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":
|
||||
|
||||
+11
-5
@@ -54,11 +54,11 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **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
|
||||
|
||||
**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.
|
||||
@@ -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 Router, Pricing · Git: credentials, worktree suggestions]
|
||||
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]
|
||||
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
+27
-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,17 +64,19 @@ 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";
|
||||
import { eventBus } from "./lib/eventBus";
|
||||
import { copyOnCtrlShiftC } from "./lib/keys";
|
||||
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 }))
|
||||
@@ -132,6 +134,10 @@ export default function App() {
|
||||
return () => window.removeEventListener("vite:preloadError", onPreloadError);
|
||||
}, []);
|
||||
|
||||
// The server came back as a newer version (any tab, any way it was updated):
|
||||
// load its UI build instead of leaving the user to press F5.
|
||||
useEffect(() => reloadOnNewBuild(), []);
|
||||
|
||||
// Ctrl+Shift+C is copy here (Linux terminal habit), not the browser's inspector.
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", copyOnCtrlShiftC, true);
|
||||
@@ -151,8 +157,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={
|
||||
|
||||
@@ -92,6 +92,7 @@ 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 type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -342,6 +343,7 @@ export function AlertsNotifications() {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly, t]);
|
||||
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -1017,11 +1019,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
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @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`.
|
||||
* @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);
|
||||
|
||||
/** 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;
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* @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
|
||||
@@ -81,6 +84,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 +199,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 +217,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 +231,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 +242,7 @@ export function Sagi() {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
@@ -279,8 +286,6 @@ export function Sagi() {
|
||||
[brain, navigate, t]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
top: place.top,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,11 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { useAuth } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -188,6 +190,7 @@ export function Sidebar({
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -531,6 +534,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}
|
||||
|
||||
@@ -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,12 +24,13 @@ 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;
|
||||
@@ -121,6 +122,7 @@ 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",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -143,7 +145,7 @@ 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">
|
||||
<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) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
@@ -159,16 +161,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 +259,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 +276,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 +308,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
|
||||
@@ -85,15 +85,13 @@ import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-r
|
||||
import { api } from "../lib/api";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
|
||||
/** `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
|
||||
@@ -171,19 +169,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 = () => {
|
||||
@@ -264,22 +249,41 @@ export function UpdateNotifier() {
|
||||
* stretch, so polling naively would get a 200 from the process we are
|
||||
* waiting to replace and reload onto the same old bundle.
|
||||
*/
|
||||
const pollUntilBack = useCallback((requireDownFirst = false) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(() => {
|
||||
if (sawDown) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
}, []);
|
||||
const pollUntilBack = useCallback(
|
||||
(requireDownFirst = false, fromVersion: string | null = null) => {
|
||||
let sawDown = !requireDownFirst;
|
||||
// Its own verdicts only: an older plugin-update.json is still on disk
|
||||
// until this chain writes one (5 s slack for a clock a little behind).
|
||||
const since = Date.now() - 5000;
|
||||
const attempt = () => {
|
||||
api.updates
|
||||
.status()
|
||||
.then((s) => {
|
||||
const v = s.last_plugin_update;
|
||||
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
|
||||
// A failed step, or nothing to pull, leaves the OLD server up: it
|
||||
// never goes down, so waiting for that spun "Restarting…" forever.
|
||||
if (requireDownFirst && v && mine && (v.ok === false || v.step === "already_current")) {
|
||||
syncFromPayload(s);
|
||||
setRestarting(false);
|
||||
if (v.ok) toast.info(t("pluginAlreadyCurrent"));
|
||||
return;
|
||||
}
|
||||
// A new version answering is back too, even if a poll never
|
||||
// landed in the short gap between the old server and the new one.
|
||||
const moved = !!fromVersion && !!s.local_version && s.local_version !== fromVersion;
|
||||
if (sawDown || moved) window.location.reload();
|
||||
else setTimeout(attempt, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
sawDown = true;
|
||||
setTimeout(attempt, 1500);
|
||||
});
|
||||
};
|
||||
setTimeout(attempt, 1500);
|
||||
},
|
||||
[syncFromPayload, t]
|
||||
);
|
||||
|
||||
const applyNow = async () => {
|
||||
if (applying || restarting) return;
|
||||
@@ -292,7 +296,7 @@ export function UpdateNotifier() {
|
||||
setRestarting(true);
|
||||
// mode "plugin": the old server lingers while the chain installs, so
|
||||
// only treat a reachable server as "back" after it has gone away once.
|
||||
pollUntilBack(result.mode === "plugin");
|
||||
pollUntilBack(result.mode === "plugin", status?.local_version ?? null);
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
|
||||
@@ -6,14 +6,23 @@
|
||||
* 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'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";
|
||||
@@ -21,6 +30,7 @@ import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { 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). */
|
||||
@@ -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,100 @@
|
||||
/**
|
||||
* @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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
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() } },
|
||||
}));
|
||||
|
||||
import { AuthGate } from "../AuthGate";
|
||||
|
||||
const user = { id: 1, username: "mia", role: "member" };
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -217,7 +217,7 @@ describe("StatusBar", () => {
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin 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");
|
||||
@@ -244,7 +244,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());
|
||||
|
||||
@@ -222,6 +222,40 @@ describe("UpdateNotifier — Update now", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — plugin chain verdict", () => {
|
||||
it("stops Restarting when the chain fails and the old server stays up", async () => {
|
||||
const PLUGIN: UpdateStatusPayload = {
|
||||
...BASE_STATUS,
|
||||
situation: "plugin_managed",
|
||||
install_kind: "plugin",
|
||||
plugin_marketplace: "sg",
|
||||
plugin_name: "agenthub",
|
||||
commits_behind: 0,
|
||||
local_version: "1.5.0",
|
||||
release_check: "ok",
|
||||
latest_release: "v1.6.0",
|
||||
};
|
||||
statusMock.mockResolvedValue(PLUGIN);
|
||||
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
|
||||
render(<UpdateNotifier />);
|
||||
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
|
||||
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
|
||||
|
||||
// The old server answers again, now carrying this chain's failed verdict.
|
||||
statusMock.mockResolvedValue({
|
||||
...PLUGIN,
|
||||
last_plugin_update: {
|
||||
ok: false,
|
||||
step: "plugin_update",
|
||||
error: "boom",
|
||||
finished_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — closing vs skipping", () => {
|
||||
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
|
||||
const { unmount } = render(<UpdateNotifier />);
|
||||
|
||||
@@ -81,14 +81,12 @@ 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" })),
|
||||
iconUrl: () => "",
|
||||
},
|
||||
aiRouters: { list: vi.fn(async () => ({ routers: [] })) },
|
||||
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -351,97 +349,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";
|
||||
|
||||
@@ -106,7 +107,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 +242,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 +305,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 () => {
|
||||
|
||||
@@ -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,16 +13,22 @@
|
||||
* 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 Undo /
|
||||
* Keep bar under its last line (Undo asks twice) in place of its Revert; the
|
||||
* panel turns it off while the editor holds unsaved text, because git acts on
|
||||
* the file on disk.
|
||||
* 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 { EditorState, RangeSetBuilder, StateEffect, StateField, Text } from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
@@ -30,7 +38,8 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -60,7 +69,7 @@ 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 ?? [];
|
||||
@@ -139,6 +148,83 @@ 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 = "keep" | "undo";
|
||||
|
||||
/** 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),
|
||||
});
|
||||
}
|
||||
|
||||
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)";
|
||||
@@ -194,12 +280,12 @@ const theme = EditorView.theme({
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
});
|
||||
|
||||
/** 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 +303,7 @@ export default function DiffEditor({
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -231,11 +318,65 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
/** Keep / Undo one changed block in git; absent = no bars. */
|
||||
onBlock?: (action: BlockAction, block: EditorBlock) => 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, t });
|
||||
cb.current = { onChange, onSave, onBlock, t };
|
||||
|
||||
/** 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);
|
||||
const keep = chip(
|
||||
tr("changes.stageHunkShort"),
|
||||
tr("changes.stageHunk"),
|
||||
"border-status-success bg-status-success/25 text-status-success hover:bg-status-success/35"
|
||||
);
|
||||
// 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);
|
||||
};
|
||||
keep.onclick = () => cb.current.onBlock?.("keep", block);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
@@ -263,23 +404,22 @@ export default function DiffEditor({
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
}),
|
||||
blockBars(Text.of(original.split("\n")), buildBar),
|
||||
];
|
||||
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!,
|
||||
@@ -311,6 +451,11 @@ export default function DiffEditor({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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;
|
||||
@@ -324,7 +469,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);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* file — one file, as a diff or as its whole content (issue #35); a
|
||||
* working file is always the editor (`DiffEditor`, diffed
|
||||
* against HEAD / the branch's merge base, unified or side by
|
||||
* side): Changes folds the unchanged lines away, Whole file
|
||||
* side, each block with the diff's Undo / Keep while nothing
|
||||
* is unsaved): Changes folds the unchanged lines away, Whole file
|
||||
* shows them; any other file opens on its read-only diff; a
|
||||
* whole markdown file can also be read rendered (Preview), and an
|
||||
* image file is shown as the picture (`GET …/git/raw`)
|
||||
@@ -30,10 +31,20 @@
|
||||
* before the server is told `confirm: true` — it is the one action in this
|
||||
* panel git cannot undo.
|
||||
*
|
||||
* During a merge (issue #45) a banner offers **Complete merge** (only once no
|
||||
* file is conflicted) and **Abort merge** (two clicks, `confirm: true`), and
|
||||
* conflicted files get their own group with Keep mine / Take theirs / Mark
|
||||
* resolved instead of stage — staging one blind would mark it resolved.
|
||||
* During a merge, cherry-pick or revert (issue #45) a banner offers
|
||||
* **Complete** (only once no file is conflicted) and **Abort** (two clicks,
|
||||
* `confirm: true`), and conflicted files get their own group with Keep mine /
|
||||
* Take theirs / Mark resolved instead of stage — staging one blind would mark
|
||||
* it resolved.
|
||||
*
|
||||
* History rewrites through its commit menu: Revert (any commit), Undo commit
|
||||
* (the top one; the server refuses a pushed one) and, while another branch's
|
||||
* history is shown, Cherry-pick onto this branch. The commit row's Amend box
|
||||
* folds the staged changes into the last commit. All four ask first
|
||||
* (`confirmDialog`).
|
||||
*
|
||||
* "vs base" compares against the lane's base, else the repo's default branch
|
||||
* (`git.base`), and is hidden when there is none or the lane IS on it.
|
||||
*
|
||||
* A diff line's number opens a comment box (LaneDiffView `onComment`); the
|
||||
* comment is pasted, as `<path>:<line> — <text>`, into the workspace's pane
|
||||
@@ -45,7 +56,7 @@
|
||||
* reloading the browser tab asks the browser's own question, and
|
||||
* `onDirtyChange` tells the Workspace, which asks before closing the panel,
|
||||
* switching its tab, or moving it to another workspace or session. Two-click
|
||||
* actions (discard, a hunk's Undo, Abort merge) disarm after 3 s (`useArmed`).
|
||||
* actions (discard, a hunk's Undo, Abort) disarm after 3 s (`useArmed`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -76,15 +87,18 @@ import {
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useArmed } from "../../hooks/useArmed";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useUnsavedGuard } from "../../hooks/useUnsavedGuard";
|
||||
import type { BlockAction, EditorBlock } from "./DiffEditor";
|
||||
import {
|
||||
LaneDiffView,
|
||||
LaneFileView,
|
||||
changeBlocks,
|
||||
langFor,
|
||||
type DiffLayout,
|
||||
type LaneDiffChange,
|
||||
@@ -349,6 +363,22 @@ function IconButton({
|
||||
const HUNK_CHIP =
|
||||
"flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
/** The git change block an editor block is, by its line span on both sides;
|
||||
* none when the editor's diff and git's split the change differently. */
|
||||
export function gitChangeFor(f: LaneChangeFile, b: EditorBlock): LaneDiffChange | undefined {
|
||||
return (f.hunks ?? []).flatMap(changeBlocks).find((c) => {
|
||||
const lines = c.hunk.lines.slice(c.firstLine, c.lastLine + 1);
|
||||
const del = lines.filter((l) => l.type === "del");
|
||||
const add = lines.filter((l) => l.type === "add");
|
||||
return (
|
||||
del.length === b.oldCount &&
|
||||
add.length === b.newCount &&
|
||||
(!del.length || del[0]!.oldLine === b.oldFrom) &&
|
||||
(!add.length || add[0]!.newLine === b.newFrom)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
type FileAct = (action: "stage" | "unstage" | "discard", path: string, confirm?: boolean) => void;
|
||||
|
||||
/** A work-range file's stage / unstage / discard buttons — the overview row's
|
||||
@@ -400,20 +430,32 @@ function FileActions({
|
||||
{kept ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3" />}
|
||||
</IconButton>
|
||||
) : null}
|
||||
<IconButton
|
||||
label={armed ? t("changes.discardConfirm") : t("changes.discard")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onClick={() => {
|
||||
// Two clicks, always: the first arms the row, the second is the
|
||||
// only one that reaches the server (which itself refuses the
|
||||
// call without `confirm: true`).
|
||||
if (armed) onAct("discard", f.path, true);
|
||||
else onArm(f.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 className={`h-3 w-3 ${armed ? "text-status-danger" : ""}`} />
|
||||
</IconButton>
|
||||
{/* Two clicks, always: the first arms the row, the second is the only
|
||||
one that reaches the server (which itself refuses the call without
|
||||
`confirm: true`). Armed, the icon becomes a red word, so the second
|
||||
click is asked for where the eye already is. */}
|
||||
{armed ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-discard-confirm"
|
||||
aria-label={t("changes.discardConfirm")}
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
>
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
<IconButton
|
||||
label={t("changes.discard")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onClick={() => onArm(f.path)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</IconButton>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -693,6 +735,8 @@ export function LaneChangesPanel({
|
||||
// One read of the working copy, shared by the header and every write below:
|
||||
// a stage, a discard or a commit changes the counts the header shows.
|
||||
const [git, refreshGit] = useLaneGitFacts(laneId, session);
|
||||
// A merge, cherry-pick or revert is open: no new one starts from history.
|
||||
const opOpen = !!(git?.available && git.merging);
|
||||
// Every git read and write below goes to the same tree as those facts.
|
||||
const target = gitTarget(laneId, session);
|
||||
// A diff line's comment goes into this workspace's pane, as a paste.
|
||||
@@ -822,6 +866,12 @@ export function LaneChangesPanel({
|
||||
const returnTo = useRef<{ mode: Mode; scroll: number } | null>(null);
|
||||
const pendingScroll = useRef<number | null>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// History of another branch (where Cherry-pick finds its commits); null =
|
||||
// this branch's own.
|
||||
const [historyRef, setHistoryRef] = useState<string | null>(null);
|
||||
const [historyBranches, setHistoryBranches] = useState<string[]>([]);
|
||||
const [suggesting, setSuggesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -880,7 +930,10 @@ export function LaneChangesPanel({
|
||||
void load();
|
||||
// Only the cheap shape polls; see the file header.
|
||||
if (mode !== "overview") return;
|
||||
const timer = window.setInterval(() => void load(), OVERVIEW_REFRESH_MS);
|
||||
const timer = window.setInterval(
|
||||
() => document.visibilityState === "visible" && void load(),
|
||||
OVERVIEW_REFRESH_MS
|
||||
);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, mode]);
|
||||
|
||||
@@ -894,11 +947,18 @@ export function LaneChangesPanel({
|
||||
if (mode !== "history") return;
|
||||
setHistoryLoading(true);
|
||||
void api.lanes
|
||||
.gitLog(target, { limit: historyLimit })
|
||||
.gitLog(target, { limit: historyLimit, ...(historyRef ? { ref: historyRef } : {}) })
|
||||
.then(forLane(laneId, (r) => setCommits(r.commits)))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => laneNow.current === laneId && setHistoryLoading(false));
|
||||
}, [mode, laneId, reread, historyLimit]);
|
||||
}, [mode, laneId, reread, historyLimit, historyRef]);
|
||||
useEffect(() => {
|
||||
if (mode !== "history") return;
|
||||
void api.lanes
|
||||
.gitBranches(target)
|
||||
.then(forLane(laneId, (r) => setHistoryBranches(r.branches)))
|
||||
.catch(() => setHistoryBranches([]));
|
||||
}, [mode, laneId, reread]);
|
||||
|
||||
// Re-run when a lane switch empties the cache too: the full-width sidebar
|
||||
// stays across the switch, so the mode alone never changes.
|
||||
@@ -1034,6 +1094,8 @@ export function LaneChangesPanel({
|
||||
setConfirmDiscard(null);
|
||||
// Same for a half-typed commit message: it describes the OTHER lane's work.
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
setHistoryRef(null);
|
||||
returnTo.current = null;
|
||||
pendingScroll.current = null;
|
||||
}, [laneId]);
|
||||
@@ -1173,6 +1235,69 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const resolve: Resolve = (path, side) => void mergeAct("resolve", { path, side });
|
||||
|
||||
// Revert / cherry-pick / undo from the history's commit menu. A conflict is
|
||||
// an operation that STARTED: the overview is where it gets resolved.
|
||||
const historyAct = async (action: "cherry-pick" | "revert" | "undo-commit", c: LaneCommit) => {
|
||||
// Every one of the three rewrites this branch's history, so each asks.
|
||||
const branch = git?.available ? git.branch : "HEAD";
|
||||
const ask =
|
||||
action === "undo-commit"
|
||||
? {
|
||||
title: t("changes.undoCommitTitle"),
|
||||
message: t("changes.undoCommitBody", { short: c.short, subject: c.subject }),
|
||||
confirmLabel: t("changes.undoCommitConfirm"),
|
||||
}
|
||||
: action === "revert"
|
||||
? {
|
||||
title: t("changes.revertCommit"),
|
||||
message: t("changes.revertConfirm", { short: c.short, subject: c.subject, branch }),
|
||||
confirmLabel: t("changes.revertCommit"),
|
||||
destructive: false,
|
||||
}
|
||||
: {
|
||||
title: t("changes.cherryPick", { branch }),
|
||||
message: t("changes.cherryPickConfirm", {
|
||||
short: c.short,
|
||||
subject: c.subject,
|
||||
from: historyRef,
|
||||
branch,
|
||||
}),
|
||||
confirmLabel: t("changes.cherryPick", { branch }),
|
||||
destructive: false,
|
||||
};
|
||||
if (!(await confirmDialog(ask))) return;
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await api.lanes.gitAction(
|
||||
target,
|
||||
action,
|
||||
// The commit the dialog named: the server refuses an undo once HEAD
|
||||
// is another one (ESTALE) — an agent may have committed meanwhile.
|
||||
action === "undo-commit" ? { confirm: true, sha: c.sha } : { sha: c.sha }
|
||||
);
|
||||
if (laneNow.current !== lane) return;
|
||||
if (r.conflicts?.length) {
|
||||
toast.error(t("changes.opConflicts", { op: action, count: r.conflicts.length }));
|
||||
} else if (action !== "undo-commit") {
|
||||
toast.success(t("changes.opDone", { op: action }));
|
||||
}
|
||||
// Conflicts and an undone commit's staged changes are both work-range
|
||||
// facts; a clean pick or revert is best seen in this branch's history.
|
||||
setHistoryRef(null);
|
||||
if (action === "undo-commit" || r.conflicts?.length) {
|
||||
setRange("work");
|
||||
setMode("overview");
|
||||
}
|
||||
void rereadAll();
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (laneNow.current === lane) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fileCtx = useContextMenu<LaneChangeFile>();
|
||||
const treeCtx = useContextMenu<LaneTreeEntry>();
|
||||
const commitCtx = useContextMenu<LaneCommit>();
|
||||
@@ -1301,6 +1426,45 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(commitCtx.close, () => copy(commitCtx.menu!.item.subject))}>
|
||||
{t("changes.copySubject")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{historyRef ? (
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-cherry-pick"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("cherry-pick", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.cherryPick", { branch: git?.available ? git.branch : "HEAD" })}
|
||||
</MenuItem>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-revert"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("revert", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.revertCommit")}
|
||||
</MenuItem>
|
||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||
<MenuItem
|
||||
danger
|
||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
||||
testId="lane-changes-menu-undo-commit"
|
||||
onSelect={pick(
|
||||
commitCtx.close,
|
||||
() => void historyAct("undo-commit", commitCtx.menu!.item)
|
||||
)}
|
||||
>
|
||||
{t("changes.undoCommit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
@@ -1329,6 +1493,14 @@ export function LaneChangesPanel({
|
||||
setArmedHunk(null);
|
||||
refreshGit();
|
||||
await load();
|
||||
// Undone under the open editor: it shows the file as it is now (the
|
||||
// bars only exist while nothing is unsaved, so no draft is lost).
|
||||
if (action === "discard-hunk" && text?.path === path) {
|
||||
const r = await api.lanes.gitFile(target, path, "work");
|
||||
if (laneNow.current !== lane || !r.available) return;
|
||||
setText({ path: r.path, lines: r.lines, truncated: r.truncated, sha: r.sha });
|
||||
setDraft(r.lines.join("\n"));
|
||||
}
|
||||
} catch (err) {
|
||||
if (laneNow.current === lane) setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -1339,8 +1511,10 @@ export function LaneChangesPanel({
|
||||
// Stage / Undo on each change block of a file with unstaged changes. The diff is
|
||||
// HEAD → working tree, so a staged-only file gets none: undoing its tree
|
||||
// would leave the index holding the change.
|
||||
const hunkActable = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary;
|
||||
const hunkActions = (f: LaneChangeFile) =>
|
||||
range === "work" && f.unstaged && !f.untracked && !f.binary
|
||||
hunkActable(f)
|
||||
? (change: LaneDiffChange) => {
|
||||
const key = `${f.path}\n${change.hunk.header}\n${change.block}`;
|
||||
const armed = armedHunk === key;
|
||||
@@ -1378,6 +1552,19 @@ export function LaneChangesPanel({
|
||||
: undefined;
|
||||
|
||||
const commitStaged = async () => {
|
||||
// Amend rewrites the last commit: asked, like every history rewrite.
|
||||
if (
|
||||
amend &&
|
||||
!(await confirmDialog({
|
||||
title: t("changes.amendTitle"),
|
||||
message: t("changes.amendConfirm", {
|
||||
subject: git?.available ? git.subject : "",
|
||||
}),
|
||||
confirmLabel: t("changes.amend"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const lane = laneId;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -1385,9 +1572,20 @@ export function LaneChangesPanel({
|
||||
// `only: "staged"` — the whole point of staging file by file is that the
|
||||
// commit takes the index as it stands, with no `add -A` sweeping the
|
||||
// rest of the tree in behind it.
|
||||
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||||
if (amend) {
|
||||
// No message keeps the old one (`--no-edit`).
|
||||
// `sha`: the commit the dialog named (ESTALE if HEAD moved since).
|
||||
const head = git?.available ? git.head : null;
|
||||
await api.lanes.gitAction(target, "amend", {
|
||||
...(message.trim() ? { message: message.trim() } : {}),
|
||||
...(head ? { sha: head } : {}),
|
||||
});
|
||||
} else {
|
||||
await api.lanes.gitAction(target, "commit", { message: message.trim(), only: "staged" });
|
||||
}
|
||||
if (laneNow.current !== lane) return;
|
||||
setMessage("");
|
||||
setAmend(false);
|
||||
refreshGit();
|
||||
await load();
|
||||
} catch (err) {
|
||||
@@ -1418,8 +1616,9 @@ export function LaneChangesPanel({
|
||||
setTreeChildren({});
|
||||
setExpandedDirs(new Set());
|
||||
setReread((n) => n + 1);
|
||||
void load();
|
||||
return load();
|
||||
};
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
@@ -1448,9 +1647,17 @@ export function LaneChangesPanel({
|
||||
? files.filter((f) => (f.untracked || f.unstaged) && !f.conflicted && !neverStage.has(f.path))
|
||||
: [];
|
||||
const current = mode === "file" ? files.find((f) => f.path === openFile) : undefined;
|
||||
// "vs base" needs a base, and means nothing on the base branch itself. An
|
||||
// older server reports no `base` at all: keep the toggle as it was.
|
||||
const base = git?.available ? git.base : undefined;
|
||||
const showBranchRange =
|
||||
base === undefined || (base !== null && !(git?.available && git.branch === base));
|
||||
|
||||
// The armed path left the list (committed, reverted, lane switched): disarm,
|
||||
// so a later reappearance starts from the first click again.
|
||||
useEffect(() => {
|
||||
if (!showBranchRange && range === "branch") setRange("work");
|
||||
}, [showBranchRange, range]);
|
||||
useEffect(() => {
|
||||
if (confirmDiscard && data && !files.some((f) => f.path === confirmDiscard)) {
|
||||
setConfirmDiscard(null);
|
||||
@@ -1606,46 +1813,55 @@ export function LaneChangesPanel({
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={rereadAll}
|
||||
onClick={runReread}
|
||||
aria-label={t("changes.refresh")}
|
||||
title={t("changes.refresh")}
|
||||
className="order-last ml-auto shrink-0 text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
<RefreshCw className={`h-3.5 w-3.5 ${loading || rereading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode !== "history" && mode !== "browse" && (
|
||||
<div className="contents">
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["work", "branch"] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
aria-pressed={range === r}
|
||||
onClick={() => {
|
||||
setRange(r);
|
||||
setSha(null);
|
||||
}}
|
||||
title={t(`changes.range.${r}Hint`)}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.range.${r}`)}
|
||||
</button>
|
||||
))}
|
||||
{range === "commit" && sha && (
|
||||
<span
|
||||
data-testid="lane-changes-commit-chip"
|
||||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* With nothing to compare against, "Uncommitted" alone would be a
|
||||
toggle with one side: none is drawn, unless a commit is open —
|
||||
then Uncommitted is the way back. */}
|
||||
{(showBranchRange || (range === "commit" && sha)) && (
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(showBranchRange ? (["work", "branch"] as const) : (["work"] as const)).map(
|
||||
(r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
aria-pressed={range === r}
|
||||
onClick={() => {
|
||||
setRange(r);
|
||||
setSha(null);
|
||||
}}
|
||||
title={t(`changes.range.${r}Hint`, {
|
||||
base: base || t("changes.range.baseBranch"),
|
||||
})}
|
||||
className={`rounded px-2 py-0.5 text-[11px] transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`changes.range.${r}`, { base: base || t("changes.range.baseBranch") })}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{range === "commit" && sha && (
|
||||
<span
|
||||
data-testid="lane-changes-commit-chip"
|
||||
className="rounded bg-surface-4 px-2 py-0.5 font-mono text-[11px] text-fg-primary"
|
||||
>
|
||||
{sha.slice(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === "file" || mode === "review") && (
|
||||
<div className="flex items-center gap-0.5 rounded border border-border bg-surface-2 p-0.5">
|
||||
@@ -1753,8 +1969,8 @@ export function LaneChangesPanel({
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
{git.conflicts
|
||||
? t("changes.mergeBanner", { count: git.conflicts })
|
||||
: t("changes.mergeReady")}
|
||||
? t("changes.mergeBanner", { count: git.conflicts, op: git.operation ?? "merge" })
|
||||
: t("changes.mergeReady", { op: git.operation ?? "merge" })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1762,7 +1978,7 @@ export function LaneChangesPanel({
|
||||
onClick={() => void mergeAct("merge-commit")}
|
||||
className="shrink-0 rounded border border-accent/40 bg-accent/15 px-1.5 py-0.5 text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.mergeCommit")}
|
||||
{t("changes.mergeCommit", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1773,7 +1989,9 @@ export function LaneChangesPanel({
|
||||
}
|
||||
className="shrink-0 rounded border border-status-danger/40 px-1.5 py-0.5 text-status-danger hover:bg-status-danger/10 disabled:opacity-40"
|
||||
>
|
||||
{armedAbort ? t("changes.mergeAbortConfirm") : t("changes.mergeAbort")}
|
||||
{armedAbort
|
||||
? t("changes.mergeAbortConfirm", { op: git.operation ?? "merge" })
|
||||
: t("changes.mergeAbort", { op: git.operation ?? "merge" })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -1791,6 +2009,29 @@ export function LaneChangesPanel({
|
||||
>
|
||||
{mode === "history" ? (
|
||||
<ul data-testid="lane-changes-history">
|
||||
{historyBranches.length > 1 && (
|
||||
<li className="mb-1 flex items-center gap-1.5 px-1 text-[11px] text-fg-secondary">
|
||||
<span className="shrink-0">{t("changes.historyOf")}</span>
|
||||
<select
|
||||
data-testid="lane-changes-history-ref"
|
||||
value={historyRef ?? ""}
|
||||
onChange={(e) => {
|
||||
setHistoryRef(e.target.value || null);
|
||||
setCommits(null);
|
||||
}}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-1 py-0.5 text-[11px] text-fg-primary"
|
||||
>
|
||||
<option value="">{git?.available ? git.branch : "HEAD"}</option>
|
||||
{historyBranches
|
||||
.filter((b) => !(git?.available && b === git.branch))
|
||||
.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
)}
|
||||
{(() => {
|
||||
const list = commits ?? [];
|
||||
const graph = commitGraph(list);
|
||||
@@ -1858,22 +2099,30 @@ export function LaneChangesPanel({
|
||||
{(stagedCount > 0 || toStage.length > 0) && (
|
||||
<div
|
||||
data-testid="lane-changes-actions"
|
||||
className="sticky top-0 z-10 mb-1.5 flex items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
|
||||
className="sticky top-0 z-10 mb-1.5 flex flex-wrap items-center gap-1.5 border-b border-border-light bg-surface-1 pb-2"
|
||||
>
|
||||
{/* ONE row: message, commit, then the two bulk index moves. */}
|
||||
{/* One row when there is room; in a narrow panel the buttons
|
||||
wrap below rather than squeeze the message to a few
|
||||
characters (its min width is what triggers the wrap). */}
|
||||
{stagedCount > 0 && (
|
||||
<div
|
||||
data-testid="lane-changes-commit"
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5"
|
||||
className="flex min-w-[14rem] flex-1 items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && message.trim() && !busy) void commitStaged();
|
||||
if (isSubmitEnter(e) && (message.trim() || amend) && !busy) {
|
||||
void commitStaged();
|
||||
}
|
||||
}}
|
||||
placeholder={t("changes.commitPlaceholder")}
|
||||
aria-label={t("changes.commitPlaceholder")}
|
||||
placeholder={t(
|
||||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||||
)}
|
||||
aria-label={t(
|
||||
amend ? "changes.amendPlaceholder" : "changes.commitPlaceholder"
|
||||
)}
|
||||
className="min-w-0 flex-1 rounded border border-border-light bg-surface-0 px-2 py-0.5 text-[11px] text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
@@ -1891,49 +2140,69 @@ export function LaneChangesPanel({
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || suggesting || !message.trim()}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{toStage.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
"stage",
|
||||
toStage.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("changes.stageAll", { count: toStage.length })}
|
||||
</button>
|
||||
)}
|
||||
{stagedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-unstage-all"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
"unstage",
|
||||
files.filter((f) => f.staged).map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
{t("changes.unstageAll", { count: stagedCount })}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{stagedCount > 0 && (
|
||||
<>
|
||||
<label
|
||||
title={t("changes.amendHint")}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="lane-changes-amend"
|
||||
checked={amend}
|
||||
onChange={(e) => setAmend(e.target.checked)}
|
||||
/>
|
||||
{t("changes.amend")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || suggesting || !(message.trim() || amend)}
|
||||
onClick={() => void commitStaged()}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{amend
|
||||
? t("changes.amend")
|
||||
: t("changes.commitStaged", { count: stagedCount })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{toStage.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
"stage",
|
||||
toStage.map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("changes.stageAll", { count: toStage.length })}
|
||||
</button>
|
||||
)}
|
||||
{stagedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-changes-unstage-all"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void act(
|
||||
"unstage",
|
||||
files.filter((f) => f.staged).map((f) => f.path)
|
||||
)
|
||||
}
|
||||
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
{t("changes.unstageAll", { count: stagedCount })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* The work range splits like VS Code's Source Control: what the
|
||||
@@ -2179,6 +2448,21 @@ export function LaneChangesPanel({
|
||||
onSave={() => {
|
||||
if (!busy) void saveDraft();
|
||||
}}
|
||||
// The same Undo / Keep as the diff, on the file on
|
||||
// disk — so only while the editor holds no unsaved text.
|
||||
onBlock={
|
||||
current && hunkActable(current) && !dirty && !busy
|
||||
? (action: BlockAction, b: EditorBlock) => {
|
||||
const change = gitChangeFor(current, b);
|
||||
if (!change) return setError(t("changes.blockNotInDiff"));
|
||||
void actHunk(
|
||||
action === "keep" ? "stage-hunk" : "discard-hunk",
|
||||
current.path,
|
||||
change
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -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 });
|
||||
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;
|
||||
}
|
||||
@@ -547,37 +590,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 +640,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 +681,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)}
|
||||
/>
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* 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
|
||||
* router's models, plus Effort); under it the quieter choices — AI Router
|
||||
* 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
|
||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -58,7 +60,7 @@ import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../run/RouterPicker";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../run/ProxyPicker";
|
||||
import { Select } from "../Select";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./WorkspaceRowMenu";
|
||||
@@ -101,6 +103,9 @@ type HomeProps = Parameters<typeof HomeBody>[0];
|
||||
/** Keyed by workspace: every piece of state below (a half-typed prompt, the
|
||||
* 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} />;
|
||||
}
|
||||
@@ -143,25 +148,25 @@ function HomeBody({
|
||||
}
|
||||
}, [draftKey, prompt]);
|
||||
const [images, setImages] = useState<PromptImage[]>([]);
|
||||
const [router, setRouter] = useSessionRouter();
|
||||
const routers = useAiRouters();
|
||||
// Claude and Codex, also before the catalog has loaded (as RouterPicker).
|
||||
const routerCapable = entry?.routerCapable ?? (claude || codex);
|
||||
const [proxy, setProxy] = useSessionProxy();
|
||||
const proxies = useAiProxies();
|
||||
// Claude and Codex, also before the catalog has loaded (as ProxyPicker).
|
||||
const proxyCapable = entry?.proxyCapable ?? (claude || codex);
|
||||
// A profile picked for a tool that can use one; its models replace the tool's own.
|
||||
const profile = routerCapable
|
||||
? (routers?.find((r) => r.name === router && !r.builtin) ?? null)
|
||||
const profile = proxyCapable
|
||||
? (proxies?.find((r) => r.name === proxy && !r.builtin) ?? null)
|
||||
: null;
|
||||
const routerModels: ModelChoice[] | undefined = profile
|
||||
const proxyModels: ModelChoice[] | undefined = profile
|
||||
? [
|
||||
...new Set(
|
||||
[profile.model, profile.models.opus, profile.models.sonnet, profile.models.haiku].filter(
|
||||
Boolean
|
||||
(m): m is string => !!m
|
||||
)
|
||||
),
|
||||
].map((id) => ({ id, label: id }))
|
||||
: undefined;
|
||||
// The tool's default model (also when the catalog arrives after mount); on
|
||||
// a router, the router's own default. A new tool or router drops the pick.
|
||||
// a proxy, the proxy's own default. A new tool or proxy drops the pick.
|
||||
const defaultModel = profile ? "" : (entry?.defaultModel ?? "");
|
||||
const [model, setModel] = useState(defaultModel);
|
||||
useEffect(() => setModel(defaultModel), [defaultModel, tool, profile?.name]);
|
||||
@@ -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.
|
||||
@@ -319,7 +348,7 @@ function HomeBody({
|
||||
// Only a different tool is named: the workspace's own keeps starting
|
||||
// even after it was disabled in Settings.
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...routerArg(routerCapable, router),
|
||||
...proxyArg(proxyCapable, proxy),
|
||||
...(hasModel && model ? { model } : {}),
|
||||
...(claude ? { permissionMode: mode } : {}),
|
||||
...(codex ? { sandbox, approval } : {}),
|
||||
@@ -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">
|
||||
@@ -533,7 +572,7 @@ function HomeBody({
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
tool={tool}
|
||||
choices={routerModels}
|
||||
choices={proxyModels}
|
||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||
/>
|
||||
)}
|
||||
@@ -556,12 +595,12 @@ function HomeBody({
|
||||
data-testid="workspace-home-options"
|
||||
className="mt-1.5 flex flex-wrap items-center gap-0.5 px-1"
|
||||
>
|
||||
<RouterPicker
|
||||
<ProxyPicker
|
||||
variant="ghost"
|
||||
tool={tool}
|
||||
routers={routers}
|
||||
value={router}
|
||||
onChange={setRouter}
|
||||
proxies={proxies}
|
||||
value={proxy}
|
||||
onChange={setProxy}
|
||||
/>
|
||||
{claude && (
|
||||
<Select<PermissionMode>
|
||||
@@ -839,7 +878,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,12 +916,10 @@ 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>
|
||||
|
||||
@@ -12,10 +12,8 @@
|
||||
* (`?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
|
||||
* 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 beside the focused pane) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
@@ -32,23 +30,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,47 +262,16 @@ 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;
|
||||
@@ -433,9 +397,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 +554,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
|
||||
@@ -651,42 +577,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";
|
||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiProxies: { list: vi.fn() },
|
||||
aiTools: { list: vi.fn(), iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
run: { start: vi.fn() },
|
||||
},
|
||||
@@ -50,7 +50,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
proxyCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
@@ -62,8 +62,8 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
const TOOLS: AiToolsView = {
|
||||
default: "claude",
|
||||
tools: [
|
||||
tool("claude", "Claude Code", { launcher: "claude", routerCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", routerCapable: true, prompt: null }),
|
||||
tool("claude", "Claude Code", { launcher: "claude", proxyCapable: true, prompt: null }),
|
||||
tool("codex", "Codex", { launcher: "codex", proxyCapable: true, prompt: null }),
|
||||
tool("gemini", "Gemini"),
|
||||
tool("aider", "Aider", { installed: false, path: null }),
|
||||
tool("grok", "Grok", { enabled: false }),
|
||||
@@ -119,6 +119,13 @@ function renderModal(over: Partial<React.ComponentProps<typeof AddLaneModal>> =
|
||||
);
|
||||
}
|
||||
|
||||
/** The modal opens on Repo; worktree tests switch to Worktree first. */
|
||||
function renderWorktree(over: Partial<React.ComponentProps<typeof AddLaneModal>> = {}) {
|
||||
const r = renderModal(over);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Worktree" }));
|
||||
return r;
|
||||
}
|
||||
|
||||
/** ConfirmModal focuses its Cancel button on a 0ms timer after mount, which
|
||||
* 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>,
|
||||
@@ -158,7 +165,7 @@ beforeEach(() => {
|
||||
.mockReset()
|
||||
.mockResolvedValue({ scaffolded: false, reason: "no detectable Node.js project" });
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiProxies.list).mockReset();
|
||||
});
|
||||
|
||||
describe("AddLaneModal — picking an existing branch (issue #34)", () => {
|
||||
@@ -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();
|
||||
@@ -639,12 +652,12 @@ describe("AddLaneModal — create/update readme", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — no router here", () => {
|
||||
it("asks for no AI Router: a new session picks its own", async () => {
|
||||
describe("AddLaneModal — no proxy here", () => {
|
||||
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||
renderModal({});
|
||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(screen.queryByRole("radio", { name: "AI Router" })).toBeNull();
|
||||
expect(api.aiRouters.list).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -727,7 +740,7 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
|
||||
it("jumps to Repo mode from the branch choice", async () => {
|
||||
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,11 @@
|
||||
/**
|
||||
* @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, and the per-block Undo / Keep bar.
|
||||
* @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 DiffEditor, { keepsText } from "../DiffEditor";
|
||||
|
||||
// jsdom lays nothing out; CodeMirror's measuring asks a Range for its boxes.
|
||||
@@ -72,6 +72,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 +165,55 @@ describe("DiffEditor", () => {
|
||||
const buttons = [...container.querySelectorAll<HTMLButtonElement>(".cm-chunkButtons button")];
|
||||
expect(buttons.filter((b) => !b.hidden).map((b) => b.textContent)).toEqual(["Revert"]);
|
||||
});
|
||||
|
||||
it("puts an Undo / Keep 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} />);
|
||||
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"]'),
|
||||
];
|
||||
fireEvent.click(second!.querySelectorAll("button")[1]!);
|
||||
expect(onBlock).toHaveBeenLastCalledWith("keep", {
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
locks: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: {
|
||||
aiProxies: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
run: {
|
||||
@@ -45,10 +45,10 @@ beforeEach(() => {
|
||||
// stack, so the runtime strip and its button stay absent unless a test opts in.
|
||||
vi.mocked(api.lanes.runtime).mockReset();
|
||||
vi.mocked(api.lanes.runtime).mockResolvedValue({ available: false });
|
||||
// Only the built-in profile by default: a user who never opened the AI Router
|
||||
// Only the built-in profile by default: a user who never opened the AI Proxy
|
||||
// tab has nothing to switch between, so the row stays out of every other test.
|
||||
vi.mocked(api.aiRouters.list).mockReset();
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.aiProxies.list).mockReset();
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValue({ items: [] });
|
||||
});
|
||||
vi.mocked(api.locks.list).mockReset();
|
||||
vi.mocked(api.locks.list).mockResolvedValue({ locks: [] });
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
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";
|
||||
|
||||
@@ -153,6 +153,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 +263,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" }));
|
||||
});
|
||||
|
||||
@@ -739,6 +748,129 @@ 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" })
|
||||
);
|
||||
});
|
||||
|
||||
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) =>
|
||||
@@ -937,7 +1069,7 @@ 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 () => {
|
||||
it("unstages every staged file in one call, in the same pinned bar as Stage all and commit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-overview");
|
||||
@@ -1647,3 +1779,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(
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file WorkspaceHome.test.tsx
|
||||
* @description The workspace home: a new session starts in the first free
|
||||
* pane slot and opens it, on the AI tool picked for it (the workspace's by
|
||||
* default), AI and Model in the box and AI Router / Permission / Worktree
|
||||
* under it (the router's models on a router), all-busy is refused, Recents open a live pane or the
|
||||
* default), AI and Model in the box and AI Proxy / Permission / Worktree
|
||||
* under it (the proxy's models on a proxy), all-busy is refused, Recents open a live pane or the
|
||||
* transcript (never a resume), instructions save, context files upload and
|
||||
* remove, a folder is added with the repo's untracked worktrees suggested
|
||||
* (minus hidden sources), an unsupported tool is said so, and memory opens
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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";
|
||||
@@ -117,7 +117,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
aiRouters: { list: vi.fn(async () => ({ items: [] })) },
|
||||
aiProxies: { list: vi.fn(async () => ({ items: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -241,7 +241,7 @@ describe("WorkspaceHome", () => {
|
||||
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
|
||||
it("keeps AI and Model (with effort) in the box, Proxy/Permission/Worktree under it", async () => {
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-live");
|
||||
const labels = (id: string) =>
|
||||
@@ -252,7 +252,7 @@ describe("WorkspaceHome", () => {
|
||||
].map((x) => x.getAttribute("aria-label"));
|
||||
expect(labels("workspace-home-composer")).toEqual(["AI", "model"]);
|
||||
expect(labels("workspace-home-options")).toEqual([
|
||||
"AI Router",
|
||||
"AI Proxy",
|
||||
"Permission mode",
|
||||
expect.stringContaining("own git worktree"),
|
||||
]);
|
||||
@@ -263,8 +263,8 @@ describe("WorkspaceHome", () => {
|
||||
vi.mocked(api.aiTools.list).mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", routerCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", routerCapable: true, bypass: true },
|
||||
{ id: "claude", label: "Claude Code", launcher: "claude", proxyCapable: true },
|
||||
{ id: "codex", label: "Codex", launcher: "codex", proxyCapable: true, bypass: true },
|
||||
{ id: "cursor", label: "Cursor CLI", installed: false, enabled: true },
|
||||
].map((x) => ({ installed: true, enabled: true, ...x })),
|
||||
} as never);
|
||||
@@ -289,9 +289,9 @@ describe("WorkspaceHome", () => {
|
||||
__resetAiTools();
|
||||
});
|
||||
|
||||
it("offers the router's models on a router, and sends the router", async () => {
|
||||
localStorage.setItem("agenthub.session.router", "glm");
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValueOnce({
|
||||
it("offers the proxy's models on a proxy, and sends the proxy", async () => {
|
||||
localStorage.setItem("agenthub.session.proxy", "glm");
|
||||
vi.mocked(api.aiProxies.list).mockResolvedValueOnce({
|
||||
items: [
|
||||
{ name: "native", label: "Native", builtin: true, models: {} },
|
||||
{
|
||||
@@ -316,7 +316,7 @@ describe("WorkspaceHome", () => {
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(
|
||||
1,
|
||||
"start",
|
||||
expect.objectContaining({ router: "glm" })
|
||||
expect.objectContaining({ proxy: "glm" })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -338,6 +338,21 @@ describe("WorkspaceHome", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens a terminal tab at once from the AI menu, without a prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHome();
|
||||
await screen.findByTestId("workspace-recent-s-old");
|
||||
const ai = screen.getByTestId("workspace-home-tool");
|
||||
await user.click(within(ai).getByRole("button"));
|
||||
await user.click(screen.getByRole("option", { name: /Open terminal/ }));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", shell: true });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("where").textContent).toMatch(/^\/workspace\?lane=1&run=/)
|
||||
);
|
||||
// The AI picked for the next prompt is untouched.
|
||||
expect(within(ai).getByRole("button")).not.toHaveTextContent(/terminal/i);
|
||||
});
|
||||
|
||||
it("says so when the server finds all ten sessions in use", async () => {
|
||||
vi.mocked(api.lanes.action).mockRejectedValueOnce(
|
||||
Object.assign(new Error("every tab of this workspace is busy"), { code: "ERUNLIVE" })
|
||||
@@ -454,6 +469,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("\n"));
|
||||
const chunks0 = Chunk.build(origDoc, Text.of(value.split("\n")));
|
||||
|
||||
const root = parent.appendChild(document.createElement("div"));
|
||||
root.className = "flex h-full min-h-0";
|
||||
const pane = () => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "h-full min-w-0 flex-1";
|
||||
return el;
|
||||
};
|
||||
const paneA = root.appendChild(pane());
|
||||
const gutter = root.appendChild(document.createElement("div"));
|
||||
gutter.className =
|
||||
"cm-merge-revert relative h-full shrink-0 overflow-hidden border-x border-border-light bg-surface-1";
|
||||
gutter.style.width = `${GUTTER_PX}px`;
|
||||
gutter.dataset.testid = "lane-editor-connectors";
|
||||
const svg = gutter.appendChild(document.createElementNS(SVG, "svg"));
|
||||
svg.setAttribute("class", "absolute inset-0 h-full w-full");
|
||||
const buttons = gutter.appendChild(document.createElement("div"));
|
||||
const paneB = root.appendChild(pane());
|
||||
|
||||
let frame = 0;
|
||||
const draw = () => {
|
||||
if (frame) return;
|
||||
const run = () => {
|
||||
frame = 0;
|
||||
paint();
|
||||
};
|
||||
frame =
|
||||
typeof requestAnimationFrame === "function"
|
||||
? requestAnimationFrame(run)
|
||||
: window.setTimeout(run);
|
||||
};
|
||||
|
||||
const side = (s: "a" | "b"): Extension[] => [
|
||||
splitField.init(() => ({ chunks: chunks0, side: s, orig: s === "b" ? origDoc : null })),
|
||||
chunkDecorations,
|
||||
chunkGutter,
|
||||
EditorView.editorAttributes.of({ class: `cm-merge-${s}` }),
|
||||
...(collapse
|
||||
? [collapsedField.init((st) => buildCollapsed(st, collapse.margin, collapse.minSize))]
|
||||
: []),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged || u.geometryChanged || u.viewportChanged) draw();
|
||||
}),
|
||||
];
|
||||
|
||||
const a = new EditorView({
|
||||
parent: paneA,
|
||||
state: EditorState.create({ doc: origDoc, extensions: [...side("a"), ...extA] }),
|
||||
});
|
||||
const b = new EditorView({
|
||||
parent: paneB,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
...side("b"),
|
||||
...extB,
|
||||
// The left side is told the chunks the right side's edit produced.
|
||||
EditorView.updateListener.of((u) => {
|
||||
const now = u.state.field(splitField).chunks;
|
||||
if (now !== u.startState.field(splitField).chunks)
|
||||
a.dispatch({ effects: setChunks.of(now) });
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
siblings.set(a, b);
|
||||
siblings.set(b, a);
|
||||
|
||||
const spans = () => spansOf(b.state.field(splitField).chunks, a.state.doc, b.state.doc);
|
||||
|
||||
// Scrolling one side scrolls the other to the line beside it. A scroll we
|
||||
// caused comes back as the other side's own event: `echo` holds where we
|
||||
// put it, so that event is recognised and not mirrored back.
|
||||
const echo = { a: -1, b: -1 };
|
||||
const follow = (from: "a" | "b") => () => {
|
||||
const [src, dst, to] = from === "a" ? [a, b, "b" as const] : [b, a, "a" as const];
|
||||
if (Math.abs(src.scrollDOM.scrollTop - echo[from]) < 1) {
|
||||
echo[from] = -1;
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
const line = mapLine(topLine(src), spans(), from === "a");
|
||||
const target = Math.round(yOf(dst, line) + docOffset(dst));
|
||||
const before = dst.scrollDOM.scrollTop;
|
||||
dst.scrollDOM.scrollTop = target;
|
||||
if (dst.scrollDOM.scrollTop !== before) echo[to] = dst.scrollDOM.scrollTop;
|
||||
draw();
|
||||
};
|
||||
const onA = follow("a");
|
||||
const onB = follow("b");
|
||||
a.scrollDOM.addEventListener("scroll", onA, { passive: true });
|
||||
b.scrollDOM.addEventListener("scroll", onB, { passive: true });
|
||||
const resize = typeof ResizeObserver === "function" ? new ResizeObserver(draw) : null;
|
||||
resize?.observe(root);
|
||||
|
||||
// » puts the original block back on the right (the merge package's rule:
|
||||
// a non-empty source brings its line break unless it ends the document).
|
||||
buttons.addEventListener("mousedown", (e) => {
|
||||
const i = Number((e.target as HTMLElement).closest<HTMLElement>("[data-chunk]")?.dataset.chunk);
|
||||
const c = b.state.field(splitField).chunks[i];
|
||||
if (!c) return;
|
||||
let insert = a.state.sliceDoc(c.fromA, Math.max(c.fromA, c.toA - 1));
|
||||
if (c.fromA !== c.toA && c.toB <= b.state.doc.length) insert += a.state.lineBreak;
|
||||
b.dispatch({
|
||||
changes: { from: c.fromB, to: Math.min(b.state.doc.length, c.toB), insert },
|
||||
userEvent: "revert",
|
||||
});
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
function paint() {
|
||||
const g = gutter.getBoundingClientRect();
|
||||
const height = gutter.clientHeight;
|
||||
const ya = (l: number) => a.documentTop - g.top + yOf(a, l);
|
||||
const yb = (l: number) => b.documentTop - g.top + yOf(b, l);
|
||||
const paths: SVGPathElement[] = [];
|
||||
const btns: HTMLButtonElement[] = [];
|
||||
spans().forEach((s, i) => {
|
||||
const [y0, y1, z0, z1] = [ya(s.a0), ya(s.a1), yb(s.b0), yb(s.b1)];
|
||||
// Off screen on both sides: nothing to draw (no layout = draw all).
|
||||
if (height > 0 && (Math.max(y1, z1) < 0 || Math.min(y0, z0) > height)) return;
|
||||
const kind = s.a0 === s.a1 ? "add" : s.b0 === s.b1 ? "del" : "mod";
|
||||
const p = document.createElementNS(SVG, "path");
|
||||
p.setAttribute("d", bandPath(GUTTER_PX, y0, y1, z0, z1));
|
||||
p.setAttribute("class", BAND[kind]);
|
||||
p.setAttribute("vector-effect", "non-scaling-stroke");
|
||||
p.dataset.kind = kind;
|
||||
paths.push(p);
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = "»";
|
||||
btn.title = revertLabel;
|
||||
btn.setAttribute("aria-label", revertLabel);
|
||||
btn.dataset.chunk = String(i);
|
||||
btn.className =
|
||||
"absolute left-0.5 z-10 rounded px-0.5 text-[11px] leading-none text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
btn.style.top = `${Math.max(0, y0) + 1}px`;
|
||||
btns.push(btn);
|
||||
});
|
||||
svg.replaceChildren(...paths);
|
||||
buttons.replaceChildren(...btns);
|
||||
}
|
||||
draw();
|
||||
|
||||
return {
|
||||
a,
|
||||
b,
|
||||
destroy: () => {
|
||||
if (frame)
|
||||
(typeof cancelAnimationFrame === "function" ? cancelAnimationFrame : clearTimeout)(frame);
|
||||
resize?.disconnect();
|
||||
a.scrollDOM.removeEventListener("scroll", onA);
|
||||
b.scrollDOM.removeEventListener("scroll", onB);
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, router or worktree is still the workspace home's prompt
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`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)
|
||||
@@ -22,7 +22,10 @@
|
||||
* a `+` item) brings it into the tab on screen, split to the right, 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>
|
||||
*/
|
||||
@@ -53,8 +56,10 @@ import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./Pan
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
detachSlot,
|
||||
groupOf,
|
||||
joinGroup,
|
||||
leaves,
|
||||
reorderGroups,
|
||||
} from "../../lib/paneLayout";
|
||||
@@ -207,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));
|
||||
@@ -221,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) => {
|
||||
@@ -432,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));
|
||||
@@ -445,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
|
||||
|
||||
@@ -106,20 +106,27 @@ export function usePaneGroups(laneId: number | null): [PaneGroups, GroupsUpdate]
|
||||
return [current.groups, update];
|
||||
}
|
||||
|
||||
type Zone = Edge | "center";
|
||||
export type Zone = Edge | "center";
|
||||
const pct = (r: Rect) => ({
|
||||
left: `${r.x * 100}%`,
|
||||
top: `${r.y * 100}%`,
|
||||
width: `${r.w * 100}%`,
|
||||
height: `${r.h * 100}%`,
|
||||
});
|
||||
/** The half (or the whole) of a pane a drop zone would fill. */
|
||||
function zoneRect(r: Rect, zone: Zone): Rect {
|
||||
if (zone === "left") return { ...r, w: r.w / 2 };
|
||||
if (zone === "right") return { ...r, x: r.x + r.w / 2, w: r.w / 2 };
|
||||
if (zone === "top") return { ...r, h: r.h / 2 };
|
||||
if (zone === "bottom") return { ...r, y: r.y + r.h / 2, h: r.h / 2 };
|
||||
return r;
|
||||
/** What dropping `src` on a pane's zone does: the center swaps, an edge
|
||||
* splits. The drop applies it and the preview draws it, so they agree. */
|
||||
export function applyDrop(l: PaneLayout, src: number, slot: number, zone: Zone): PaneLayout {
|
||||
return zone === "center" ? swapInLayout(l, src, slot) : moveInLayout(l, src, slot, zone);
|
||||
}
|
||||
|
||||
/** Where `src` would land, or null for a drop that changes nothing. A pane
|
||||
* already shown leaves its place first, so its target grows before the
|
||||
* split — the target's own half is not where it lands. */
|
||||
export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone): Rect | null {
|
||||
if (src === slot) return null;
|
||||
const next = applyDrop(l, src, slot, zone);
|
||||
if (next === l) return null;
|
||||
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||
}
|
||||
|
||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||
@@ -323,7 +330,7 @@ export function PaneGrid({
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ slot: number; zone: Zone } | null>(null);
|
||||
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||
const { panes, dividers } = layoutRects(layout.root);
|
||||
const shown = leaves(layout.root);
|
||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||
@@ -391,6 +398,8 @@ export function PaneGrid({
|
||||
return { slot: p.slot, zone: dist < 0.25 ? edge : "center" };
|
||||
};
|
||||
|
||||
const preview = drop && dropPreview(layout, drop.src, drop.slot, drop.zone);
|
||||
|
||||
const isPaneDrag = (e: React.DragEvent) =>
|
||||
dragSlot !== null || !!e.dataTransfer?.types?.includes(PANE_DRAG_TYPE);
|
||||
|
||||
@@ -404,7 +413,10 @@ export function PaneGrid({
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
const z = zoneAt(e);
|
||||
setDrop((d) => (d?.slot === z?.slot && d?.zone === z?.zone ? d : z));
|
||||
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||
setDrop((d) =>
|
||||
d?.src === next?.src && d?.slot === next?.slot && d?.zone === next?.zone ? d : next
|
||||
);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!gridRef.current?.contains(e.relatedTarget as Node | null)) setDrop(null);
|
||||
@@ -417,11 +429,7 @@ export function PaneGrid({
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||
onChange((l) =>
|
||||
z.zone === "center"
|
||||
? swapInLayout(l, src, z.slot)
|
||||
: moveInLayout(l, src, z.slot, z.zone as Edge)
|
||||
);
|
||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||
}}
|
||||
>
|
||||
{mounted.map((slot) => {
|
||||
@@ -561,11 +569,11 @@ export function PaneGrid({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{drop && rectOf.has(drop.slot) && (
|
||||
{preview && (
|
||||
<div
|
||||
data-testid="pane-drop-preview"
|
||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
style={pct(zoneRect(rectOf.get(drop.slot) as Rect, drop.zone))}
|
||||
style={pct(preview)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file ProxyPicker.tsx
|
||||
* @description The AI Proxy a NEW session starts on — a session's choice, not
|
||||
* its workspace's (`proxy` on `POST /api/lanes/:id/start`, `server/lib/proxy-apply.js`):
|
||||
* Native or one of the saved profiles (`useAiProxies`, which the caller also
|
||||
* reads to list the proxy's models). A tool that cannot use a proxy (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.proxy`; the pre-rename
|
||||
* `agenthub.session.router` is still read when that is unset).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AiProxy } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
const KEY = "agenthub.session.proxy";
|
||||
const LEGACY_KEY = "agenthub.session.router";
|
||||
|
||||
/** The remembered pick, `"native"` by default. */
|
||||
export function useSessionProxy(): [string, (v: string) => void] {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) || localStorage.getItem(LEGACY_KEY) || "native";
|
||||
} catch {
|
||||
return "native";
|
||||
}
|
||||
});
|
||||
const set = (v: string) => {
|
||||
setValue(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage off — the pick still holds for this page */
|
||||
}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
export function useAiProxies(): AiProxy[] | null {
|
||||
const [proxies, setProxies] = useState<AiProxy[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiProxies
|
||||
.list()
|
||||
.then((r) => alive && setProxies(r.items))
|
||||
.catch(() => alive && setProxies([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return proxies;
|
||||
}
|
||||
|
||||
/** What to send as `proxy` for `tool`: nothing for native or a tool that has none. */
|
||||
export function proxyArg(proxyCapable: boolean | undefined, proxy: string): { proxy?: string } {
|
||||
return proxyCapable && proxy && proxy !== "native" ? { proxy } : {};
|
||||
}
|
||||
|
||||
export function ProxyPicker({
|
||||
tool,
|
||||
proxies,
|
||||
value,
|
||||
onChange,
|
||||
variant,
|
||||
}: {
|
||||
variant?: "field" | "ghost";
|
||||
tool: string;
|
||||
/** From `useAiProxies`. */
|
||||
proxies: AiProxy[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const capable = aiTools.byId(tool)?.proxyCapable ?? (tool === "claude" || tool === "codex");
|
||||
const native = t("proxy.native", { tool: aiTools.label(tool) });
|
||||
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a proxy the user cannot see.
|
||||
useEffect(() => {
|
||||
if (proxies && value !== "native" && !proxies.some((r) => r.name === value)) onChange("native");
|
||||
}, [proxies, value, onChange]);
|
||||
|
||||
const options =
|
||||
capable && proxies?.length
|
||||
? proxies.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="proxy-picker">
|
||||
<Select<string>
|
||||
value={capable ? value : "native"}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
disabled={!capable}
|
||||
variant={variant}
|
||||
label={t("proxy.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @file RouterPicker.tsx
|
||||
* @description The AI Router a NEW session starts on — a session's choice, not
|
||||
* its workspace's (`router` on `POST /api/lanes/:id/start`, `server/lib/router-apply.js`):
|
||||
* Native or one of the saved profiles (`useAiRouters`, which the caller also
|
||||
* reads to list the router's models). A tool that cannot use a router (only
|
||||
* Claude and Codex can) shows Native, disabled. The last pick is remembered
|
||||
* for the next session (`agenthub.session.router`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type AiRouter } from "../../lib/api";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { Select } from "../Select";
|
||||
|
||||
const KEY = "agenthub.session.router";
|
||||
|
||||
/** The remembered pick, `"native"` by default. */
|
||||
export function useSessionRouter(): [string, (v: string) => void] {
|
||||
const [value, setValue] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(KEY) || "native";
|
||||
} catch {
|
||||
return "native";
|
||||
}
|
||||
});
|
||||
const set = (v: string) => {
|
||||
setValue(v);
|
||||
try {
|
||||
localStorage.setItem(KEY, v);
|
||||
} catch {
|
||||
/* storage off — the pick still holds for this page */
|
||||
}
|
||||
};
|
||||
return [value, set];
|
||||
}
|
||||
|
||||
/** The saved profiles (Native included), null until they arrive. */
|
||||
export function useAiRouters(): AiRouter[] | null {
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
api.aiRouters
|
||||
.list()
|
||||
.then((r) => alive && setRouters(r.items))
|
||||
.catch(() => alive && setRouters([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return routers;
|
||||
}
|
||||
|
||||
/** What to send as `router` for `tool`: nothing for native or a tool that has none. */
|
||||
export function routerArg(routerCapable: boolean | undefined, router: string): { router?: string } {
|
||||
return routerCapable && router && router !== "native" ? { router } : {};
|
||||
}
|
||||
|
||||
export function RouterPicker({
|
||||
tool,
|
||||
routers,
|
||||
value,
|
||||
onChange,
|
||||
variant,
|
||||
}: {
|
||||
variant?: "field" | "ghost";
|
||||
tool: string;
|
||||
/** From `useAiRouters`. */
|
||||
routers: AiRouter[] | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const aiTools = useAiTools();
|
||||
const capable = aiTools.byId(tool)?.routerCapable ?? (tool === "claude" || tool === "codex");
|
||||
const native = t("router.native", { tool: aiTools.label(tool) });
|
||||
|
||||
// A remembered profile that has since been deleted falls back to native,
|
||||
// or the start would be refused for a router the user cannot see.
|
||||
useEffect(() => {
|
||||
if (routers && value !== "native" && !routers.some((r) => r.name === value)) onChange("native");
|
||||
}, [routers, value, onChange]);
|
||||
|
||||
const options =
|
||||
capable && routers?.length
|
||||
? routers.map((r) => ({ value: r.name, label: r.builtin ? native : r.label }))
|
||||
: [{ value: "native", label: native }];
|
||||
return (
|
||||
<div data-testid="router-picker">
|
||||
<Select<string>
|
||||
value={capable ? value : "native"}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
disabled={!capable}
|
||||
variant={variant}
|
||||
label={t("router.label")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@
|
||||
* box (`WorkspaceHome.tsx`):
|
||||
* - `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 Router's models), plus Custom…;
|
||||
* or a fixed one (an AI Proxy's models), plus Custom…;
|
||||
* - `ModelMenu` — the same list as ONE composer chip, Claude Desktop's
|
||||
* "Sonnet 5.5 · Medium": the main models with their hints, the rest under
|
||||
* More models, Custom…, and the Effort slider (Faster → Smarter, a reset
|
||||
@@ -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")}
|
||||
@@ -671,7 +728,7 @@ const MODEL_CUSTOM = "__custom__";
|
||||
/**
|
||||
* The models `tool` can start on, the inherit entry (`""`) first: the tool's
|
||||
* own list (Codex's models cache, Claude's aliases named after the newest
|
||||
* version, a catalog tool's vendor rows), or `fixed` (a router's models);
|
||||
* version, a catalog tool's vendor rows), or `fixed` (a proxy's models);
|
||||
* Claude's static aliases only when the server cannot answer. `loaded` once
|
||||
* the list is final.
|
||||
*/
|
||||
@@ -707,7 +764,7 @@ export function ModelPicker({
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
tool: LaneTool;
|
||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||
choices?: ModelChoice[];
|
||||
variant?: "field" | "ghost";
|
||||
}) {
|
||||
@@ -781,7 +838,7 @@ export function ModelMenu({
|
||||
value: string;
|
||||
onChange: (s: string) => void;
|
||||
tool: LaneTool;
|
||||
/** A fixed list instead of the tool's own — an AI Router's models. */
|
||||
/** A fixed list instead of the tool's own — an AI Proxy's models. */
|
||||
choices?: ModelChoice[];
|
||||
/** Omitted for a tool that takes no effort. */
|
||||
effort?: EffortLevel;
|
||||
|
||||
@@ -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
|
||||
@@ -32,12 +36,10 @@
|
||||
*/
|
||||
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";
|
||||
@@ -45,7 +47,7 @@ import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
|
||||
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 +74,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 +184,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 +200,293 @@ 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.
|
||||
// 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);
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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 }));
|
||||
};
|
||||
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);
|
||||
// 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 === "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) => {
|
||||
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
|
||||
};
|
||||
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]);
|
||||
|
||||
@@ -583,9 +651,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}`)}
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
action: vi.fn(),
|
||||
list: vi.fn(),
|
||||
},
|
||||
aiRouters: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiProxies: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @file PaneGrid.test.ts
|
||||
* @description The pane drop preview: it draws where the dragged pane will
|
||||
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||
* current box, and nothing for a drop that changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropPreview } from "../PaneGrid";
|
||||
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||
|
||||
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
|
||||
describe("dropPreview", () => {
|
||||
it("a shown pane leaves its place first: A onto B's top is the whole top half", () => {
|
||||
expect(dropPreview(AB, 0, 1, "top")).toEqual({ slot: 0, x: 0, y: 0, w: 1, h: 0.5 });
|
||||
});
|
||||
|
||||
it("a pane from another tab splits the target's current box", () => {
|
||||
expect(dropPreview(AB, 2, 1, "bottom")).toEqual({ slot: 2, x: 0.5, y: 0.5, w: 0.5, h: 0.5 });
|
||||
});
|
||||
|
||||
it("the center swaps into the target's box", () => {
|
||||
expect(dropPreview(AB, 0, 1, "center")).toEqual({ slot: 0, x: 0.5, y: 0, w: 0.5, h: 1 });
|
||||
});
|
||||
|
||||
it("nothing on its own pane or on a refused move", () => {
|
||||
expect(dropPreview(AB, 0, 0, "left")).toBeNull();
|
||||
const full: PaneLayout = {
|
||||
root: split("row", split("col", pane(0), pane(1)), split("col", pane(2), pane(3))),
|
||||
focus: 0,
|
||||
};
|
||||
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||
});
|
||||
});
|
||||
+33
-28
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* @file RouterPicker.test.tsx
|
||||
* @description The per-session AI Router picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a router,
|
||||
* @file ProxyPicker.test.tsx
|
||||
* @description The per-session AI Proxy picker: Native plus the saved
|
||||
* profiles, Native alone and disabled on a tool that cannot use a proxy,
|
||||
* remembering the pick, and falling back to Native when the remembered
|
||||
* profile is gone; plus `routerArg`, which never sends native.
|
||||
* profile is gone; plus `proxyArg`, which never sends native.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import type { AiRouter } from "../../../lib/api";
|
||||
import type { AiProxy } from "../../../lib/api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RouterPicker, routerArg, useAiRouters, useSessionRouter } from "../RouterPicker";
|
||||
import { ProxyPicker, proxyArg, useAiProxies, useSessionProxy } from "../ProxyPicker";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
@@ -22,67 +22,72 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
list: vi.fn().mockResolvedValue({
|
||||
default: "claude",
|
||||
tools: [
|
||||
{ id: "claude", label: "Claude Code", routerCapable: true },
|
||||
{ id: "gemini", label: "Gemini", routerCapable: false },
|
||||
{ id: "claude", label: "Claude Code", proxyCapable: true },
|
||||
{ id: "gemini", label: "Gemini", proxyCapable: false },
|
||||
].map((x) => ({ ...x, installed: true, enabled: true })),
|
||||
}),
|
||||
},
|
||||
aiRouters: { list: vi.fn() },
|
||||
aiProxies: { list: vi.fn() },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiRouter;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiRouter;
|
||||
const native = { name: "native", label: "Native", builtin: true } as AiProxy;
|
||||
const nine = { name: "9router", label: "9Router", builtin: false } as AiProxy;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.aiRouters.list)
|
||||
vi.mocked(api.aiProxies.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ items: [native, nine] } as never);
|
||||
});
|
||||
|
||||
describe("RouterPicker", () => {
|
||||
describe("ProxyPicker", () => {
|
||||
it("lists the saved profiles for a tool that can use one", async () => {
|
||||
render(
|
||||
<RouterPicker tool="claude" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="claude" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
expect(await screen.findByText("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a router", () => {
|
||||
it("shows Native, disabled, on a tool with no endpoint to point at a proxy", () => {
|
||||
render(
|
||||
<RouterPicker tool="gemini" routers={[native, nine]} value="9router" onChange={() => {}} />
|
||||
<ProxyPicker tool="gemini" proxies={[native, nine]} value="9router" onChange={() => {}} />
|
||||
);
|
||||
const picker = screen.getByTestId("router-picker");
|
||||
const picker = screen.getByTestId("proxy-picker");
|
||||
expect(picker.querySelector("button")).toBeDisabled();
|
||||
expect(picker).not.toHaveTextContent("9Router");
|
||||
});
|
||||
|
||||
it("falls back to Native when the remembered profile is gone", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RouterPicker tool="claude" routers={[native]} value="deleted" onChange={onChange} />);
|
||||
render(<ProxyPicker tool="claude" proxies={[native]} value="deleted" onChange={onChange} />);
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith("native"));
|
||||
});
|
||||
|
||||
it("useAiRouters reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiRouters());
|
||||
it("useAiProxies reads the saved profiles once", async () => {
|
||||
const { result } = renderHook(() => useAiProxies());
|
||||
await waitFor(() => expect(result.current).toEqual([native, nine]));
|
||||
expect(api.aiRouters.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.aiProxies.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionRouter / routerArg", () => {
|
||||
describe("useSessionProxy / proxyArg", () => {
|
||||
it("remembers the last pick for the next session", () => {
|
||||
const first = renderHook(() => useSessionRouter());
|
||||
const first = renderHook(() => useSessionProxy());
|
||||
expect(first.result.current[0]).toBe("native");
|
||||
act(() => first.result.current[1]("9router"));
|
||||
expect(renderHook(() => useSessionRouter()).result.current[0]).toBe("9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a router only for a capable tool and never native", () => {
|
||||
expect(routerArg(true, "9router")).toEqual({ router: "9router" });
|
||||
expect(routerArg(true, "native")).toEqual({});
|
||||
expect(routerArg(false, "9router")).toEqual({});
|
||||
it("still reads the pick remembered before the rename", () => {
|
||||
localStorage.setItem("agenthub.session.router", "9router");
|
||||
expect(renderHook(() => useSessionProxy()).result.current[0]).toBe("9router");
|
||||
});
|
||||
|
||||
it("sends a proxy only for a capable tool and never native", () => {
|
||||
expect(proxyArg(true, "9router")).toEqual({ proxy: "9router" });
|
||||
expect(proxyArg(true, "native")).toEqual({});
|
||||
expect(proxyArg(false, "9router")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* `PromptEditor`'s `/` and `@` autocomplete (Enter picks while the list is
|
||||
* open, `/` only where slash commands apply), Shift/Alt+Enter newlines, a
|
||||
* pasted image shown as a thumbnail and sent as its saved path, and `ModelPicker` — a tool's own
|
||||
* list from the server, a fixed list (an AI Router's models) when given one,
|
||||
* list from the server, a fixed list (an AI Proxy's models) when given one,
|
||||
* and Custom…, which is a sentinel, never a model id — and `ModelMenu`, the
|
||||
* same list as one chip: aliases up front, pinned versions under More models,
|
||||
* plus the effort slider.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,7 +267,7 @@ describe("ModelPicker", () => {
|
||||
expect(await screen.findByText("GPT-6-Sol")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers only a fixed list when given one (an AI Router's models)", async () => {
|
||||
it("offers only a fixed list when given one (an AI Proxy's models)", async () => {
|
||||
render(
|
||||
<ModelPicker
|
||||
value=""
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* @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 { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { pasteToRun } from "../../../lib/panePaste";
|
||||
|
||||
const writeMock = vi.fn();
|
||||
@@ -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.
|
||||
@@ -87,7 +88,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 +106,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";
|
||||
@@ -388,6 +392,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 +421,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 "
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ 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",
|
||||
|
||||
@@ -56,6 +56,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))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* computed from — as shown on a tool's Settings tab (Overview for Claude Code
|
||||
* and Codex, the Antigravity tab itself): Claude
|
||||
* Code's shows `claude-…` rows, Codex's shows OpenAI rows (`gpt-…`, `o<n>…`,
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a router model can serve either tool), so a
|
||||
* `codex-…`), Antigravity's Gemini rows (`gemini-…`). It is ONE table (a proxy model can serve either tool), so a
|
||||
* **Show all models** switch lists every row, and a row added outside the
|
||||
* filter turns it on rather than vanishing. Reset Defaults reseeds Claude's
|
||||
* rates, so only the Claude tab offers it; the server re-fetches any row the
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* @file UsersSettings.tsx
|
||||
* @description Settings → Users, server mode only and only for an admin or
|
||||
* analyst (docs/SERVER-MODE.md): every account with its role, state,
|
||||
* workspaces, sessions and the tokens/$ it used in the last 7 and 30 days
|
||||
* (`GET /api/users`), and the audit log of staff reads of members' sessions
|
||||
* (`GET /api/users/audit`). Read-only — accounts are managed with
|
||||
* `agenthub users …` on the host.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ServerUserUsage, type AuditEntry } from "../../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
export function UsersSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch((e: Error) => setError(e.message));
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([]));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title={t("users.title")} description={t("users.description")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("users.role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("users.last7d")}</th>
|
||||
<th className="py-2 text-right font-medium">{t("users.last30d")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{u.disabled && (
|
||||
<span className="ml-2 text-[11px]">({t("users.disabled")})</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{u.role}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 text-right tabular-nums"
|
||||
title={t("users.tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-2 text-[11px] text-fg-muted">{t("users.manageHint")}</p>
|
||||
</SettingsSection>
|
||||
<SettingsSection title={t("users.auditTitle")} description={t("users.auditDescription")}>
|
||||
{audit?.length === 0 && (
|
||||
<p className="py-2 text-sm text-fg-muted">{t("users.auditEmpty")}</p>
|
||||
)}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ const tool = (id: string, label: string, over: Partial<AiTool> = {}): AiTool =>
|
||||
prompt: { mode: "argv" },
|
||||
resume: false,
|
||||
bypass: false,
|
||||
routerCapable: false,
|
||||
proxyCapable: false,
|
||||
install: null,
|
||||
update: null,
|
||||
modelFlag: false,
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("AppearanceSettings", () => {
|
||||
"Apple Dark",
|
||||
"Orca Dark",
|
||||
"Apple Light",
|
||||
"IntelliJ Light",
|
||||
"Canvas",
|
||||
"Orca Light",
|
||||
]) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+32
-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",
|
||||
@@ -182,5 +126,5 @@ i18n
|
||||
|
||||
syncDocumentLang();
|
||||
|
||||
/** Configured i18next instance — import side effects run {@link init}. */
|
||||
/** Configured i18next instance — import side effects start {@link init}. */
|
||||
export default i18n;
|
||||
|
||||
@@ -263,7 +263,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.",
|
||||
|
||||
@@ -202,5 +202,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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
"changes.noMatches": "No tracked file matches.",
|
||||
"changes.openFile": "Open {{path}}",
|
||||
"changes.quickOpen": "Find a file by path…",
|
||||
"changes.range.branch": "vs base",
|
||||
"changes.range.branchHint": "base...HEAD — everything this workspace's branch adds on top of its base",
|
||||
"changes.range.branch": "Compared to {{base}}",
|
||||
"changes.range.baseBranch": "base branch",
|
||||
"changes.range.branchHint": "{{base}}...HEAD — everything this branch adds on top of {{base}}",
|
||||
"changes.range.work": "Uncommitted",
|
||||
"changes.range.workHint": "Working tree vs HEAD — what has been touched and not committed",
|
||||
"changes.refresh": "Refresh",
|
||||
@@ -215,8 +216,8 @@
|
||||
"proof.ticketReport": "Task report",
|
||||
"renameLabel": "Rename this workspace",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"router.native": "Native ({{tool}})",
|
||||
"router.label": "AI Router",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"runtime.boot": "▶ up",
|
||||
"runtime.busy.down": "stopping…",
|
||||
"runtime.busy.up": "booting…",
|
||||
@@ -244,6 +245,7 @@
|
||||
"changes.edit": "Edit",
|
||||
"changes.save": "Save",
|
||||
"changes.revertBlock": "Revert",
|
||||
"changes.blockNotInDiff": "That change is no longer in git's diff; refresh and retry",
|
||||
"changes.cancelEdit": "Cancel",
|
||||
"changes.preview": "Preview",
|
||||
"changes.raw": "Raw",
|
||||
@@ -269,14 +271,35 @@
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "The branch has diverged from its upstream: merge it in (conflicts are resolved in the Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} into {{current}}? Files in this workspace change now — if an agent is working in this workspace, let it finish first.",
|
||||
"gitBar.pushConfirm": "Push {{count}} commit(s) from {{branch}} to {{upstream}}? Anyone with access to the remote will see them, and a push cannot be taken back from here.",
|
||||
"gitBar.publishBranchConfirm": "Publish branch {{branch}} to the remote? Anyone with access to the remote will see it.",
|
||||
"gitBar.publishConfirm": "Add {{url}} as this repo's remote and push {{branch}} to it? Anyone with access to that remote will see the code.",
|
||||
"gitBar.branchDeleteConfirm": "Delete the local branch {{branch}}? Git refuses if it holds commits merged nowhere else.",
|
||||
"gitBar.mergeConflicts": "Merge stopped on {{count}} conflicted file(s) — resolve them in the list below.",
|
||||
"gitBar.merging": "A merge is in progress: finish or abort it first",
|
||||
"gitBar.merging": "A {{op}} is in progress: finish or abort it first",
|
||||
"changes.conflictsGroup": "Merge conflicts",
|
||||
"changes.mergeBanner": "Merge in progress — {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "Merge in progress — every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete merge",
|
||||
"changes.mergeAbort": "Abort merge",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the merge",
|
||||
"changes.mergeBanner": "A {{op}} is in progress \u2014 {{count}} file(s) still conflicted",
|
||||
"changes.mergeReady": "A {{op}} is in progress \u2014 every conflict is resolved",
|
||||
"changes.mergeCommit": "Complete {{op}}",
|
||||
"changes.mergeAbort": "Abort {{op}}",
|
||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||
"changes.discardShortConfirm": "Discard?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Fold the staged changes into the last commit (only before it is pushed). Leave the message empty to keep the old one.",
|
||||
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||
"changes.historyOf": "History of branch",
|
||||
"changes.cherryPick": "Cherry-pick onto {{branch}}",
|
||||
"changes.revertCommit": "Revert this commit",
|
||||
"changes.undoCommit": "Undo commit (keep changes)",
|
||||
"changes.undoCommitTitle": "Undo the last commit?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||
"changes.undoCommitConfirm": "Undo commit",
|
||||
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||
"changes.amendTitle": "Amend the last commit?",
|
||||
"changes.amendConfirm": "\"{{subject}}\" is replaced by a new commit with the staged changes folded in. The server refuses if it is already pushed.",
|
||||
"changes.opConflicts": "The {{op}} stopped on {{count}} conflict(s) — resolve them in Changes",
|
||||
"changes.opDone": "{{op}} done",
|
||||
"changes.takeOurs": "Keep mine",
|
||||
"changes.takeTheirs": "Take theirs",
|
||||
"changes.markResolved": "Mark resolved (after editing out the markers)",
|
||||
@@ -330,9 +353,6 @@
|
||||
"tree.allTools": "All tools",
|
||||
"tree.newSession": "New session",
|
||||
"tree.noSessions": "No sessions yet",
|
||||
"tree.closedTerminal": "Closed terminal — click opens a new one in this workspace's folder (Ctrl/Cmd+click: in a split)",
|
||||
"tree.reopenTerminal": "Open a new terminal",
|
||||
"tree.forgetTerminal": "Remove from list",
|
||||
"tree.transcriptHint": "No live pane — opens its conversation; a message you send resumes it",
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
@@ -346,6 +366,7 @@
|
||||
"tree.unarchive": "Unarchive",
|
||||
"home.breadcrumb": "Workspaces",
|
||||
"home.ai": "AI",
|
||||
"home.openTerminal": "Open terminal",
|
||||
"home.repoRoot": "Repository root",
|
||||
"home.mainBranch": "Main worktree's branch",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
|
||||
@@ -142,11 +142,10 @@
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Drop image to attach",
|
||||
"uploading": "Uploading image…",
|
||||
"failed": "Could not attach the image",
|
||||
"imagesOnly": "Only images can be dropped here",
|
||||
"remove": "Remove image",
|
||||
"hint": "Drop files or images to attach",
|
||||
"uploading": "Uploading…",
|
||||
"failed": "Could not attach the file",
|
||||
"remove": "Remove attachment",
|
||||
"view": "View image"
|
||||
},
|
||||
"terminal": {
|
||||
|
||||
@@ -577,13 +577,13 @@
|
||||
"fieldLabelPlaceholder": "Dev box"
|
||||
},
|
||||
"cursorPathsNote": "Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes).",
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No router profile saved yet. Native runs against the vendor's own API.",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
"builtinHint": "Built in — this is the vendor's own API, and it cannot be edited or removed.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
@@ -592,16 +592,16 @@
|
||||
"keySet": "Saved",
|
||||
"keyMissing": "None",
|
||||
"fieldLabel": "Name",
|
||||
"fieldLabelHint": "What you will see in the workspace form. Free text.",
|
||||
"fieldLabelHint": "What you will see in the session's AI Proxy picker. Free text.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "The router's OpenAI/Anthropic-compatible endpoint, e.g. http://localhost:20128/v1",
|
||||
"fieldBaseUrlHint": "The proxy's root URL, without /v1 — e.g. http://localhost:20128. Claude Code adds /v1/messages and Codex /v1/responses; a /v1 you type is dropped.",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Stored on this machine at 0600 and never sent back to the browser.",
|
||||
"fieldApiKeyKeep": "Leave blank to keep the saved key.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many routers accept one symbolic name.",
|
||||
"fieldModelHint": "Used for every tier unless you override one below. Many proxies accept one symbolic name.",
|
||||
"fieldBehavesAs": "Behaves as (Claude Code)",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this router's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the router really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"fieldBehavesAsHint": "Optional. The Claude model id Claude Code should treat this proxy's model names as (effort defaults, context window), e.g. claude-opus-5-5. Fill it in when Claude Code says a model isn't described by its model catalog, and name the model the proxy really sends to. While set, it replaces your own modelPicker setting in these sessions.",
|
||||
"perTier": "Per-tier overrides",
|
||||
"perTierHint": "Leave a field blank to use the model above.",
|
||||
"tier": {
|
||||
@@ -613,12 +613,12 @@
|
||||
},
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Every workspace using it is reset to the vendor's own API right away — on disk and in the dashboard.",
|
||||
"removeConfirm": "Remove the profile \"{{label}}\"? Its session files go with it, and a session that ran on it resumes on the vendor's own API.",
|
||||
"loading": "Loading profiles…",
|
||||
"test": "Test",
|
||||
"testing": "Testing…",
|
||||
"testOk": "The endpoint answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "No answer ({{status}}): {{message}}"
|
||||
"testOk": "answered — {{model}} is reachable with this key.",
|
||||
"testFailed": "no answer ({{status}}): {{message}}"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -700,7 +700,7 @@
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Router layers into it, and starts every codex workspace with it.",
|
||||
"description": "Root directory for Codex config and session journals. The dashboard reads it, writes AI Proxy layers into it, and starts every codex workspace with it.",
|
||||
"restartNote": "A codex workspace already running keeps the home it started with, and shows as idle, until it is restarted.",
|
||||
"placeholder": "/path/to/codex-home"
|
||||
},
|
||||
@@ -792,8 +792,8 @@
|
||||
"tool": {
|
||||
"description": "{{tool}}: path, usage, default model and config files."
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "Endpoints that stand in front of Claude Code and Codex."
|
||||
},
|
||||
"pricing": {
|
||||
@@ -823,6 +823,10 @@
|
||||
"reports": {
|
||||
"title": "Reports",
|
||||
"description": "Analytics summaries written on a schedule, as HTML or JSON."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Accounts, their usage and the audit log."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Back",
|
||||
@@ -942,5 +946,21 @@
|
||||
"importBody": "The file's preferences replace this browser's, then the page reloads.",
|
||||
"imported": "Imported {{count}} preference(s). Reloading…",
|
||||
"badFile": "That file is not an AgentHub preferences file."
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
"description": "Every account on this server, with its workspaces, sessions and recent usage.",
|
||||
"name": "User",
|
||||
"role": "Role",
|
||||
"workspaces": "Workspaces",
|
||||
"sessions": "Sessions",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"disabled": "disabled",
|
||||
"tokens": "{{n}} tokens",
|
||||
"manageHint": "Add, disable or change a user on the host: agenthub users …",
|
||||
"auditTitle": "Audit log",
|
||||
"auditDescription": "Each time an admin or analyst opened a session a member owns.",
|
||||
"auditEmpty": "No reads recorded yet."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"releaseUnavailable": "Could not read the latest release from the repository ({{reason}}); fell back to comparing commits.",
|
||||
"pluginAutoNote": "This dashboard is installed as a Claude Code plugin. \"Update now\" pulls the new plugin version, reinstalls the runtime, and restarts the server for you — it takes a few minutes. Newly added skills and slash commands only appear in an already-open Claude Code session after you run /reload-plugins there.",
|
||||
"pluginUpdating": "Pulling the new plugin version and reinstalling the runtime — this takes a few minutes, the page reloads itself when the new server is up...",
|
||||
"pluginAlreadyCurrent": "No newer plugin version was pulled, so the running dashboard was left as it is.",
|
||||
"lastPluginUpdateFailed": "The last plugin update failed at step \"{{step}}\": {{error}} — run /agenthub-update in Claude Code to recover.",
|
||||
"restartingShort": "Restarting…",
|
||||
"installedTitle": "Version {{version}} is installed but not running",
|
||||
|
||||
@@ -258,7 +258,8 @@
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
|
||||
"warning": "Các phiên Claude Code đang chạy tự đi theo khi đổi: chúng nhận đăng nhập mới trong khoảng 30 giây.",
|
||||
"mismatch": "Đăng nhập đang dùng thuộc tài khoản {{owner}}, nhưng config của Claude Code lại ghi {{config}}. Mọi request đang tính vào {{owner}}. Bấm Chuyển sang đúng tài khoản bạn muốn để sửa.",
|
||||
"activeLabel": "Đang đăng nhập",
|
||||
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
|
||||
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
|
||||
|
||||
@@ -202,5 +202,32 @@
|
||||
"message": "Các chỉnh sửa của bạn chưa được lưu và sẽ bị mất.",
|
||||
"discard": "Bỏ thay đổi",
|
||||
"keep": "Tiếp tục sửa"
|
||||
},
|
||||
"auth": {
|
||||
"title": "Đăng nhập AgentHub",
|
||||
"username": "Tên đăng nhập",
|
||||
"password": "Mật khẩu",
|
||||
"submit": "Đăng nhập",
|
||||
"submitting": "Đang đăng nhập…",
|
||||
"failed": "Sai tên đăng nhập hoặc mật khẩu",
|
||||
"tooMany": "Sai quá nhiều lần — hãy thử lại sau vài phút",
|
||||
"logout": "Đăng xuất",
|
||||
"signedInAs": "Đang đăng nhập: {{name}} ({{role}})"
|
||||
},
|
||||
"insights": {
|
||||
"help": {
|
||||
"sessions": "Một phiên là một cuộc trò chuyện với agent AI, từ lúc bắt đầu đến khi kết thúc.",
|
||||
"agents": "Agent là các 'nhân công' AI: agent chính trong mỗi phiên cộng với các agent phụ nó tạo ra.",
|
||||
"tokens": "Token là các mẩu chữ AI đọc và viết, khoảng 1 token ≈ 4 ký tự. Càng nhiều token càng tốn tiền.",
|
||||
"cost": "Số tiền ước tính, tính từ số token và giá của từng mô hình.",
|
||||
"events": "Mỗi bước AI làm (chạy công cụ, trả lời xong…) được ghi lại thành một sự kiện.",
|
||||
"tokenMix": "Input = nội dung bạn và các file gửi cho AI. Output = nội dung AI viết ra. Cache read = nội dung dùng lại từ bộ nhớ đệm (rẻ hơn khoảng 10 lần). Cache write = nội dung lưu vào bộ nhớ đệm để dùng lại.",
|
||||
"spendPace": "Tốc độ chi tiền, và cuối tháng sẽ tốn bao nhiêu nếu giữ tốc độ này.",
|
||||
"toolCalls": "Số thao tác AI đã làm: đọc file, sửa file, chạy lệnh…",
|
||||
"subagents": "Các agent phụ mà AI chính tạo ra để làm song song một phần công việc.",
|
||||
"compactions": "Khi cuộc trò chuyện quá dài, nó được tóm tắt lại để có chỗ trống. Chi tiết ở đoạn đầu có thể bị mất.",
|
||||
"errors": "Các bước bị lỗi. Vài lỗi là bình thường; nhiều lỗi có thể nghĩa là AI bị kẹt.",
|
||||
"tokenFlow": "Input = gửi cho AI, Output = AI viết ra, Cache = dùng lại từ bộ nhớ đệm (rẻ hơn)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +93,9 @@
|
||||
"changes.noMatches": "Không có file nào khớp.",
|
||||
"changes.openFile": "Mở {{path}}",
|
||||
"changes.quickOpen": "Tìm file theo đường dẫn…",
|
||||
"changes.range.branch": "So nhánh gốc",
|
||||
"changes.range.branchHint": "base...HEAD — toàn bộ phần nhánh của workspace này thêm vào so với nhánh gốc",
|
||||
"changes.range.branch": "So với {{base}}",
|
||||
"changes.range.baseBranch": "nhánh gốc",
|
||||
"changes.range.branchHint": "{{base}}...HEAD — toàn bộ phần nhánh này thêm vào so với {{base}}",
|
||||
"changes.range.work": "Chưa commit",
|
||||
"changes.range.workHint": "Working tree so với HEAD — những gì vừa sửa mà chưa commit",
|
||||
"changes.refresh": "Tải lại",
|
||||
@@ -215,8 +216,8 @@
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"renameLabel": "Đổi tên workspace này",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"router.native": "Native ({{tool}})",
|
||||
"router.label": "AI Router",
|
||||
"proxy.native": "Native ({{tool}})",
|
||||
"proxy.label": "AI Proxy",
|
||||
"runtime.boot": "▶ chạy",
|
||||
"runtime.busy.down": "đang dừng…",
|
||||
"runtime.busy.up": "đang khởi động…",
|
||||
@@ -244,6 +245,7 @@
|
||||
"changes.edit": "Sửa",
|
||||
"changes.save": "Lưu",
|
||||
"changes.revertBlock": "Hoàn tác",
|
||||
"changes.blockNotInDiff": "Thay đổi này không còn trong diff của git; hãy làm mới rồi thử lại",
|
||||
"changes.cancelEdit": "Huỷ",
|
||||
"changes.preview": "Xem trước",
|
||||
"changes.raw": "Mã nguồn",
|
||||
@@ -269,14 +271,35 @@
|
||||
"gitBar.mergeUpstream": "Merge {{upstream}}",
|
||||
"gitBar.mergeUpstreamHint": "Nhánh đã lệch với upstream: merge vào (conflict được giải quyết trong Changes panel)",
|
||||
"gitBar.mergeConfirm": "Merge {{branch}} vào {{current}}? File trong workspace sẽ thay đổi ngay - nếu có agent đang làm việc trong workspace này, hãy để nó xong trước.",
|
||||
"gitBar.pushConfirm": "Push {{count}} commit từ {{branch}} lên {{upstream}}? Ai có quyền vào remote đều thấy được, và không thể rút lại push từ đây.",
|
||||
"gitBar.publishBranchConfirm": "Publish nhánh {{branch}} lên remote? Ai có quyền vào remote đều thấy được.",
|
||||
"gitBar.publishConfirm": "Thêm {{url}} làm remote của repo và push {{branch}} lên đó? Ai có quyền vào remote đó đều thấy được code.",
|
||||
"gitBar.branchDeleteConfirm": "Xoá nhánh local {{branch}}? Git sẽ từ chối nếu nhánh còn commit chưa được merge vào đâu.",
|
||||
"gitBar.mergeConflicts": "Merge dừng ở {{count}} file conflict - hãy giải quyết trong danh sách bên dưới.",
|
||||
"gitBar.merging": "Đang merge: hãy hoàn tất hoặc huỷ merge trước",
|
||||
"gitBar.merging": "Đang {{op}}: hãy hoàn tất hoặc huỷ trước",
|
||||
"changes.conflictsGroup": "Conflict khi merge",
|
||||
"changes.mergeBanner": "Đang merge - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang merge - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất merge",
|
||||
"changes.mergeAbort": "Huỷ merge",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ merge",
|
||||
"changes.mergeBanner": "Đang {{op}} - còn {{count}} file conflict",
|
||||
"changes.mergeReady": "Đang {{op}} - đã giải quyết hết conflict",
|
||||
"changes.mergeCommit": "Hoàn tất {{op}}",
|
||||
"changes.mergeAbort": "Huỷ {{op}}",
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||
"changes.discardShortConfirm": "Xoá?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendHint": "Gộp phần đã stage vào commit cuối (chỉ khi chưa push). Để trống message để giữ message cũ.",
|
||||
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||
"changes.historyOf": "Lịch sử của nhánh",
|
||||
"changes.cherryPick": "Cherry-pick vào {{branch}}",
|
||||
"changes.revertCommit": "Revert commit này",
|
||||
"changes.undoCommit": "Hoàn tác commit (giữ thay đổi)",
|
||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||
"changes.amendTitle": "Amend commit cuối?",
|
||||
"changes.amendConfirm": "\"{{subject}}\" sẽ được thay bằng một commit mới gộp cả phần đã stage. Server sẽ từ chối nếu commit đã được push.",
|
||||
"changes.opConflicts": "{{op}} dừng lại ở {{count}} file conflict — hãy giải quyết trong Changes",
|
||||
"changes.opDone": "Đã {{op}} xong",
|
||||
"changes.takeOurs": "Giữ bản của mình",
|
||||
"changes.takeTheirs": "Lấy bản kia",
|
||||
"changes.markResolved": "Đánh dấu đã xong (sau khi đã sửa hết dấu conflict)",
|
||||
@@ -330,9 +353,6 @@
|
||||
"tree.allTools": "Mọi tool",
|
||||
"tree.newSession": "Session mới",
|
||||
"tree.noSessions": "Chưa có session",
|
||||
"tree.closedTerminal": "Terminal đã đóng — bấm để mở terminal mới ở thư mục của workspace (Ctrl/Cmd+click: mở ở split)",
|
||||
"tree.reopenTerminal": "Mở terminal mới",
|
||||
"tree.forgetTerminal": "Xóa khỏi danh sách",
|
||||
"tree.transcriptHint": "Không còn pane — mở hội thoại; gửi tin nhắn sẽ tiếp tục (resume) session",
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
@@ -346,6 +366,7 @@
|
||||
"tree.unarchive": "Bỏ lưu trữ",
|
||||
"home.breadcrumb": "Không gian làm việc",
|
||||
"home.ai": "AI",
|
||||
"home.openTerminal": "Mở terminal",
|
||||
"home.repoRoot": "Thư mục gốc của repo",
|
||||
"home.mainBranch": "Branch của worktree chính",
|
||||
"home.worktreeCount_one": "{{count}} worktree",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
|
||||
@@ -141,11 +141,10 @@
|
||||
"output": "Output"
|
||||
},
|
||||
"drop": {
|
||||
"hint": "Thả ảnh để đính kèm",
|
||||
"uploading": "Đang tải ảnh lên…",
|
||||
"failed": "Không đính kèm được ảnh",
|
||||
"imagesOnly": "Chỉ thả được ảnh vào đây",
|
||||
"remove": "Bỏ ảnh",
|
||||
"hint": "Thả file hoặc ảnh để đính kèm",
|
||||
"uploading": "Đang tải lên…",
|
||||
"failed": "Không đính kèm được file",
|
||||
"remove": "Bỏ đính kèm",
|
||||
"view": "Xem ảnh"
|
||||
},
|
||||
"terminal": {
|
||||
|
||||
@@ -577,13 +577,13 @@
|
||||
"fieldLabelPlaceholder": "Máy dev"
|
||||
},
|
||||
"cursorPathsNote": "Chỉ mang tính thông tin: phiên Cursor cũng được tính — Cursor tình cờ dùng cùng đường dẫn ~/.claude với Claude Code (cục bộ và trên máy từ xa đã đồng bộ).",
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||
"add": "Thêm profile",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile router nào. Native là chạy thẳng vào API của hãng.",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
"builtinHint": "Dựng sẵn — đây là API của chính hãng, không sửa và không xoá được.",
|
||||
"colLabel": "Profile",
|
||||
"colBaseUrl": "Endpoint",
|
||||
@@ -592,16 +592,16 @@
|
||||
"keySet": "Đã lưu",
|
||||
"keyMissing": "Chưa có",
|
||||
"fieldLabel": "Tên",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong form tạo workspace. Gõ tự do.",
|
||||
"fieldLabelHint": "Tên bạn sẽ thấy trong ô chọn AI Proxy của session. Gõ tự do.",
|
||||
"fieldBaseUrl": "Base URL",
|
||||
"fieldBaseUrlHint": "Endpoint tương thích OpenAI/Anthropic của router, ví dụ http://localhost:20128/v1",
|
||||
"fieldBaseUrlHint": "URL gốc của proxy, không có /v1 — ví dụ http://localhost:20128. Claude Code tự thêm /v1/messages, Codex tự thêm /v1/responses; /v1 bạn gõ vào sẽ được bỏ đi.",
|
||||
"fieldApiKey": "API key",
|
||||
"fieldApiKeyHint": "Lưu trên máy này ở quyền 0600, không bao giờ gửi ngược về trình duyệt.",
|
||||
"fieldApiKeyKeep": "Để trống nếu muốn giữ key đã lưu.",
|
||||
"fieldModel": "Model",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều router chỉ cần một tên tượng trưng.",
|
||||
"fieldModelHint": "Dùng cho mọi bậc trừ khi bạn ghi đè bên dưới. Nhiều proxy chỉ cần một tên tượng trưng.",
|
||||
"fieldBehavesAs": "Hoạt động như (Claude Code)",
|
||||
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của router này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model router thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
|
||||
"fieldBehavesAsHint": "Không bắt buộc. Model id của Claude mà Claude Code dùng để hiểu các tên model của proxy này (mặc định effort, context window), ví dụ claude-opus-5-5. Điền khi Claude Code báo một model không có trong model catalog của nó, và chọn đúng model proxy thật sự chuyển tới. Khi đã đặt, nó thay thiết lập modelPicker của bạn trong các phiên này.",
|
||||
"perTier": "Ghi đè theo từng bậc",
|
||||
"perTierHint": "Để trống một ô là dùng model ở trên.",
|
||||
"tier": {
|
||||
@@ -613,12 +613,12 @@
|
||||
},
|
||||
"save": "Lưu",
|
||||
"cancel": "Huỷ",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Mọi workspace đang dùng nó sẽ được đưa về API gốc của hãng ngay lập tức — cả trên đĩa lẫn trong dashboard.",
|
||||
"removeConfirm": "Xoá profile \"{{label}}\"? Các file session của nó bị xoá theo, và một session từng chạy trên nó sẽ resume bằng API gốc của hãng.",
|
||||
"loading": "Đang tải danh sách profile…",
|
||||
"test": "Kiểm tra",
|
||||
"testing": "Đang kiểm tra…",
|
||||
"testOk": "Endpoint đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "Không trả lời ({{status}}): {{message}}"
|
||||
"testOk": "đã trả lời — {{model}} dùng được với key này.",
|
||||
"testFailed": "không trả lời ({{status}}): {{message}}"
|
||||
},
|
||||
"aiTools": {
|
||||
"title": "AI Tools",
|
||||
@@ -700,7 +700,7 @@
|
||||
},
|
||||
"codexHome": {
|
||||
"title": "CODEX_HOME",
|
||||
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Router vào đây, và mở mọi workspace codex với thư mục này.",
|
||||
"description": "Thư mục gốc chứa config và nhật ký phiên của Codex. Dashboard đọc từ đây, ghi layer AI Proxy vào đây, và mở mọi workspace codex với thư mục này.",
|
||||
"restartNote": "Workspace codex đang chạy vẫn dùng thư mục cũ, và hiện là idle, cho tới khi được khởi động lại.",
|
||||
"placeholder": "/đường-dẫn/tới/codex-home"
|
||||
},
|
||||
@@ -792,8 +792,8 @@
|
||||
"tool": {
|
||||
"description": "{{tool}}: đường dẫn, mức dùng, mô hình mặc định và file cấu hình."
|
||||
},
|
||||
"aiRouter": {
|
||||
"title": "AI Router",
|
||||
"aiProxy": {
|
||||
"title": "AI Proxy",
|
||||
"description": "Các endpoint đứng trước Claude Code và Codex."
|
||||
},
|
||||
"pricing": {
|
||||
@@ -823,6 +823,10 @@
|
||||
"reports": {
|
||||
"title": "Báo cáo",
|
||||
"description": "Bản tóm tắt phân tích ghi ra theo lịch, dạng HTML hoặc JSON."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"description": "Tài khoản, mức dùng và nhật ký truy cập."
|
||||
}
|
||||
},
|
||||
"backPrevious": "Quay lại",
|
||||
@@ -942,5 +946,21 @@
|
||||
"importBody": "Tùy chọn trong tệp sẽ thay thế tùy chọn của trình duyệt này, sau đó trang tải lại.",
|
||||
"imported": "Đã nhập {{count}} tùy chọn. Đang tải lại…",
|
||||
"badFile": "Tệp này không phải tệp tùy chọn của AgentHub."
|
||||
},
|
||||
"users": {
|
||||
"title": "Người dùng",
|
||||
"description": "Mọi tài khoản trên server này, cùng workspace, session và mức dùng gần đây.",
|
||||
"name": "Người dùng",
|
||||
"role": "Vai trò",
|
||||
"workspaces": "Workspace",
|
||||
"sessions": "Session",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"disabled": "đã khoá",
|
||||
"tokens": "{{n}} token",
|
||||
"manageHint": "Thêm, khoá hoặc sửa người dùng trên máy chủ: agenthub users …",
|
||||
"auditTitle": "Nhật ký truy cập",
|
||||
"auditDescription": "Mỗi lần admin hoặc analyst mở một session của member.",
|
||||
"auditEmpty": "Chưa có lượt đọc nào."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"releaseUnavailable": "Không đọc được bản phát hành mới nhất từ repo ({{reason}}); đã quay về so sánh theo commit.",
|
||||
"pluginAutoNote": "Dashboard này cài dạng plugin của Claude Code. Bấm \"Cập nhật ngay\" sẽ tự tải bản plugin mới, cài lại runtime và khởi động lại server — mất vài phút. Riêng skill và slash command mới thì session Claude Code đang mở chỉ thấy sau khi bạn chạy /reload-plugins ở đó.",
|
||||
"pluginUpdating": "Đang tải bản plugin mới và cài lại runtime — mất vài phút, trang sẽ tự reload khi server mới lên...",
|
||||
"pluginAlreadyCurrent": "Không có phiên bản plugin mới nào được tải về, nên dashboard đang chạy được giữ nguyên.",
|
||||
"lastPluginUpdateFailed": "Lần cập nhật plugin trước thất bại ở bước \"{{step}}\": {{error}} — chạy /agenthub-update trong Claude Code để khắc phục.",
|
||||
"restartingShort": "Đang khởi động lại…",
|
||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
||||
|
||||
+38
-95
@@ -17,8 +17,7 @@
|
||||
* 11-12 text (11 secondary, 12 primary)
|
||||
* The DARK theme follows it literally: `slate` supplies surface/border/fg and
|
||||
* `blue` supplies accent. The LIGHT theme keeps the structure (same roles, same
|
||||
* rising-lightness ramp) but swaps the hue family for Claude's own warm ivory
|
||||
* and terracotta, because a cool near-white page reads as glare. `green`/
|
||||
* rising-lightness ramp) with IntelliJ Light's neutrals and blue accent. `green`/
|
||||
* `red`/`amber` step 11 supplies the status tokens — the same step used for
|
||||
* fg-secondary, since 11 is Radix's guaranteed-readable-text step. Values are
|
||||
* the literal Radix hex constants (steps 1/2/3/4/5/6/7/9/11/12), converted to
|
||||
@@ -29,37 +28,19 @@
|
||||
/* Light theme — the CSS default, so it applies with no class on <html>.
|
||||
`[data-mode="light"]` lets a light theme preview re-scope inside a dark
|
||||
page (the Settings appearance cards).
|
||||
Radix `slate` light, RE-ORDERED so lightness rises with the index exactly
|
||||
as it does in dark: the page is the DARKEST step and a card is brighter
|
||||
than the page it sits on. The previous light block used the scale in its
|
||||
native direction, which inverted the whole language — a near-white page
|
||||
(glare) under grey cards that read as recessed, and every `hover:bg-*`
|
||||
darkening instead of lifting.
|
||||
|
||||
The HUE is warm — Claude's own ivory/terracotta family, not a cool slate.
|
||||
A cool near-white ramp kept reading as glare no matter how far the page
|
||||
step was pulled down, because the brightest thing on screen was also the
|
||||
bluest. Cream lowers the perceived glare at the same measured lightness
|
||||
and gives every neutral a warm cast to sit against. The whole ramp sits
|
||||
one notch below Claude's own ivory (#faf9f5 card) and no step reaches pure
|
||||
white: at full brightness a large light panel still tires the eye. */
|
||||
--surface-0: 232 229 218; /* #e8e5da — page background */
|
||||
--surface-1: 236 233 223; /* #ece9df — sub-headers inside a card */
|
||||
--surface-2: 240 237 228; /* #f0ede4 — inputs, chips, toolbars */
|
||||
--surface-3: 245 243 236; /* #f5f3ec — `.card`, Claude's ivory a step down */
|
||||
--surface-4: 249 247 241; /* #f9f7f1 — hover / active lift */
|
||||
--surface-5: 252 251 247; /* #fcfbf7 — topmost, rare */
|
||||
--border: 207 201 184; /* #cfc9b8 */
|
||||
--border-light: 222 217 204; /* #ded9cc */
|
||||
/* Claude's terracotta (#cc785c), taken down two steps: the brand value 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. This is 5.3:1 as text on a card and
|
||||
5.9:1 white-on-accent. Dark mode keeps its blue — inverting a warm accent
|
||||
onto a near-black page is a different problem with a different answer.
|
||||
One more step down (#a34a28 → #924224) so `text-accent` also clears 4.5:1
|
||||
on its own selected chip (`bg-accent/15–20`), not just on the card. */
|
||||
--accent: 146 66 36; /* #924224 */
|
||||
--accent-hover: 122 55 30; /* #7a371e */
|
||||
IntelliJ Light's cool neutrals, ordered so lightness rises with the index
|
||||
exactly as it does in dark: the page is the DARKEST step and a card is
|
||||
brighter than the page it sits on, so every `hover:bg-*` lifts. */
|
||||
--surface-0: 236 238 242; /* #eceef2 — page background */
|
||||
--surface-1: 241 242 245; /* #f1f2f5 — sub-headers inside a card */
|
||||
--surface-2: 247 248 250; /* #f7f8fa — inputs, chips, toolbars */
|
||||
--surface-3: 253 253 254; /* #fdfdfe — `.card` */
|
||||
--surface-4: 255 255 255; /* #ffffff — hover / active lift */
|
||||
--surface-5: 255 255 255; /* #ffffff — topmost, rare */
|
||||
--border: 211 213 219; /* #d3d5db */
|
||||
--border-light: 228 230 235; /* #e4e6eb */
|
||||
--accent: 40 90 192; /* #285ac0 */
|
||||
--accent-hover: 33 74 158; /* #214a9e */
|
||||
/* The FILL behind white text (`.btn-primary`, a Switch's on-track, a ticked
|
||||
Checkbox, `bg-accent-solid`). In light themes the text accent is already
|
||||
dark enough to carry white, so it simply IS the accent — resolved per
|
||||
@@ -68,43 +49,37 @@
|
||||
white text cannot sit on (3.3:1). */
|
||||
--accent-solid: var(--accent);
|
||||
--accent-solid-hover: var(--accent-hover);
|
||||
--fg-primary: 31 30 27; /* #1f1e1b — 15.0:1 on a card */
|
||||
--fg-secondary: 96 91 83; /* #605b53 — 6.1:1 */
|
||||
/* Measured, not eyeballed: this carries real content (timestamps, counts),
|
||||
so it is held above 4.5:1 — 5.4:1 on a card, 4.7:1 on the page — and still
|
||||
sits a clear step below fg-secondary. */
|
||||
--fg-muted: 104 99 89; /* #686359 */
|
||||
/* Warm and soft, to match the surfaces: a neutral-grey shadow goes muddy
|
||||
over cream. The SHADOW carries "raised" here, not the quiet border. */
|
||||
--shadow-card: 0 1px 2px rgb(60 50 30 / 0.05), 0 2px 6px rgb(60 50 30 / 0.07);
|
||||
/* Status: darkened two steps from Radix 11. One step cleared 4.5:1 against
|
||||
the card; these also clear it against the status's OWN densest chip (a
|
||||
20% tint of itself — 4.5:1), which is where the label actually sits. */
|
||||
--status-success: 22 104 66; /* #166842 */
|
||||
--status-danger: 170 36 41; /* #aa2429 */
|
||||
--status-warning: 133 79 0; /* #854f00 */
|
||||
--fg-primary: 30 31 34; /* #1e1f22 */
|
||||
--fg-secondary: 73 75 87; /* #494b57 */
|
||||
/* Carries real content (timestamps, counts), so it is held above 4.5:1 on a
|
||||
card and still sits a clear step below fg-secondary. */
|
||||
--fg-muted: 103 106 120; /* #676a78 */
|
||||
/* The SHADOW carries "raised" here, not the quiet border. */
|
||||
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
|
||||
/* Status labels clear 4.5:1 on the card AND on their own 20% chip. */
|
||||
--status-success: 23 106 45; /* #176a2d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
/* What every status/accent FILL is pre-mixed against (see `tint()` in
|
||||
tailwind.config.js) — the card surface, because that is where chips and
|
||||
alert strips overwhelmingly sit. */
|
||||
/* Re-declared by every scope that sets a theme (this block, `.dark`), so it
|
||||
resolves against THAT scope's surface-3; a new scope must re-declare it too. */
|
||||
--tint-base: var(--surface-3);
|
||||
/* Syntax colours for `lib/highlight.ts`. Same hues as the dark set, taken
|
||||
four steps down the Tailwind ramp (300 → 700): a pastel keyword is legible
|
||||
on a near-black code block and invisible on a light one. */
|
||||
--syn-comment: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-string: 4 120 87; /* emerald-700 #047857 */
|
||||
--syn-number: 194 65 12; /* orange-700 #c2410c */
|
||||
--syn-keyword: 109 40 217; /* violet-700 #6d28d9 */
|
||||
--syn-builtin: 3 105 161; /* sky-700 #0369a1 */
|
||||
--syn-function: 180 83 9; /* amber-700 #b45309 */
|
||||
--syn-operator: 190 24 93; /* pink-700 #be185d */
|
||||
--syn-punctuation: 107 114 128; /* gray-500 #6b7280 */
|
||||
--syn-property: 14 116 144; /* cyan-700 #0e7490 */
|
||||
--syn-tag: 190 18 60; /* rose-700 #be123c */
|
||||
/* Syntax colours for `lib/highlight.ts` — IntelliJ Light's editor scheme. */
|
||||
--syn-comment: 112 112 112; /* #707070 */
|
||||
--syn-string: 6 125 23; /* #067d17 */
|
||||
--syn-number: 23 80 235; /* #1750eb */
|
||||
--syn-keyword: 0 51 179; /* #0033b3 */
|
||||
--syn-builtin: 0 0 128; /* #000080 */
|
||||
--syn-function: 0 98 122; /* #00627a */
|
||||
--syn-operator: 75 75 75; /* #4b4b4b */
|
||||
--syn-punctuation: 75 75 75; /* #4b4b4b */
|
||||
--syn-property: 135 16 148; /* #871094 */
|
||||
--syn-tag: 0 51 179; /* #0033b3 */
|
||||
--syn-plain: var(--fg-primary);
|
||||
--syn-add: 4 120 87; /* emerald-700 */
|
||||
--syn-del: 185 28 28; /* red-700 #b91c1c */
|
||||
--syn-add: 6 125 23; /* #067d17 */
|
||||
--syn-del: 192 31 42; /* #c01f2a */
|
||||
/* A diff line edited in place (not wholly added or removed). */
|
||||
--diff-mod: 29 78 216; /* blue-700 #1d4ed8 */
|
||||
/* A file on the Changes panel's never-stage list (orange, apart from amber M). */
|
||||
@@ -309,38 +284,6 @@
|
||||
--syn-del: 197 0 15; /* #c5000f */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="intellij-light"] {
|
||||
--surface-0: 236 238 242; /* #eceef2 */
|
||||
--surface-1: 241 242 245; /* #f1f2f5 */
|
||||
--surface-2: 247 248 250; /* #f7f8fa */
|
||||
--surface-3: 253 253 254; /* #fdfdfe */
|
||||
--surface-4: 255 255 255; /* #ffffff */
|
||||
--surface-5: 255 255 255; /* #ffffff */
|
||||
--border: 211 213 219; /* #d3d5db */
|
||||
--border-light: 228 230 235; /* #e4e6eb */
|
||||
--accent: 40 90 192; /* #285ac0 */
|
||||
--accent-hover: 33 74 158; /* #214a9e */
|
||||
--fg-primary: 30 31 34; /* #1e1f22 */
|
||||
--fg-secondary: 73 75 87; /* #494b57 */
|
||||
--fg-muted: 103 106 120; /* #676a78 */
|
||||
--shadow-card: 0 1px 2px rgb(30 31 34 / 0.05), 0 2px 6px rgb(30 31 34 / 0.06);
|
||||
--status-success: 23 106 45; /* #176a2d */
|
||||
--status-danger: 173 28 38; /* #ad1c26 */
|
||||
--status-warning: 124 82 0; /* #7c5200 */
|
||||
--syn-comment: 112 112 112; /* #707070 */
|
||||
--syn-string: 6 125 23; /* #067d17 */
|
||||
--syn-number: 23 80 235; /* #1750eb */
|
||||
--syn-keyword: 0 51 179; /* #0033b3 */
|
||||
--syn-builtin: 0 0 128; /* #000080 */
|
||||
--syn-function: 0 98 122; /* #00627a */
|
||||
--syn-operator: 75 75 75; /* #4b4b4b */
|
||||
--syn-punctuation: 75 75 75; /* #4b4b4b */
|
||||
--syn-property: 135 16 148; /* #871094 */
|
||||
--syn-tag: 0 51 179; /* #0033b3 */
|
||||
--syn-add: 6 125 23; /* #067d17 */
|
||||
--syn-del: 192 31 42; /* #c01f2a */
|
||||
}
|
||||
|
||||
[data-mode="light"][data-theme="canvas"] {
|
||||
--surface-0: 227 220 205; /* #e3dccd */
|
||||
--surface-1: 232 226 212; /* #e8e2d4 */
|
||||
|
||||
@@ -40,12 +40,3 @@ describe("changeTree", () => {
|
||||
expect(treeFiles(root.dirs[0]!).map((f) => f.path)).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeTree — an untracked folder", () => {
|
||||
it("shows git's `dir/` entry as a named row in its parent folder", () => {
|
||||
const root = changeTree([{ path: "docs/plans/analytics-parity/" }, { path: "docs/x.md" }]);
|
||||
const docs = root.dirs[0]!;
|
||||
expect(docs.files.map((f) => f.name)).toEqual(["x.md"]);
|
||||
expect(docs.dirs[0]!.files.map((f) => f.name)).toEqual(["analytics-parity/"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Tests for the side-by-side diff's connector geometry: the band path
|
||||
* between a left and a right block, and the line mapping that keeps the two
|
||||
* panes scrolled to the same place.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bandPath, mapLine, type Span } from "../diffConnectors";
|
||||
|
||||
describe("bandPath", () => {
|
||||
it("joins the left block's edges to the right block's with two S-curves", () => {
|
||||
expect(bandPath(40, 10, 30, 50, 90)).toBe(
|
||||
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,30 0,30 Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("still draws an empty side as a closed path, flat there", () => {
|
||||
expect(bandPath(40, 10, 10, 50, 90)).toBe(
|
||||
"M0,10 C20,10 20,50 40,50 L40,90 C20,90 20,10 0,10 Z"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapLine", () => {
|
||||
// Left lines 2–3 became right lines 2–6; left 8 was removed (right 11 = none).
|
||||
const spans: Span[] = [
|
||||
{ a0: 2, a1: 4, b0: 2, b1: 7 },
|
||||
{ a0: 8, a1: 9, b0: 11, b1: 11 },
|
||||
];
|
||||
|
||||
it("maps 1:1 before, between and after the blocks, shifted by the ones above", () => {
|
||||
expect(mapLine(0, spans, true)).toBe(0);
|
||||
expect(mapLine(5, spans, true)).toBe(8);
|
||||
expect(mapLine(10, spans, true)).toBe(12);
|
||||
expect(mapLine(8, spans, false)).toBe(5);
|
||||
});
|
||||
|
||||
it("interpolates inside a block, both ways", () => {
|
||||
expect(mapLine(3, spans, true)).toBe(4.5);
|
||||
expect(mapLine(4.5, spans, false)).toBe(3);
|
||||
});
|
||||
|
||||
it("puts a line removed on one side at its block's point on the other", () => {
|
||||
expect(mapLine(8.5, spans, true)).toBe(11);
|
||||
});
|
||||
|
||||
it("is the identity with no blocks", () => {
|
||||
expect(mapLine(7.25, [], true)).toBe(7.25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @file laneChange.test.ts
|
||||
* @description Tests for `laneChangeMatcher`: a lane_update fires only when the
|
||||
* chosen key changed or a lane was removed, and each matcher remembers alone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { laneChangeMatcher, runKeyOf } from "../laneChange";
|
||||
import type { Lane, WSMessage } from "../types";
|
||||
|
||||
const upd = (lane: Partial<Lane>): WSMessage => ({
|
||||
type: "lane_update",
|
||||
data: { lane: { id: 1, run_id: null, status: "idle", session_id: null, ...lane } as Lane },
|
||||
timestamp: "t",
|
||||
});
|
||||
|
||||
describe("laneChangeMatcher", () => {
|
||||
it("fires on a changed run key, a removal, and nothing else", () => {
|
||||
const m = laneChangeMatcher(runKeyOf);
|
||||
expect(m(upd({}))).toBe(true); // first sight
|
||||
expect(m(upd({ last_event_at: "x" } as Partial<Lane>))).toBe(false);
|
||||
expect(m(upd({ status: "running" }))).toBe(true);
|
||||
expect(m(upd({ status: "running" }))).toBe(false);
|
||||
expect(m({ type: "lane_update", data: { removed: 1 }, timestamp: "t" })).toBe(true);
|
||||
expect(m({ type: "new_event", data: {}, timestamp: "t" } as WSMessage)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a separate memory per matcher", () => {
|
||||
const a = laneChangeMatcher(runKeyOf);
|
||||
const b = laneChangeMatcher(runKeyOf);
|
||||
expect(a(upd({}))).toBe(true);
|
||||
expect(b(upd({}))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file Tests for newBuildReload: after a reconnect the tab reloads once when
|
||||
* the server's version is not this bundle's, never on the first connect,
|
||||
* never for the same version, and not twice for one version.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { eventBus } from "../eventBus";
|
||||
import { reloadOnNewBuild } from "../newBuildReload";
|
||||
|
||||
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function setup(serverVersion: string) {
|
||||
const reload = vi.fn();
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
};
|
||||
const off = reloadOnNewBuild({
|
||||
bundle: "2.5.4",
|
||||
health: () => Promise.resolve({ version: serverVersion }),
|
||||
reload,
|
||||
storage,
|
||||
});
|
||||
return { reload, off };
|
||||
}
|
||||
|
||||
let off: (() => void) | undefined;
|
||||
afterEach(() => off?.());
|
||||
|
||||
describe("reloadOnNewBuild", () => {
|
||||
it("reloads once after a reconnect onto a new version", async () => {
|
||||
const s = setup("2.5.5");
|
||||
off = s.off;
|
||||
eventBus.setConnected(true); // first connect: nothing to compare against yet
|
||||
await flush();
|
||||
expect(s.reload).not.toHaveBeenCalled();
|
||||
eventBus.setConnected(false);
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).toHaveBeenCalledTimes(1);
|
||||
eventBus.setConnected(false); // same version again: a failed UI build must not loop
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stays put when the server is this bundle's version", async () => {
|
||||
const s = setup("2.5.4");
|
||||
off = s.off;
|
||||
eventBus.setConnected(false);
|
||||
eventBus.setConnected(true);
|
||||
await flush();
|
||||
expect(s.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
* the pane that takes its place, swapping/moving panes, resizing a split,
|
||||
* pruning panes whose run is gone, the rectangles the grid draws, the TABS
|
||||
* that group up to four panes each (a session in exactly one), and the
|
||||
* per-workspace storage that must survive bad or stale data.
|
||||
* dropping one tab onto another (joined in a fixed order: right, under the
|
||||
* right pane, under the left pane), and the per-workspace storage that must survive bad or stale data.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
MAX_PANES,
|
||||
closeInLayout,
|
||||
detachSlot,
|
||||
joinGroup,
|
||||
layoutRects,
|
||||
leaves,
|
||||
loadGroups,
|
||||
@@ -203,7 +205,10 @@ describe("pane groups (tabs of up to four panes)", () => {
|
||||
});
|
||||
|
||||
it("activates the tab already holding a session, focusing it", () => {
|
||||
const g: PaneGroups = { groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }], active: 0 };
|
||||
const g: PaneGroups = {
|
||||
groups: [single(0), { root: split("row", pane(1), pane(2)), focus: 1 }],
|
||||
active: 0,
|
||||
};
|
||||
const out = openInGroups(g, 2, false);
|
||||
expect(out.active).toBe(1);
|
||||
expect(out.groups[1]!.focus).toBe(2);
|
||||
@@ -245,12 +250,18 @@ describe("pane groups (tabs of up to four panes)", () => {
|
||||
it("an ended session leaves its tab; an emptied active tab hands over to a neighbour", () => {
|
||||
const g: PaneGroups = { groups: [single(0), single(1), single(2)], active: 2 };
|
||||
expect(removeSlot(g, 2)).toEqual({ groups: [single(0), single(1)], active: 1 });
|
||||
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({ groups: [single(1), single(2)], active: 0 });
|
||||
expect(removeSlot({ ...g, active: 0 }, 0)).toEqual({
|
||||
groups: [single(1), single(2)],
|
||||
active: 0,
|
||||
});
|
||||
expect(removeSlot(g, 7)).toBe(g);
|
||||
});
|
||||
|
||||
it("prunes dead panes and gives every live session without a tab one", () => {
|
||||
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)], active: 1 };
|
||||
const g: PaneGroups = {
|
||||
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }, single(2)],
|
||||
active: 1,
|
||||
};
|
||||
const out = pruneGroups(g, new Set([0, 4]), new Set());
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[0], [4]]);
|
||||
expect(out.active).toBe(0);
|
||||
@@ -258,7 +269,10 @@ describe("pane groups (tabs of up to four panes)", () => {
|
||||
});
|
||||
|
||||
it("detaches a pane into a tab of its own at a given place, and reorders tabs", () => {
|
||||
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)], active: 1 };
|
||||
const g: PaneGroups = {
|
||||
groups: [{ root: split("row", pane(0), pane(1)), focus: 0 }, single(2)],
|
||||
active: 1,
|
||||
};
|
||||
const out = detachSlot(g, 1, 0);
|
||||
expect(out.groups.map((x) => leaves(x.root))).toEqual([[1], [0], [2]]);
|
||||
expect(out.active).toBe(0);
|
||||
@@ -272,19 +286,36 @@ describe("parseLayout / storage", () => {
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
it("stores tabs per workspace and reads a pre-tab single layout as one tab", () => {
|
||||
const g: PaneGroups = { groups: [{ root: split("row", pane(0), pane(9)), focus: 9 }, { root: pane(4), focus: 4 }], active: 1 };
|
||||
const g: PaneGroups = {
|
||||
groups: [
|
||||
{ root: split("row", pane(0), pane(9)), focus: 9 },
|
||||
{ root: pane(4), focus: 4 },
|
||||
],
|
||||
active: 1,
|
||||
};
|
||||
saveGroups(1, g);
|
||||
expect(loadGroups(1)).toEqual(g);
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
JSON.stringify({ 2: { root: split("row", pane(0), pane(1)), focus: 1 } })
|
||||
);
|
||||
expect(loadGroups(2)).toEqual({ groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }], active: 0 });
|
||||
expect(loadGroups(2)).toEqual({
|
||||
groups: [{ root: split("row", pane(0), pane(1)), focus: 1 }],
|
||||
active: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a slot past nine and a session stored in two tabs", () => {
|
||||
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(EMPTY_GROUPS);
|
||||
const out = parseGroups({ groups: [{ root: pane(1), focus: 1 }, { root: pane(1), focus: 1 }], active: 1 });
|
||||
expect(parseGroups({ groups: [{ root: pane(10), focus: 10 }], active: 0 })).toEqual(
|
||||
EMPTY_GROUPS
|
||||
);
|
||||
const out = parseGroups({
|
||||
groups: [
|
||||
{ root: pane(1), focus: 1 },
|
||||
{ root: pane(1), focus: 1 },
|
||||
],
|
||||
active: 1,
|
||||
});
|
||||
expect(out).toEqual({ groups: [{ root: pane(1), focus: 1 }], active: 0 });
|
||||
});
|
||||
|
||||
@@ -334,3 +365,47 @@ describe("parseLayout / storage", () => {
|
||||
expect(loadGroups(1)).toEqual(EMPTY_GROUPS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinGroup", () => {
|
||||
const solo = (s: number): PaneLayout => ({ root: pane(s), focus: s });
|
||||
const tabs = (...slots: number[]): PaneGroups => ({ groups: slots.map(solo), active: 0 });
|
||||
|
||||
it("joins tabs in order: side by side, under the right pane, under the left one", () => {
|
||||
let g = tabs(0, 1, 2, 3);
|
||||
g = joinGroup(g, [1], 0);
|
||||
expect(g.groups[0]?.root).toEqual(split("row", pane(0), pane(1)));
|
||||
g = joinGroup(g, [2], 0);
|
||||
expect(g.groups[0]?.root).toEqual(split("row", pane(0), split("col", pane(1), pane(2))));
|
||||
g = joinGroup(g, [3], 0);
|
||||
expect(g.groups[0]?.root).toEqual(
|
||||
split("row", split("col", pane(0), pane(3)), split("col", pane(1), pane(2)))
|
||||
);
|
||||
expect(g).toEqual({ groups: [g.groups[0]], active: 0 });
|
||||
expect(g.groups[0]?.focus).toBe(3);
|
||||
});
|
||||
|
||||
it("activates the target tab and takes a pane out of a combo", () => {
|
||||
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
|
||||
const g = joinGroup({ groups: [solo(0), combo], active: 0 }, [2], 1);
|
||||
expect(g.groups.map((l) => leaves(l.root))).toEqual([[0], [1, 2]]);
|
||||
const h = joinGroup({ groups: [solo(0), combo], active: 1 }, [2], 0);
|
||||
expect(h).toEqual({
|
||||
groups: [
|
||||
{ root: split("row", pane(0), pane(2)), focus: 2 },
|
||||
{ root: pane(1), focus: 1 },
|
||||
],
|
||||
active: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("joins a whole combo, and refuses a fifth pane", () => {
|
||||
const combo: PaneLayout = { root: split("row", pane(1), pane(2)), focus: 1 };
|
||||
const g = joinGroup({ groups: [solo(0), combo], active: 1 }, [1, 2], 0);
|
||||
expect(g.groups).toHaveLength(1);
|
||||
expect(leaves(g.groups[0]?.root ?? null)).toEqual([0, 1, 2]);
|
||||
const full = joinGroup(g, [], 0);
|
||||
expect(full).toBe(g);
|
||||
const four = joinGroup({ ...g, groups: [...g.groups, solo(3), solo(4)] }, [3], 0);
|
||||
expect(joinGroup(four, [4], 0)).toBe(four);
|
||||
});
|
||||
});
|
||||
|
||||
+150
-70
@@ -529,7 +529,53 @@ function ccQuery(scope?: CcScope, cwd?: string): string {
|
||||
* that needs to branch on which error this was should check `.code`, never
|
||||
* guess at substrings of the human-readable `.message`.
|
||||
*/
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
/** Fired on `window` when a request answers 401 `EAUTH` (server mode). */
|
||||
export const AUTH_REQUIRED_EVENT = "agenthub:auth-required";
|
||||
|
||||
/** One account in Settings → Users (`GET /api/users`). */
|
||||
export type ServerUserUsage = AuthUser & {
|
||||
disabled: boolean;
|
||||
workspaces: number;
|
||||
sessions: number;
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
};
|
||||
/** A staff read of a member's session (`GET /api/users/audit`). */
|
||||
export type AuditEntry = {
|
||||
id: number;
|
||||
at: string;
|
||||
method: string;
|
||||
path: string;
|
||||
session_id: string | null;
|
||||
reader: string | null;
|
||||
owner: string | null;
|
||||
};
|
||||
|
||||
/** Who is using the dashboard: local mode has no login at all. */
|
||||
export type AuthUser = { id: number; username: string; role: "admin" | "analyst" | "member" };
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
|
||||
/** GETs in flight, by path: concurrent callers of the same read (several
|
||||
* components mounting, a WS event fanning out to many listeners) share one
|
||||
* fetch and one parsed result. Deleted as soon as it settles, so this is
|
||||
* dedupe, never a cache. Callers must treat the result as read-only. */
|
||||
const inflight = new Map<string, Promise<unknown>>();
|
||||
|
||||
function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// Only a plain GET: a body, headers or an AbortSignal belongs to one caller.
|
||||
const plainGet =
|
||||
!options ||
|
||||
(Object.keys(options).every((k) => k === "method") &&
|
||||
(options.method ?? "GET").toUpperCase() === "GET");
|
||||
if (!plainGet) return send<T>(path, options);
|
||||
const hit = inflight.get(path);
|
||||
if (hit) return hit as Promise<T>;
|
||||
const p = send<T>(path, options).finally(() => inflight.delete(path));
|
||||
inflight.set(path, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = dashboardToken();
|
||||
// Build the effective header set. Order matters: defaults first so that the
|
||||
// token and any caller headers can override, and caller headers land last.
|
||||
@@ -544,6 +590,11 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// isn't JSON (or json() throws), fall back to an empty object so the `?.`
|
||||
// chain below cleanly degrades to the generic `HTTP <status>` message.
|
||||
const body = await res.json().catch(() => ({}));
|
||||
// Server mode: the login ended (expired, logged out elsewhere, disabled) —
|
||||
// AuthGate listens and shows the login page again.
|
||||
if (res.status === 401 && body?.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
throw Object.assign(new Error(body?.error?.message || `HTTP ${res.status}`), {
|
||||
code: body?.error?.code,
|
||||
status: res.status,
|
||||
@@ -605,6 +656,26 @@ export function gitTarget(laneId: number, session?: string | null): GitTarget {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// ───────────────────────── Server mode login (routes/auth.js) ─────────────────────────
|
||||
auth: {
|
||||
/** GET /api/auth/me — `{mode:"local"}`, or server mode with the user (null = logged out). */
|
||||
me: () => request<AuthState>("/auth/me"),
|
||||
/** POST /api/auth/login — sets the httpOnly login cookie. */
|
||||
login: (username: string, password: string) =>
|
||||
request<{ user: AuthUser }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
/** POST /api/auth/logout — ends this login. */
|
||||
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
/** GET /api/users/audit — staff reads of members' sessions, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
},
|
||||
// ───────────────────────── Updates / self-update API ─────────────────────────
|
||||
/** Self-update status: whether this install is a git clone and, if so,
|
||||
* whether the tracked upstream/origin remote is ahead. Backs the "update
|
||||
@@ -1770,7 +1841,13 @@ export const api = {
|
||||
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
|
||||
*/
|
||||
profiles: () =>
|
||||
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
|
||||
request<{
|
||||
items: CcProfile[];
|
||||
active: CcAccountIdentity | null;
|
||||
/** Set when the live token belongs to a different account than the
|
||||
* one `.claude.json` names; `active` is then the token's owner. */
|
||||
mismatch?: { configEmail: string | null } | null;
|
||||
}>("/cc-config/profiles"),
|
||||
/**
|
||||
* POST /api/cc-config/profiles - snapshot the logged-in account as a
|
||||
* profile. The identifier and initial label are derived from the account;
|
||||
@@ -2099,13 +2176,6 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── AI Router API ─────────────────────────────
|
||||
/** Named AI Router endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* lane can be pointed at instead of the vendor's own API. Managed in the
|
||||
* **AI Router** tab of Settings; attached to a lane when the lane is created.
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
/** AgentHub's own git credentials (issue #45), shared by every user of this
|
||||
* install. No response ever carries a password — only `hasToken`. */
|
||||
gitCredentials: {
|
||||
@@ -2219,37 +2289,44 @@ export const api = {
|
||||
return `${BASE}/ai-tools/${encodeURIComponent(id)}/icon${q}`;
|
||||
},
|
||||
},
|
||||
aiRouters: {
|
||||
/** GET /api/ai-routers - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiRouter[] }>("/ai-routers"),
|
||||
/** POST /api/ai-routers - save a new profile. */
|
||||
create: (body: AiRouterInput) =>
|
||||
request<AiRouter>("/ai-routers", { method: "POST", body: JSON.stringify(body) }),
|
||||
// ──────────────────────────────── AI Proxy API ──────────────────────────────
|
||||
/** Named AI Proxy endpoints (9Router, OpenRouter, a self-hosted gateway) a
|
||||
* new session can run on instead of the vendor's own API. Managed in the
|
||||
* **AI Proxy** page of Settings; chosen per session (`proxy` on a start).
|
||||
*
|
||||
* No response here ever carries an API key — a saved profile reports only
|
||||
* `hasKey`. */
|
||||
aiProxies: {
|
||||
/** GET /api/ai-proxies - every profile, the built-in `native` one first. */
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/ai-routers/:name - change a saved profile. Omitting `apiKey`
|
||||
* PATCH /api/ai-proxies/:name - change a saved profile. Omitting `apiKey`
|
||||
* keeps the stored one, which is what the edit form always does: it cannot
|
||||
* display a key, so it has none to send back.
|
||||
*/
|
||||
update: (name: string, body: Partial<AiRouterInput>) =>
|
||||
request<AiRouter>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
update: (name: string, body: Partial<AiProxyInput>) =>
|
||||
request<AiProxy>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/ai-routers/test - ask the endpoint itself whether it works,
|
||||
* before a profile that cannot reach anything is saved onto a lane.
|
||||
* POST /api/ai-proxies/test - ask the endpoint itself whether it works
|
||||
* for Claude Code and for Codex, before a session finds out it does not.
|
||||
*/
|
||||
test: (body: AiRouterTestInput) =>
|
||||
request<AiRouterTestResult>("/ai-routers/test", {
|
||||
test: (body: AiProxyTestInput) =>
|
||||
request<AiProxyTestResult>("/ai-proxies/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/ai-routers/:name - also removes the session files that
|
||||
* DELETE /api/ai-proxies/:name - also removes the session files that
|
||||
* copy its key.
|
||||
*/
|
||||
remove: (name: string) =>
|
||||
request<{ removed: string }>(`/ai-routers/${encodeURIComponent(name)}`, {
|
||||
request<{ removed: string }>(`/ai-proxies/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
},
|
||||
@@ -2260,17 +2337,6 @@ export const api = {
|
||||
run: {
|
||||
/** GET /api/run - lanes with a live tmux-backed run, computed fresh from tmux state. */
|
||||
list: () => request<RunListResponse>("/run"),
|
||||
/** GET /api/run/history - persisted runs, newest first; `laneId` narrows to
|
||||
* one workspace and `tool` (with it) to one tool's — `shell` = Terminal tabs. */
|
||||
history: (opts: { laneId: number; tool?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams({ laneId: String(opts.laneId) });
|
||||
if (opts.tool) qs.set("tool", opts.tool);
|
||||
if (opts.limit) qs.set("limit", String(opts.limit));
|
||||
return request<{ items: RunHistoryItem[] }>(`/run/history?${qs.toString()}`);
|
||||
},
|
||||
/** DELETE /api/run/history/:id - forget a closed Terminal tab (shell rows only). */
|
||||
forgetHistory: (id: string) =>
|
||||
request<{ removed: string }>(`/run/history/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
@@ -2292,6 +2358,14 @@ export const api = {
|
||||
body: file,
|
||||
headers: { "Content-Type": file.type },
|
||||
}),
|
||||
/** POST /api/run/drop-file - the same for any file, keeping its (sanitized)
|
||||
* name so the agent sees what it is. */
|
||||
dropFile: (file: File) =>
|
||||
request<{ path: string }>(`/run/drop-file?name=${encodeURIComponent(file.name)}`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** POST /api/run - start (or adopt, if already live) a lane's terminal run. */
|
||||
start: (args: RunStartArgs) =>
|
||||
request<RunHandle>("/run", { method: "POST", body: JSON.stringify(args) }),
|
||||
@@ -2817,10 +2891,12 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @param opts `file` narrows the list to the commits touching one path.
|
||||
*/
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string } = {}) => {
|
||||
gitLog: (id: GitTarget, opts: { limit?: number; file?: string; ref?: string } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.limit) q.set("limit", String(opts.limit));
|
||||
if (opts.file) q.set("file", opts.file);
|
||||
// Another branch's history (cherry-pick's source); absent = HEAD's.
|
||||
if (opts.ref) q.set("ref", opts.ref);
|
||||
return request<{ available: boolean; commits: LaneCommit[] }>(gitUrl(id, "/log", q));
|
||||
},
|
||||
/**
|
||||
@@ -2936,8 +3012,14 @@ export const api = {
|
||||
| "merge"
|
||||
| "resolve"
|
||||
| "merge-commit"
|
||||
| "merge-abort",
|
||||
| "merge-abort"
|
||||
| "cherry-pick"
|
||||
| "revert"
|
||||
| "undo-commit"
|
||||
| "amend",
|
||||
body: {
|
||||
/** cherry-pick / revert: the commit. */
|
||||
sha?: string;
|
||||
url?: string;
|
||||
message?: string;
|
||||
only?: "staged";
|
||||
@@ -3307,10 +3389,10 @@ export interface CcProfileUsage {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** The per-tier model names an AI Router profile carries. Each maps to one env
|
||||
* var Claude Code reads; a router that names its models once fills them all
|
||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||
* var Claude Code reads; a proxy that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
export interface AiRouterModels {
|
||||
export interface AiProxyModels {
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
@@ -3318,7 +3400,6 @@ export interface AiRouterModels {
|
||||
subagent: string;
|
||||
}
|
||||
|
||||
/** One saved AI Router profile, as the API returns it — **never** with a key. */
|
||||
/** A saved git login, as the server reports it: never the password. */
|
||||
export interface GitCredential {
|
||||
protocol: "http" | "https";
|
||||
@@ -3362,8 +3443,8 @@ export interface AiTool {
|
||||
resume: boolean;
|
||||
/** Offers the per-run "Bypass permissions" switch. */
|
||||
bypass: boolean;
|
||||
/** Can use an AI Router profile (Claude, Codex); others are native only. */
|
||||
routerCapable: boolean;
|
||||
/** Can use an AI Proxy profile (Claude, Codex); others are native only. */
|
||||
proxyCapable: boolean;
|
||||
/** What Install runs; null when the catalog names no install. */
|
||||
install: { kind: "npm"; package: string } | { kind: "script"; url: string } | null;
|
||||
/** What Update runs; `self` is the tool's own updater with these args. */
|
||||
@@ -3421,37 +3502,47 @@ export interface AiToolJob {
|
||||
log: string;
|
||||
}
|
||||
|
||||
export interface AiRouter {
|
||||
/** Identifier, derived from the label. Stable: a lane stores this. */
|
||||
/** One saved AI Proxy profile, as the API returns it — **never** with a key. */
|
||||
export interface AiProxy {
|
||||
/** Identifier, derived from the label. Stable: a session's run row stores it. */
|
||||
name: string;
|
||||
label: string;
|
||||
/** True only for the read-only `native` profile (the vendor's own API). */
|
||||
builtin: boolean;
|
||||
/** Empty string for `native`, an http(s) URL otherwise. */
|
||||
/** Empty string for `native`, else the proxy's http(s) ROOT, without `/v1`. */
|
||||
baseUrl: string;
|
||||
/** Whether a key is stored. The key itself never leaves the server. */
|
||||
hasKey: boolean;
|
||||
/** "" for `native`, which has no model of its own. */
|
||||
model: string;
|
||||
models: AiRouterModels;
|
||||
/** Claude model id the router's own model names behave as; "" when unset. */
|
||||
/** Empty for `native`. */
|
||||
models: Partial<AiProxyModels>;
|
||||
/** Claude model id the proxy's own model names behave as; "" when unset. */
|
||||
behavesAs?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/** Outcome of POST /api/ai-routers/test. A router that answered with an error
|
||||
* is still a successful HTTP call: `ok` is the ROUTER's verdict, `status` is
|
||||
* its HTTP code (null when nothing answered at all), and `message` is its own
|
||||
* words — truncated server-side, never the key. */
|
||||
export interface AiRouterTestResult {
|
||||
/** One probe of POST /api/ai-proxies/test. A proxy that answered with an
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-routers/test. `name` lets the server reuse the stored
|
||||
/** Outcome of POST /api/ai-proxies/test: Claude Code's `/v1/messages` and
|
||||
* Codex's `/v1/responses`, probed side by side. */
|
||||
export interface AiProxyTestResult {
|
||||
claude: AiProxyProbe;
|
||||
codex: AiProxyProbe;
|
||||
}
|
||||
|
||||
/** Body for POST /api/ai-proxies/test. `name` lets the server reuse the stored
|
||||
* key for a profile being edited — but only while `baseUrl` still matches the
|
||||
* saved one, so a typed-in endpoint always needs a typed-in key. */
|
||||
export interface AiRouterTestInput {
|
||||
export interface AiProxyTestInput {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
@@ -3459,14 +3550,14 @@ export interface AiRouterTestInput {
|
||||
}
|
||||
|
||||
/** Body accepted when creating or updating a profile. */
|
||||
export interface AiRouterInput {
|
||||
export interface AiProxyInput {
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
/** Omit on update to keep the stored key. */
|
||||
apiKey?: string;
|
||||
model: string;
|
||||
/** Any tier left out is filled from `model`. */
|
||||
models?: Partial<AiRouterModels>;
|
||||
models?: Partial<AiProxyModels>;
|
||||
/** A Claude model id; "" clears it. Omit on update to keep the stored one. */
|
||||
behavesAs?: string;
|
||||
}
|
||||
@@ -4124,8 +4215,8 @@ export interface RunStartArgs {
|
||||
resumeLast?: boolean;
|
||||
/** Turn off the tool's permission prompts for THIS run (its catalog bypass args). */
|
||||
bypass?: boolean;
|
||||
/** AI Router profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
router?: string;
|
||||
/** AI Proxy profile THIS session runs on; omitted = native, or a resume's own. */
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/** A lane's tmux-backed terminal run — one per lane, id is the tmux session
|
||||
@@ -4189,17 +4280,6 @@ export interface RunListResponse {
|
||||
items: RunHandle[];
|
||||
}
|
||||
|
||||
/** One row of GET /api/run/history (`dashboard_runs`; only the fields read). */
|
||||
export interface RunHistoryItem {
|
||||
id: string;
|
||||
tool: string;
|
||||
status: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
/** The run's tmux pane is still up. */
|
||||
isLive: boolean;
|
||||
}
|
||||
|
||||
/** One suggested working directory for the Run page's cwd picker. */
|
||||
export interface CwdSuggestion {
|
||||
/** "dashboard" = this server's own cwd; "home" = user's home dir; "recent"
|
||||
|
||||
@@ -20,9 +20,7 @@ export interface ChangeTreeDir<T> {
|
||||
export function changeTree<T extends { path: string }>(rows: T[]): ChangeTreeDir<T> {
|
||||
const root: ChangeTreeDir<T> = { name: "", path: "", dirs: [], files: [], count: 0 };
|
||||
for (const file of rows) {
|
||||
// An untracked folder comes from git as `dir/`: a leaf named `dir/`.
|
||||
const parts = file.path.replace(/\/$/, "").split("/");
|
||||
if (file.path.endsWith("/")) parts[parts.length - 1] += "/";
|
||||
const parts = file.path.split("/");
|
||||
let dir = root;
|
||||
dir.count++;
|
||||
for (const seg of parts.slice(0, -1)) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file Geometry for the JetBrains-style side-by-side diff, shared by the
|
||||
* Changes panel's review list (LaneDiffView) and its file editor (DiffEditor):
|
||||
* the curved band that joins a changed block on the left to its counterpart
|
||||
* on the right across the middle gutter, and the line mapping that keeps two
|
||||
* unpadded panes scrolled to the same place. Pure functions, no DOM.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** A changed block's lines on each side, 0-based and half-open: `a0 === a1`
|
||||
* is a block with no lines on the left (a pure insertion), and likewise b. */
|
||||
export type Span = { a0: number; a1: number; b0: number; b1: number };
|
||||
|
||||
/**
|
||||
* SVG path of the band joining the left block (`y0`–`y1`) to the right one
|
||||
* (`z0`–`z1`) across a gutter `w` wide: an S-curve along each edge, flat where
|
||||
* a side is empty (`y0 === y1`), so a pure insertion reads as a wedge.
|
||||
*/
|
||||
export function bandPath(w: number, y0: number, y1: number, z0: number, z1: number): string {
|
||||
const c = w / 2;
|
||||
return `M0,${y0} C${c},${y0} ${c},${z0} ${w},${z0} L${w},${z1} C${c},${z1} ${c},${y1} 0,${y1} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line on the other side that `line` (fractional, 0-based) sits beside:
|
||||
* outside a block 1:1, shifted by the blocks above it; inside one, at the same
|
||||
* fraction of the other side's block — which collapses to that block's point
|
||||
* when the other side is empty. `spans` are in document order.
|
||||
*/
|
||||
export function mapLine(line: number, spans: readonly Span[], fromA: boolean): number {
|
||||
let ours = 0;
|
||||
let theirs = 0;
|
||||
for (const s of spans) {
|
||||
const [s0, s1, t0, t1] = fromA ? [s.a0, s.a1, s.b0, s.b1] : [s.b0, s.b1, s.a0, s.a1];
|
||||
if (line < s0) break;
|
||||
if (line < s1) return t0 + ((line - s0) / (s1 - s0)) * (t1 - t0);
|
||||
ours = s1;
|
||||
theirs = t1;
|
||||
}
|
||||
return theirs + (line - ours);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user