Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ed5018a2f | |||
| aacd63b85b | |||
| 22de5009ff | |||
| e94038481e | |||
| b441f32949 | |||
| cb19d7e5d2 | |||
| 0cb5e41faf | |||
| 1c5521cf2e | |||
| c3f83d3922 | |||
| 0c2f49876d | |||
| 9c573f1e3c | |||
| d72a255294 | |||
| b08482c6aa | |||
| 763759189f | |||
| b0dd5af22f | |||
| 7cb4f64781 | |||
| 69a2c37cd2 | |||
| b3fe10a4f3 | |||
| 8ecb96e9fc | |||
| c47dd230d4 | |||
| 46e3899945 | |||
| 5d8fc9bb98 | |||
| c8ea18f594 | |||
| 64173a5428 | |||
| 943ba03b76 | |||
| 3a8be55b55 | |||
| 2fd0c903a2 | |||
| f6f1bf4a76 | |||
| f0dc1445fc | |||
| 96e4d28f7c | |||
| fa156d6a59 | |||
| ce773a3f5a | |||
| 4407b3bb16 | |||
| 44fd664670 | |||
| 118eaee2ec | |||
| 3ab750c765 | |||
| 84e2a9f0ac | |||
| b7f3735b29 | |||
| a9c71cd905 | |||
| 6ef9a5d4b7 | |||
| 1619954fad | |||
| f0656603c3 | |||
| 9a54978eef | |||
| ca821008e2 | |||
| 8fdda66893 | |||
| 7543b8d62a | |||
| 4163f431bb | |||
| fcf8d8010a | |||
| 5fe5d51b1b | |||
| dd9b23106c | |||
| af014d9829 | |||
| f8acd5fc29 |
@@ -22,7 +22,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PreToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -34,7 +35,8 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-handler.js\" PostToolUse",
|
||||
"timeout": 5
|
||||
"timeout": 5,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
# (default: enabled).
|
||||
# AGENTHUB_PRICING_SYNC=0
|
||||
|
||||
# The status bar's Usage panel reads Antigravity's quota by running
|
||||
# `agy --print /usage --output-format json` (no model call), which may let agy
|
||||
# update itself. 0 never runs it, and Antigravity shows no quota (default: on).
|
||||
# AGENTHUB_AGY_USAGE=0
|
||||
|
||||
# Heavy read-only aggregates (GET /api/workflows) run in a worker thread on
|
||||
# their own connection so they never stall other requests. 0 runs them inline
|
||||
# on the event loop instead (default: worker). A job past the timeout answers
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# commit-msg hook – runs Commitlint
|
||||
npx --no-install commitlint --edit "$1"
|
||||
@@ -8,7 +8,7 @@
|
||||
- Correctness over cleverness.
|
||||
- Small, scoped, reversible diffs.
|
||||
- Preserve existing behavior unless change is requested.
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README + VN/CN/KO mirrors, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Update docs whenever workflow or architecture changes — follow `skills/update-project-docs/` automatically at the end of every change-set (README.md — Vietnamese, the default — and README.en.md; docs/OVERVIEW.md + OVERVIEW.vi.md, ARCHITECTURE, wiki + i18n + cache bump, server/client READMEs, docs/*).
|
||||
- Every applicable source file you create or update (`.js/.ts/.tsx/.cjs/.mjs/.py/.sh/.css`) must start with the authorship header: a truthful file overview plus the exact line `@author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>`. See `skills/file-headers/` and `.claude/rules/file-headers.md`; verify with `bash skills/file-headers/scripts/check-headers.sh`.
|
||||
|
||||
## Workspace stage
|
||||
@@ -23,8 +23,8 @@ make while you work here as Codex.
|
||||
- `scripts/` for hook/install/import/cleanup utilities.
|
||||
|
||||
## Validation expectations
|
||||
- Backend changes: run `npm run test:server` when possible.
|
||||
- Frontend changes: run `npm run test:client` when possible.
|
||||
- Run only the tests the change touches, never a full suite by default (full suites take 5–6 minutes): `npm run test:server -- server/__tests__/<name>.test.js`, and in `client/` `NODE_ENV=test npx vitest related <changed files> --run` (or `vitest run <test file>`). Docs-only changes run no tests.
|
||||
- Run a full suite (`npm run test:server` / `npm run test:client`) only when genuinely needed: the change touches something every screen uses (`Layout`, `Sidebar`, `StatusBar`, `tailwind.config.js`, global CSS, shared hooks/lib), a shared server core (`server/db.js`, `server/index.js`, auth/scope, middleware), a dependency or build/test config, or more than ~10 code files; or the user asks. The pre-commit hook already picks related vs. full tests per commit, so do not add an extra full run before committing.
|
||||
- If any check is skipped, report it explicitly.
|
||||
|
||||
## Safety expectations
|
||||
|
||||
+33
-23
@@ -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 |
|
||||
@@ -374,8 +374,8 @@ graph TD
|
||||
| `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer |
|
||||
| `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-<lane.id>`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live. Every spawn also mirrors itself into `dashboard_runs` (cwd, model, prompt preview, `resume_session_id`, lane id, start time) — that row is the ONLY source `publicRun` has for those fields, since the handle itself is recomputed from tmux on every read, so a dropped insert renders as a live run with no cwd, model or start date. `mode` is NOT NULL in that table and `recordRun` swallows insert errors by design, which is why this module passes a literal `"interactive"` rather than null. **Create-or-adopt has one refusal:** a pane already running an agent is adopted silently only when the request asked for nothing but a pane — a `resumeSessionId` or an initial prompt cannot be typed over a live agent, so it throws `ERUNLIVE` (409) rather than reporting a resume that never spawned |
|
||||
| `lib/pty-attach.js` | Client-side PTY attachment via WebSocket. Establishes a `/ws-pty/:runId` connection, receives binary PTY frames, and feeds them to an xterm.js terminal instance. Handles reconnection, resize events (sending `TIOCSWINSZ` ioctl to the tmux pane), and cleanup on disconnect. A single tmux session can have many simultaneous PTY clients (browser Workspace, `agenthub lanes shell` CLI, etc.), all synced live The server side (`server/lib/pty-attach.js`) runs `tmux attach` with the LIVE tmux server's own binary (`/proc/<tmux.serverPid()>/exe`, else `tmux` on PATH): a tmux upgraded while its server runs leaves a newer client on PATH that the old server refuses with `open terminal failed: not a terminal`. |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<tmpdir>/agenthub-drops-<uid>/`) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/:action` is same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `routes/run.js` | HTTP surface for the tmux+PTY Run feature. **Same-origin guard** on every route — browser requests must come from a localhost-ish Origin (`localhost`, `127.0.0.1`, `::1`, `0.0.0.0`); missing-Origin (curl/CLI) requests pass. When `DASHBOARD_TOKEN` is configured it is **also** required on these routes (same as the rest of `/api/*`). `GET /api/run` lists all live runs (computed fresh from tmux state via `tmux list-sessions`). `GET /api/run/tmux` reports whether `tmux` is installed and on PATH (required for the feature to work). `GET /api/run/binary` probes whether `claude` is on `PATH`. `GET /api/run/cwds` suggests cwds (dashboard + home + recent from sessions table). `GET /api/run/history?laneId=...` returns persisted run history, optionally scoped to one lane. `GET /api/run/files?cwd=&q=` powers the Workspace page's `@`-file autocomplete: scoped fuzzy search inside `cwd` skipping `node_modules`, `.git`, `dist`, `build`, `.next`, `.cache`, `coverage`, `vendor`, etc., capped result count, ranked by basename match. `POST /api/run/drop-image` saves an image dropped on the terminal (raw body, four image types, random name under `<data dir>/drops/` (a member's `~/.agenthub/handoff/drops/` under S3)) and returns the path the terminal pastes. `POST /api/run` requires `laneId` in the body and starts/attaches a tmux-backed run. `GET /api/run/:id` returns the run handle. `DELETE /api/run/:id` kills the tmux session (sends SIGTERM to the pane, escalates to SIGKILL after 5 s). The PTY frames are streamed to the client over `/ws-pty/:runId` as binary frames (not JSON), rendering a real interactive terminal in xterm.js on the Workspace page. `tmux` must be installed on the dashboard server's machine (same as better-sqlite3's native-module requirements) |
|
||||
| `routes/lanes.js` | Durable-lane API. `POST /api/lanes/worktree`, `PATCH /api/lanes/:id`, destructive actions, and `DELETE /api/lanes/:id` use the Run route's same-origin guard. Worktree provisioning validates an absolute source git repository, persists a managed lane as `provisioning`, returns `202`, then uses the per-lane lock to resolve the base and add the worktree. Completion broadcasts the existing `lane_update` payload as `idle`; a git failure leaves a row that the non-destructive delete route can forget. `GET /api/lanes/:id/preflight?action=reset\|remove\|purge` produces counted confirmation facts. Confirmed `POST /:id/{reset,remove,purge}` actions require a complete `expect`, run under the same lock, kill a recorded run and wait for the spawner's actual child-exit timestamp (or return `500 ERUNTIMEOUT` before git), clear `run_id`, reject changed facts with `409 ESTALE` including expected/current diagnostics, and require `force` for unpushed managed reset/remove work. Reset and managed removal call the worktree's independent managed-kind, realpath-within-`LANES_ROOT`, and listed-worktree guard; adopted reset is refused, while adopted remove only forgets its row and never modifies its directory, and a managed lane whose directory was deleted by hand takes a prune path that still enforces the managed-kind and inside-`LANES_ROOT` checks. `start` returns `409 ERUNLIVE` rather than overwriting a live `run_id` and orphaning its child. `kind`, `source_repo`, `slug`, `base_branch`, `slot` and `ports` are not patchable — provisioning writes them through `lanesLib.setProvisioningFacts`. Worktree provisioning also runs `lane-runtime.js:provisionLane` (A2) when the repo declares a `.agenthub/profile` — seed `.env`, `bootstrap`, create the database, migrate, seed — before the lane reports `idle`; `reset`/`remove` likewise call `resetLaneData`/`removeLaneData`, with `reset` accepting a body `keepDb: true` to skip the whole drop-recreate-migrate-reseed block. The runtime routes (`GET /:id/runtime`, `POST /:id/up`, `POST /:id/down`, `POST /:id/hook/:name`, `GET /:id/logs/:svc`) are registered **before** the `/:id/:action` catch-all so `up`/`down` are not swallowed as unknown actions, and are deliberately kept out of it: that catch-all drives a lane's Claude run, these drive the application the lane is working on. `GET /api/lanes/pipeline-stats` aggregates per-node pass counts over every lane and every archived feature. The git surface (#34/#35) sits under `/:id/git/*`, again before the catch-all: `GET /:id/git/changes` (ranges `work`/`branch`/`commit`, with `context`), `/:id/git/log`, `/:id/git/file`, `/:id/git/ls`, `/:id/git/tree`, `/:id/git/download` and `/:id/git/branches` are read-only and unguarded like `/:id/git`, while `POST /:id/git/upload` (create-only, `lane-file-transfer.js`) and `POST /:id/git/:action` are same-origin guarded and runs under the lane lock — and its per-file actions (`stage`, `unstage`, `discard`) accept only paths `git status` reports as changed, with `discard` additionally requiring `confirm: true`. |
|
||||
| `lib/ports.js` | TCP probing for runtime allocation. `isListening(port)` connects rather than binds (binding races with the hook about to bind, and says nothing about a listener held by another user); a connect timeout counts as occupied. `listenerPids(port)` shells to `lsof`, falls back to `ss`, and returns `[]` with a one-time warning when neither exists — a missing tool must never fail a lane operation |
|
||||
| `lib/lane-slots.js` | Slot and port allocation — the numbering Shipyard gets free from fixed `lane1..lane9` directories and AgentHub, keyed by `cwd`, must allocate. `allocateSlot` takes the lowest free of `LANE_MAX_SLOTS` (default 9) under the per-lane lock, with a partial unique index on `lanes.slot` as the backstop; allocation is **lazy**, so a lane that is only watched never consumes one. `releaseSlot` runs on remove but never on reset (moving a lane's ports mid-feature is a silent failure, not a fresh start). `resolvePorts` prefers `PORT_BASE_<name> + slot`, then steps `+100` at a time so the last digit still reads as the slot, skipping anything listening, recorded by another lane, or already taken in the same boot. `slotDirs` puts run/log state under `LANES_ROOT/.state/lane<slot>/` — outside the worktree, because `reset`'s `git clean -fd` would otherwise sweep live pid files. `dbName`/`dataFacts` (A2) derive the same kind of slot-based fact one layer up: database name, `DATABASE_URL`/`TEST_DATABASE_URL`, a Redis logical index, and the upload directory — each `null` when its owning profile declaration (`DB_PREFIX`, `REDIS`, `UPLOAD_SUBDIR`) is absent |
|
||||
| `lib/lane-profile.js` | The stack seam. Resolves `<repo>/.agenthub/profile/` from the lane's own working copy first (a branch that edits its boot command must boot with it), the source repo second. `profile.env` is **parsed, not sourced** — `$(…)` stays literal, because sourcing repo shell into the dashboard process would be a code-execution path. `runHook` spawns `bash <hook>` with an argv array through a wrapper that `export -f`s `harness_spawn`/`die`, exports Shipyard's env contract verbatim (`LANE` is the **slot**, plus `<NAME>_PORT` per declared port, plus A2's `DB_NAME`/`DATABASE_URL`/`TEST_DATABASE_URL`/`PG_HOST`/`PG_PORT`/`PG_USER`/`REDIS_URL`/`REDIS_HOST`/`REDIS_PORT`/`UPLOAD_DIR` when their profile declaration is present) so its profiles port unchanged, scrubs `GIT_*` exactly as `worktree.js:git()` does, redacts any `secrets.js` password from its output before it reaches the log file or the `lane_hook_output` broadcast, and streams the rest to both `$LOG_DIR/<hook>.log` and that broadcast. Hook names come from a fixed allowlist, never from a request |
|
||||
@@ -385,8 +385,9 @@ graph TD
|
||||
| `lib/lane-env.js` | (A2) `seedEnv` copies a repo's real `.env` into a lane on first boot (or `--force`) and rewrites the declared `ENV_REWRITE` keys (`DATABASE_URL`/`REDIS_URL`/`UPLOAD_DIR`) in place, byte-identical otherwise. A `--force` refresh preserves `ENV_PRESERVE` keys (e.g. `JWT_SECRET`) from the lane's own existing file — swapping in the source's secret would 401 a running lane until reboot. Falls back to `.env.example` with a warning when the source is missing. Refuses on an adopted lane: that file is the user's real config |
|
||||
| `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-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; in a folder that is not a git repo they, and `listTreeAll`, read the disk instead through `walkFolder` — realpath-confined, symlinks never followed, `.git` hidden, `node_modules` never entered, 20 000 entries at most). 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/lane-file-transfer.js` | (#78) The Files tab's download and upload. `openDownload` streams one working file of any type (up to 512 MB) behind the file read's guards (`lanePath`, `resolveInLane` realpath confinement, `isSecretOnDisk`, `openSafe` for a member's file); the route serves it as an `application/octet-stream` attachment under a `sandbox` CSP with `nosniff`. `saveUpload` only CREATES (`EEXISTS` for a taken name, a symlink included), into an existing folder, never under `.git`, never a secret name, at most 25 MB — under S3 as the folder's member through `write-as`, else with an exclusive `wx` create. Works in a folder that is not a git repo |
|
||||
| `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 +488,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.
|
||||
@@ -757,6 +758,7 @@ erDiagram
|
||||
TEXT metadata "JSON blob"
|
||||
TEXT awaiting_input_since "ISO 8601 or NULL — set by waiting Notifications"
|
||||
TEXT awaiting_reason "notification|stop|session_start|interrupted or NULL"
|
||||
TEXT pinned_at "ISO 8601 or NULL — pinned to the top of its workspace's sidebar list"
|
||||
TEXT source "Data source — 'local' or a remote source id; NOT NULL DEFAULT 'local'"
|
||||
}
|
||||
|
||||
@@ -911,10 +913,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 +968,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 +2226,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. |
|
||||
@@ -2239,6 +2246,7 @@ interface UpdateStatusPayload {
|
||||
situation?: // categorical hint for the UI
|
||||
| "plugin_managed" // ~/.claude/plugins/cache — /apply delegates
|
||||
| "release_available" // fast-forward onto the release tag
|
||||
| "diverged_from_release" // HEAD has commits the release lacks — fetch only, no apply
|
||||
| "tracking_canonical"
|
||||
| "fork_or_diverged_tracking"
|
||||
| "feature_branch"
|
||||
@@ -2296,14 +2304,15 @@ 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. |
|
||||
| Plugin `/apply` after a failed release check | 409 `{reason:"plugin_managed", release_check:"unreachable"\|"no_local_version"\|...}` | No chain is spawned, and the message says the check did not succeed rather than claiming nothing newer exists — an unknown reported as a negative is how a broken check looks identical to an up-to-date install. |
|
||||
| Healthy, behind, on canonical branch | `{update_available:true, situation:"tracking_canonical", manual_command:"...git pull --ff-only..."}` | Modal opens with `git pull` flow + restart hint. |
|
||||
| Healthy, behind, fork (origin = fork, upstream = canonical) | `{update_available:true, situation:"fork_or_diverged_tracking", manual_command:"...git fetch upstream && git merge --ff-only upstream/master..."}` | Modal opens with merge flow + restart hint + `situation_note` explaining the divergence. |
|
||||
| Newer release, but HEAD holds commits the release lacks (e.g. local wip the release squashed) | `{update_available:true, situation:"diverged_from_release", manual_command:"...git fetch <remote> --tags"}` | Modal opens with no **Update now**; `situation_note` says how many commits diverge and offers `git reset --keep <tag>` (when they are already in the release) or merge/rebase. `/apply` answers `not_fast_forwardable` instead of a raw `merge --ff-only` failure. |
|
||||
| Healthy, behind, on a feature branch | `{update_available:true, situation:"feature_branch", manual_command:"...git fetch <remote>"}` | Modal opens; `situation_note` explains the user is off the canonical branch; restart hint suppressed because the working tree isn't being changed. |
|
||||
|
||||
### Why Self-Apply Stays Narrow
|
||||
@@ -2316,7 +2325,7 @@ The same shape is used by `GET /status`, `POST /check`, and the `update_status`
|
||||
- **Branch coverage.** A feature branch or detached HEAD would need branch-aware integration (rebase vs merge vs switch) and merge-conflict handling. That belongs in the user's shell, not in a background daemon — which is why those situations get a printed `git fetch` and no button.
|
||||
- **Ownership.** A plugin-cache install is not ours to rewrite by hand — its directory names are content hashes Claude Code manages. So that case does not fast-forward anything; it calls `claude plugin update` and then re-runs `plugin-bootstrap.js` from the directory that tool produced. The residual limit is only `/reload-plugins`, which is REPL-scoped: an interactive session keeps its old skills/commands until the user reloads, even though the dashboard server has already moved.
|
||||
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js` + the detached `scripts/restart-helper.js` rather than an in-process guess at the supervisor.
|
||||
What `--ff-only` buys is the absence of the first three problems inside the supported cases: the move is either a clean fast-forward or it aborts, `npm run setup`/`build` failures reject the request before any restart is scheduled, and the restart itself goes through `self-restart.js`: a server our own service unit runs (its cgroup ends in `/agenthub.service`, or it is our launchd job) is restarted BY that manager (`systemctl restart --no-block`, via `sudo -n` for the server-mode system unit; launchd's KeepAlive after an exit), anything else by the detached `scripts/restart-helper.js`. A detached replacement under a `Restart=always` unit held the port while the unit restarted every 3 s, each start exiting "already running" (v2.7.2).
|
||||
|
||||
---
|
||||
|
||||
@@ -2347,7 +2356,7 @@ graph TD
|
||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||
PANEL["SagiPanel.tsx<br/>status + quick actions + Ask box"]
|
||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||
end
|
||||
|
||||
@@ -2394,16 +2403,17 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech-bubble queue, mute, and clear-alerts. Produces the derived `{ mood, status, bubble }` the presentational components render. |
|
||||
| **`brain.ts`** | Pure, framework-free core. Exposes a `WSMessage` reducer (`reduceSagi`, which also returns the `subject` session a pulse is about — name, cwd, lane) and a mood state machine (`deriveMood`) with the priority order `disconnected > worried > stuck > happy > thinking > watching > sleeping > idle`. An error alerts on the transition INTO `error` only, and **Clear alerts** (`clearErrors`) moves the errored sessions into `dismissed`: their later updates, agent errors and failure events stay silent until the session goes active again or ends. `attentionOf` lists errored then waiting sessions by name; `MOMENT_OF_PULSE` maps each pulse to a pet animation. The clock is injected and there are zero side effects, so the brain is fully unit-tested in isolation. |
|
||||
| **`useSagiBrain.ts`** | The **only** consumer of the global `eventBus`. Wires the pure brain to real timers (idle / sleep / stuck), the speech bubble (naming the session it is about; the same pulse again within 8 s joins it as a count), the one-shot `moment` animation, a once-per-browser-session greeting, an idle fidget every minute or so, pat, mute, and clear-alerts (the dismissed ids persist in `localStorage` `agenthub.pet.dismissedErrors`, newest 200). The reducer runs outside React's updater so StrictMode never doubles a pulse. Produces `{ mood, status, attention, bubble, moment }`. |
|
||||
| **`SagiAvatar.tsx`** | Pure presentational SVG mascot. The `data-mood` attribute drives CSS; pupils track the cursor. Rendered at 90×90 px (`size` default, kept in sync with `SAGI_SIZE` in `useSagiPosition.ts`, which is the draggable button's real footprint) through a `viewBox` cropped to the artwork's measured bounding box instead of the exporter's padded `0 0 1280 1280` canvas. Path data is never rescaled, so `MAX_PUPIL_SHIFT` stays in the original 1280-unit space. The `transform-origin` values in `sagi.css` do **not**: an origin on an inner SVG element resolves against `transform-box: view-box`, whose 0,0 sits at the `viewBox` min (`253.5 106`), so those are mascot coordinates **minus that offset**. The root `.sagi-avatar` origin is a percentage on purpose — on the outermost `<svg>` a length would be measured against the 68 px CSS box, and the resulting ~9× overshoot turns every mood animation into a whole-avatar lurch. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble. |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: status readout + quick actions + the Ask box. |
|
||||
| **`Sagi.tsx`** | Shell component. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Pure local Q&A over the cached status snapshot. Queries that don't match a local intent become a handoff: `Sagi.tsx` calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Pure mood → phrase pools. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted preferences with cross-tab sync. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions; selected via the `data-mood` attribute. |
|
||||
| **`SpeechBubble.tsx`** | Pure presentational speech bubble: the line, the session(s) it is about, and an **Open** button for a single one (`workspaceHref` — its live pane, else its replay, else `/sessions/<id>`; never a resume). |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
|
||||
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`quips.ts`** | Mood/pulse → phrase pools, plus greetings and pat lines, read from `settings:sagi.{quips,greet,pat}` so they follow the UI language. |
|
||||
| **`prefs.ts`** | `localStorage`-backed enabled / muted / pet / size / nickname preferences with cross-tab sync; `petName` = nickname, else the worn pet's own name, else Sagi. |
|
||||
| **`sagi.css`** | Keyframes and per-mood expressions (`data-mood`), plus the one-shot moments (`data-moment`: jump, wave, fail, wait, review) on Sagi's art and an uploaded image. |
|
||||
|
||||
### Touchpoints Outside the Folder
|
||||
|
||||
@@ -2415,7 +2425,7 @@ Sagi's only contact with the rest of the app is four light, additive touchpoints
|
||||
| `client/src/pages/Settings.tsx` | On/off toggle wired to `sagiPrefs` (`localStorage`). |
|
||||
| `client/src/pages/Run.tsx` | Consumes `?laneId=` to attach the page to the lane whose run Sagi just spawned. |
|
||||
| `server/routes/lanes.js` (`POST /api/lanes/ensure`) | Accepts a missing `cwd` and resolves it to `os.homedir()` so the mascot can adopt a stable "Sagi" lane. |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | `sagi.*` strings for the Settings toggle (zh / ko fall back to the hardcoded English defaults). |
|
||||
| `client/src/i18n/locales/{en,vi}/settings.json` | Every `sagi.*` string: the Settings toggle, the panel, the bubbles' quips, coalesced counts, greetings and Ask answers. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ result and `/agenthub-open` to build the UI and get the URL.
|
||||
|
||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup, is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
Follow the steps below instead when you want a checkout — to develop the
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
@@ -323,7 +323,7 @@ agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
```
|
||||
|
||||
See [`README.md` → Run as a service](./README.md#run-as-a-service-auto-start-on-boot)
|
||||
See [`docs/OVERVIEW.md` → Run as a service](./docs/OVERVIEW.md#run-as-a-service-auto-start-on-boot)
|
||||
and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot).
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# AgentHub
|
||||
|
||||
[Tiếng Việt](README.md) · **English**
|
||||
|
||||
A dashboard that runs on your machine to watch and drive your Claude Code (and
|
||||
Codex) sessions from the browser.
|
||||
|
||||
| Section | Tiếng Việt |
|
||||
| --- | --- |
|
||||
| [Requirements](#requirements) | [Yêu cầu](README.md#requirements) |
|
||||
| [Option 1 — Through Claude Code](#install-claude-code) | [Cách 1 — Cài qua Claude Code](README.md#install-claude-code) |
|
||||
| [Option 2 — From a terminal](#install-terminal) | [Cách 2 — Cài bằng terminal](README.md#install-terminal) |
|
||||
| [More docs](#more-docs) | [Tài liệu thêm](README.md#more-docs) |
|
||||
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
To install them:
|
||||
|
||||
- Ubuntu: `sudo apt install git tmux` (get Node.js ≥ 22.5 from <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
## Option 1 — Install through Claude Code (recommended)
|
||||
|
||||
Send **each line on its own** straight into the Claude Code chat:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
```
|
||||
|
||||
```
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
The last one prints the dashboard's address (by default <http://localhost:4820>) —
|
||||
open it in the browser and you are done.
|
||||
|
||||
Something wrong? Send `/agenthub:agenthub-doctor` to check and fix it.
|
||||
To update to a new version: `/agenthub:agenthub-update`.
|
||||
Not using it? Quit so nothing runs in the background: **Settings → System → Quit**,
|
||||
or `agenthub stop` — opening Claude Code no longer starts it; `agenthub start` does.
|
||||
AgentHub's icon beside the clock (top bar / menu bar / notification area) offers
|
||||
**Open**, **Quit** and **Start AgentHub** too; hide it from its menu or in **Settings → System**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Option 2 — Install from a terminal (without Claude Code)
|
||||
|
||||
Run these in a terminal, one after another:
|
||||
|
||||
```bash
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # install the dependencies + the agenthub command
|
||||
npm run build # build the UI
|
||||
npm start # run the dashboard
|
||||
```
|
||||
|
||||
Open <http://localhost:4820> in the browser.
|
||||
|
||||
`npm start` stops when the terminal closes. To have the dashboard start every time
|
||||
the machine boots:
|
||||
|
||||
```bash
|
||||
agenthub service install
|
||||
```
|
||||
|
||||
> ⚠️ Pick **only one** of the two options. With both installed, every event is
|
||||
> counted twice.
|
||||
|
||||
<a id="more-docs"></a>
|
||||
|
||||
## More docs
|
||||
|
||||
- Vietnamese README: [README.md](README.md)
|
||||
- The full feature overview: [docs/OVERVIEW.md](docs/OVERVIEW.md) ·
|
||||
Tiếng Việt: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md)
|
||||
- Detailed install, troubleshooting: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- The `agenthub` command: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
Internal build — all rights reserved.
|
||||
@@ -1,360 +1,94 @@
|
||||
# AgentHub
|
||||
|
||||
Internal SmartGift build. Local-first **control surface** for Claude Code agents —
|
||||
monitoring is one part of it, not the whole. Hooks POST every tool call to an
|
||||
Express + SQLite server and a React UI updates over WebSocket; **workspaces** track
|
||||
parallel agent work, one working directory each; and the `agenthub` CLI drives real terminal
|
||||
sessions, workspace runtimes, and Claude Code's own configuration.
|
||||
**Tiếng Việt** · [English](README.en.md)
|
||||
|
||||
AgentHub records and shows — it does not orchestrate. The session in control makes
|
||||
every decision.
|
||||
Dashboard chạy trên máy anh chị để theo dõi và điều khiển các phiên Claude Code
|
||||
(và Codex) ngay trên trình duyệt.
|
||||
|
||||
Internal build — all rights reserved.
|
||||
| Phần | English |
|
||||
| --- | --- |
|
||||
| [Yêu cầu](#requirements) | [Requirements](README.en.md#requirements) |
|
||||
| [Cách 1 — Cài qua Claude Code](#install-claude-code) | [Option 1 — Through Claude Code](README.en.md#install-claude-code) |
|
||||
| [Cách 2 — Cài bằng terminal](#install-terminal) | [Option 2 — From a terminal](README.en.md#install-terminal) |
|
||||
| [Tài liệu thêm](#more-docs) | [More docs](README.en.md#more-docs) |
|
||||
|
||||
*(Tiếng Việt: [README.vi.md](README.vi.md))*
|
||||
<a id="requirements"></a>
|
||||
|
||||
## Main features
|
||||
## Yêu cầu
|
||||
|
||||
**Observe**
|
||||
- **Node.js ≥ 22.5**
|
||||
- **git**
|
||||
- **tmux**
|
||||
|
||||
- **Sessions, agents, events.** Everything Claude Code emits, recorded and
|
||||
searchable: tool calls, token usage, cost, subagent trees, transcripts.
|
||||
- **Analytics and cost.** Per-model token totals and estimated cost, with
|
||||
editable pricing rules; top tools, agent types, and workflow patterns.
|
||||
- **Activity feed and alerts.** A live event stream, alert rules, and webhook
|
||||
targets that fire on them.
|
||||
- **History import.** Every session under `~/.claude/projects/` is imported on
|
||||
startup; archives from another machine come in through Settings → Import
|
||||
History (idempotent, never double-counts tokens).
|
||||
- **Remote sources.** Pull session history from other machines over SSH
|
||||
(`agenthub remote-sources`).
|
||||
Cài:
|
||||
|
||||
**Drive**
|
||||
- Ubuntu: `sudo apt install git tmux` (Node.js ≥ 22.5 tải ở <https://nodejs.org>)
|
||||
- macOS: `brew install node git tmux`
|
||||
|
||||
- **Workspaces.** One workspace per working directory, surviving session restarts. Once you
|
||||
install a pipeline template (none ships), a workspace moves through its stages and
|
||||
the dashboard shows where it is.
|
||||
- **Stage detection.** With a template installed, the stage is inferred from the
|
||||
tool stream, so a session that never calls `agenthub stage` still shows
|
||||
progress — rendered dashed amber and never as done, because an inference is
|
||||
not evidence.
|
||||
- **Run Claude Code or Codex from the browser.** A real terminal (tmux + a real
|
||||
PTY, rendered with xterm.js) attached to a workspace's directory — the exact TUI
|
||||
you'd see locally, fully interactive, resumable, and attachable from a real
|
||||
terminal too via `agenthub workspace shell`. Which CLI it starts is the workspace's own
|
||||
setting. **This is the part that needs `tmux` installed** — see
|
||||
[Prerequisites](#prerequisites).
|
||||
- **Git, from the dashboard.** Each workspace card shows its branch, `↑ahead ↓behind`
|
||||
and its uncommitted files, with buttons for the ordinary moves: fetch, pull
|
||||
(always `--ff-only`), push, commit-all, and switching branch on a clean tree.
|
||||
Beside the workspace sits a collapsible **Changes** panel — an overview of the
|
||||
changed files, a full review of every diff, or one file at a time, comparing
|
||||
either uncommitted work or the whole branch against its base. There is no
|
||||
merge/rebase/reset button by design: recovery from those needs a terminal.
|
||||
- **Per-workspace app stacks.** A repo that declares `.agenthub/profile/` gets its own
|
||||
slot, ports and services per workspace (`agenthub workspace up|down|logs`).
|
||||
<a id="install-claude-code"></a>
|
||||
|
||||
**Configure**
|
||||
## Cách 1 — Cài qua Claude Code (khuyên dùng)
|
||||
|
||||
- **Workspaces like Claude Desktop projects.** The sidebar lists every
|
||||
workspace with its sessions under it (a live one opens its tmux pane, any
|
||||
other its conversation with a prompt box — sending a message resumes it,
|
||||
opening never does; **Analyst** in the right panel opens its analysis). A workspace opens on its home: a
|
||||
prompt box that starts a new session, Recents, and the workspace's own
|
||||
Instructions, Memory, Context files and extra Folders, which Claude and Codex
|
||||
sessions start with.
|
||||
- **Settings as one page per section** (`/settings/<section>`): a left nav
|
||||
grouped General (General / Data / About / Appearance / Pet / Notifications) /
|
||||
Agents / Routing / Cost / Git, a search box (Ctrl+F) over every section whose
|
||||
results deep-link a row's sub-tab and anchor, a page per installed AI tool, per-vendor model pricing, and which
|
||||
untracked worktrees a workspace's Add folder suggests.
|
||||
- **Claude Code + Codex configuration explorer.** Settings hosts one page per
|
||||
tool: every plugin, skill, subagent, slash command, MCP server, hook, settings
|
||||
file, memory file, marketplace, keybinding and statusline Claude Code knows
|
||||
about — read for all of them, edit/create/delete for the text-file surfaces
|
||||
(skills, agents, commands, output styles, `CLAUDE.md`). Plugins, MCP, hooks and
|
||||
`settings.json` stay read-only: they race with the live CLI.
|
||||
- **Updates.** Release-based update detection with an in-UI **Update all**
|
||||
(marketplaces → every plugin → AgentHub last → one session reload).
|
||||
- **Appearance.** Settings → Appearance picks Dark / Light / System (follows the
|
||||
OS live) and a theme per mode — dark: Default, Darcula, Apple Dark, Orca Dark;
|
||||
light: Default, Apple Light, IntelliJ Light, Canvas, Orca Light. Each mode
|
||||
remembers its own theme, so the sidebar's quick toggle swaps between the two.
|
||||
Per-browser only.
|
||||
- **Notification sounds.** Settings → Notifications picks a sound (None, Chime,
|
||||
Pop, Alert — off by default) played with every notification shown, and each
|
||||
notification type can pick its own.
|
||||
- **A CLI for all of it** (`agenthub`, plus `agenthub repl`), a PWA install, and
|
||||
a UI in English, Vietnamese, Chinese and Korean.
|
||||
- **Outside the dashboard, all opt-in:** a read-only MCP server
|
||||
(`claude mcp add agenthub -- agenthub mcp`, [docs/MCP.md](docs/MCP.md)), a
|
||||
Claude Code statusline with session and today's cost
|
||||
(`agenthub statusline install`, [docs/STATUSLINE.md](docs/STATUSLINE.md)), and
|
||||
Prometheus rules + Grafana boards for `/api/metrics`
|
||||
(`docker-compose.monitoring.yml`, [docs/MONITORING.md](docs/MONITORING.md)).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Need | Version | Required for |
|
||||
|---|---|---|
|
||||
| **Node.js** | >= 20 (>= 22.5 for a plugin install) | Everything. Both suites are verified green on **24** and **26** |
|
||||
| **npm** | 9+ | Ships with Node |
|
||||
| **git** | any | Cloning, and every workspace operation (worktrees, branch/status reads) |
|
||||
| **tmux** | 3.x | The browser run console, `agenthub workspace shell`, typing `/reload-plugins` into a live pane |
|
||||
| **Claude Code** | 2.x | Hook ingestion — the whole monitoring side |
|
||||
| **Codex CLI** | any | Optional — only for `codex` workspaces |
|
||||
| **GitHub CLI (`gh`)** | any | Optional — only for `agenthub workspace ci status` |
|
||||
| **systemd (user)** | — | Optional — only for `agenthub service install` (Linux auto-start on boot) |
|
||||
|
||||
Node **25** is the one version to avoid: it breaks 6 server tests through a
|
||||
better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage`
|
||||
change. Both were gone again by 26 (1712 server + 488 client tests green on
|
||||
v26.8.1).
|
||||
|
||||
### Installing tmux
|
||||
|
||||
The dashboard shells out to the `tmux` binary; there is no bundled fallback, so
|
||||
**Start Run** and `agenthub workspace shell` fail without it. Everything else
|
||||
(sessions, analytics, workspaces, stage detection, the CLI) works fine.
|
||||
|
||||
```bash
|
||||
sudo apt install tmux # Debian / Ubuntu
|
||||
sudo dnf install tmux # Fedora / RHEL
|
||||
brew install tmux # macOS
|
||||
sudo pacman -S tmux # Arch
|
||||
```
|
||||
|
||||
Windows has no native tmux — run AgentHub under WSL2 and install it there.
|
||||
|
||||
macOS (code-audited, untested on a Mac): `brew install node git tmux` (Node
|
||||
>= 22.5 for the plugin), add `~/.local/bin` to `PATH` in `~/.zshrc`. There is
|
||||
no systemd, so the dashboard runs until logout/reboot and comes back at the
|
||||
next Claude Code session, or by hand with `agenthub start`. Details and a
|
||||
checklist to verify it: [INSTALL.md § macOS](INSTALL.md#macos).
|
||||
|
||||
Verify, both from your shell and from the server's point of view:
|
||||
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
curl -s localhost:4820/api/run/tmux # {"available":true}
|
||||
```
|
||||
|
||||
A workspace's run lives in a tmux session named `agenthub-lane-<id>`, so the browser
|
||||
terminal and a local `tmux attach -t agenthub-lane-<id>` (or `agenthub workspace
|
||||
shell`) are two clients on the same pane — they stay in sync, and killing the
|
||||
dashboard never kills the run.
|
||||
|
||||
## Install as a Claude Code plugin
|
||||
|
||||
Two commands on a machine that has nothing but Claude Code, no clone and no
|
||||
`npm run setup`:
|
||||
Anh chị hãy gửi **riêng từng dòng** sau thẳng vào khung chat Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
```
|
||||
|
||||
```
|
||||
/plugin install agenthub@agenthub
|
||||
```
|
||||
|
||||
The first session start installs the hooks, boots the server, puts `agenthub` on
|
||||
PATH; it runs detached, so the session never waits on
|
||||
it. `/agenthub-doctor` reports the state, `/agenthub-open` builds the UI and prints the
|
||||
URL, `/agenthub-update` refreshes after a plugin update. This path needs Node
|
||||
**>= 22.5** (no native `better-sqlite3`, so the server uses `node:sqlite`).
|
||||
Details, including what to delete on uninstall:
|
||||
[`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
|
||||
## Install from a checkout
|
||||
|
||||
```bash
|
||||
npm run setup # root and client dependencies
|
||||
npm run build # builds the client into client/dist
|
||||
npm start # serves the built client and the API on :4820
|
||||
```
|
||||
/agenthub:agenthub-open
|
||||
```
|
||||
|
||||
Open <http://localhost:4820>.
|
||||
Dòng cuối in ra địa chỉ dashboard (mặc định <http://localhost:4820>) — mở nó
|
||||
trên trình duyệt là xong.
|
||||
|
||||
Development, with hot reload:
|
||||
Gặp lỗi thì gửi `/agenthub:agenthub-doctor` để tự kiểm tra và sửa.
|
||||
Cập nhật bản mới: `/agenthub:agenthub-update`.
|
||||
Không dùng thì tắt hẳn cho khỏi chạy ngầm: **Settings → Hệ thống → Thoát**, hoặc
|
||||
`agenthub stop` — mở Claude Code sẽ không tự bật lại; bật lại bằng `agenthub start`.
|
||||
Gỡ hẳn khỏi máy: **Settings → Hệ thống → Gỡ cài đặt**, hoặc `agenthub uninstall`
|
||||
(chỉ liệt kê) rồi `agenthub uninstall --yes` — xoá cả database của AgentHub; các cuộc
|
||||
trò chuyện của Claude Code/Codex, worktree và repository của bạn được giữ lại.
|
||||
Icon AgentHub cạnh đồng hồ (thanh trên cùng / menu bar / khay hệ thống) cũng có
|
||||
**Open**, **Quit** và **Start AgentHub**; ẩn nó trong menu của icon hoặc ở **Settings → Hệ thống**.
|
||||
|
||||
<a id="install-terminal"></a>
|
||||
|
||||
## Cách 2 — Cài bằng terminal (không qua Claude Code)
|
||||
|
||||
Chạy lần lượt trong terminal:
|
||||
|
||||
```bash
|
||||
npm run dev # server on :4820, Vite client on :5173
|
||||
git clone https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git
|
||||
cd AgentHub
|
||||
npm run setup # cài thư viện + lệnh agenthub
|
||||
npm run build # build giao diện
|
||||
npm start # chạy dashboard
|
||||
```
|
||||
|
||||
`DASHBOARD_PORT` overrides the port, `DASHBOARD_CLIENT_DIST` overrides where the
|
||||
built UI is served from (defaults to `client/dist`; the plugin install points it
|
||||
at its own runtime directory). `postinstall` writes the Claude Code hook entries
|
||||
that feed the dashboard — do not run it when the `agenthub` plugin is installed, or
|
||||
every event is counted twice.
|
||||
Mở <http://localhost:4820> trên trình duyệt.
|
||||
|
||||
### Run as a service (auto-start on boot)
|
||||
|
||||
`npm start` only runs while that terminal (or `agenthub start`'s background
|
||||
process) is alive — a reboot loses it. `agenthub service install` sets up a
|
||||
systemd `--user` service (Linux) or a LaunchAgent (macOS) instead, so `:4820` is up again on its own after
|
||||
every reboot, with no manual step and no Claude Code (or any AI tool) needed
|
||||
to trigger it:
|
||||
`npm start` tắt khi đóng terminal. Muốn dashboard tự chạy mỗi lần bật máy:
|
||||
|
||||
```bash
|
||||
npm run build # the service serves the built client, same as npm start
|
||||
agenthub service install # Linux: ~/.config/systemd/user/agenthub.service
|
||||
# macOS: ~/Library/LaunchAgents/io.smartgift.agenthub.plist
|
||||
agenthub service status # systemctl --user status / launchctl print
|
||||
agenthub service uninstall # disable and remove it
|
||||
agenthub service install
|
||||
```
|
||||
|
||||
On Linux, `agenthub service install` also tries `loginctl enable-linger` so the service
|
||||
starts on a cold boot, before anyone logs in; if that needs elevated rights on
|
||||
your machine it prints the `sudo loginctl enable-linger <user>` command to run
|
||||
once yourself. The same is a switch in the dashboard — Settings → About →
|
||||
**Start AgentHub automatically** (off by default; it enables or disables the
|
||||
unit and never stops the running server). Plugin installs get it only once
|
||||
that switch is on — see
|
||||
[Auto-start on boot](docs/PLUGINS.md#auto-start-on-boot) in `docs/PLUGINS.md`.
|
||||
> ⚠️ Chỉ chọn **một** trong hai cách. Cài cả hai thì mỗi sự kiện bị đếm hai lần.
|
||||
|
||||
## The CLI
|
||||
<a id="more-docs"></a>
|
||||
|
||||
`agenthub` is linked by `npm run setup`; otherwise call `node bin/agenthub.js`.
|
||||
## Tài liệu thêm
|
||||
|
||||
```bash
|
||||
agenthub status # is the dashboard up
|
||||
agenthub start # start it in the background and wait for healthy
|
||||
agenthub service install # auto-start on boot via systemd --user (Linux)
|
||||
agenthub sessions # recent sessions
|
||||
agenthub workspaces # workspaces with stage and progress
|
||||
agenthub workspace pipeline # this lane's pipeline template, or switch it
|
||||
agenthub workspace node <id> # run ONE pipeline node's own command (no id: list them)
|
||||
agenthub stage <name> # declare the current lane's stage
|
||||
agenthub tail # live event feed
|
||||
```
|
||||
- README tiếng Anh: [README.en.md](README.en.md)
|
||||
- Giới thiệu đầy đủ tính năng: [docs/OVERVIEW.vi.md](docs/OVERVIEW.vi.md) ·
|
||||
English: [docs/OVERVIEW.md](docs/OVERVIEW.md)
|
||||
- Cài đặt chi tiết, xử lý sự cố: [INSTALL.md](INSTALL.md)
|
||||
- Plugin: [docs/PLUGINS.md](docs/PLUGINS.md)
|
||||
- Lệnh `agenthub`: [docs/CLI.md](docs/CLI.md)
|
||||
|
||||
`agenthub --help` lists the rest.
|
||||
|
||||
## Workspaces
|
||||
|
||||
A workspace is a working directory the dashboard watches. Two kinds:
|
||||
|
||||
- **adopted** — a directory you already had. The dashboard only reads it; it is
|
||||
never reset or deleted.
|
||||
- **managed** — a git worktree the dashboard created under `LANES_ROOT`. It may
|
||||
reset that worktree, behind a three-check destroy guard and a counted preflight
|
||||
the caller has to echo back. Removing a workspace — either kind — deletes only the
|
||||
dashboard's record of it: the working tree, its branch and its database stay,
|
||||
for you to delete yourself.
|
||||
|
||||
```bash
|
||||
agenthub workspace add --cwd /path/to/repo --title "My feature" # adopt
|
||||
agenthub workspace add --repo /path/to/repo --slug my-feature # managed worktree
|
||||
```
|
||||
|
||||
Every workspace also carries the CLI that drives it — `claude` (the default) or
|
||||
`codex` — picked in the Add-workspace modal and fixed for the life of the workspace. A
|
||||
Codex workspace's run panel offers Codex's own sandbox, approval and reasoning-effort
|
||||
choices, and resumes with `codex resume --last`. Codex sessions and token usage
|
||||
are read from Codex's own rollout journals: every Codex session (a workspace's or
|
||||
not) appears in Sessions with a **Codex** badge and is priced like any other,
|
||||
with OpenAI rates filled in automatically (Settings → Codex → Overview). For a
|
||||
live feed, install **Codex hooks** from the same tab: the next time Codex starts
|
||||
it asks you to trust them (**Trust all and continue**), and from then on each
|
||||
tool call lands in the session's timeline, a codex workspace turns running/idle as
|
||||
each turn starts and ends, and "Codex needs your permission" shows on the workspace
|
||||
card and as a notification while Codex waits for an approval. A Codex workspace also
|
||||
carries **no pipeline** — no stage, no pipeline map, nothing inferred or
|
||||
advanced; without hooks the dashboard mirrors its running/idle status from the
|
||||
rollout journals on disk (`$CODEX_HOME/sessions/**/rollout-*.jsonl`, polled
|
||||
every `DASHBOARD_CODEX_POLL_MS` ms, default 15000).
|
||||
|
||||
An **Antigravity** workspace runs Antigravity's `agy` CLI. Its sessions and token
|
||||
usage are read from agy's own conversation files
|
||||
(`~/.gemini/antigravity-cli/conversations/<id>.db`, every
|
||||
`DASHBOARD_ANTIGRAVITY_POLL_MS` ms, default 15000): every agy CLI session
|
||||
appears in Sessions with an **Antigravity** badge, priced with Gemini rates that
|
||||
are filled in automatically (Settings → Antigravity). **Antigravity hooks**, in
|
||||
the same tab, add a live feed: each tool call in the session's timeline and a
|
||||
workspace that turns running/idle as a run starts and ends. agy asks for no trust,
|
||||
and it has no hook for a permission prompt, so an Antigravity workspace never shows
|
||||
"needs your permission". agy fires no hook when it exits, nor when a run ends
|
||||
on an API error or a permission refused in headless mode, so a session gone
|
||||
quiet is marked completed and its workspace set idle — after ten minutes of quiet
|
||||
for a session the hooks report on (a long tool call writes nothing, and after a
|
||||
Stop agy may still be waiting for you), after two minutes for one without
|
||||
hooks. Antigravity workspaces carry no pipeline either.
|
||||
|
||||
**Cursor** sessions — the Cursor CLI's and the Cursor editor's agent chats — are
|
||||
read from Cursor's agent transcripts
|
||||
(`~/.cursor/projects/*/agent-transcripts/`, every `DASHBOARD_CURSOR_POLL_MS` ms,
|
||||
default 15000) and appear in Sessions with a **Cursor CLI** badge. Cursor writes
|
||||
no token counts or model ids there, so a Cursor session shows no tokens and no
|
||||
cost.
|
||||
|
||||
**Pipelines are dormant.** AgentHub ships no pipeline template, so a workspace has no
|
||||
stages until you drop a template JSON into `DASHBOARD_PIPELINES_DIR` (see
|
||||
[`docs/LANES.md`](docs/LANES.md#custom-pipeline-templates)); until then
|
||||
`agenthub stage` answers `ENOPIPELINE` and the Workspace hides the pipeline map.
|
||||
Further development is planned.
|
||||
|
||||
The declared stage comes from `agenthub stage`. The inferred stage comes from tool
|
||||
events and expires after `DETECTION_TTL_MS` (default 5 minutes), so a workspace can
|
||||
move backwards between work sessions. Detection never writes the declared stage,
|
||||
and an inferred node never renders as done.
|
||||
|
||||
A workspace's pipeline template can be switched after creation — `agenthub workspace
|
||||
pipeline <template>` from the terminal, or the pipeline-template picker next to
|
||||
the workspace's title in the Workspace detail panel. Both re-resolve the workspace's
|
||||
current declared stage against the new template's nodes and warn if it no
|
||||
longer matches one.
|
||||
|
||||
A workspace can also run **its own application stack**, isolated per workspace, when its
|
||||
repository declares a profile at `<repo>/.agenthub/profile/` — a `profile.env` of
|
||||
declarations plus shell hooks the dashboard calls. Each workspace gets a slot, and its
|
||||
ports and per-workspace directories derive from it:
|
||||
|
||||
```bash
|
||||
agenthub workspace up # boot the stack of the lane owning this directory
|
||||
agenthub workspace runtime # slot, ports, service health
|
||||
agenthub workspace logs api # tail a service log
|
||||
agenthub workspace down
|
||||
```
|
||||
|
||||
Services are fully detached, so restarting the dashboard never stops a running
|
||||
workspace. This is resource namespacing on the host, not a container: workspaces run as the
|
||||
same user and share the network.
|
||||
|
||||
[`docs/LANES.md`](docs/LANES.md) has the pipeline model, the destroy guard, the
|
||||
preflight contract, the Workspace page, the git surface (`GET /api/lanes/:id/git`,
|
||||
`/git/changes`, `/git/log`, `/git/file`, `/git/ls`, `/git/tree`,
|
||||
`POST /:id/git/:action`), and the full
|
||||
runtime/profile contract.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
npm run test:server # node:test
|
||||
npm run test:client # Vitest
|
||||
```
|
||||
|
||||
Both must be green before a commit. The pre-commit hook (`.husky/pre-commit`)
|
||||
runs Prettier, then the tests the commit needs: both full suites for a big or
|
||||
risky commit (over 10 code files, a dependency, build/test config or the hook
|
||||
itself, a deleted file), only the related tests for a small one (`vitest
|
||||
related` for `client/`, the server test files that name each other staged
|
||||
module), nothing for docs alone. `AGENTHUB_FULL_TESTS=1 git commit …` forces
|
||||
both full suites.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Claude Code plugins shipped with the dashboard |
|
||||
|
||||
## Docs
|
||||
|
||||
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — request flow, schema, WebSocket surface
|
||||
- [`docs/LANES.md`](docs/LANES.md) — workspaces, pipelines, stage detection
|
||||
- [`docs/API.md`](docs/API.md) — REST endpoints (`openapi.yaml` is generated)
|
||||
- [`docs/DATABASE.md`](docs/DATABASE.md) — tables and migrations
|
||||
- [`INSTALL.md`](INSTALL.md)
|
||||
- [`CLAUDE.md`](CLAUDE.md) — the rules an agent working in this repo must follow
|
||||
Internal build — all rights reserved.
|
||||
|
||||
+11
-11
File diff suppressed because one or more lines are too long
+619
-5
@@ -40,7 +40,7 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
|
||||
// Resolve the repo root relative to this script's REAL location so the CLI
|
||||
// works both from a checkout (./bin/agenthub.js) and through the global symlink
|
||||
@@ -160,7 +160,13 @@ async function api(method, pathname, body, options = {}) {
|
||||
const form = body instanceof FormData;
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers: body && !form ? { "Content-Type": "application/json" } : undefined,
|
||||
headers: {
|
||||
...(body && !form ? { "Content-Type": "application/json" } : {}),
|
||||
// Server mode: inside a dashboard pane, act as the workspace's owner.
|
||||
...(process.env.AGENTHUB_PANE_TOKEN
|
||||
? { "x-agenthub-pane-token": process.env.AGENTHUB_PANE_TOKEN }
|
||||
: {}),
|
||||
},
|
||||
body: body ? (form ? body : JSON.stringify(body)) : undefined,
|
||||
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
||||
});
|
||||
@@ -1391,14 +1397,36 @@ async function cmdReinstallHooks() {
|
||||
* built client) and wait until /api/health answers. No-ops with a pointer to
|
||||
* the live URL when a server is already up. The child is fully detached with
|
||||
* its output appended to data/agenthub-server.log, so closing this terminal does
|
||||
* not stop the dashboard; stop it later with `kill <pid>` (the PID is
|
||||
* printed and registered in ~/.claude/.agenthub.json).
|
||||
* not stop the dashboard; stop it later with `agenthub stop`.
|
||||
*/
|
||||
async function cmdStart(flags) {
|
||||
// A Quit (`agenthub stop`) held the plugin bootstrap off; starting ends it.
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).clearQuit();
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`);
|
||||
return;
|
||||
}
|
||||
// A plugin install starts the way a Claude Code session does: its bootstrap
|
||||
// knows the runtime dir, the UI bundle and the autostart unit.
|
||||
if (/[\\/]\.claude[\\/]plugins[\\/]cache[\\/]/.test(REPO_ROOT)) {
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"), "--worker"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
}
|
||||
);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (await serverIsUp()) {
|
||||
console.log(`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())}`);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.error(c.red("✖ Server did not become healthy within 30 s — run /agenthub-doctor"));
|
||||
process.exit(1);
|
||||
}
|
||||
const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html");
|
||||
if (!fs.existsSync(clientDist)) {
|
||||
console.error(c.red("✖ client/dist is missing — the production server needs a built client."));
|
||||
@@ -1441,7 +1469,7 @@ async function cmdStart(flags) {
|
||||
console.log(
|
||||
`${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}`
|
||||
);
|
||||
console.log(c.dim(` Stop it with: kill ${child.pid}`));
|
||||
console.log(c.dim(" Stop it with: agenthub stop"));
|
||||
return;
|
||||
}
|
||||
tick++;
|
||||
@@ -1457,6 +1485,79 @@ async function cmdStart(flags) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quit AgentHub (issue #65): every lane pane is killed, the server stops, and
|
||||
* the plugin bootstrap starts none until `agenthub start` — or a boot/login
|
||||
* through an enabled autostart unit (`server/lib/quit.js`).
|
||||
*/
|
||||
async function cmdStop() {
|
||||
if (!(await serverIsUp())) {
|
||||
require(path.join(REPO_ROOT, "server", "lib", "quit.js")).markQuit();
|
||||
console.log(
|
||||
`${c.dim("○")} Dashboard is not running — it stays stopped until ${c.bold("agenthub start")}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const res = await post("/api/settings/quit", undefined, { allowError: true });
|
||||
if (res?.status) {
|
||||
// Server mode: a shell on the host carries no login, and Quit is an admin's.
|
||||
if (res.data?.error?.code === "EAUTH" || res.data?.error?.code === "EADMIN") {
|
||||
console.error(
|
||||
`${c.red("✖ This dashboard runs in server mode: Quit needs an admin login.")}\n` +
|
||||
" Log in as an admin and use Settings → System → Quit AgentHub, or stop its service:\n" +
|
||||
` ${c.bold("sudo systemctl stop agenthub")} ${c.dim("# system unit (one Linux user per account)")}\n` +
|
||||
` ${c.bold("systemctl --user stop agenthub")} ${c.dim("# per-user unit")}`
|
||||
);
|
||||
} else {
|
||||
const msg = res.data?.error?.message || `HTTP ${res.status}`;
|
||||
console.error(c.red(`✖ POST /api/settings/quit → ${msg}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
const { killed } = res;
|
||||
const deadline = Date.now() + 15_000;
|
||||
while ((await serverIsUp()) && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
}
|
||||
if (await serverIsUp()) {
|
||||
console.error(c.red(`✖ The dashboard at ${baseUrl()} is still answering after 15 s`));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(
|
||||
`${c.green("✔")} AgentHub stopped${killed ? ` — ${killed} terminal session(s) closed` : ""}. Start it again with: ${c.bold("agenthub start")}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall AgentHub (scripts/uninstall.js): without `--yes` it only lists what
|
||||
* would go. Needs no server — it is the thing being removed.
|
||||
*/
|
||||
function cmdUninstall(flags) {
|
||||
const settingsEnv = require(path.join(REPO_ROOT, "server", "lib", "settings-env.js"));
|
||||
settingsEnv.loadEnvFiles({ owned: [path.join(REPO_ROOT, ".env")] });
|
||||
const uninstall = require(path.join(REPO_ROOT, "scripts", "uninstall.js"));
|
||||
if (flags.yes !== true) {
|
||||
const steps = uninstall.plan();
|
||||
if (!steps.length) return console.log(`${c.dim("○")} Nothing of AgentHub's is left here`);
|
||||
console.log(c.bold("agenthub uninstall --yes would:"));
|
||||
for (const s of steps) {
|
||||
console.log(` ${c.red("✖")} ${s.label}`);
|
||||
for (const i of s.items) console.log(c.dim(` ${i}`));
|
||||
}
|
||||
console.log(
|
||||
c.dim(
|
||||
" Kept: the AI tools' own conversations, workspace worktrees and branches, your repos."
|
||||
)
|
||||
);
|
||||
console.error(c.red("Nothing was removed. Re-run with --yes: agenthub uninstall --yes"));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const r = uninstall.run({ onLine: (l) => console.log(l) });
|
||||
console.log(c.dim(`Log: ${r.log}`));
|
||||
if (!r.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs/removes a systemd `--user` service (Linux) or a LaunchAgent
|
||||
* (macOS) so the dashboard survives reboots without anything (Claude Code
|
||||
@@ -2303,6 +2404,17 @@ async function cmdLockAcquire(args) {
|
||||
console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`);
|
||||
return;
|
||||
}
|
||||
// Only 409 means "held by someone else"; any other answer (a refused
|
||||
// name, a server error) will not change by waiting.
|
||||
if (result.status !== 409) {
|
||||
console.error(
|
||||
c.red(
|
||||
`✖ ${result.data?.error?.message || `could not acquire lock (HTTP ${result.status})`}`
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastPrinted >= 60_000) {
|
||||
const waited = Math.floor((Date.now() - startedAt) / 1000);
|
||||
console.log(
|
||||
@@ -2714,11 +2826,36 @@ const COMMAND_GROUPS = [
|
||||
[
|
||||
["status", "", "Up/down indicator for the dashboard server"],
|
||||
["start", "[--port N]", "Start the server in the background and wait for healthy"],
|
||||
[
|
||||
"stop",
|
||||
"",
|
||||
"Quit: close every workspace terminal and stop the server until `agenthub start`",
|
||||
],
|
||||
[
|
||||
"uninstall",
|
||||
"[--yes]",
|
||||
"Remove AgentHub from this machine, its database included (lists first without --yes)",
|
||||
],
|
||||
[
|
||||
"service",
|
||||
"install|uninstall|status [--port N]",
|
||||
"Auto-start on boot via systemd --user (Linux)",
|
||||
],
|
||||
[
|
||||
"server",
|
||||
"setup|off|status [--hostname name] [--admin name]",
|
||||
"Server mode: logins for a shared dashboard (docs/SERVER-MODE.md)",
|
||||
],
|
||||
[
|
||||
"users",
|
||||
"list|add|passwd|role|disable|enable|assign|audit [name]",
|
||||
"Server mode's accounts (add: --role admin|analyst|developer)",
|
||||
],
|
||||
[
|
||||
"accounts",
|
||||
"list|add|remove [name] [--tool codex]",
|
||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
||||
],
|
||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||
],
|
||||
],
|
||||
@@ -2916,6 +3053,11 @@ const COMMAND_GROUPS = [
|
||||
"",
|
||||
"Run the read-only MCP server on stdio (claude mcp add agenthub -- agenthub mcp)",
|
||||
],
|
||||
[
|
||||
"tray",
|
||||
"",
|
||||
"Show AgentHub's icon in the OS status area now (the server keeps it up; Settings → System hides it)",
|
||||
],
|
||||
["statusline", "", "Render the Claude Code statusline from the JSON on stdin"],
|
||||
[
|
||||
"statusline install|uninstall",
|
||||
@@ -3668,6 +3810,9 @@ const COMMANDS = (() => {
|
||||
const SUBCOMMANDS = {
|
||||
alerts: ["ack", "ack-all"],
|
||||
service: ["install", "uninstall", "status"],
|
||||
server: ["setup", "off", "status"],
|
||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||
accounts: ["list", "add", "remove"],
|
||||
pricing: ["set", "delete", "reset"],
|
||||
webhooks: ["test"],
|
||||
import: ["rescan", "path"],
|
||||
@@ -3708,6 +3853,463 @@ 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 === "install-system") return installSystem(flags, settingsEnv);
|
||||
if (sub === "tools") {
|
||||
// S3/S4: copy this install's scripts and every installed AI tool under
|
||||
// /opt/agenthub, where every account's pane runs them (the server also
|
||||
// does this at start and after each install/update).
|
||||
const shared = require(path.join(REPO_ROOT, "server", "lib", "shared-tools.js"));
|
||||
await shared.publishApp(REPO_ROOT);
|
||||
const rows = await shared.publishAll();
|
||||
table(
|
||||
["tool", "shared copy"],
|
||||
rows.map((r) => [
|
||||
r.id,
|
||||
r.error ? c.red(r.error) : `${r.path}${r.copied ? "" : c.dim(" (unchanged)")}`,
|
||||
])
|
||||
);
|
||||
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] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub server install-system` — one Linux user per account (S3,
|
||||
* docs/SERVER-MODE.md). Run as the HOST account, never as root: it resolves
|
||||
* this install's paths as the host, hands them to the root half
|
||||
* (`scripts/server/install-system.sh`) through ONE `sudo`, points the system
|
||||
* unit at this install, and moves the dashboard from the `systemd --user`
|
||||
* unit to the system one (panes survive: KillMode=process). `--uninstall`
|
||||
* goes back to the user unit.
|
||||
*/
|
||||
async function installSystem(flags, settingsEnv) {
|
||||
const os = require("node:os");
|
||||
if (process.getuid && process.getuid() === 0) {
|
||||
throw new Error("run this as the account that runs the dashboard, not as root (it uses sudo)");
|
||||
}
|
||||
const which = (bin) =>
|
||||
spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
|
||||
const host = os.userInfo().username;
|
||||
const { getDataDir } = require(path.join(REPO_ROOT, "server", "lib", "claude-home.js"));
|
||||
const data = getDataDir();
|
||||
const port = String(process.env.DASHBOARD_PORT || "4820");
|
||||
const script = path.join(REPO_ROOT, "scripts", "server", "install-system.sh");
|
||||
const args = flags.uninstall
|
||||
? [script, "--uninstall", "--host", host]
|
||||
: [
|
||||
script,
|
||||
...["--host", host, "--node", process.execPath, "--root", REPO_ROOT, "--data", data],
|
||||
...["--port", port, "--tmux", which("tmux"), "--git", which("git")],
|
||||
...(which("gh") ? ["--gh", which("gh")] : []),
|
||||
];
|
||||
if (flags["dry-run"]) {
|
||||
console.log(["sudo", "bash", ...args].join(" "));
|
||||
return;
|
||||
}
|
||||
const userUnit = (...a) => spawnSync("systemctl", ["--user", ...a, "agenthub.service"]);
|
||||
// What either unit runs: this install's node and server, with its env.
|
||||
const runtime = path.join(data, "runtime");
|
||||
const clientDist = fs.existsSync(path.join(REPO_ROOT, "client", "dist", "index.html"))
|
||||
? path.join(REPO_ROOT, "client", "dist")
|
||||
: path.join(runtime, "client-dist");
|
||||
const env = { NODE_ENV: "production", PATH: process.env.PATH || "", DASHBOARD_PORT: port };
|
||||
env.DASHBOARD_CLIENT_DIST = clientDist;
|
||||
if (!fs.existsSync(path.join(REPO_ROOT, "node_modules"))) {
|
||||
env.NODE_PATH = path.join(runtime, "node_modules");
|
||||
}
|
||||
const serverArgv = [process.execPath, path.join(REPO_ROOT, "server", "index.js")];
|
||||
if (flags.uninstall) {
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system --uninstall failed");
|
||||
}
|
||||
// Repoint the user unit first: the build it named may have been pruned
|
||||
// while the system unit was the one protected.
|
||||
require(path.join(REPO_ROOT, "scripts", "systemd-service.js")).writeUserService({
|
||||
argv: serverArgv,
|
||||
env,
|
||||
});
|
||||
userUnit("enable", "--now");
|
||||
console.log(`${c.green("✔")} back on the systemd --user unit`);
|
||||
return;
|
||||
}
|
||||
if (spawnSync("sudo", ["bash", ...args], { stdio: "inherit" }).status !== 0) {
|
||||
throw new Error("install-system failed — nothing was started");
|
||||
}
|
||||
// The unit reads node, the plugin root and the env from a file this account
|
||||
// owns (the bootstrap repoints it after an update without root).
|
||||
const sys = require(path.join(REPO_ROOT, "scripts", "systemd-system-service.js"));
|
||||
const wrote = sys.writeUserService({ argv: serverArgv, env });
|
||||
if (wrote === "failed" || wrote === "unavailable") {
|
||||
throw new Error(`could not write ${sys.envPath()} (${wrote})`);
|
||||
}
|
||||
settingsEnv.writeSetting(
|
||||
"AGENTHUB_USERS_ROOT",
|
||||
require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js")).serverConf().usersRoot
|
||||
);
|
||||
// Hand over: the user unit stops (its panes stay), and so does a dashboard
|
||||
// started without one (detached) — else the system unit finds the port
|
||||
// taken and the old process, without the S3 groups, keeps serving.
|
||||
userUnit("disable", "--now");
|
||||
const boot = require(path.join(REPO_ROOT, "scripts", "plugin-bootstrap.js"));
|
||||
const sysUp = () =>
|
||||
spawnSync("systemctl", ["is-active", "agenthub.service"], {
|
||||
encoding: "utf8",
|
||||
}).stdout.trim() === "active";
|
||||
// A re-run while the system unit serves: that server is the one to keep.
|
||||
if (!sysUp()) boot.stopDashboard();
|
||||
for (let i = 0; i < 50 && !sysUp() && boot.livePids().length; i++) {
|
||||
spawnSync("sleep", ["0.2"]);
|
||||
}
|
||||
if (!sysUp() && boot.livePids().length) {
|
||||
throw new Error("a dashboard is still running — stop it first");
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "enable", "agenthub.service"]).status !== 0) {
|
||||
console.log(c.yellow("! could not enable agenthub.service for boot"));
|
||||
}
|
||||
if (spawnSync("sudo", ["-n", "systemctl", "start", "--no-block", "agenthub.service"]).status) {
|
||||
throw new Error("could not start agenthub.service — `sudo systemctl status agenthub`");
|
||||
}
|
||||
spawnSync("sleep", ["3"]);
|
||||
if (!sysUp()) {
|
||||
throw new Error("agenthub.service did not stay up — `sudo journalctl -u agenthub -n 50`");
|
||||
}
|
||||
console.log(`${c.green("✔")} the dashboard now runs as the system unit agenthub.service`);
|
||||
console.log(" Give the existing users their Linux accounts: agenthub users provision --all");
|
||||
console.log(
|
||||
c.dim(" Log out and back in once: your own session joins groups agenthub/agenthub-proc.")
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub users …` — server mode's accounts, written straight to the DB. */
|
||||
async function cmdUsers(sub, positional, flags) {
|
||||
const { auth } = serverAuth();
|
||||
const name = positional[0];
|
||||
const need = () => {
|
||||
if (!name) throw new Error(`usage: agenthub users ${sub} <username>`);
|
||||
};
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
case "list":
|
||||
table(
|
||||
["id", "username", "role", "state"],
|
||||
auth.listUsers().map((u) => [u.id, u.username, u.role, u.disabled ? "disabled" : "active"])
|
||||
);
|
||||
return;
|
||||
case "add": {
|
||||
need();
|
||||
const role = typeof flags.role === "string" ? flags.role : "developer";
|
||||
const user = await auth.createUser(name, await newPassword(`Password for ${name}`), role);
|
||||
console.log(`${c.green("✔")} ${user.username} added (${user.role})`);
|
||||
return;
|
||||
}
|
||||
case "passwd":
|
||||
need();
|
||||
await auth.setPassword(name, await newPassword(`New password for ${name}`));
|
||||
console.log(`${c.green("✔")} password changed — ${name}'s logins ended`);
|
||||
return;
|
||||
case "role":
|
||||
need();
|
||||
auth.setRole(name, positional[1]);
|
||||
console.log(`${c.green("✔")} ${name} is now ${positional[1]}`);
|
||||
return;
|
||||
case "assign":
|
||||
if (!name || !positional[1]) {
|
||||
throw new Error("usage: agenthub users assign <workspace id> <username>");
|
||||
}
|
||||
auth.assignWorkspace(name, positional[1]);
|
||||
console.log(`${c.green("✔")} workspace ${name} now belongs to ${positional[1]}`);
|
||||
return;
|
||||
case "audit": {
|
||||
const { listAudit } = require(path.join(REPO_ROOT, "server", "lib", "audit.js"));
|
||||
table(
|
||||
["when", "reader", "owner", "path"],
|
||||
listAudit(typeof flags.limit === "string" ? flags.limit : 50).map((a) => [
|
||||
a.at.replace("T", " ").slice(0, 19),
|
||||
a.reader,
|
||||
a.owner,
|
||||
a.path,
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "provision": {
|
||||
// S3: the Linux account of a user made before install-system (or after a
|
||||
// failed create). Idempotent: an existing member account is kept.
|
||||
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||
if (!linux.s3Enabled()) {
|
||||
throw new Error(
|
||||
"one Linux user per account is not installed (agenthub server install-system)"
|
||||
);
|
||||
}
|
||||
const names = flags.all
|
||||
? auth
|
||||
.listUsers()
|
||||
.filter((u) => !u.disabled)
|
||||
.map((u) => u.username)
|
||||
: (need(), [name]);
|
||||
const memberClaude = require(path.join(REPO_ROOT, "server", "lib", "member-claude.js"));
|
||||
for (const n of names) {
|
||||
// One user that cannot be provisioned (a name Linux refuses, a folder
|
||||
// already there) is reported and skipped — never the end of the run.
|
||||
try {
|
||||
console.log(`${c.green("✔")} ${linux.provision("create", n)}`);
|
||||
} catch (e) {
|
||||
console.error(c.red(`✖ ${n}: ${e.message}`));
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
// Opt-in plugin baseline (/etc/agenthub/plugin-baseline.json); prints per entry.
|
||||
await memberClaude.applyPluginBaseline(n);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "disable":
|
||||
case "enable":
|
||||
need();
|
||||
auth.setDisabled(name, sub === "disable");
|
||||
console.log(`${c.green("✔")} ${name} ${sub}d`);
|
||||
return;
|
||||
default:
|
||||
console.error(
|
||||
c.red(
|
||||
"✖ Usage: agenthub users list | add <name> [--role admin|analyst|developer] | passwd <name> | role <name> <role> | disable <name> | enable <name> | provision <name>|--all | assign <workspace id> <name> | audit [--limit N]"
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub accounts … --tool codex` — the Codex account pool (server/lib/codex-pool.js). */
|
||||
async function cmdCodexAccounts(sub, name, flags) {
|
||||
const pool = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (sub === "add") {
|
||||
let added;
|
||||
if (flags["api-key"]) {
|
||||
added = pool.addAccount(name, { apiKey: await askPassword(`OpenAI API key for ${name}: `) });
|
||||
} else if (typeof flags.from === "string") {
|
||||
// A `codex login` home (or its auth.json) — read here, on the host's shell.
|
||||
const from = path.resolve(flags.from);
|
||||
const file = fs.statSync(from).isDirectory() ? path.join(from, "auth.json") : from;
|
||||
added = pool.addAccount(name, { auth: JSON.parse(fs.readFileSync(file, "utf8")) });
|
||||
console.log(c.dim(` You can delete ${file} now; the pool keeps its own copy.`));
|
||||
} else {
|
||||
throw new Error(
|
||||
"usage: agenthub accounts add <name> --tool codex --api-key | --from <dir>\n" +
|
||||
" A ChatGPT account: `CODEX_HOME=$(mktemp -d) codex login` signed in as it, then --from that dir."
|
||||
);
|
||||
}
|
||||
console.log(`${c.green("✔")} ${added.name} (${added.kind}) added`);
|
||||
console.log(c.dim(" New Codex 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") {
|
||||
throw new Error("usage: agenthub accounts list | add <name> | remove <name> --tool codex");
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
a.name,
|
||||
a.kind,
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.quota
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
a.full ? "full" : "ok",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||
async function cmdAccounts(sub, positional, flags = {}) {
|
||||
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> [--tool codex]`);
|
||||
}
|
||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||
throw new Error("--tool is claude (the default) or codex");
|
||||
}
|
||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||
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",
|
||||
])
|
||||
);
|
||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||
if (codex.poolStatus().length) await cmdCodexAccounts("list", null, flags);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** Run one parsed command. Returns the handler's promise; may throw
|
||||
* ServerDownError (for the caller's offline routing) or call process.exit
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
@@ -3723,8 +4325,18 @@ async function runCommand(argv) {
|
||||
return cmdStatus();
|
||||
case "start":
|
||||
return cmdStart(flags);
|
||||
case "stop":
|
||||
return cmdStop();
|
||||
case "uninstall":
|
||||
return cmdUninstall(flags);
|
||||
case "service":
|
||||
return cmdService(positional[0], flags);
|
||||
case "server":
|
||||
return cmdServer(positional[0], flags);
|
||||
case "users":
|
||||
return cmdUsers(positional[0], positional.slice(1), flags);
|
||||
case "accounts":
|
||||
return cmdAccounts(positional[0], positional.slice(1), flags);
|
||||
case "stats":
|
||||
return cmdStats();
|
||||
case "kanban":
|
||||
@@ -3852,6 +4464,8 @@ async function runCommand(argv) {
|
||||
return;
|
||||
}
|
||||
return require(path.join(REPO_ROOT, "statusline", "statusline.js")).main();
|
||||
case "tray":
|
||||
return require(path.join(REPO_ROOT, "scripts", "tray.js")).main();
|
||||
case "open":
|
||||
return cmdOpen();
|
||||
case "version":
|
||||
|
||||
+17
-9
@@ -52,13 +52,13 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#e8e5da`) and a `.card` is brighter than the page it sits on (`surface-3`, `#f5f3ec`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
Other rules that fall out of this:
|
||||
- **Light mode's hue family is warm, dark mode's is cool.** The light ramp is Claude's own ivory/terracotta (`#e8e5da` → `#fcfbf7`, warm neutrals, warm `--shadow-card`), not Radix slate: a cool near-white page reads as glare at any lightness, because the brightest thing on screen is also the bluest. Dark mode stays slate + blue — a warm accent on a near-black page is a different problem with a different answer.
|
||||
- Light `--accent` is **not** the dark one, in hue or in step. Dark's `#0090ff` is 3.3:1 on white; Claude's brand terracotta `#cc785c` is 3.1:1 on ivory and 3.3:1 under white text, so it works as a large fill and fails as a link or a button label. Light mode uses it three steps down, `#924224` (it was `#a34a28`, which missed 4.5:1 as text on its own selected chip).
|
||||
- **The light Default is IntelliJ Light's palette** (`#eceef2` → `#ffffff` neutrals, blue accent, IntelliJ's editor syntax colours); the separate IntelliJ Light theme was folded into it.
|
||||
- Light `--accent` is **not** the dark one: dark's `#0090ff` is 3.3:1 on white. Light mode uses IntelliJ's `#285ac0`, dark enough to clear 4.5:1 as text and under white.
|
||||
- **White text sits on `accent-solid`, never on `accent`.** `--accent` is a TEXT colour; in the dark themes it is the light step (Default `#70b8ff`, Orca Dark `#6aa0f8`) that reads on a near-black card and is far too light to carry white. Every theme also defines `--accent-solid`/`--accent-solid-hover` (Tailwind `bg-accent-solid`) for the fill behind white text — `.btn-primary`, a Switch's on state, a Checkbox tick — darkened until white clears 4.5:1 (Default `#0076d1`). In the light themes the text accent is already dark enough, so `--accent-solid` is simply `var(--accent)`.
|
||||
- `--fg-muted` carries real content (timestamps, counts), so it is held at ≥4.5:1 (`#686359`, 5.4:1 on a card), not at the decorative value the Radix step suggests.
|
||||
- Code colouring goes through the `syntax.*` tokens (`--syn-*`), never fixed Tailwind steps: the dark set is pastel (300-level), the light set is the same hues four steps darker, because a pastel keyword on a light code block is unreadable.
|
||||
@@ -199,13 +199,14 @@ client/
|
||||
│ │ │ ├── ClaudeAgentHubSettings.tsx # Claude Code → Overview: CLAUDE_HOME, hook status, pricing
|
||||
│ │ │ ├── HomeDirSetting.tsx # CLAUDE_HOME / CODEX_HOME editor (env-owned warning)
|
||||
│ │ │ ├── CodexHooksSettings.tsx # Codex → Overview → Codex hooks card
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ ├── ReportSettings.tsx # Settings → Snapshots & reports: scheduled report definitions, Run now, run history + downloads
|
||||
│ │ │ └── AntigravityHooksSettings.tsx # Antigravity → Antigravity hooks card
|
||||
│ │ └── workflows/ # Dashboard → Analytics → Workflow panels: own-pipeline stats + D3 analytics (11 files)
|
||||
│ │
|
||||
│ ├── pages/ # Route pages
|
||||
│ │ ├── Dashboard.tsx # Monitor / Analytics / Health tabs; Analytics' date range (Today/This week from Monday/This month from the 1st/7d/30d/90d/All/Custom, `?range=`, lib/dateRange.ts) narrows its headline, cost and token cards
|
||||
│ │ ├── QueryExplorer.tsx # /query: filter sessions / events / agents, sort, page, export CSV/JSON (GET /api/query)
|
||||
│ │ ├── History.tsx # /sessions tab shell (?view=): Sessions · Events (ActivityFeed) · Agents (AgentsTable)
|
||||
│ │ ├── AgentsTable.tsx # /sessions?view=agents (the old /query redirects here): filter agents, sort, page, export CSV/JSON (GET /api/query?entity=agents)
|
||||
│ │ ├── Sessions.tsx # Filterable table; shows each session's real name (synced live from the transcript), falls back to the short ID
|
||||
│ │ ├── SessionDetail.tsx # Agent tree + event timeline + Conversation tab (slash-command pills & output, inline rename markers) + Replay tab (EventReplay) + Snapshot action
|
||||
│ │ ├── SnapshotView.tsx # Read-only /snapshot/:token page over a frozen session payload
|
||||
@@ -258,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 · System<br/>Agents: AI tools, Claude Code, Codex, each catalog tool, AI Proxy<br/>Data: Database · Pricing · Snapshots & reports · Git (credentials, worktree suggestions)]
|
||||
|
||||
App --> Layout
|
||||
Layout --> Dashboard & Sessions & Detail & Feed & Settings
|
||||
@@ -504,7 +505,8 @@ function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="sessions" element={<History />} />
|
||||
<Route path="sessions/:id" element={<SessionDetail />} />
|
||||
<Route path="query" element={<QueryExplorer />} />
|
||||
{/* legacy path - the Query Explorer is now the Agents tab on /sessions */}
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
{/* legacy path - the activity feed is now a tab on /sessions */}
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route path="workflows" element={<Navigate to="/?tab=analytics&sub=workflow" replace />} />
|
||||
@@ -523,7 +525,7 @@ function App() {
|
||||
}
|
||||
```
|
||||
|
||||
The heavy pages — `Workspace` (xterm, the diff viewer), `SettingsHub` and `SessionDetail` — are `React.lazy` imports in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so they load on first visit instead of in the initial bundle. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
Every page — `Dashboard`, `History`, `Workspace` (the diff viewer), `SettingsHub`, `SessionDetail`, `SnapshotView` — is a `React.lazy` import in `App.tsx`, each route wrapped in `<Suspense>` (the `Page` helper), so it loads on first visit instead of in the initial bundle; `main.tsx` starts the Workspace chunk early on a `/workspace` deep link. xterm and its addons load with the first terminal (`loadXterm` in `TerminalView.tsx`), the sidebar's add/destructive workspace modals on first open, and Sagi only while it is enabled (`SagiMount`). Only the active UI language is fetched (`i18n/index.ts`, one lazy chunk per language; `main.tsx` renders once it is in). `vite build` writes hidden source maps and a `.br`/`.gz` sibling of every text asset ≥ 1 KB. `App.tsx` also mounts the app-wide `<Toaster />` and `<ConfirmHost />` once, beside `<GlobalTooltip />`.
|
||||
|
||||
### Navigation Flow
|
||||
|
||||
@@ -791,6 +793,12 @@ in the file is shown as text, never run (no `rehype-raw`), links open in a new
|
||||
tab, and a leading YAML frontmatter block is shown as a code block. Styles are
|
||||
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
|
||||
|
||||
+14
-10
@@ -2,7 +2,8 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount, and
|
||||
src/lib/fontSize.ts). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
@@ -17,6 +18,9 @@
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
|
||||
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
|
||||
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -30,7 +34,7 @@
|
||||
<title>AgentHub - SmartGift Solution</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
@@ -45,23 +49,23 @@
|
||||
<meta property="og:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Track sessions, monitor agents, visualize analytics, manage cost tracking, and receive browser notifications - all with live WebSocket updates."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta property="og:image" content="/og-image.svg" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/svg+xml" />
|
||||
<meta property="og:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
|
||||
<!-- Twitter / X Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="AgentHub - SmartGift Solution" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Real-time monitoring platform for Claude Code agent activity. Sessions, Kanban board, analytics, cost tracking, browser notifications, and WebSocket push."
|
||||
content="Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time."
|
||||
/>
|
||||
<meta name="twitter:image" content="/og-image.svg" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - real-time Claude Code monitoring" />
|
||||
<meta name="twitter:image" content="/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="AgentHub - run, steer and track your AI coding agents" />
|
||||
<meta name="twitter:creator" content="@smartgift" />
|
||||
|
||||
<!-- JSON-LD structured data -->
|
||||
@@ -70,7 +74,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "AgentHub",
|
||||
"description": "Real-time monitoring platform for Claude Code agent activity. Tracks sessions, agents, tool events, token usage analytics, cost tracking, and browser notifications via native hooks with live WebSocket push.",
|
||||
"description": "Run, steer and track your AI coding agents in one browser tab: workspaces with live terminals for Claude Code, Codex and more, built-in Git, chat replay, usage and cost tracking, and alerts - local-first and real-time.",
|
||||
"applicationCategory": "DeveloperApplication",
|
||||
"operatingSystem": "Windows, macOS, Linux",
|
||||
"softwareVersion": "1.0.0",
|
||||
|
||||
Generated
+587
-548
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
||||
"@codemirror/view": "^6.43.13",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 411 KiB |
+72
-86
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 30 KiB |
+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
|
||||
|
||||
+63
-18
@@ -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,20 @@ 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 { usePermissions } from "./components/AuthGate";
|
||||
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 }))
|
||||
@@ -85,9 +88,9 @@ const SessionDetail = lazy(() =>
|
||||
const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
const QueryExplorer = lazy(() =>
|
||||
import("./pages/QueryExplorer").then((m) => ({ default: m.QueryExplorer }))
|
||||
);
|
||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +110,11 @@ function Page({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||
function ServerOnly({ children }: { children: ReactNode }) {
|
||||
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
@@ -135,6 +143,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);
|
||||
@@ -154,8 +166,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={
|
||||
@@ -172,14 +198,7 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="query"
|
||||
element={
|
||||
<Page>
|
||||
<QueryExplorer />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route path="query" element={<Navigate to="/sessions?view=agents" replace />} />
|
||||
<Route path="activity" element={<Navigate to="/sessions?view=events" replace />} />
|
||||
<Route
|
||||
path="workflows"
|
||||
@@ -199,6 +218,32 @@ export default function App() {
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="artifacts"
|
||||
element={
|
||||
<Page>
|
||||
<Artifacts />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
element={
|
||||
<ServerOnly>
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
</ServerOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -92,6 +92,8 @@ import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextM
|
||||
import { fmtCost, timeAgo } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
@@ -283,7 +285,9 @@ export function AlertsNotifications() {
|
||||
const { t } = useTranslation("alerts");
|
||||
const { t: ts } = useTranslation("settings");
|
||||
|
||||
const [tab, setTab] = useState<TabKey>("rules");
|
||||
// Rules and channels (webhooks) fire for every user's sessions: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const [tab, setTab] = useState<TabKey>(isAdmin ? "rules" : "activity");
|
||||
|
||||
// Rules
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
@@ -342,6 +346,7 @@ export function AlertsNotifications() {
|
||||
setLoadingAlerts(false);
|
||||
}
|
||||
}, [unackedOnly, t]);
|
||||
const [spinning, refreshAlerts] = useSpin(loadAlerts);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
try {
|
||||
@@ -359,8 +364,8 @@ export function AlertsNotifications() {
|
||||
}, [unackedOnly, alerts.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRules();
|
||||
}, [loadRules]);
|
||||
if (isAdmin) loadRules();
|
||||
}, [loadRules, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAlerts();
|
||||
@@ -502,7 +507,7 @@ export function AlertsNotifications() {
|
||||
(form.rule_type !== "budget" ||
|
||||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
|
||||
|
||||
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
const ALL_TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
|
||||
{
|
||||
key: "rules",
|
||||
label: ts("alertsHub.tabRules"),
|
||||
@@ -517,6 +522,7 @@ export function AlertsNotifications() {
|
||||
badge: unacked || undefined,
|
||||
},
|
||||
];
|
||||
const TABS = isAdmin ? ALL_TABS : ALL_TABS.filter((tb) => tb.key === "activity");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -1017,11 +1023,13 @@ export function AlertsNotifications() {
|
||||
label={t("feed.unackedOnly")}
|
||||
/>
|
||||
<button
|
||||
onClick={() => loadAlerts()}
|
||||
onClick={refreshAlerts}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost border border-border inline-flex items-center gap-1.5 text-xs"
|
||||
title={t("refresh")}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{unacked > 0 && (
|
||||
<button
|
||||
@@ -1082,7 +1090,7 @@ export function AlertsNotifications() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!alert.acknowledged_at && (
|
||||
{!alert.acknowledged_at && alert.can_ack !== false && (
|
||||
<button
|
||||
onClick={() => onAck(alert.id)}
|
||||
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors flex-shrink-0"
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* @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, `logout`, and
|
||||
* `updateUser` (Settings → Account's saved display name shows at once);
|
||||
* `usePermissions()` says what that user's role may do, so pages hide the rest.
|
||||
* Once an account is known (local mode is one), its own UI preferences are
|
||||
* loaded into this browser (`lib/userPrefs.ts`).
|
||||
* The login page follows the OS's light/dark (`applySystemTheme`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
type FormEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { BRAND_LOGO } from "../lib/brand";
|
||||
import { applySystemTheme } from "../hooks/useTheme";
|
||||
import { api, AUTH_REQUIRED_EVENT, type AuthState, type AuthUser } from "../lib/api";
|
||||
import { hydrate } from "../lib/userPrefs";
|
||||
|
||||
type AuthValue = {
|
||||
state: AuthState;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (patch: Partial<AuthUser>) => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthValue>({
|
||||
state: { mode: "local" },
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
});
|
||||
|
||||
/** The current login; `{mode: "local"}` outside server mode (and in tests). */
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
|
||||
/**
|
||||
* What the logged-in user may do — the server's rules (server/lib/auth.js,
|
||||
* scope.js) mirrored so the UI hides what would only be refused. Local mode:
|
||||
* everything. `isAdmin`: the host's own setup (Settings → System, updates,
|
||||
* AI tools, proxies, users, alert rules…). `seesAll`: every user's rows
|
||||
* (admin, analyst). `canEditLane`: an admin any workspace, everyone else
|
||||
* only their own — the one that matters is an analyst looking at another
|
||||
* user's.
|
||||
*/
|
||||
export function usePermissions() {
|
||||
const { state } = useAuth();
|
||||
const user = state.mode === "server" ? state.user : null;
|
||||
const local = state.mode !== "server";
|
||||
const isAdmin = local || user?.role === "admin";
|
||||
return {
|
||||
isAdmin,
|
||||
// Local mode: one person on their own machine — what only serves a
|
||||
// shared server (File Explorer, uploads, AgentHub's own git logins) is
|
||||
// hidden; their desktop and their own git config already do it.
|
||||
local,
|
||||
seesAll: local || (!!user && user.role !== "developer"),
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
isAdmin || user?.role === "developer" || (!!user && lane?.owner_id === user.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** The mode this browser last saw, so a local dashboard paints at once. */
|
||||
const MODE_KEY = "agenthub.authMode";
|
||||
const RETRY_MS = 2000;
|
||||
|
||||
function cachedState(): AuthState | null {
|
||||
try {
|
||||
return localStorage.getItem(MODE_KEY) === "local" ? { mode: "local" } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// null = still asking the server. A browser that last saw local mode starts
|
||||
// there and is corrected by the answer (or by a 401) if that changed.
|
||||
const [state, setState] = useState<AuthState | null>(cachedState);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: number | undefined;
|
||||
let alive = true;
|
||||
const ask = () => {
|
||||
api.auth
|
||||
.me()
|
||||
.then((next) => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, next.mode);
|
||||
} catch {
|
||||
/* storage blocked */
|
||||
}
|
||||
setState(next);
|
||||
})
|
||||
.catch((err: { status?: number; code?: string }) => {
|
||||
if (!alive) return;
|
||||
// An answer that is not this build's: an older server still running
|
||||
// under a freshly built UI (404, or its index.html → EBADRESPONSE), or
|
||||
// DASHBOARD_TOKEN's own 401 — neither is server mode, so render the
|
||||
// app as before and let it show its own state.
|
||||
if (
|
||||
err?.status === 404 ||
|
||||
err?.code === "EBADRESPONSE" ||
|
||||
(err?.status === 401 && err?.code !== "EAUTH")
|
||||
) {
|
||||
setState({ mode: "local" });
|
||||
return;
|
||||
}
|
||||
// No answer at all (a restart, a proxy's 502): ask again rather than
|
||||
// guess — "local" would hide a server-mode dashboard's login page.
|
||||
timer = window.setTimeout(ask, RETRY_MS);
|
||||
});
|
||||
};
|
||||
ask();
|
||||
// Any request answering 401 EAUTH means server mode with no valid login.
|
||||
const onRequired = () => setState({ mode: "server", user: null });
|
||||
window.addEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener(AUTH_REQUIRED_EVENT, onRequired);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// The signed-in account's own preferences (lib/userPrefs.ts): loaded once
|
||||
// per account, so a second login in this browser brings that account's.
|
||||
const account =
|
||||
state?.mode === "local"
|
||||
? "local"
|
||||
: state?.mode === "server" && state.user
|
||||
? `u${state.user.id}`
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (account) void hydrate();
|
||||
}, [account]);
|
||||
|
||||
// Stable, so a page may list it as an effect dependency.
|
||||
const updateUser = useCallback(
|
||||
(patch: Partial<AuthUser>) =>
|
||||
setState((s) =>
|
||||
s && s.mode === "server" && s.user ? { ...s, user: { ...s.user, ...patch } } : s
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
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, updateUser }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginPage({ onLoggedIn }: { onLoggedIn: (user: AuthUser) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Nobody is signed in, so no account's theme applies: follow the OS, live.
|
||||
useLayoutEffect(() => {
|
||||
applySystemTheme();
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
mq?.addEventListener?.("change", applySystemTheme);
|
||||
return () => mq?.removeEventListener?.("change", applySystemTheme);
|
||||
}, []);
|
||||
|
||||
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 relative w-full max-w-md overflow-hidden px-6 pb-8 pt-12 sm:px-10"
|
||||
>
|
||||
{/* Accent glow and a grid fading out from the top, drawn from the
|
||||
theme's own accent so it holds in every theme. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-56"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, rgb(var(--accent) / 0.12) 1px, transparent 1px), linear-gradient(to bottom, rgb(var(--accent) / 0.12) 1px, transparent 1px), radial-gradient(ellipse at top, rgb(var(--accent) / 0.16), transparent 70%)",
|
||||
backgroundSize: "48px 48px, 48px 48px, 100% 100%",
|
||||
backgroundPosition: "center top",
|
||||
maskImage: "radial-gradient(ellipse 70% 100% at top, black 30%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative space-y-5">
|
||||
<div className="text-center">
|
||||
<img src={BRAND_LOGO} alt="SmartGift" className="block h-auto w-full" />
|
||||
<h1 className="mt-5 text-3xl font-semibold tracking-tight text-fg-primary">
|
||||
{t("auth.title")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-fg-secondary">{t("auth.subtitle")}</p>
|
||||
</div>
|
||||
<label className="block space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<span>{t("auth.username")}</span>
|
||||
<input
|
||||
className="input h-11 w-full px-4"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
required
|
||||
placeholder={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="space-y-1.5 text-sm font-medium text-fg-primary">
|
||||
<label htmlFor="login-password">{t("auth.password")}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
className="input h-11 w-full pl-4 pr-11"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? t("auth.hidePassword") : t("auth.showPassword")}
|
||||
aria-pressed={showPassword}
|
||||
className="absolute inset-y-0 right-0 flex w-11 items-center justify-center rounded-r-lg text-fg-muted hover:text-fg-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{/* Inverted fill (text colour behind the surface colour): black in a
|
||||
light theme, light in a dark one, like the reference design. */}
|
||||
<button
|
||||
type="submit"
|
||||
className="h-11 w-full rounded-xl bg-fg-primary text-sm font-medium text-surface-0 shadow-sm transition-opacity hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t("auth.submitting") : t("auth.submit")}
|
||||
</button>
|
||||
<p className="text-center text-sm text-fg-secondary">{t("auth.noAccount")}</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -85,14 +85,14 @@ export function ConfirmModal({
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="relative w-full max-w-md rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex max-h-full w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={message ? messageId : undefined}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-5">
|
||||
<div className="flex min-h-0 items-start gap-3 overflow-y-auto p-5">
|
||||
{destructive && (
|
||||
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertTriangle className="w-4.5 h-4.5 text-status-danger" aria-hidden />
|
||||
|
||||
@@ -28,8 +28,6 @@ export function DocumentTitle() {
|
||||
title = t("workspace");
|
||||
} else if (path.startsWith("/settings")) {
|
||||
title = t("settings");
|
||||
} else if (path.startsWith("/query")) {
|
||||
title = t("query");
|
||||
} else if (path !== "/") {
|
||||
title = t("notFound");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @file ImageLightbox.tsx
|
||||
* @description One image shown full size over the whole page; a click or
|
||||
* Escape closes it. Rendered into `document.body`, so no pane, its stacking
|
||||
* layer or a header drawn over a pane can cover it — opened from inside a
|
||||
* pane's Chat view, a `fixed` overlay would otherwise stay in that pane's
|
||||
* layer, under anything stacked above it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [onClose]);
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={alt}
|
||||
data-testid="image-lightbox"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-[200] flex cursor-zoom-out items-center justify-center bg-black/60 p-8"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,8 @@
|
||||
* ## Internal dependencies
|
||||
* - `./Sidebar`
|
||||
* - `./UpdateNotifier`
|
||||
* - `./Sagi/Sagi`
|
||||
* - `./lanes/InterruptedSessionsPrompt` (the "resume interrupted sessions?" dialog)
|
||||
* - `./Sagi/SagiMount`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `Layout` — exported API; see TSDoc on the symbol for behavior.
|
||||
@@ -81,8 +82,11 @@ 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 { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||
|
||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
@@ -171,7 +175,10 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
{t("skipToContent")}
|
||||
</a>
|
||||
<UpdateNotifier />
|
||||
<Sagi />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
<OnboardingTour />
|
||||
{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;
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* user chose. A browser's picker cannot give a page that path, so the dialog
|
||||
* opens on the server's desktop — which is only this user's screen when the
|
||||
* browser runs on the same machine. Anywhere else (a LAN browser, a server
|
||||
* with no desktop or no zenity/kdialog) a folder falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure), and a file to typing
|
||||
* the path.
|
||||
* with no desktop or no zenity/kdialog) it falls back to the in-app
|
||||
* `FolderBrowseModal` (as it does on any other failure) — for a file field
|
||||
* too, which then lists files and picks one on click.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,12 +16,8 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { toast } from "../lib/toast";
|
||||
import { FolderBrowseModal } from "./lanes/FolderBrowseModal";
|
||||
|
||||
/** Server answers that mean "no native picker for this browser" — not a fault. */
|
||||
const FALLBACK = new Set(["EREMOTE", "EBADORIGIN", "ENODIALOG"]);
|
||||
|
||||
export function PathPickButton({
|
||||
kind = "dir",
|
||||
start,
|
||||
@@ -44,15 +40,10 @@ export function PathPickButton({
|
||||
try {
|
||||
const r = await api.lanes.pickPath(kind, start?.trim() || undefined);
|
||||
if (r.path) onPick(r.path);
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
// A folder always has the in-app browser to fall back on, whatever went
|
||||
// wrong with the native one (a crashed dialog, one already open).
|
||||
if (kind === "dir") setFallback(true);
|
||||
else if (code && FALLBACK.has(code)) toast.info(t("browse.typePath"));
|
||||
else {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
} catch {
|
||||
// The in-app browser is the fallback whatever went wrong with the
|
||||
// native one: a LAN browser (EREMOTE), no desktop, a crashed dialog.
|
||||
setFallback(true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -76,6 +67,7 @@ export function PathPickButton({
|
||||
<div className="contents" onClick={(e) => e.stopPropagation()}>
|
||||
<FolderBrowseModal
|
||||
open={fallback}
|
||||
kind={kind}
|
||||
initialPath={start?.trim() || undefined}
|
||||
onSelect={onPick}
|
||||
onClose={() => setFallback(false)}
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||
* chat bubbles read from the session's transcript. Follow-ups are typed into
|
||||
* that session's pane through a terminal attached OFF-SCREEN (`TerminalView`
|
||||
* in a fixed box left of the viewport — `submitToRun` needs a mounted,
|
||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
answerRunMenu,
|
||||
chooseRunOption,
|
||||
interruptRun,
|
||||
readRunMenu,
|
||||
submitToRun,
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
import { MarkdownContent } from "../conversation/MarkdownContent";
|
||||
|
||||
/** Tools whose session the chat can read back (a transcript AgentHub parses). */
|
||||
export const PET_CHAT_TOOLS = new Set(["claude", "codex"]);
|
||||
const CHAT_KEY = "agenthub.pet.chat";
|
||||
const POLL_MS = 1500;
|
||||
const MENU_POLL_MS = 700;
|
||||
// A follow-up waits this long for the off-screen pane to attach.
|
||||
const SEND_TRIES = 20;
|
||||
const SEND_RETRY_MS = 250;
|
||||
|
||||
/** The session the chat is talking to. */
|
||||
export interface PetChatRef {
|
||||
laneId: number;
|
||||
runId: string;
|
||||
tool: string;
|
||||
}
|
||||
|
||||
export function readPetChat(): PetChatRef | null {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(CHAT_KEY) ?? "null") as Partial<PetChatRef> | null;
|
||||
if (
|
||||
v &&
|
||||
typeof v.laneId === "number" &&
|
||||
typeof v.runId === "string" &&
|
||||
typeof v.tool === "string"
|
||||
)
|
||||
return { laneId: v.laneId, runId: v.runId, tool: v.tool };
|
||||
} catch {
|
||||
/* unreadable: no chat */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePetChat(ref: PetChatRef | null) {
|
||||
try {
|
||||
if (ref) localStorage.setItem(CHAT_KEY, JSON.stringify(ref));
|
||||
else localStorage.removeItem(CHAT_KEY);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool a new chat starts: the user's default, when the chat can show it. */
|
||||
export function petChatTool(): string | null {
|
||||
const tool = readDefaultTool() ?? "claude";
|
||||
return PET_CHAT_TOOLS.has(tool) ? tool : null;
|
||||
}
|
||||
|
||||
type Line =
|
||||
| { key: string; role: "user" | "assistant"; text: string }
|
||||
| { key: string; role: "tools"; names: string[] };
|
||||
|
||||
/** A human line's text: Claude Code's own injected lines (`<command-…>`,
|
||||
* `<system-reminder>`…) are not something the user said. */
|
||||
const humanText = (m: TranscriptMessage) =>
|
||||
m.content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!.trim())
|
||||
.filter((t) => t && !t.startsWith("<"))
|
||||
.join("\n\n");
|
||||
|
||||
/** The transcript as chat lines: the user's words, the assistant's prose, and
|
||||
* each run of tool calls between them folded into one chip. */
|
||||
export function chatLines(messages: TranscriptMessage[]): Line[] {
|
||||
const out: Line[] = [];
|
||||
messages.forEach((m, i) => {
|
||||
const sender = m.sender ?? m.type;
|
||||
if (sender === "user") {
|
||||
const text = humanText(m);
|
||||
if (text) out.push({ key: `u${i}`, role: "user", text });
|
||||
return;
|
||||
}
|
||||
if (m.type !== "assistant") return;
|
||||
m.content.forEach((c, j) => {
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
out.push({ key: `a${i}-${j}`, role: "assistant", text: c.text.trim() });
|
||||
} else if (c.type === "tool_use" && c.name) {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "tools") last.names.push(c.name);
|
||||
else out.push({ key: `t${i}-${j}`, role: "tools", names: [c.name] });
|
||||
}
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Still working: the last line is the user's, or an assistant turn not ended. */
|
||||
function isBusy(messages: TranscriptMessage[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
const sender = m.sender ?? m.type;
|
||||
if (m.type === "assistant") return m.stop_reason !== undefined && m.stop_reason !== "end_turn";
|
||||
if (sender === "user" && humanText(m)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** "Read ×2 · Bash" */
|
||||
function toolSummary(names: string[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
names.forEach((n) => counts.set(n, (counts.get(n) ?? 0) + 1));
|
||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||
}
|
||||
|
||||
interface PetChatProps {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
/** The thinking mood while a reply is being written. */
|
||||
onBusy?: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
}: PetChatProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [chat, setChat] = useState<PetChatRef | null>(readPetChat);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// Sent but not in the transcript yet.
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const remember = (ref: PetChatRef | null) => {
|
||||
writePetChat(ref);
|
||||
setChat(ref);
|
||||
setSessionId(null);
|
||||
setMessages([]);
|
||||
setMenu(null);
|
||||
};
|
||||
|
||||
// A remembered chat whose pane is gone is over: forget it, the next message starts anew.
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
let live = true;
|
||||
const check = () =>
|
||||
api.run
|
||||
.get(chat.runId)
|
||||
.then((h) => {
|
||||
if (!live) return;
|
||||
if (h.status !== "running") remember(null);
|
||||
else if (h.sessionId) setSessionId(h.sessionId);
|
||||
})
|
||||
.catch(() => live && remember(null));
|
||||
void check();
|
||||
// Codex learns its session only after a while; Claude's is known at start.
|
||||
const timer = window.setInterval(() => {
|
||||
if (!sessionId) void check();
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chat?.runId, sessionId]);
|
||||
|
||||
// The conversation, polled while the window is open.
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
let live = true;
|
||||
const load = () =>
|
||||
api.sessions
|
||||
.transcript(sessionId, { limit: 60 })
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages((prev) =>
|
||||
prev.length === r.messages.length &&
|
||||
JSON.stringify(prev[prev.length - 1]) ===
|
||||
JSON.stringify(r.messages[r.messages.length - 1])
|
||||
? prev
|
||||
: r.messages
|
||||
);
|
||||
})
|
||||
.catch(() => undefined); // not started yet: the next poll asks again
|
||||
void load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
live = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// A menu the CLI shows in the pane (permission, trust, a question).
|
||||
useEffect(() => {
|
||||
if (!chat) return;
|
||||
const read = () =>
|
||||
setMenu((prev) => {
|
||||
const next = readRunMenu(chat.runId);
|
||||
return JSON.stringify(next) === JSON.stringify(prev) ? prev : next;
|
||||
});
|
||||
const timer = window.setInterval(read, MENU_POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
setPending(null);
|
||||
}, [lines, pending]);
|
||||
const busy = starting || !!pending || isBusy(messages);
|
||||
useEffect(() => onBusy?.(busy), [busy, onBusy]);
|
||||
useEffect(() => () => onBusy?.(false), [onBusy]);
|
||||
|
||||
// Keep the newest in sight.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length, pending, busy, menu]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
setError(null);
|
||||
setPending(prompt);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
for (let i = 0; i < SEND_TRIES; i++) {
|
||||
if (submitToRun(chat.runId, prompt)) return;
|
||||
await new Promise((r) => setTimeout(r, SEND_RETRY_MS));
|
||||
}
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(t("sagi.chat.notSent"));
|
||||
return;
|
||||
}
|
||||
const tool = petChatTool() ?? "claude";
|
||||
setStarting(true);
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool !== lane.tool ? { tool } : {}),
|
||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
});
|
||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setDraft(prompt);
|
||||
setError(
|
||||
t("sagi.startFailed", { error: err instanceof Error ? err.message : String(err) })
|
||||
);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
|
||||
const submit = (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!draft.trim()) return;
|
||||
const text = draft;
|
||||
setDraft("");
|
||||
void send(text);
|
||||
};
|
||||
|
||||
const toolLabel = chat?.tool ?? petChatTool() ?? "claude";
|
||||
const empty = lines.length === 0 && !pending;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pet-chat flex h-[30rem] max-h-[calc(100vh-6rem)] w-80 flex-col overflow-hidden rounded-3xl border border-border-light shadow-2xl animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.chat.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
||||
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
busy
|
||||
? "animate-pulse bg-status-warning"
|
||||
: chat
|
||||
? "bg-status-success"
|
||||
: "bg-fg-muted"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
onClick={() => onOpenWorkspace(chat.laneId, chat.runId)}
|
||||
>
|
||||
<ExternalLink size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
<IconButton label={t("sagi.chat.close", { name })} onClick={onClose}>
|
||||
<X size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<span className="text-3xl" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
{t("sagi.chat.emptyTitle", { name })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted">{t("sagi.chat.emptyHint", { tool: toolLabel })}</p>
|
||||
</div>
|
||||
)}
|
||||
{lines.map((l) =>
|
||||
l.role === "tools" ? (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<span className="pet-chat-tools inline-flex max-w-[85%] items-center gap-1 truncate rounded-full px-2 py-0.5 text-[10px] text-fg-muted">
|
||||
<Wrench size={10} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{toolSummary(l.names)}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : l.role === "user" ? (
|
||||
<div key={l.key} className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white shadow-sm">
|
||||
{l.text}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={l.key} className="flex justify-start">
|
||||
<div className="pet-chat-reply max-w-[90%] break-words rounded-2xl rounded-bl-md px-3 py-2 text-sm text-fg-primary shadow-sm">
|
||||
<MarkdownContent text={l.text} dense prose />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{pending && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] whitespace-pre-wrap break-words rounded-2xl rounded-br-md bg-accent-solid px-3 py-2 text-sm text-white opacity-70 shadow-sm">
|
||||
{pending}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{busy && !menu && (
|
||||
<div className="flex justify-start" aria-label={t("sagi.chat.typing")}>
|
||||
<div className="pet-chat-reply flex gap-1 rounded-2xl rounded-bl-md px-3 py-3 shadow-sm">
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
<span className="pet-chat-dot" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{starting && (
|
||||
<p className="flex items-center justify-center gap-1.5 text-[11px] text-fg-muted">
|
||||
<Loader2 size={12} className="animate-spin" aria-hidden />
|
||||
{t("sagi.chat.starting", { tool: toolLabel })}
|
||||
</p>
|
||||
)}
|
||||
{chat && !sessionId && !starting && (
|
||||
<p className="text-center text-[11px] text-fg-muted">{t("sagi.chat.notStarted")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mx-3 mb-1 rounded-lg bg-status-danger/10 px-2 py-1 text-[11px] text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* prompt, or the question the CLI asks */}
|
||||
<div className="pet-chat-foot px-2.5 py-2.5">
|
||||
{menu && chat ? (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
<PaneMenuCard
|
||||
menu={menu}
|
||||
onChoose={(i) => {
|
||||
if (chooseRunOption(chat.runId, menu, i)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onAnswer={(text) => {
|
||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
onCancel={() => {
|
||||
if (interruptRun(chat.runId)) setMenu(null);
|
||||
else setError(t("sagi.chat.notSent"));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="flex items-end gap-1.5">
|
||||
<textarea
|
||||
rows={1}
|
||||
autoFocus
|
||||
className="max-h-28 min-h-[2.25rem] flex-1 resize-none rounded-2xl border border-border bg-surface-1 px-3.5 py-2 text-sm text-fg-primary placeholder-fg-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.chat.placeholder", { name })}
|
||||
aria-label={t("sagi.chat.placeholder", { name })}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e) && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!draft.trim() || starting}
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent-solid text-white shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
aria-label={t("sagi.panel.send")}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The chat's pane, attached off-screen: the only way a follow-up or a
|
||||
menu answer reaches it. A real size, so tmux does not shrink the pane. */}
|
||||
{chat && (
|
||||
<div className="pet-chat-term" aria-hidden>
|
||||
<TerminalView
|
||||
runId={chat.runId}
|
||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||
autoFocus={false}
|
||||
// The pane ended: the conversation stays on screen, the next
|
||||
// message starts a new session.
|
||||
onExit={() => {
|
||||
writePetChat(null);
|
||||
setChat(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { PET_SIZE, sagiPrefs, type PetChoice } from "./prefs";
|
||||
import { usePets } from "./usePets";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const ITEM =
|
||||
@@ -50,6 +51,8 @@ export function PetMenu({
|
||||
const bundleInput = useRef<HTMLInputElement>(null);
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { list, own, installed, busy, error, wear, install, add, remove } = usePets();
|
||||
// Adding or deleting a pet changes the whole install: an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
|
||||
// Closes on a press outside the menu, or Escape.
|
||||
useEffect(() => {
|
||||
@@ -133,6 +136,7 @@ export function PetMenu({
|
||||
</button>
|
||||
{list?.featured.map((f) => {
|
||||
const have = installed.get(f.id);
|
||||
if (!have && !isAdmin) return null;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
@@ -173,48 +177,54 @@ export function PetMenu({
|
||||
<span className="min-w-0 truncate">{p.displayName}</span>
|
||||
{mark(p.id)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`pet-delete-${p.id}`}
|
||||
aria-label={t("sagi.pet.delete", { name: p.displayName })}
|
||||
title={t("sagi.pet.delete", { name: p.displayName })}
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(p)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-4 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="my-1.5 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => imageInput.current?.click()}
|
||||
>
|
||||
{busy === "image" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={busy !== null}
|
||||
className={ITEM}
|
||||
onClick={() => bundleInput.current?.click()}
|
||||
>
|
||||
{busy === "bundle" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PackageOpen className="h-4 w-4" />
|
||||
)}
|
||||
{t("sagi.pet.import")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a
|
||||
href="https://codex-pets.net/"
|
||||
target="_blank"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
/**
|
||||
* @file PetSprite.tsx
|
||||
* @description The mascot wearing a custom pet (`/api/pets`). A Codex pet is
|
||||
* a 1536×1872 spritesheet — 8 columns × 9 rows of 192×208 frames, one row per
|
||||
* animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review) — played one row at a time by Sagi's mood; an
|
||||
* a spritesheet of 192×208 frames, 8 columns wide: 9 rows (v1, 1536×1872),
|
||||
* one per animation state (idle, run right, run left, waving, jumping, failed,
|
||||
* waiting, running, review), or 11 (v2, 1536×2288), whose rows 9–10 are 16
|
||||
* gaze poses. The sprite plays the row of Sagi's mood at Codex's own frame
|
||||
* timings; a moment (waving, jumping, failed, review) plays three times and
|
||||
* settles into idle, a condition loops. An `action` (dragged left/right,
|
||||
* hovered) plays the run-left/run-right or waving row over the mood's, like
|
||||
* Codex's own pet; a `moment` (a new session waves, a finish jumps, an error
|
||||
* fails, a wait waits, a new subagent reviews — `useSagiBrain`) plays its row
|
||||
* over the mood's, under the action; a v2 pet at idle holds the gaze pose `gaze` names. An
|
||||
* uploaded image is shown still and bobs with the same `data-mood` CSS as
|
||||
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
|
||||
* or waving row over the mood's, like Codex's own pet. Each row steps at its
|
||||
* own pace and holds its last frame longer, so a loop breathes instead of
|
||||
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
|
||||
* load (deleted from another browser) calls `onBroken`, so the mascot falls
|
||||
* back to Sagi instead of an empty box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -16,12 +20,32 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
import type { Mood } from "./brain";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
const CELL = { w: 192, h: 208 };
|
||||
const SHEET = { cols: 8, rows: 9 };
|
||||
/** Frames in each row of the Codex atlas. */
|
||||
const FRAMES = [6, 8, 8, 4, 5, 8, 6, 6, 6];
|
||||
const COLS = 8;
|
||||
/** `n` frames of `step` ms, the last one `last` ms. */
|
||||
const frames = (n: number, step: number, last: number) =>
|
||||
Array.from({ length: n }, (_, i) => (i === n - 1 ? last : step));
|
||||
/** Per row, each frame's ms: Codex's own table (codex-rs/tui/src/pets/model.rs).
|
||||
* Idle is the pace the pets are drawn at; Codex plays it 6× slower, which is
|
||||
* how a sleeping pet breathes here. */
|
||||
const DURATIONS = [
|
||||
[280, 110, 110, 140, 140, 320], // idle
|
||||
frames(8, 120, 220), // run right
|
||||
frames(8, 120, 220), // run left
|
||||
frames(4, 140, 280), // waving
|
||||
frames(5, 140, 280), // jumping
|
||||
frames(8, 140, 240), // failed
|
||||
frames(6, 150, 260), // waiting
|
||||
frames(6, 120, 220), // running (working)
|
||||
frames(6, 150, 280), // review
|
||||
];
|
||||
const SLEEP_SLOWDOWN = 6;
|
||||
/** Rows that play three times and settle into idle; the others loop. */
|
||||
const MOMENTS = new Set([3, 4, 5, 8]);
|
||||
/** v2 only: 16 gaze poses, 22.5° apart clockwise from straight up. */
|
||||
const GAZE_ROW = 9;
|
||||
/** Which row plays for each mood. */
|
||||
const ROW: Record<Mood, number> = {
|
||||
idle: 0,
|
||||
@@ -36,25 +60,19 @@ const ROW: Record<Mood, number> = {
|
||||
/** What the user is doing to the pet, which outranks its mood. */
|
||||
export type PetAction = "run-left" | "run-right" | "wave";
|
||||
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
|
||||
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
|
||||
const PACE: [number, number][] = [
|
||||
[220, 1400], // idle: a breath, then a pause
|
||||
[120, 120], // run right — keeps up with the drag
|
||||
[120, 120], // run left
|
||||
[180, 400], // waving
|
||||
[160, 700], // jumping
|
||||
[200, 1200], // failed
|
||||
[220, 900], // waiting
|
||||
[170, 400], // running (working)
|
||||
[220, 900], // review
|
||||
];
|
||||
const SLEEP_PACE: [number, number] = [450, 2000];
|
||||
/** A moment's row: wave, jump, failed, waiting, review. It outranks the mood,
|
||||
* and the user's own action outranks it. */
|
||||
const MOMENT_ROW: Record<PetMoment, number> = { wave: 3, jump: 4, fail: 5, wait: 6, review: 8 };
|
||||
/** Loops of a row before the sprite rests on its first frame. */
|
||||
const SPRITE_LOOPS = 4;
|
||||
|
||||
export function PetSprite({
|
||||
id,
|
||||
kind,
|
||||
mood,
|
||||
action,
|
||||
moment,
|
||||
gaze,
|
||||
size,
|
||||
reducedMotion,
|
||||
onBroken,
|
||||
@@ -63,31 +81,44 @@ export function PetSprite({
|
||||
kind: "codex" | "image";
|
||||
mood: Mood;
|
||||
action?: PetAction | null;
|
||||
moment?: PetMoment | null;
|
||||
/** Gaze pose 0–15 (0 = up, clockwise), or none. Only a v2 sheet has them. */
|
||||
gaze?: number | null;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
onBroken: () => void;
|
||||
}) {
|
||||
const url = api.pets.spriteUrl(id);
|
||||
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && mood === "sleeping";
|
||||
const [frame, setFrame] = useState(0);
|
||||
const row = action ? ACTION_ROW[action] : moment ? MOMENT_ROW[moment] : (ROW[mood] ?? 0);
|
||||
const sleeping = !action && !moment && mood === "sleeping";
|
||||
// The cell on show, tagged with the row it was played for: a cell left over
|
||||
// from the previous row (say column 7 of a run) is never drawn on the next.
|
||||
const [cell, setCell] = useState({ of: row, row, col: 0 });
|
||||
const [rows, setRows] = useState(9);
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
setCell({ of: row, row, col: 0 });
|
||||
if (kind !== "codex" || reducedMotion) return;
|
||||
const count = FRAMES[row] ?? 1;
|
||||
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
|
||||
let f = 0;
|
||||
const play = (r: number, slow = 1) =>
|
||||
(DURATIONS[r] ?? [200]).map((ms, col) => ({ of: row, row: r, col, ms: ms * slow }));
|
||||
const own = play(row, sleeping ? SLEEP_SLOWDOWN : 1);
|
||||
let list = MOMENTS.has(row) ? [...own, ...own, ...own] : own;
|
||||
let i = 0;
|
||||
let loops = 0;
|
||||
let timer = 0;
|
||||
// `i` always indexes `list`: it wraps at its end. A few loops, then the
|
||||
// first frame rests — a sprite that never stops re-paints the page several
|
||||
// times a second for as long as it is open; a new mood or action plays again.
|
||||
const tick = () => {
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
f = (f + 1) % count;
|
||||
setFrame(f);
|
||||
tick();
|
||||
},
|
||||
f === count - 1 ? hold : step
|
||||
);
|
||||
timer = window.setTimeout(() => {
|
||||
if (++i === list.length) {
|
||||
i = 0;
|
||||
if (MOMENTS.has(row)) list = play(0);
|
||||
if (++loops >= SPRITE_LOOPS) return setCell(list[0]!);
|
||||
}
|
||||
setCell(list[i]!);
|
||||
tick();
|
||||
}, list[i]!.ms);
|
||||
};
|
||||
tick();
|
||||
return () => window.clearTimeout(timer);
|
||||
@@ -96,10 +127,13 @@ export function PetSprite({
|
||||
// A background image reports no error of its own: probe it once.
|
||||
useEffect(() => {
|
||||
if (kind !== "codex") return;
|
||||
// It also tells a v2 sheet (11 rows) from a v1 (9).
|
||||
const probe = new Image();
|
||||
probe.onload = () => setRows(Math.round(probe.naturalHeight / CELL.h) || 9);
|
||||
probe.onerror = onBroken;
|
||||
probe.src = url;
|
||||
return () => {
|
||||
probe.onload = null;
|
||||
probe.onerror = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -111,6 +145,7 @@ export function PetSprite({
|
||||
src={url}
|
||||
alt=""
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
className="sagi-pet-image"
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -120,11 +155,19 @@ export function PetSprite({
|
||||
/>
|
||||
);
|
||||
}
|
||||
const pose =
|
||||
gaze != null && rows > GAZE_ROW && !action && !moment && mood === "idle" ? gaze : null;
|
||||
const shown =
|
||||
pose != null
|
||||
? { row: GAZE_ROW + Math.floor(pose / COLS), col: pose % COLS }
|
||||
: cell.of === row
|
||||
? cell
|
||||
: { row, col: 0 };
|
||||
const scale = size / CELL.h;
|
||||
return (
|
||||
<div
|
||||
data-testid="pet-sprite"
|
||||
data-row={row}
|
||||
data-row={shown.row}
|
||||
aria-hidden
|
||||
style={{
|
||||
width: Math.round(CELL.w * scale),
|
||||
@@ -132,8 +175,8 @@ export function PetSprite({
|
||||
margin: "0 auto",
|
||||
backgroundImage: `url("${url}")`,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: `${SHEET.cols * CELL.w * scale}px ${SHEET.rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-frame * CELL.w * scale}px ${-row * CELL.h * scale}px`,
|
||||
backgroundSize: `${COLS * CELL.w * scale}px ${rows * CELL.h * scale}px`,
|
||||
backgroundPosition: `${-shown.col * CELL.w * scale}px ${-shown.row * CELL.h * scale}px`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
/**
|
||||
* @file Sagi.tsx
|
||||
* @description Floating mascot companion shell. Mounts once (next to UpdateNotifier
|
||||
* in Layout) so it persists across routes and shares the single WebSocket.
|
||||
* @description Floating mascot companion shell. Mounted once by `SagiMount` (next
|
||||
* to UpdateNotifier in Layout), which loads this chunk only while the mascot is
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* detection, and route navigation. Reactive personality + status/Ask come
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition, and the bubble/panel render in a self-clamping flyout so
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
@@ -73,6 +77,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
@@ -81,9 +86,21 @@ 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 { petName, sagiPrefs } from "./prefs";
|
||||
import type { SagiSubject } from "./brain";
|
||||
import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { workspaceHref } from "../SessionRowMenu";
|
||||
import { PetChat, petChatTool } from "./PetChat";
|
||||
|
||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
const STROKE_TURNS = 4;
|
||||
const STROKE_MS = 1200;
|
||||
const STROKE_COOLDOWN_MS = 1500;
|
||||
import "./sagi.css";
|
||||
|
||||
const FLYOUT_GAP = 10; // px between avatar and flyout
|
||||
@@ -132,15 +149,21 @@ export function usePrefersReducedMotion(): boolean {
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
/**
|
||||
* `front`: above the mascot itself — the pet menu, which may overlap it.
|
||||
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||
*/
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
beside = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
beside?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
@@ -153,6 +176,18 @@ function SagiFlyout({
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (beside) {
|
||||
let left =
|
||||
anchor.side === "left"
|
||||
? anchor.left + anchor.size + FLYOUT_GAP
|
||||
: anchor.left - w - FLYOUT_GAP;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
let top = anchor.top + anchor.size - h;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
@@ -165,7 +200,7 @@ function SagiFlyout({
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
@@ -195,35 +230,65 @@ 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());
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const navigate = useNavigate();
|
||||
const href = useSettingsHref();
|
||||
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());
|
||||
setName(petName(sagiPrefs.getPet()));
|
||||
setSize(sagiPrefs.getSize());
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// A pet worn before names rode along with the choice: look its name up once.
|
||||
const petId = pet.id;
|
||||
const unnamed = "kind" in pet && !pet.name;
|
||||
useEffect(() => {
|
||||
if (!unnamed) return;
|
||||
let live = true;
|
||||
api.pets
|
||||
.list()
|
||||
.then((list) => {
|
||||
const found = list.pets.find((p) => p.id === petId);
|
||||
const cur = sagiPrefs.getPet();
|
||||
if (live && found && cur.id === petId && "kind" in cur)
|
||||
sagiPrefs.setPet({ ...cur, name: found.displayName });
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [petId, unnamed]);
|
||||
|
||||
// ⌘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;
|
||||
@@ -231,11 +296,38 @@ export function Sagi() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [enabled]);
|
||||
}, []);
|
||||
|
||||
// A v2 pet at idle looks toward the pointer: one of 16 poses by the
|
||||
// pointer's angle around it (0 = up, clockwise), dropped once it rests.
|
||||
// ponytail: rest after 2 s is a guess; Codex Desktop's own rule is not public.
|
||||
const btn = useRef<HTMLButtonElement>(null);
|
||||
const [gaze, setGaze] = useState<number | null>(null);
|
||||
const wearsSheet = "kind" in pet && pet.kind === "codex";
|
||||
useEffect(() => {
|
||||
if (!wearsSheet || reducedMotion) return;
|
||||
let rest = 0;
|
||||
const onMove = (e: PointerEvent) => {
|
||||
const r = btn.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
const deg =
|
||||
(Math.atan2(e.clientX - (r.left + r.width / 2), r.top + r.height / 2 - e.clientY) * 180) /
|
||||
Math.PI;
|
||||
setGaze(Math.round((deg + 360) / 22.5) % 16);
|
||||
window.clearTimeout(rest);
|
||||
rest = window.setTimeout(() => setGaze(null), 2000);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.clearTimeout(rest);
|
||||
};
|
||||
}, [wearsSheet, reducedMotion]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(route: string) => {
|
||||
@@ -245,27 +337,53 @@ export function Sagi() {
|
||||
[navigate]
|
||||
);
|
||||
|
||||
// A session a bubble or the panel names: its live pane when a dashboard run
|
||||
// holds it, else its workspace's replay, else its analysis page. Opening
|
||||
// never resumes anything.
|
||||
const openSession = useCallback(
|
||||
(s: SagiSubject) => {
|
||||
const go = (runs?: Parameters<typeof workspaceHref>[1]) => {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
.then((r) => go(r.items))
|
||||
.catch(() => go());
|
||||
},
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
const result = matchIntent(query, brain.status);
|
||||
if (result.kind === "answer") return result.text;
|
||||
// Handoff: ensure the "Sagi" lane exists for this dashboard, spawn a
|
||||
// fresh claude session in it carrying the user's prompt as its first
|
||||
// message, then open that workspace's session view on the new pane.
|
||||
// Fire-and-forget — the panel already clears its composer on null and
|
||||
// closes; errors surface as an answer instead of silently swallowing.
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
}
|
||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
brain.setThinking(true);
|
||||
(async () => {
|
||||
try {
|
||||
const { lane } = await api.lanes.ensure({ title: "Sagi" });
|
||||
const run = await api.run.start({
|
||||
laneId: lane.id,
|
||||
cwd: lane.cwd,
|
||||
initialPrompt: prompt,
|
||||
permissionMode: "acceptEdits",
|
||||
const tool = readDefaultTool();
|
||||
const r = await startInFreeTabDetailed(lane.id, {
|
||||
prompt,
|
||||
...(tool && tool !== lane.tool ? { tool } : {}),
|
||||
});
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`);
|
||||
navigate(`/workspace?lane=${r.laneId}&run=${encodeURIComponent(r.runId)}`);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -276,10 +394,31 @@ export function Sagi() {
|
||||
})();
|
||||
return null;
|
||||
},
|
||||
[brain, navigate, t]
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
// Stroke the pet (move the pointer back and forth over it) to pat it.
|
||||
const stroke = useRef({ dir: 0, x: 0, turns: [] as number[], until: 0 });
|
||||
const onStroke = (e: React.PointerEvent) => {
|
||||
if (e.buttons !== 0 || e.pointerType === "touch") return;
|
||||
const s = stroke.current;
|
||||
const now = performance.now();
|
||||
const dx = e.clientX - s.x;
|
||||
s.x = e.clientX;
|
||||
if (Math.abs(dx) < 2 || now < s.until) return;
|
||||
const dir = Math.sign(dx);
|
||||
if (s.dir && dir !== s.dir) {
|
||||
s.turns = [...s.turns.filter((t0) => now - t0 < STROKE_MS), now];
|
||||
if (s.turns.length >= STROKE_TURNS) {
|
||||
s.turns = [];
|
||||
s.until = now + STROKE_COOLDOWN_MS;
|
||||
brain.pat();
|
||||
}
|
||||
}
|
||||
s.dir = dir;
|
||||
};
|
||||
|
||||
const onBusy = useCallback((b: boolean) => brain.setThinking(b), [brain.setThinking]);
|
||||
|
||||
const anchor: Anchor = {
|
||||
left: place.left,
|
||||
@@ -297,6 +436,13 @@ export function Sagi() {
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
@@ -308,6 +454,29 @@ export function Sagi() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1), and kept mounted: unmounting would detach its terminal and
|
||||
lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
@@ -316,63 +485,100 @@ export function Sagi() {
|
||||
onClose={() => setMenu(false)}
|
||||
onSettings={() => {
|
||||
setMenu(false);
|
||||
navigate("/settings/appearance#pet");
|
||||
navigate(href("appearance#pet"));
|
||||
}}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && brain.bubble && (
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble text={brain.bubble} onDismiss={brain.dismissBubble} />
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
onDismiss={brain.dismissBubble}
|
||||
onOpen={openSession}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={btn}
|
||||
className="sagi-avatar-btn"
|
||||
data-dragging={place.dragging ? "1" : "0"}
|
||||
style={{ left: place.left, top: place.top, width: size, height: size }}
|
||||
onPointerEnter={() => setHover(true)}
|
||||
onPointerLeave={() => setHover(false)}
|
||||
onPointerDown={place.onPointerDown}
|
||||
onPointerMove={place.onPointerMove}
|
||||
onPointerMove={(e) => {
|
||||
place.onPointerMove(e);
|
||||
onStroke(e);
|
||||
}}
|
||||
onPointerUp={place.onPointerUp}
|
||||
onKeyDown={place.onKeyDown}
|
||||
onClick={() => {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
setOpen((v) => !v);
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
}}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open ? t("sagi.closeAvatar") : t("sagi.open")}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle")}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||
<SagiAvatar mood={brain.mood} reducedMotion={reducedMotion} size={size} />
|
||||
<SagiAvatar
|
||||
mood={brain.mood}
|
||||
moment={reducedMotion ? null : brain.moment?.kind}
|
||||
reducedMotion={reducedMotion}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<PetSprite
|
||||
id={pet.id}
|
||||
kind={pet.kind}
|
||||
mood={brain.mood}
|
||||
// Dragged → runs that way; hovered → waves (Codex's pet does both).
|
||||
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
|
||||
// A moment (a pat's hop) outranks the hover wave, or stroking the
|
||||
// pet would only ever show it waving.
|
||||
action={place.moving ? `run-${place.moving}` : hover && !brain.moment ? "wave" : null}
|
||||
moment={brain.moment?.kind}
|
||||
gaze={gaze}
|
||||
size={size}
|
||||
reducedMotion={reducedMotion}
|
||||
// Deleted elsewhere, or unreadable: back to Sagi.
|
||||
onBroken={() => sagiPrefs.setPet({ id: "sagi" })}
|
||||
/>
|
||||
)}
|
||||
{brain.status.errorCount > 0 && (
|
||||
{brain.reaction && (
|
||||
<span key={brain.reaction.key} className="sagi-reaction" aria-live="polite">
|
||||
<span className="sagi-hearts" aria-hidden>
|
||||
<span>💜</span>
|
||||
<span>💗</span>
|
||||
<span>💜</span>
|
||||
</span>
|
||||
<span className="sagi-reaction-text" data-side={place.side}>
|
||||
{brain.reaction.text}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{brain.status.errorCount > 0 ? (
|
||||
<span className="sagi-error-dot" aria-hidden>
|
||||
{brain.status.errorCount > 9 ? "9+" : brain.status.errorCount}
|
||||
</span>
|
||||
) : (
|
||||
brain.status.waitingCount > 0 && (
|
||||
<span className="sagi-error-dot" data-kind="waiting" aria-hidden>
|
||||
{brain.status.waitingCount > 9 ? "9+" : brain.status.waitingCount}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
|
||||
@@ -36,13 +36,15 @@
|
||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
||||
* ============================================================================= */
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Mood } from "./brain";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Mood, PetMoment } from "./brain";
|
||||
|
||||
interface SagiAvatarProps {
|
||||
mood: Mood;
|
||||
reducedMotion: boolean;
|
||||
size?: number;
|
||||
/** A one-shot reaction (hop, wiggle, flinch…) played over the mood. */
|
||||
moment?: PetMoment | null;
|
||||
}
|
||||
|
||||
const MAX_PUPIL_SHIFT = 36; // px in the 1280-unit path coordinate space
|
||||
@@ -76,10 +78,22 @@ 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,
|
||||
moment = null,
|
||||
}: 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 +103,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 +119,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)
|
||||
@@ -137,6 +151,7 @@ export function SagiAvatar({ mood, reducedMotion, size = 68 }: SagiAvatarProps)
|
||||
ref={rootRef}
|
||||
className="sagi-avatar"
|
||||
data-mood={mood}
|
||||
data-moment={moment ?? undefined}
|
||||
data-reduced={reducedMotion ? "1" : "0"}
|
||||
width={size}
|
||||
height={size}
|
||||
@@ -202,7 +217,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 +241,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 +807,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>
|
||||
);
|
||||
}
|
||||
@@ -50,9 +50,6 @@
|
||||
|
||||
import { useState, type FormEvent, type ReactNode } from "react";
|
||||
import {
|
||||
Play,
|
||||
Activity,
|
||||
LayoutList,
|
||||
Bell,
|
||||
BellOff,
|
||||
Trash2,
|
||||
@@ -61,12 +58,29 @@ import {
|
||||
AlertTriangle,
|
||||
Hourglass,
|
||||
Radio,
|
||||
Heart,
|
||||
ChevronRight,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiStatus } from "./brain";
|
||||
import { subjectLabel, type AttentionItem, type SagiStatus, type SagiSubject } from "./brain";
|
||||
import { PET_NAME_MAX } from "./prefs";
|
||||
|
||||
/** How many sessions the "Needs you" list shows before pointing at Sessions. */
|
||||
const ATTENTION_SHOWN = 4;
|
||||
|
||||
interface SagiPanelProps {
|
||||
/** The name the pet goes by now. */
|
||||
name: string;
|
||||
/** The pet's own name, which an empty nickname falls back to. */
|
||||
ownName: string;
|
||||
/** The user's nickname for the pet, "" when none. */
|
||||
nickname: string;
|
||||
onRename: (name: string) => void;
|
||||
onPat: () => void;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: ReadonlyArray<AttentionItem>;
|
||||
onOpenSession: (s: SagiSubject) => void;
|
||||
status: SagiStatus;
|
||||
muted: boolean;
|
||||
onToggleMute: () => void;
|
||||
@@ -78,6 +92,13 @@ interface SagiPanelProps {
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
@@ -89,6 +110,13 @@ export function SagiPanel({
|
||||
const { t } = useTranslation("settings");
|
||||
const [query, setQuery] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
// The name being typed, or null while not renaming.
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const saveName = () => {
|
||||
if (renaming === null) return;
|
||||
onRename(renaming);
|
||||
setRenaming(null);
|
||||
};
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -101,7 +129,7 @@ export function SagiPanel({
|
||||
<div
|
||||
className="w-72 overflow-hidden rounded-2xl border border-border-light bg-surface-2/95 shadow-2xl backdrop-blur-md animate-slide-up"
|
||||
role="dialog"
|
||||
aria-label={t("sagi.panel.label")}
|
||||
aria-label={t("sagi.panel.label", { name })}
|
||||
>
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/70 bg-gradient-to-r from-accent/10 to-transparent px-3.5 py-2.5">
|
||||
@@ -109,7 +137,40 @@ export function SagiPanel({
|
||||
<span className="text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-fg-primary">Sagi</span>
|
||||
{renaming === null ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 truncate rounded px-0.5 text-sm font-semibold text-fg-primary hover:bg-surface-4"
|
||||
onClick={() => setRenaming(nickname)}
|
||||
aria-label={t("sagi.panel.rename", { name })}
|
||||
title={t("sagi.panel.rename", { name })}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
className="w-28 rounded border border-accent bg-surface-1 px-1 py-0.5 text-sm text-fg-primary focus:outline-none"
|
||||
value={renaming}
|
||||
maxLength={PET_NAME_MAX}
|
||||
placeholder={ownName}
|
||||
aria-label={t("sagi.panel.renameLabel")}
|
||||
title={t("sagi.panel.renameHint")}
|
||||
onChange={(e) => setRenaming(e.target.value)}
|
||||
onBlur={saveName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveName();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc cancels the rename only, not the whole panel.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
setRenaming(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`ml-0.5 inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
status.connected
|
||||
@@ -126,13 +187,23 @@ export function SagiPanel({
|
||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close")}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||
onClick={onPat}
|
||||
aria-label={t("sagi.panel.pat", { name })}
|
||||
title={t("sagi.panel.pat", { name })}
|
||||
>
|
||||
<Heart size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-secondary"
|
||||
onClick={onClose}
|
||||
aria-label={t("sagi.panel.close", { name })}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3">
|
||||
@@ -158,29 +229,55 @@ export function SagiPanel({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* the sessions that need the user, by name */}
|
||||
{attention.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">
|
||||
{t("sagi.panel.attention")}
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{attention.slice(0, ATTENTION_SHOWN).map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
className="group flex w-full items-center gap-2 rounded-lg bg-surface-1 px-2 py-1.5 text-left text-xs transition-colors hover:bg-surface-4"
|
||||
onClick={() => onOpenSession(item)}
|
||||
title={item.cwd ?? item.id}
|
||||
>
|
||||
{item.kind === "error" ? (
|
||||
<AlertTriangle
|
||||
size={13}
|
||||
className="shrink-0 text-status-danger"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<Hourglass size={13} className="shrink-0 text-status-warning" aria-hidden />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary group-hover:text-fg-primary">
|
||||
{subjectLabel(item)}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-fg-muted">
|
||||
{item.kind === "error"
|
||||
? t("sagi.panel.attentionError")
|
||||
: t("sagi.panel.attentionWaiting")}
|
||||
</span>
|
||||
<ChevronRight size={12} className="shrink-0 text-fg-muted" aria-hidden />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{attention.length > ATTENTION_SHOWN && (
|
||||
<button
|
||||
className="mt-1 text-[11px] text-accent hover:underline"
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
>
|
||||
{t("sagi.panel.attentionMore", { count: attention.length - ATTENTION_SHOWN })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* quick actions */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
<ActionButton
|
||||
icon={Play}
|
||||
label={t("sagi.panel.runClaude")}
|
||||
onClick={() => onNavigate("/workspace")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={Activity}
|
||||
label={t("sagi.panel.activity")}
|
||||
onClick={() => onNavigate("/activity")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={LayoutList}
|
||||
label={t("sagi.panel.sessions")}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={AlertTriangle}
|
||||
label={t("sagi.panel.errored")}
|
||||
disabled={status.errorCount === 0}
|
||||
onClick={() => onNavigate("/sessions")}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={muted ? BellOff : Bell}
|
||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||
@@ -198,10 +295,10 @@ export function SagiPanel({
|
||||
<form onSubmit={submit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className="flex-1 rounded-lg border border-border bg-surface-1 px-2.5 py-1.5 text-xs text-fg-secondary placeholder-fg-muted transition-colors focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent/30"
|
||||
placeholder={t("sagi.panel.askPlaceholder")}
|
||||
placeholder={t("sagi.panel.askPlaceholder", { name })}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label={t("sagi.panel.ask")}
|
||||
aria-label={t("sagi.panel.ask", { name })}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -284,7 +381,7 @@ function ActionButton({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="flex w-full items-center gap-1.5 rounded-lg bg-surface-1 px-2 py-1.5 text-xs text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file SpeechBubble.tsx
|
||||
* @description Transient speech bubble rendered above the Sagi mascot.
|
||||
* Pure presentation — visibility timing and quip selection live in
|
||||
* {@link useSagiBrain}; this component only paints the bubble and handles
|
||||
* user dismissal.
|
||||
* Pure presentation — visibility timing, coalescing and quip selection live in
|
||||
* {@link useSagiBrain}; this component paints the line, the session(s) it is
|
||||
* about, an Open button when it is about exactly one, and handles dismissal.
|
||||
*
|
||||
* ## Accessibility
|
||||
* Uses `role="status"` with `aria-live="polite"` so screen readers announce
|
||||
* new quips without interrupting current speech. Click anywhere on the bubble
|
||||
* to dismiss early.
|
||||
* new quips without interrupting current speech. Click the bubble to dismiss
|
||||
* it early; its Open button opens the session instead.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -52,30 +52,52 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SagiBubble } from "./useSagiBrain";
|
||||
import type { SagiSubject } from "./brain";
|
||||
|
||||
/** Props for {@link SpeechBubble}. */
|
||||
interface SpeechBubbleProps {
|
||||
/** Quip text to display inside the bubble. */
|
||||
text: string;
|
||||
bubble: SagiBubble;
|
||||
/** Called when the user clicks to dismiss. */
|
||||
onDismiss: () => void;
|
||||
/** Opens the session the bubble is about. */
|
||||
onOpen?: (subject: SagiSubject) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated speech bubble above Sagi.
|
||||
* @param props See {@link SpeechBubbleProps}.
|
||||
*/
|
||||
export function SpeechBubble({ text, onDismiss }: SpeechBubbleProps) {
|
||||
export function SpeechBubble({ bubble, onDismiss, onOpen }: SpeechBubbleProps) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { text, detail, subject, tone } = bubble;
|
||||
return (
|
||||
<div
|
||||
className="sagi-bubble sagi-bubble-enter"
|
||||
data-tone={tone}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
onClick={onDismiss}
|
||||
title={t("sagi.bubbleDismiss")}
|
||||
>
|
||||
{text}
|
||||
<div>{text}</div>
|
||||
{(detail || (subject && onOpen)) && (
|
||||
<div className="sagi-bubble-detail">
|
||||
{detail && <span className="sagi-bubble-subject">{detail}</span>}
|
||||
{subject && onOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="sagi-bubble-open"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpen(subject);
|
||||
}}
|
||||
>
|
||||
{t("sagi.bubbleOpen")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file PetChat.test.ts
|
||||
* @description Unit tests for the pet chat's transcript-to-bubbles folding (`chatLines`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { chatLines } from "../PetChat";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const msg = (m: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||
({ type: "user", timestamp: null, content: [], ...m }) as TranscriptMessage;
|
||||
|
||||
describe("chatLines", () => {
|
||||
it("keeps the user's words and the assistant's prose, folding tool calls into one chip", () => {
|
||||
const lines = chatLines([
|
||||
msg({ type: "user", sender: "user", content: [{ type: "text", text: "hi" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", text: "hmm" },
|
||||
{ type: "tool_use", name: "Read" },
|
||||
],
|
||||
}),
|
||||
msg({ type: "user", sender: "tool", content: [{ type: "tool_result", output: "x" }] }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello!" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(lines.map((l) => (l.role === "tools" ? l.names : l.text))).toEqual([
|
||||
"hi",
|
||||
["Read", "Read"],
|
||||
"hello!",
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops Claude Code's own injected lines", () => {
|
||||
const lines = chatLines([
|
||||
msg({ sender: "user", content: [{ type: "text", text: "<command-name>/clear</command-name>" }] }),
|
||||
]);
|
||||
expect(lines).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -93,9 +93,9 @@ describe("PetSprite", () => {
|
||||
// 104 px tall = half a 208 px cell: row 7 (running) starts 7 × 104 px down.
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px");
|
||||
act(() => vi.advanceTimersByTime(130));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
act(() => vi.advanceTimersByTime(110));
|
||||
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // Codex: 120 ms a frame
|
||||
act(() => vi.advanceTimersByTime(10));
|
||||
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
|
||||
rerender(
|
||||
<PetSprite
|
||||
@@ -129,7 +129,7 @@ describe("PetSprite", () => {
|
||||
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("holds a loop's last frame before starting over", () => {
|
||||
it("plays idle at the drawn pace, the last frame barely longer than the rest", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
@@ -141,14 +141,67 @@ describe("PetSprite", () => {
|
||||
/>
|
||||
);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
|
||||
act(() => vi.advanceTimersByTime(280 + 110 + 110 + 140 + 140)); // → the 6th, last frame
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(1000));
|
||||
act(() => vi.advanceTimersByTime(310));
|
||||
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
act(() => vi.advanceTimersByTime(10)); // 320 ms, not a long hold
|
||||
expect(sprite.style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("plays a moment three times, then settles into idle; a condition keeps looping", () => {
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="happy" onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
expect(sprite.dataset.row).toBe("4"); // jumping: 5 frames, 4 × 140 + 280 ms
|
||||
act(() => vi.advanceTimersByTime(3 * 840 - 1));
|
||||
expect(sprite.dataset.row).toBe("4");
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(sprite.dataset.row).toBe("0");
|
||||
rerender(<PetSprite {...props} mood="thinking" onBroken={() => {}} />);
|
||||
act(() => vi.advanceTimersByTime(10_000));
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
});
|
||||
|
||||
it("looks toward the pointer on a v2 sheet, only while idle", () => {
|
||||
// A v2 atlas is 11 rows tall; the sprite learns that from the loaded image.
|
||||
vi.stubGlobal(
|
||||
"Image",
|
||||
class {
|
||||
naturalHeight = 2288;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
);
|
||||
const props = { id: "cp-goku", kind: "codex" as const, size: 208, reducedMotion: false };
|
||||
const { rerender } = render(<PetSprite {...props} mood="idle" gaze={12} onBroken={() => {}} />);
|
||||
const sprite = screen.getByTestId("pet-sprite");
|
||||
// Pose 12 (270°, left) = row 10, column 4.
|
||||
expect(sprite.style.backgroundPosition).toBe("-768px -2080px");
|
||||
expect(sprite.style.backgroundSize).toBe("1536px 2288px");
|
||||
rerender(<PetSprite {...props} mood="thinking" gaze={12} onBroken={() => {}} />);
|
||||
expect(sprite.dataset.row).toBe("7");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("ignores the pointer on a v1 sheet, which has no gaze rows", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
id="cp-goku"
|
||||
kind="codex"
|
||||
mood="idle"
|
||||
gaze={4}
|
||||
size={208}
|
||||
reducedMotion={false}
|
||||
onBroken={() => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("pet-sprite").style.backgroundPosition).toBe("0px 0px");
|
||||
});
|
||||
|
||||
it("holds the first frame under reduced motion", () => {
|
||||
render(
|
||||
<PetSprite
|
||||
@@ -208,7 +261,7 @@ describe("PetMenu", () => {
|
||||
renderMenu();
|
||||
fireEvent.click(screen.getByTestId("pet-choose"));
|
||||
fireEvent.click(await screen.findByTestId("pet-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
});
|
||||
|
||||
@@ -231,7 +284,7 @@ describe("PetMenu", () => {
|
||||
});
|
||||
fireEvent.change(screen.getByTestId("pet-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
expect(api.pets.upload).toHaveBeenCalledWith(file);
|
||||
});
|
||||
@@ -260,11 +313,11 @@ describe("PetSettings", () => {
|
||||
it("downloads a featured pet on its first pick, wears an own one, deletes it back to Sagi", async () => {
|
||||
render(<PetSettings />);
|
||||
fireEvent.click(await screen.findByTestId("pet-card-featured-goku"));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex" }));
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "cp-goku", kind: "codex", name: "Goku" }));
|
||||
expect(api.pets.installFeatured).toHaveBeenCalledWith("goku");
|
||||
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa"));
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image" });
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-aaaaaaaaaaaa", kind: "image", name: "My cat" });
|
||||
fireEvent.click(screen.getByTestId("pet-card-own-p-aaaaaaaaaaaa-delete"));
|
||||
answerConfirm(true);
|
||||
await waitFor(() => expect(sagiPrefs.getPet()).toEqual({ id: "sagi" }));
|
||||
@@ -276,7 +329,7 @@ describe("PetSettings", () => {
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "Dog.png", { type: "image/png" });
|
||||
fireEvent.change(screen.getByTestId("pet-settings-image-input"), { target: { files: [file] } });
|
||||
await waitFor(() =>
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image" })
|
||||
expect(sagiPrefs.getPet()).toEqual({ id: "p-bbbbbbbbbbbb", kind: "image", name: "Dog" })
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -88,6 +88,93 @@ describe("Sagi widget", () => {
|
||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("names the errored session in its bubble and in the panel", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
type: "session_updated",
|
||||
data: { id: "a", status: "error", name: "Fix login", cwd: "/w/app", lane_id: 3 } as Session,
|
||||
timestamp: "t",
|
||||
});
|
||||
});
|
||||
const bubble = screen.getByRole("status");
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays quiet about a cleared error when that session updates again", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear alerts" }));
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "error"));
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(within(btn).queryByText("1")).not.toBeInTheDocument();
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("renames the pet from the panel header", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hands a question it cannot answer to the chat window", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("has no Chat button — Ask opens the chat — and Back returns to the panel", () => {
|
||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
expect(screen.queryByRole("button", { name: /chat with sagi/i })).not.toBeInTheDocument();
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
const pet = screen.getByRole("button", { name: /close sagi/i, expanded: true });
|
||||
expect(pet.querySelector(".sagi-reaction-text")?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stroking the pet back and forth pats it", () => {
|
||||
renderSagi();
|
||||
const pet = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
const xs = [100, 130, 100, 130, 100, 130, 100];
|
||||
act(() => {
|
||||
for (const x of xs) fireEvent.pointerMove(pet, { clientX: x, clientY: 50, buttons: 0 });
|
||||
});
|
||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reflects the live count in the panel status", () => {
|
||||
renderSagi();
|
||||
act(() => {
|
||||
|
||||
@@ -225,6 +225,43 @@ describe("clearErrors", () => {
|
||||
expect(statusOf(s).liveCount).toBe(1);
|
||||
expect(s.worriedUntil).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a cleared session quiet while it stays errored", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
const again = reduceSagi(s, sessionMsg("b", "error", T0 + 1), T0 + 1);
|
||||
expect(again.pulse).toBeNull();
|
||||
expect(statusOf(again.state).errorCount).toBe(0);
|
||||
expect(again.state.worriedUntil).toBe(0);
|
||||
const ev = reduceSagi(
|
||||
again.state,
|
||||
{ type: "new_event", data: { session_id: "b", event_type: "toolError" }, timestamp: "" } as WSMessage,
|
||||
T0 + 2
|
||||
);
|
||||
expect(ev.pulse).toBeNull();
|
||||
expect(seedSessions(s, [{ id: "b", status: "error" }], T0).sessions.b).toBeUndefined();
|
||||
});
|
||||
|
||||
it("alerts again once a cleared session recovers and fails anew", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "error"), T0));
|
||||
s = clearErrors(s);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("b", "active"), T0));
|
||||
expect(s.dismissed).not.toHaveProperty("b");
|
||||
const r = reduceSagi(s, sessionMsg("b", "error"), T0);
|
||||
expect(r.pulse).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error alerts fire on the transition only", () => {
|
||||
it("does not re-alert on each update of a still-errored session", () => {
|
||||
let s = initialSagiState(T0);
|
||||
({ state: s } = reduceSagi(s, sessionMsg("a", "error"), T0));
|
||||
const r = reduceSagi(s, sessionMsg("a", "error"), T0 + 10_000);
|
||||
expect(r.pulse).toBeNull();
|
||||
expect(statusOf(r.state).errorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Reference the imported constant so it is exercised and tsc-clean.
|
||||
|
||||
@@ -75,4 +75,27 @@ describe("matchIntent", () => {
|
||||
const r = matchIntent("Refactor my auth module", status());
|
||||
expect(r).toEqual({ kind: "handoff", prompt: "Refactor my auth module" });
|
||||
});
|
||||
|
||||
it("understands Vietnamese keywords", () => {
|
||||
const r = matchIntent("có lỗi nào không?", status({ errorCount: 2 }));
|
||||
if (r.kind === "answer") expect(r.text).toContain("2 sessions errored");
|
||||
expect(matchIntent("đang chạy gì vậy", status({ liveCount: 1 })).kind).toBe("answer");
|
||||
expect(matchIntent("tình hình sao rồi", status()).kind).toBe("answer");
|
||||
});
|
||||
|
||||
it("names the sessions it reports when it knows them", () => {
|
||||
const r = matchIntent("any errors?", status({ errorCount: 1 }), {
|
||||
attention: [{ id: "a", kind: "error", name: "Fix login", cwd: null, lane_id: null }],
|
||||
});
|
||||
if (r.kind === "answer") expect(r.text).toContain("errored: Fix login");
|
||||
});
|
||||
|
||||
it("introduces itself by the pet's name and reacts to a pat", () => {
|
||||
const who = matchIntent("who are you", status(), { name: "Mochi" });
|
||||
expect(who).toMatchObject({ kind: "answer", moment: "wave" });
|
||||
if (who.kind === "answer") expect(who.text).toContain("Mochi");
|
||||
expect(matchIntent("pat", status())).toMatchObject({ kind: "answer", moment: "jump" });
|
||||
// "pat" inside another word is not a pat.
|
||||
expect(matchIntent("update the path", status()).kind).toBe("handoff");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,14 +137,7 @@ import type { WSMessage, Session, Agent, RunStatusPayload, DashboardEvent } from
|
||||
|
||||
/** All moods Sagi can express, highest priority first (see `deriveMood`). */
|
||||
export type Mood =
|
||||
| "disconnected"
|
||||
| "worried"
|
||||
| "stuck"
|
||||
| "happy"
|
||||
| "thinking"
|
||||
| "watching"
|
||||
| "sleeping"
|
||||
| "idle";
|
||||
"disconnected" | "worried" | "stuck" | "happy" | "thinking" | "watching" | "sleeping" | "idle";
|
||||
|
||||
/**
|
||||
* A one-shot signal describing what just happened, emitted by `reduceSagi`.
|
||||
@@ -152,13 +145,21 @@ export type Mood =
|
||||
* was irrelevant or non-notable.
|
||||
*/
|
||||
export type SagiPulse =
|
||||
| "session_done"
|
||||
| "session_start"
|
||||
| "subagent_spawn"
|
||||
| "waiting"
|
||||
| "error"
|
||||
| "run_done"
|
||||
| null;
|
||||
"session_done" | "session_start" | "subagent_spawn" | "waiting" | "error" | "run_done" | null;
|
||||
|
||||
/** A short animation the pet plays on an event or a pat, over its mood. */
|
||||
export type PetMoment = "wave" | "jump" | "fail" | "wait" | "review";
|
||||
|
||||
/** Which moment each pulse plays: greet a new session, hop at a finish,
|
||||
* inspect a new subagent, tap a foot while you are awaited, flinch at an error. */
|
||||
export const MOMENT_OF_PULSE: Record<NonNullable<SagiPulse>, PetMoment> = {
|
||||
session_start: "wave",
|
||||
session_done: "jump",
|
||||
run_done: "jump",
|
||||
subagent_spawn: "review",
|
||||
waiting: "wait",
|
||||
error: "fail",
|
||||
};
|
||||
|
||||
export interface SagiStatus {
|
||||
/** Active + waiting sessions (everything not finished/errored). */
|
||||
@@ -182,6 +183,47 @@ export interface SagiState {
|
||||
worriedUntil: number;
|
||||
/** True while an Ask request is in flight (panel). */
|
||||
thinking: boolean;
|
||||
/** Errored sessions the user cleared ("clear alerts"). Their further
|
||||
* updates, agent errors and failure events stay silent until the session
|
||||
* goes active again or ends — otherwise every update of a still-errored
|
||||
* session re-raised the alert the user had just dismissed. */
|
||||
dismissed: Record<string, true>;
|
||||
/** Name and owning workspace of each tracked session, so a bubble or the
|
||||
* panel can say WHICH session it means and open it. */
|
||||
info: Record<string, SessionInfo>;
|
||||
}
|
||||
|
||||
/** What Sagi knows about a session beyond its status. */
|
||||
export interface SessionInfo {
|
||||
name: string | null;
|
||||
cwd: string | null;
|
||||
lane_id: number | null;
|
||||
}
|
||||
|
||||
/** The session a pulse is about, when it is about one. */
|
||||
export interface SagiSubject extends SessionInfo {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** A session that needs the user: errored, or waiting on their input. */
|
||||
export interface AttentionItem extends SagiSubject {
|
||||
kind: "error" | "waiting";
|
||||
}
|
||||
|
||||
const infoOf = (s: {
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}): SessionInfo => ({ name: s.name ?? null, cwd: s.cwd ?? null, lane_id: s.lane_id ?? null });
|
||||
|
||||
/** A session's label for a bubble: its name, else its folder, else a short id. */
|
||||
export function subjectLabel(s: SagiSubject): string {
|
||||
if (s.name && s.name.trim()) return s.name.trim();
|
||||
const base = s.cwd
|
||||
?.replace(/[\\/]+$/, "")
|
||||
.split(/[\\/]/)
|
||||
.pop();
|
||||
return base || s.id.slice(0, 8);
|
||||
}
|
||||
|
||||
// Tunable timing constants (ms).
|
||||
@@ -211,6 +253,8 @@ export function initialSagiState(now: number): SagiState {
|
||||
happyUntil: 0,
|
||||
worriedUntil: 0,
|
||||
thinking: false,
|
||||
dismissed: {},
|
||||
info: {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -256,16 +300,28 @@ export function reduceSagi(
|
||||
state: SagiState,
|
||||
msg: WSMessage,
|
||||
now: number
|
||||
): { state: SagiState; pulse: SagiPulse } {
|
||||
): { state: SagiState; pulse: SagiPulse; subject?: SagiSubject } {
|
||||
// The tracked session an agent/event message belongs to, when known.
|
||||
const known = (id: string | undefined): SagiSubject | undefined =>
|
||||
id ? { id, ...(state.info[id] ?? { name: null, cwd: null, lane_id: null }) } : undefined;
|
||||
switch (msg.type) {
|
||||
case "session_created":
|
||||
case "session_updated": {
|
||||
const s = msg.data as Session;
|
||||
if (!s || !s.id) return { state, pulse: null };
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info, [s.id]: infoOf(s) };
|
||||
const subject: SagiSubject = { id: s.id, ...info[s.id]! };
|
||||
let dismissed = state.dismissed;
|
||||
let pulse: SagiPulse = null;
|
||||
let happyUntil = state.happyUntil;
|
||||
let worriedUntil = state.worriedUntil;
|
||||
// Anything but a (still) errored status ends a dismissal: a later error
|
||||
// of that session is a new one and must alert again.
|
||||
if (s.status !== "error" && s.id in dismissed) {
|
||||
dismissed = { ...dismissed };
|
||||
delete dismissed[s.id];
|
||||
}
|
||||
|
||||
if (s.status === "active") {
|
||||
// "waiting" = active session blocked on user input (permission prompt
|
||||
@@ -280,12 +336,20 @@ export function reduceSagi(
|
||||
if (prev === undefined) pulse = "session_start";
|
||||
}
|
||||
} else if (s.status === "error") {
|
||||
if (s.id in dismissed) {
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
}
|
||||
// Alert on the transition INTO error only; a session that stays
|
||||
// errored keeps getting updates and must not re-alert on each one.
|
||||
if (sessions[s.id] !== "error") {
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
}
|
||||
sessions[s.id] = "error";
|
||||
worriedUntil = now + WORRIED_MS;
|
||||
pulse = "error";
|
||||
} else if (s.status === "completed" || s.status === "abandoned") {
|
||||
const wasTracked = s.id in sessions;
|
||||
delete sessions[s.id];
|
||||
delete info[s.id];
|
||||
if (s.status === "completed") {
|
||||
happyUntil = now + HAPPY_MS;
|
||||
if (wasTracked) pulse = "session_done";
|
||||
@@ -293,31 +357,46 @@ export function reduceSagi(
|
||||
}
|
||||
|
||||
return {
|
||||
state: { ...state, sessions, happyUntil, worriedUntil, lastActivityAt: now },
|
||||
state: {
|
||||
...state,
|
||||
sessions,
|
||||
dismissed,
|
||||
info,
|
||||
happyUntil,
|
||||
worriedUntil,
|
||||
lastActivityAt: now,
|
||||
},
|
||||
pulse,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_created": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
// A freshly spawned subagent is worth announcing; the main agent landing
|
||||
// is already covered by session_start.
|
||||
const pulse: SagiPulse = a && a.type === "subagent" ? "subagent_spawn" : null;
|
||||
return { state: { ...state, lastActivityAt: now }, pulse };
|
||||
return {
|
||||
state: { ...state, lastActivityAt: now },
|
||||
pulse,
|
||||
subject: pulse ? known(a.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
case "agent_updated": {
|
||||
const a = msg.data as Agent;
|
||||
if (a && a.status === "error") {
|
||||
if (a && a.status === "error" && !(a.session_id in state.dismissed)) {
|
||||
return {
|
||||
state: { ...state, worriedUntil: now + WORRIED_MS, lastActivityAt: now },
|
||||
pulse: "error",
|
||||
subject: known(a.session_id),
|
||||
};
|
||||
}
|
||||
return { state: { ...state, lastActivityAt: now }, pulse: null };
|
||||
@@ -325,7 +404,8 @@ export function reduceSagi(
|
||||
|
||||
case "new_event": {
|
||||
const e = msg.data as DashboardEvent;
|
||||
const isFailure = !!e && FAILURE_EVENT_TYPES.has(e.event_type);
|
||||
const isFailure =
|
||||
!!e && FAILURE_EVENT_TYPES.has(e.event_type) && !(e.session_id in state.dismissed);
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
@@ -333,6 +413,7 @@ export function reduceSagi(
|
||||
worriedUntil: isFailure ? now + WORRIED_MS : state.worriedUntil,
|
||||
},
|
||||
pulse: isFailure ? "error" : null,
|
||||
subject: isFailure ? known(e.session_id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,24 +442,60 @@ export function reduceSagi(
|
||||
*/
|
||||
export function seedSessions(
|
||||
state: SagiState,
|
||||
rows: ReadonlyArray<{ id: string; status: string; awaiting_input_since?: string | null }>,
|
||||
rows: ReadonlyArray<{
|
||||
id: string;
|
||||
status: string;
|
||||
awaiting_input_since?: string | null;
|
||||
name?: string | null;
|
||||
cwd?: string | null;
|
||||
lane_id?: number | null;
|
||||
}>,
|
||||
now: number
|
||||
): SagiState {
|
||||
const sessions = { ...state.sessions };
|
||||
const info = { ...state.info };
|
||||
for (const r of rows) {
|
||||
if (!r || !r.id) continue;
|
||||
if (r.status === "error") sessions[r.id] = "error";
|
||||
else if (r.status === "active") sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
// completed / abandoned: leave untracked.
|
||||
if (r.status === "error") {
|
||||
if (r.id in state.dismissed) continue;
|
||||
sessions[r.id] = "error";
|
||||
} else if (r.status === "active") {
|
||||
sessions[r.id] = r.awaiting_input_since ? "waiting" : "active";
|
||||
} else continue; // completed / abandoned: leave untracked.
|
||||
info[r.id] = infoOf(r);
|
||||
}
|
||||
return { ...state, sessions, lastActivityAt: now };
|
||||
return { ...state, sessions, info, lastActivityAt: now };
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts"). */
|
||||
/** Errored sessions first, then the ones waiting on the user — the panel's list. */
|
||||
export function attentionOf(state: SagiState): AttentionItem[] {
|
||||
const out: AttentionItem[] = [];
|
||||
for (const kind of ["error", "waiting"] as const) {
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== kind) continue;
|
||||
out.push({
|
||||
id,
|
||||
kind,
|
||||
...(state.info[id] ?? { name: null, cwd: null, lane_id: null }),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Drop all errored sessions from tracking (used by "clear alerts") and
|
||||
* remember them as dismissed, so their next update does not bring the alert
|
||||
* straight back. */
|
||||
export function clearErrors(state: SagiState): SagiState {
|
||||
const sessions: SagiState["sessions"] = {};
|
||||
const dismissed = { ...state.dismissed };
|
||||
const info = { ...state.info };
|
||||
for (const [id, s] of Object.entries(state.sessions)) {
|
||||
if (s !== "error") sessions[id] = s;
|
||||
else {
|
||||
dismissed[id] = true;
|
||||
delete info[id];
|
||||
}
|
||||
}
|
||||
return { ...state, sessions, worriedUntil: 0 };
|
||||
return { ...state, sessions, dismissed, info, worriedUntil: 0 };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* @description Sagi's local "Ask" brain. Matches a free-text question against a
|
||||
* small set of intents answerable from cached dashboard status. Anything it
|
||||
* can't answer becomes a handoff to the Run page (spawn a real `claude`).
|
||||
* Pure function - no network, no DOM - so it's fully unit-testable.
|
||||
* Understands English and Vietnamese keywords, names the sessions it reports
|
||||
* when it knows them, and answers in the UI language (`settings:sagi.intent`).
|
||||
* No network, no DOM - so it's fully unit-testable.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -28,6 +30,8 @@
|
||||
*
|
||||
* ## Public surface
|
||||
* - `AskResult` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `AskContext` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `statusSummary` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `matchIntent` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -54,70 +58,157 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import type { SagiStatus } from "./brain";
|
||||
import i18n from "../../i18n";
|
||||
import { subjectLabel, type AttentionItem, type PetMoment, type SagiStatus } from "./brain";
|
||||
import { pickLine } from "./quips";
|
||||
|
||||
export type AskResult = { kind: "answer"; text: string } | { kind: "handoff"; prompt: string };
|
||||
export type AskResult =
|
||||
{ kind: "answer"; text: string; moment?: PetMoment } | { kind: "handoff"; prompt: string };
|
||||
|
||||
const plural = (n: number) => (n === 1 ? "" : "s");
|
||||
/** What the answer may mention beyond the counts. */
|
||||
export interface AskContext {
|
||||
/** The pet's shown name. */
|
||||
name?: string;
|
||||
/** Errored and waiting sessions, named (`attentionOf`). */
|
||||
attention?: ReadonlyArray<AttentionItem>;
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus): AskResult {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: "ask me about your sessions - what's running, any errors, or a quick status.",
|
||||
};
|
||||
}
|
||||
const t = (key: string, vars?: Record<string, unknown>) =>
|
||||
i18n.t(`settings:sagi.intent.${key}`, vars ?? {});
|
||||
|
||||
/** ": a, b, c" for up to three named sessions of a kind, "" when none known. */
|
||||
function listOf(items: ReadonlyArray<AttentionItem> | undefined, kind: AttentionItem["kind"]) {
|
||||
const names = (items ?? []).filter((i) => i.kind === kind).map(subjectLabel);
|
||||
if (names.length === 0) return "";
|
||||
const shown = names.slice(0, 3).join(", ");
|
||||
return `: ${names.length > 3 ? `${shown}…` : shown}`;
|
||||
}
|
||||
|
||||
/** "2 sessions live, 1 waiting on you" — the one-line status a greeting carries. */
|
||||
export function statusSummary(status: SagiStatus): string {
|
||||
const parts = [i18n.t("settings:sagi.greet.summary", { count: status.liveCount })];
|
||||
if (status.waitingCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryWaiting", { count: status.waitingCount }));
|
||||
if (status.errorCount > 0)
|
||||
parts.push(i18n.t("settings:sagi.greet.summaryErrors", { count: status.errorCount }));
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export function matchIntent(query: string, status: SagiStatus, ctx: AskContext = {}): AskResult {
|
||||
const q = query.trim().toLowerCase().normalize("NFC");
|
||||
const name = ctx.name || "Sagi";
|
||||
if (!q) return { kind: "answer", text: t("empty") };
|
||||
|
||||
const has = (...words: string[]) => words.some((w) => q.includes(w));
|
||||
// Whole-word match, for short words that hide inside longer ones ("hi" in "this").
|
||||
const word = (...words: string[]) =>
|
||||
words.some((w) => new RegExp(`(^|[^\\p{L}])${w}($|[^\\p{L}])`, "u").test(q));
|
||||
|
||||
if (has("help", "what can you", "what do you do")) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: 'I watch your sessions. Try "what\'s running", "any errors", or "status". Anything else, I\'ll hand to Claude.',
|
||||
};
|
||||
if (has("help", "what can you", "what do you do", "trợ giúp", "làm được gì", "hướng dẫn")) {
|
||||
return { kind: "answer", text: t("help", { name }) };
|
||||
}
|
||||
|
||||
if (has("who are you", "your name", "bạn là ai", "tên gì", "tên bạn", "là ai")) {
|
||||
return { kind: "answer", text: t("whoami", { name }), moment: "wave" };
|
||||
}
|
||||
|
||||
if (has("thank", "thx", "cảm ơn", "cám ơn") || word("ty", "tks")) {
|
||||
return { kind: "answer", text: pickLine("intent.thanks"), moment: "jump" };
|
||||
}
|
||||
|
||||
if (
|
||||
has("good boy", "good girl", "cute", "ngoan", "dễ thương", "xoa đầu", "vuốt ve", "giỏi quá") ||
|
||||
word("pat")
|
||||
) {
|
||||
return { kind: "answer", text: pickLine("pat"), moment: "jump" };
|
||||
}
|
||||
|
||||
// Errors first: "any failed runs" should report errors, not live count.
|
||||
if (has("error", "broke", "broken", "fail", "wrong", "crash")) {
|
||||
if (
|
||||
has("error", "broke", "broken", "fail", "wrong", "crash", "lỗi", "hỏng", "thất bại", "sự cố")
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.errorCount > 0
|
||||
? `${status.errorCount} session${plural(status.errorCount)} errored - open the panel to jump to them.`
|
||||
: "no errors - all clean 🐾",
|
||||
? t("errors", { count: status.errorCount, list: listOf(ctx.attention, "error") })
|
||||
: t("errorsNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("waiting", "stuck", "blocked", "input", "my turn", "paused")) {
|
||||
if (
|
||||
has(
|
||||
"waiting",
|
||||
"stuck",
|
||||
"blocked",
|
||||
"input",
|
||||
"my turn",
|
||||
"paused",
|
||||
"chờ",
|
||||
"đợi",
|
||||
"kẹt",
|
||||
"tới lượt",
|
||||
"đến lượt"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.waitingCount > 0
|
||||
? `${status.waitingCount} session${plural(status.waitingCount)} waiting on you 👀`
|
||||
: "nothing's waiting on you right now 🐾",
|
||||
? t("waiting", { count: status.waitingCount, list: listOf(ctx.attention, "waiting") })
|
||||
: t("waitingNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("running", "active", "live", "going on", "happening", "in progress")) {
|
||||
const tail = status.waitingCount > 0 ? ` (${status.waitingCount} waiting on you 👀)` : "";
|
||||
if (
|
||||
has(
|
||||
"running",
|
||||
"active",
|
||||
"live",
|
||||
"going on",
|
||||
"happening",
|
||||
"in progress",
|
||||
"đang chạy",
|
||||
"đang làm",
|
||||
"hoạt động"
|
||||
)
|
||||
) {
|
||||
const tail = status.waitingCount > 0 ? t("liveTail", { count: status.waitingCount }) : "";
|
||||
return {
|
||||
kind: "answer",
|
||||
text:
|
||||
status.liveCount > 0
|
||||
? `${status.liveCount} session${plural(status.liveCount)} live right now 🐾${tail}`
|
||||
: "nothing's running right now - all quiet.",
|
||||
text: status.liveCount > 0 ? t("live", { count: status.liveCount, tail }) : t("liveNone"),
|
||||
};
|
||||
}
|
||||
|
||||
if (has("status", "summary", "overview", "how are things", "how's it", "how is it")) {
|
||||
if (
|
||||
has(
|
||||
"status",
|
||||
"summary",
|
||||
"overview",
|
||||
"how are things",
|
||||
"how's it",
|
||||
"how is it",
|
||||
"tình hình",
|
||||
"trạng thái",
|
||||
"tổng quan",
|
||||
"thế nào",
|
||||
"sao rồi"
|
||||
)
|
||||
) {
|
||||
return {
|
||||
kind: "answer",
|
||||
text: `${status.liveCount} live · ${status.waitingCount} waiting · ${status.errorCount} errored · ${
|
||||
status.connected ? "connected" : "offline"
|
||||
}.`,
|
||||
text: t("status", {
|
||||
live: status.liveCount,
|
||||
waiting: status.waitingCount,
|
||||
errors: status.errorCount,
|
||||
conn: t(status.connected ? "connected" : "offline"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (word("hi", "hello", "hey", "yo", "chào", "xin chào", "alo")) {
|
||||
return { kind: "answer", text: t("hello", { summary: statusSummary(status) }), moment: "wave" };
|
||||
}
|
||||
|
||||
return { kind: "handoff", prompt: query.trim() };
|
||||
}
|
||||
|
||||
@@ -56,12 +56,19 @@ const MUTED_KEY = "agent-dashboard-sagi-muted";
|
||||
const POS_KEY = "agent-dashboard-sagi-pos";
|
||||
const PET_KEY = "agenthub.pet";
|
||||
const SIZE_KEY = "agenthub.pet.size";
|
||||
const DISMISSED_KEY = "agenthub.pet.dismissedErrors";
|
||||
/** Per-pet nicknames the user typed, `{ [petId]: name }`. */
|
||||
const NAMES_KEY = "agenthub.pet.names";
|
||||
export const PET_NAME_MAX = 40;
|
||||
/** Most recent dismissed error sessions kept across reloads. */
|
||||
const DISMISSED_MAX = 200;
|
||||
/** Sagi's own size, and the slider's range. */
|
||||
export const PET_SIZE = { default: 68, min: 48, max: 250 };
|
||||
|
||||
/** The pet the mascot wears: Sagi itself, or a custom one (`/api/pets`).
|
||||
* The kind rides along so the mascot renders without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image" };
|
||||
* The kind (and its display name) ride along so the mascot renders, and
|
||||
* introduces itself, without a fetch. */
|
||||
export type PetChoice = { id: "sagi" } | { id: string; kind: "codex" | "image"; name?: string };
|
||||
const SAGI: PetChoice = { id: "sagi" };
|
||||
|
||||
function readPet(): PetChoice {
|
||||
@@ -69,6 +76,7 @@ function readPet(): PetChoice {
|
||||
const p = JSON.parse(localStorage.getItem(PET_KEY) ?? "null") as Partial<{
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
}> | null;
|
||||
if (
|
||||
p &&
|
||||
@@ -76,13 +84,34 @@ function readPet(): PetChoice {
|
||||
p.id !== "sagi" &&
|
||||
(p.kind === "codex" || p.kind === "image")
|
||||
)
|
||||
return { id: p.id, kind: p.kind };
|
||||
return typeof p.name === "string" && p.name
|
||||
? { id: p.id, kind: p.kind, name: p.name.slice(0, PET_NAME_MAX) }
|
||||
: { id: p.id, kind: p.kind };
|
||||
} catch {
|
||||
/* unreadable: Sagi */
|
||||
}
|
||||
return SAGI;
|
||||
}
|
||||
|
||||
function readNames(): Record<string, string> {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(NAMES_KEY) ?? "{}") as unknown;
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [id, v] of Object.entries(raw)) if (typeof v === "string" && v) out[id] = v;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** The name the pet goes by: the user's nickname, else the pet's own, else Sagi. */
|
||||
export function petName(pet: PetChoice): string {
|
||||
const nick = readNames()[pet.id];
|
||||
if (nick) return nick;
|
||||
return ("name" in pet && pet.name) || "Sagi";
|
||||
}
|
||||
|
||||
function readSize(): number {
|
||||
const n = Number(localStorage.getItem(SIZE_KEY));
|
||||
return Number.isFinite(n) && n > 0
|
||||
@@ -181,6 +210,37 @@ export const sagiPrefs = {
|
||||
SIZE_KEY,
|
||||
String(Math.round(Math.min(PET_SIZE.max, Math.max(PET_SIZE.min, px))))
|
||||
),
|
||||
/** Name a pet; an empty name gives it back its own. */
|
||||
setNickname: (id: string, name: string) => {
|
||||
const names = readNames();
|
||||
const clean = name.replace(/\s+/g, " ").trim().slice(0, PET_NAME_MAX);
|
||||
if (clean) names[id] = clean;
|
||||
else delete names[id];
|
||||
writeAndNotify(NAMES_KEY, JSON.stringify(names));
|
||||
},
|
||||
/** The user's nickname for a pet, "" when none. */
|
||||
getNickname: (id: string) => readNames()[id] ?? "",
|
||||
/** Errored session ids the user cleared, so a reload keeps them quiet. */
|
||||
getDismissed: (): Record<string, true> => {
|
||||
try {
|
||||
const ids = JSON.parse(localStorage.getItem(DISMISSED_KEY) || "[]");
|
||||
const out: Record<string, true> = {};
|
||||
if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") out[id] = true;
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
setDismissed: (dismissed: Record<string, true>) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
DISMISSED_KEY,
|
||||
JSON.stringify(Object.keys(dismissed).slice(-DISMISSED_MAX))
|
||||
);
|
||||
} catch {
|
||||
// Best-effort, like every pref here; no prefs event — widget-local state.
|
||||
}
|
||||
},
|
||||
/** Subscribe to any pref change; returns an unsubscribe fn. */
|
||||
subscribe(handler: () => void): () => void {
|
||||
const listener = () => handler();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file quips.ts
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood,
|
||||
* plus a deterministic-by-injection picker. Pure data + a pure function so it
|
||||
* can be unit-tested without randomness leaking in.
|
||||
* @description Sagi's personality: pools of short phrases keyed by pulse/mood
|
||||
* (and greetings, pat reactions), read from the `settings:sagi.*` i18n arrays
|
||||
* so they follow the UI language, plus a deterministic-by-injection picker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -24,10 +24,13 @@
|
||||
*
|
||||
* ## Internal dependencies
|
||||
* - `./brain`
|
||||
* - `../../i18n`
|
||||
*
|
||||
* ## Public surface
|
||||
* - `QuipKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickQuip` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `pickLine` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `greetingKey` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `ALL_QUIP_KEYS` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -59,76 +62,60 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import i18n from "../../i18n";
|
||||
import type { Mood, SagiPulse } from "./brain";
|
||||
|
||||
export type QuipKey = NonNullable<SagiPulse> | Mood;
|
||||
|
||||
const QUIPS: Record<QuipKey, string[]> = {
|
||||
export const ALL_QUIP_KEYS: QuipKey[] = [
|
||||
// Pulses (event-driven, transient bubbles)
|
||||
session_done: [
|
||||
"a session just wrapped up! 🐾",
|
||||
"a session finished - nice work! ✨",
|
||||
"that session's all done 😺",
|
||||
"clean finish on that one 💜",
|
||||
],
|
||||
session_start: [
|
||||
"a new session started! 👀",
|
||||
"a fresh session just landed 🐾",
|
||||
"ooh, a new session to watch 😻",
|
||||
"something new is cooking 🍲",
|
||||
],
|
||||
subagent_spawn: [
|
||||
"a subagent just spawned! 🐾",
|
||||
"a little helper joined in 🤝",
|
||||
"a subagent's on the job 🚀",
|
||||
"reinforcements - new subagent! 😺",
|
||||
],
|
||||
waiting: [
|
||||
"a session needs your input 👀",
|
||||
"a session is waiting on you ⏳",
|
||||
"a session paused for your reply 💬",
|
||||
"your turn - a session's waiting 🐾",
|
||||
],
|
||||
error: [
|
||||
"uh oh, a session hit an error 😿",
|
||||
"something broke - wanna peek? 🙀",
|
||||
"a hook tripped on something ⚠️",
|
||||
"hiss… an error popped up 💢",
|
||||
],
|
||||
run_done: [
|
||||
"your run just finished! 🐾",
|
||||
"the run's all wrapped up ✨",
|
||||
"run complete - that's a wrap 😸",
|
||||
"all done with that run 💜",
|
||||
],
|
||||
// Moods (steady-state flavor, used by the panel / idle bubbles)
|
||||
disconnected: [
|
||||
"lost the connection… 😴",
|
||||
"can't reach the server 📡",
|
||||
"no signal - taking a nap 💤",
|
||||
],
|
||||
worried: ["that didn't look right 😟", "keeping an eye out 👀", "hmm, something's off 🫣"],
|
||||
stuck: [
|
||||
"a session's been quiet a while… 🤔",
|
||||
"is something stuck? ⏳",
|
||||
"still chewing on it… 😾",
|
||||
],
|
||||
happy: ["great run! 😻", "love a tidy finish ✨", "purrfect 💜"],
|
||||
thinking: ["hmm, let me look… 🤔", "sniffing around… 🐾", "one sec, checking 🔍"],
|
||||
watching: ["on the prowl 👀", "watching your sessions 😼", "eyes peeled 🐾"],
|
||||
sleeping: ["zzz… 💤", "wake me if something happens 😴", "curled up, all calm 🐈"],
|
||||
idle: ["all quiet 😺", "ready when you are 🐾", "just vibing ✨"],
|
||||
};
|
||||
"session_done",
|
||||
"session_start",
|
||||
"subagent_spawn",
|
||||
"waiting",
|
||||
"error",
|
||||
"run_done",
|
||||
// Moods (steady-state flavor)
|
||||
"disconnected",
|
||||
"worried",
|
||||
"stuck",
|
||||
"happy",
|
||||
"thinking",
|
||||
"watching",
|
||||
"sleeping",
|
||||
"idle",
|
||||
];
|
||||
const KNOWN = new Set<string>(ALL_QUIP_KEYS);
|
||||
|
||||
/**
|
||||
* Pick a quip for a key. `rand` is injectable for deterministic tests; defaults
|
||||
* to Math.random. Returns "" only for an unknown key (never throws).
|
||||
* Pick one line from an i18n string array under `settings:sagi.<path>`.
|
||||
* `rand` is injectable for deterministic tests. Returns "" for a missing or
|
||||
* empty pool (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
const pool = QUIPS[key];
|
||||
if (!pool || pool.length === 0) return "";
|
||||
export function pickLine(
|
||||
path: string,
|
||||
rand: () => number = Math.random,
|
||||
vars?: Record<string, unknown>
|
||||
): string {
|
||||
const pool = i18n.t(`settings:sagi.${path}`, { returnObjects: true, ...vars }) as unknown;
|
||||
if (!Array.isArray(pool) || pool.length === 0) return "";
|
||||
const i = Math.min(pool.length - 1, Math.max(0, Math.floor(rand() * pool.length)));
|
||||
return pool[i] ?? "";
|
||||
const line = pool[i];
|
||||
return typeof line === "string" ? line : "";
|
||||
}
|
||||
|
||||
export const ALL_QUIP_KEYS = Object.keys(QUIPS) as QuipKey[];
|
||||
/**
|
||||
* Pick a quip for a key, in the UI language. Returns "" only for an unknown
|
||||
* key (never throws).
|
||||
*/
|
||||
export function pickQuip(key: QuipKey, rand: () => number = Math.random): string {
|
||||
return KNOWN.has(key) ? pickLine(`quips.${key}`, rand) : "";
|
||||
}
|
||||
|
||||
/** Which greeting pool fits the hour: morning 5–11, afternoon 12–17, evening 18–22, else night. */
|
||||
export function greetingKey(hour: number): "morning" | "afternoon" | "evening" | "night" {
|
||||
if (hour >= 5 && hour < 12) return "morning";
|
||||
if (hour >= 12 && hour < 18) return "afternoon";
|
||||
if (hour >= 18 && hour < 23) return "evening";
|
||||
return "night";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/**
|
||||
* sagi.css - animations + mood expressions for the Sagi companion.
|
||||
* Colors mirror the app's theme tokens (accent #6366f1/#818cf8, surface scale)
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. All continuous
|
||||
* motion is disabled under [data-reduced="1"] and prefers-reduced-motion.
|
||||
* with warm pink accents (ears, cheeks, nose) for cuteness. No mood animates
|
||||
* forever: each plays a few cycles when the mood begins and then rests — an
|
||||
* infinite animation makes the browser draw a new frame 60 times a second for
|
||||
* as long as the page is open (the drop-shadow filter re-rendered each time),
|
||||
* which made the whole desktop stutter. All motion is disabled under
|
||||
* [data-reduced="1"] and prefers-reduced-motion.
|
||||
* Transform origins are the delicate part - see the comments on .sagi-avatar
|
||||
* and .sagi-eye-blink for which reference box each one resolves against.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -206,20 +210,20 @@
|
||||
|
||||
/* idle: gentle breathing + occasional blink */
|
||||
.sagi-avatar[data-mood="idle"] {
|
||||
animation: sagi-breathe 4s ease-in-out infinite;
|
||||
animation: sagi-breathe 4s ease-in-out 0s 3;
|
||||
}
|
||||
/* Blink lives on the inner group so it never overrides the outer group's
|
||||
eye-tracking translate (which would freeze the eyes - see the CSS-blink
|
||||
note in SagiAvatar.tsx). */
|
||||
.sagi-avatar[data-mood="idle"] .sagi-eye-blink {
|
||||
animation: sagi-blink 5s ease-in-out infinite;
|
||||
animation: sagi-blink 5s ease-in-out 0s 3;
|
||||
}
|
||||
/* happy: whole-mascot bob + twinkling sparkle */
|
||||
.sagi-avatar[data-mood="happy"] {
|
||||
animation: sagi-bob 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-mood="happy"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out infinite;
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px; /* sparkle centre, mascot 1050,380 */
|
||||
}
|
||||
/* worried: alarmed shake */
|
||||
@@ -228,19 +232,97 @@
|
||||
}
|
||||
/* stuck: slow, drooping breathe (fatigued) */
|
||||
.sagi-avatar[data-mood="stuck"] {
|
||||
animation: sagi-droop 2.4s ease-in-out infinite;
|
||||
animation: sagi-droop 2.4s ease-in-out 0s 3;
|
||||
}
|
||||
/* thinking: rocking head tilt */
|
||||
.sagi-avatar[data-mood="thinking"] {
|
||||
animation: sagi-rock 1.8s ease-in-out infinite;
|
||||
animation: sagi-rock 1.8s ease-in-out 0s 4;
|
||||
}
|
||||
/* watching: attentive sway while a session is live */
|
||||
.sagi-avatar[data-mood="watching"] {
|
||||
animation: sagi-sway 3.2s ease-in-out infinite;
|
||||
animation: sagi-sway 3.2s ease-in-out 0s 3;
|
||||
}
|
||||
/* sleeping: slow breathe, droop */
|
||||
.sagi-avatar[data-mood="sleeping"] {
|
||||
animation: sagi-breathe 5s ease-in-out infinite;
|
||||
animation: sagi-breathe 5s ease-in-out 0s 3;
|
||||
}
|
||||
|
||||
/* ── moments: one-shot reactions to an event or a pat (useSagiBrain), played
|
||||
over the mood for ~2.4 s. Declared after the moods so they win the cascade.
|
||||
Shared by Sagi's own art and an uploaded still image. ── */
|
||||
.sagi-avatar[data-moment="jump"],
|
||||
.sagi-pet-image[data-moment="jump"] {
|
||||
animation: sagi-hop 0.6s cubic-bezier(0.3, 0, 0.4, 1) 0s 3;
|
||||
}
|
||||
.sagi-avatar[data-moment="wave"],
|
||||
.sagi-pet-image[data-moment="wave"] {
|
||||
animation: sagi-wiggle 0.5s ease-in-out 0s 4;
|
||||
}
|
||||
.sagi-avatar[data-moment="fail"],
|
||||
.sagi-pet-image[data-moment="fail"] {
|
||||
animation: sagi-shake 0.35s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="wait"],
|
||||
.sagi-pet-image[data-moment="wait"] {
|
||||
animation: sagi-tap 0.4s ease-in-out 0s 5;
|
||||
}
|
||||
.sagi-avatar[data-moment="review"],
|
||||
.sagi-pet-image[data-moment="review"] {
|
||||
animation: sagi-peek 2.2s ease-in-out 0s 1;
|
||||
}
|
||||
.sagi-avatar[data-moment="jump"] .sagi-sparkle {
|
||||
animation: sagi-sparkle-twinkle 0.9s ease-in-out 0s 4;
|
||||
transform-origin: 797px 274px;
|
||||
}
|
||||
@keyframes sagi-hop {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) scale(1, 1);
|
||||
}
|
||||
15% {
|
||||
transform: translateY(0) scale(1.08, 0.92);
|
||||
}
|
||||
45% {
|
||||
transform: translateY(-14%) scale(0.96, 1.05);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(0) scale(1.04, 0.96);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-9deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(9deg);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-tap {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4%);
|
||||
}
|
||||
}
|
||||
@keyframes sagi-peek {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) translateX(0);
|
||||
}
|
||||
25%,
|
||||
40% {
|
||||
transform: rotate(-7deg) translateX(-4%);
|
||||
}
|
||||
60%,
|
||||
75% {
|
||||
transform: rotate(7deg) translateX(4%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── reduced motion: kill all continuous animation ── */
|
||||
@@ -339,7 +421,15 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #0c0c14;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out infinite;
|
||||
animation: sagi-error-pulse 1.2s ease-in-out 0s 5;
|
||||
}
|
||||
/* Sessions waiting on the user, when none errored: the same dot, amber. */
|
||||
.sagi-error-dot[data-kind="waiting"] {
|
||||
background: #f59e0b;
|
||||
animation: none;
|
||||
}
|
||||
.sagi-pet-image {
|
||||
transform-origin: 50% 92%;
|
||||
}
|
||||
.sagi-bubble {
|
||||
max-width: 16rem;
|
||||
@@ -356,6 +446,41 @@
|
||||
.sagi-bubble-enter {
|
||||
animation: sagi-bubble-in 0.22s ease-out;
|
||||
}
|
||||
.sagi-bubble[data-tone="error"] {
|
||||
border-color: rgba(239, 68, 68, 0.6);
|
||||
}
|
||||
.sagi-bubble[data-tone="waiting"] {
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
}
|
||||
/* The session(s) a bubble is about, and the button that opens it. */
|
||||
.sagi-bubble-detail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: #a8a8c0;
|
||||
}
|
||||
.sagi-bubble-subject {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sagi-bubble-open {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #c4b5fd;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sagi-bubble-open:hover {
|
||||
background: rgba(139, 92, 246, 0.35);
|
||||
}
|
||||
@keyframes sagi-bubble-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -375,7 +500,7 @@
|
||||
/* An uploaded still image has no frames of its own: it bobs, faster while an
|
||||
agent works and slower asleep. A Codex spritesheet animates by its frames. */
|
||||
.sagi-pet-image {
|
||||
animation: sagi-pet-bob 2.6s ease-in-out infinite;
|
||||
animation: sagi-pet-bob 2.6s ease-in-out 0s 3;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
.sagi-pet-image[data-mood="thinking"] {
|
||||
@@ -403,3 +528,165 @@
|
||||
.sagi-flyout[data-front="1"] {
|
||||
z-index: 42;
|
||||
}
|
||||
|
||||
/* ════════ the pet's chat window (PetChat.tsx) ════════ */
|
||||
|
||||
/* The reference's soft gradient, in the theme's own accent rather than a fixed
|
||||
colour: a tint of the accent at the top fading into the surface, so it
|
||||
reads in every light and dark theme. Replies sit on the plain surface like
|
||||
the reference's white bubbles; the user's own are accent-solid (white text
|
||||
is only ever on accent-solid). */
|
||||
.pet-chat {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, rgb(var(--accent)) 22%, rgb(var(--surface-1))) 0%,
|
||||
color-mix(in srgb, rgb(var(--accent)) 9%, rgb(var(--surface-1))) 45%,
|
||||
rgb(var(--surface-1)) 100%
|
||||
);
|
||||
}
|
||||
.pet-chat-head {
|
||||
border-bottom: 1px solid color-mix(in srgb, rgb(var(--accent)) 18%, transparent);
|
||||
}
|
||||
.pet-chat-reply {
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 12%, transparent);
|
||||
}
|
||||
.pet-chat-tools {
|
||||
background: color-mix(in srgb, rgb(var(--surface-0)) 70%, transparent);
|
||||
border: 1px dashed color-mix(in srgb, rgb(var(--accent)) 25%, transparent);
|
||||
}
|
||||
.pet-chat-foot {
|
||||
background: rgb(var(--surface-0));
|
||||
border-top: 1px solid rgb(var(--border) / 0.6);
|
||||
}
|
||||
/* Three bouncing dots while a reply is written. */
|
||||
.pet-chat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--accent));
|
||||
opacity: 0.5;
|
||||
animation: pet-chat-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
.pet-chat-dot:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
.pet-chat-dot:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes pet-chat-dot {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.35;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-4px);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
/* The chat's terminal, attached but off-screen. It keeps a real size (so tmux
|
||||
does not shrink the pane to fit it) and a non-zero box (so the paste
|
||||
registry counts it as on screen). */
|
||||
.pet-chat-term {
|
||||
position: fixed;
|
||||
left: -10000px;
|
||||
top: 0;
|
||||
width: 760px;
|
||||
height: 460px;
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-chat-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ════════ a pat's reaction, drawn on the pet itself ════════ */
|
||||
|
||||
/* Hearts float up from the pet and its line shows beside it, inside the
|
||||
avatar button — so a pat reads even while the panel or the chat is open
|
||||
(the speech bubble hides then). */
|
||||
.sagi-reaction {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sagi-hearts {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 10%;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.sagi-hearts > span {
|
||||
animation: sagi-heart-up 1.6s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
font-size: 18px;
|
||||
}
|
||||
.sagi-hearts > span:nth-child(3) {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
@keyframes sagi-heart-up {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.6);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translateY(-4px) scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-34px) scale(0.9);
|
||||
}
|
||||
}
|
||||
/* The line sits on the side away from the screen edge the pet docks to. */
|
||||
.sagi-reaction-text {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
white-space: nowrap;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(var(--surface-0));
|
||||
border: 1px solid color-mix(in srgb, rgb(var(--accent)) 35%, transparent);
|
||||
color: rgb(var(--fg-primary));
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.1rem;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
animation: sagi-reaction-in 2.4s ease-out forwards;
|
||||
}
|
||||
.sagi-reaction-text[data-side="right"] {
|
||||
right: calc(100% + 6px);
|
||||
}
|
||||
.sagi-reaction-text[data-side="left"] {
|
||||
left: calc(100% + 6px);
|
||||
}
|
||||
@keyframes sagi-reaction-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
}
|
||||
10%,
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sagi-hearts > span,
|
||||
.sagi-reaction-text {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function usePets() {
|
||||
}
|
||||
};
|
||||
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind });
|
||||
const wear = (p: PetView) => sagiPrefs.setPet({ id: p.id, kind: p.kind, name: p.displayName });
|
||||
const install = (featuredId: string) =>
|
||||
run(featuredId, async () => {
|
||||
wear((await api.pets.installFeatured(featuredId)).pet);
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* @file useSagiBrain.ts
|
||||
* @description React hook that wires the pure Sagi brain to the live event bus
|
||||
* and to real timers. It is the only unit that subscribes to `eventBus`. It
|
||||
* exposes the derived mood, a status summary, the current speech bubble, and
|
||||
* imperative controls (mute, clear alerts, set thinking) for the UI shell.
|
||||
* exposes the derived mood, a status summary, the sessions that need the user,
|
||||
* the current speech bubble (naming the session it is about, and coalescing a
|
||||
* burst of the same event into one count), the pet's one-shot animation
|
||||
* "moment", and imperative controls (mute, clear alerts, pat, set thinking)
|
||||
* for the UI shell. It also greets once per browser session and makes an
|
||||
* idle pet fidget now and then.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -62,124 +66,303 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { api } from "../../lib/api";
|
||||
import i18n from "../../i18n";
|
||||
import type { WSMessage } from "../../lib/types";
|
||||
import {
|
||||
initialSagiState,
|
||||
reduceSagi,
|
||||
deriveMood,
|
||||
statusOf,
|
||||
attentionOf,
|
||||
clearErrors,
|
||||
seedSessions,
|
||||
subjectLabel,
|
||||
MOMENT_OF_PULSE,
|
||||
type AttentionItem,
|
||||
type Mood,
|
||||
type PetMoment,
|
||||
type SagiPulse,
|
||||
type SagiState,
|
||||
type SagiStatus,
|
||||
type SagiSubject,
|
||||
} from "./brain";
|
||||
import { pickQuip } from "./quips";
|
||||
import { greetingKey, pickLine, pickQuip } from "./quips";
|
||||
import { statusSummary } from "./intents";
|
||||
import { sagiPrefs } from "./prefs";
|
||||
|
||||
/** `next` differs from `prev` only by a lastActivityAt less than a second
|
||||
* newer — nothing the mascot shows, at the moods' 1 s resolution. */
|
||||
function onlyActivityMoved(prev: SagiState, next: SagiState): boolean {
|
||||
if (next === prev) return true;
|
||||
if (next.lastActivityAt - prev.lastActivityAt >= 1000) return false;
|
||||
for (const k of Object.keys(next) as (keyof SagiState)[]) {
|
||||
if (k !== "lastActivityAt" && next[k] !== prev[k]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const BUBBLE_MS = 4500;
|
||||
// A bubble that names a session stays long enough to click it.
|
||||
const SUBJECT_BUBBLE_MS = 7000;
|
||||
// Minimum gap between non-error bubbles, so a burst of activity doesn't spam.
|
||||
const BUBBLE_THROTTLE_MS = 3000;
|
||||
// The same pulse again within this window joins the bubble on show as a count.
|
||||
const COALESCE_MS = 8000;
|
||||
// How long a moment (wave, hop, flinch…) plays over the mood.
|
||||
const MOMENT_MS = 2400;
|
||||
// An idle pet fidgets (waves or looks around) once in a while.
|
||||
const FIDGET_MIN_MS = 45_000;
|
||||
const FIDGET_SPAN_MS = 60_000;
|
||||
// Greet once per browser session, shortly after the snapshot lands.
|
||||
const GREETED_KEY = "agenthub.pet.greeted";
|
||||
const GREET_DELAY_MS = 1200;
|
||||
|
||||
/** What the speech bubble shows. */
|
||||
export interface SagiBubble {
|
||||
text: string;
|
||||
/** Which session(s) it is about, shown under the text. */
|
||||
detail?: string;
|
||||
/** The one session it is about, which a click opens. */
|
||||
subject?: SagiSubject;
|
||||
/** Errors and waits are drawn with an accent. */
|
||||
tone?: "error" | "waiting";
|
||||
}
|
||||
|
||||
export interface SagiBrain {
|
||||
mood: Mood;
|
||||
status: SagiStatus;
|
||||
bubble: string | null;
|
||||
/** Errored, then waiting sessions, named. */
|
||||
attention: AttentionItem[];
|
||||
bubble: SagiBubble | null;
|
||||
dismissBubble: () => void;
|
||||
/** Imperative bubble setter for one-off messages from the shell (e.g. spawn
|
||||
* errors). Bypasses the throttle because the caller decided this message
|
||||
* must be seen right now. Auto-clears after the standard bubble TTL. */
|
||||
setBubble: (text: string) => void;
|
||||
/** The one-shot animation playing now, if any; `key` changes per moment so a
|
||||
* repeat of the same one restarts it. */
|
||||
moment: { kind: PetMoment; key: number } | null;
|
||||
/** Play a moment by hand (an answer, a pat). */
|
||||
playMoment: (kind: PetMoment) => void;
|
||||
/** Pat the pet: a hop, hearts and a happy line right on the pet — shown
|
||||
* even while the panel or chat is open, and while muted (it was asked). */
|
||||
pat: () => void;
|
||||
/** The pet's answer to a pat, drawn on the pet itself; `key` restarts it. */
|
||||
reaction: { text: string; key: number } | null;
|
||||
muted: boolean;
|
||||
toggleMute: () => void;
|
||||
clearAlerts: () => void;
|
||||
setThinking: (v: boolean) => void;
|
||||
}
|
||||
|
||||
type Burst = { pulse: NonNullable<SagiPulse>; count: number; subjects: SagiSubject[]; at: number };
|
||||
|
||||
function burstBubble(b: Burst): SagiBubble {
|
||||
const tone = b.pulse === "error" ? "error" : b.pulse === "waiting" ? "waiting" : undefined;
|
||||
const one = b.subjects.length === 1 ? b.subjects[0] : undefined;
|
||||
const labels = b.subjects.map(subjectLabel);
|
||||
const detail =
|
||||
labels.length > 2
|
||||
? i18n.t("settings:sagi.bubbleMore", {
|
||||
list: labels.slice(0, 2).join(", "),
|
||||
count: labels.length - 2,
|
||||
})
|
||||
: labels.join(", ") || undefined;
|
||||
const text =
|
||||
b.count > 1 ? i18n.t(`settings:sagi.notify.${b.pulse}`, { count: b.count }) : pickQuip(b.pulse);
|
||||
return { text, detail, subject: one, tone };
|
||||
}
|
||||
|
||||
export function useSagiBrain(): SagiBrain {
|
||||
const now0 = Date.now();
|
||||
// Start optimistically connected (idle, open eyes) so the cursor-tracking
|
||||
// eyes are live from the first frame instead of after the WebSocket finishes
|
||||
// its initial handshake. onConnection below corrects this if it's truly down.
|
||||
const [state, setState] = useState<SagiState>(() => ({
|
||||
const [state, setStateRaw] = useState<SagiState>(() => ({
|
||||
...initialSagiState(now0),
|
||||
connected: true,
|
||||
dismissed: sagiPrefs.getDismissed(),
|
||||
}));
|
||||
// The reducer runs OUTSIDE React's updater (which StrictMode calls twice),
|
||||
// so a pulse notifies exactly once; this ref is the state it folds into.
|
||||
const stateRef = useRef(state);
|
||||
const update = useCallback((fn: (prev: SagiState) => SagiState) => {
|
||||
const next = fn(stateRef.current);
|
||||
if (next === stateRef.current) return;
|
||||
stateRef.current = next;
|
||||
setStateRaw(next);
|
||||
}, []);
|
||||
|
||||
const [tick, setTick] = useState(now0);
|
||||
const [bubble, setBubbleState] = useState<string | null>(null);
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
const mutedRef = useRef(muted);
|
||||
mutedRef.current = muted;
|
||||
|
||||
// Keep cleared alerts cleared across reloads.
|
||||
useEffect(() => sagiPrefs.setDismissed(state.dismissed), [state.dismissed]);
|
||||
|
||||
// Keep mute in sync with the Settings page / other tabs.
|
||||
useEffect(() => sagiPrefs.subscribe(() => setMuted(sagiPrefs.getMuted())), []);
|
||||
|
||||
const showBubble = useCallback((text: string, force: boolean) => {
|
||||
if (!text) return;
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const putBubble = useCallback((b: SagiBubble, ms = BUBBLE_MS) => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
setBubbleState(b);
|
||||
bubbleTimer.current = setTimeout(() => {
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, ms);
|
||||
}, []);
|
||||
|
||||
const playMoment = useCallback((kind: PetMoment) => {
|
||||
clearTimeout(momentTimer.current);
|
||||
setMoment({ kind, key: ++momentKey.current });
|
||||
momentTimer.current = setTimeout(() => setMoment(null), MOMENT_MS);
|
||||
}, []);
|
||||
|
||||
const notify = useCallback(
|
||||
(pulse: NonNullable<SagiPulse>, subject?: SagiSubject) => {
|
||||
playMoment(MOMENT_OF_PULSE[pulse]);
|
||||
if (mutedRef.current) return;
|
||||
const t = Date.now();
|
||||
const b = burst.current;
|
||||
// The same kind of news again, while its bubble is still up: count it in.
|
||||
if (b && b.pulse === pulse && t - b.at < COALESCE_MS) {
|
||||
b.count++;
|
||||
b.at = t;
|
||||
if (subject && !b.subjects.some((s) => s.id === subject.id)) b.subjects.push(subject);
|
||||
putBubble(burstBubble(b), SUBJECT_BUBBLE_MS);
|
||||
return;
|
||||
}
|
||||
const force = pulse === "error" || pulse === "waiting";
|
||||
if (!force && t - lastBubbleAt.current < BUBBLE_THROTTLE_MS) return;
|
||||
lastBubbleAt.current = t;
|
||||
const next: Burst = { pulse, count: 1, subjects: subject ? [subject] : [], at: t };
|
||||
burst.current = next;
|
||||
putBubble(burstBubble(next), subject ? SUBJECT_BUBBLE_MS : BUBBLE_MS);
|
||||
},
|
||||
[playMoment, putBubble]
|
||||
);
|
||||
|
||||
// Seed from the REST snapshot on mount so counts are accurate immediately -
|
||||
// the brain otherwise only learns about sessions from WS deltas that arrive
|
||||
// after it mounts, showing "0 live" on a fresh load even when sessions exist.
|
||||
// Pull a generous page of non-finished sessions; live WS deltas refine it.
|
||||
// Then say hello, once per browser session.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let greetTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const greet = () => {
|
||||
try {
|
||||
if (sessionStorage.getItem(GREETED_KEY)) return;
|
||||
sessionStorage.setItem(GREETED_KEY, "1");
|
||||
} catch {
|
||||
return; // No storage: rather silent than a hello on every page.
|
||||
}
|
||||
greetTimer = setTimeout(() => {
|
||||
if (cancelled || mutedRef.current) return;
|
||||
const hello = pickLine(`greet.${greetingKey(new Date().getHours())}`);
|
||||
if (!hello) return;
|
||||
lastBubbleAt.current = Date.now();
|
||||
putBubble({ text: hello, detail: statusSummary(statusOf(stateRef.current)) });
|
||||
playMoment("wave");
|
||||
}, GREET_DELAY_MS);
|
||||
};
|
||||
api.sessions
|
||||
.list({ status: "active", limit: 100 })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
setState((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
update((prev) => seedSessions(prev, res.sessions, Date.now()));
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(greet);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(greetTimer);
|
||||
};
|
||||
}, []);
|
||||
}, [update, putBubble, playMoment]);
|
||||
|
||||
// Subscribe to the live stream and connection status.
|
||||
useEffect(() => {
|
||||
const unsubMsg = eventBus.subscribe((msg: WSMessage) => {
|
||||
const t = Date.now();
|
||||
setState((prev) => {
|
||||
const { state: next, pulse } = reduceSagi(prev, msg, t);
|
||||
if (pulse) showBubble(pickQuip(pulse), pulse === "error");
|
||||
return next;
|
||||
});
|
||||
const prev = stateRef.current;
|
||||
const { state: next, pulse, subject } = reduceSagi(prev, msg, Date.now());
|
||||
// Most frames only move lastActivityAt, which the moods read at a 1 s
|
||||
// resolution: keep the old state then, so a hook burst (several frames a
|
||||
// second) does not re-render the mascot on every frame.
|
||||
if (!onlyActivityMoved(prev, next)) update(() => next);
|
||||
if (pulse) notify(pulse, subject);
|
||||
});
|
||||
const unsubConn = eventBus.onConnection((connected) => {
|
||||
setState((prev) => ({ ...prev, connected }));
|
||||
update((prev) => (prev.connected === connected ? prev : { ...prev, connected }));
|
||||
});
|
||||
return () => {
|
||||
unsubMsg();
|
||||
unsubConn();
|
||||
};
|
||||
}, [showBubble]);
|
||||
}, [update, notify]);
|
||||
|
||||
// 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.
|
||||
// Only a tick that changes the mood re-renders: an idle mascot is not
|
||||
// drawn again every second for nothing.
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const now = Date.now();
|
||||
if (deriveMood(stateRef.current, now) !== moodRef.current) setTick(now);
|
||||
}, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearTimeout(bubbleTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
clearTimeout(momentTimer.current);
|
||||
clearTimeout(reactionTimer.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const mood = useMemo(() => deriveMood(state, tick), [state, tick]);
|
||||
// Judged at render time: `tick` only moves when the mood would change, so
|
||||
// it may be old when a frame re-renders.
|
||||
const mood = useMemo(() => deriveMood(state, Math.max(tick, Date.now())), [state, tick]);
|
||||
const status = useMemo(() => statusOf(state), [state]);
|
||||
const attention = useMemo(() => attentionOf(state), [state]);
|
||||
|
||||
// An idle pet is not a statue: every minute or so it waves or looks around.
|
||||
const moodRef = useRef(mood);
|
||||
moodRef.current = mood;
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const schedule = () => {
|
||||
timer = setTimeout(
|
||||
() => {
|
||||
if (moodRef.current === "idle" && document.visibilityState === "visible")
|
||||
playMoment(Math.random() < 0.5 ? "wave" : "review");
|
||||
schedule();
|
||||
},
|
||||
FIDGET_MIN_MS + Math.random() * FIDGET_SPAN_MS
|
||||
);
|
||||
};
|
||||
schedule();
|
||||
return () => clearTimeout(timer);
|
||||
}, [playMoment]);
|
||||
|
||||
const dismissBubble = useCallback(() => {
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(null);
|
||||
burst.current = null;
|
||||
}, []);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
@@ -189,29 +372,47 @@ export function useSagiBrain(): SagiBrain {
|
||||
if (next) dismissBubble();
|
||||
}, [dismissBubble]);
|
||||
|
||||
const clearAlerts = useCallback(() => setState((prev) => clearErrors(prev)), []);
|
||||
const clearAlerts = useCallback(() => {
|
||||
update((prev) => clearErrors(prev));
|
||||
if (burst.current?.pulse === "error") dismissBubble();
|
||||
}, [update, dismissBubble]);
|
||||
|
||||
const setThinking = useCallback(
|
||||
(v: boolean) => setState((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[]
|
||||
(v: boolean) => update((prev) => (prev.thinking === v ? prev : { ...prev, thinking: v })),
|
||||
[update]
|
||||
);
|
||||
|
||||
// One-shot bubble: clears itself after BUBBLE_MS. Used by the shell for
|
||||
// imperative messages (e.g. spawn errors) that must appear now and fade,
|
||||
// bypassing the throttled mood-driven showBubble path.
|
||||
const setBubble = useCallback((text: string) => {
|
||||
if (!text) return;
|
||||
clearTimeout(bubbleTimer.current);
|
||||
setBubbleState(text);
|
||||
bubbleTimer.current = setTimeout(() => setBubbleState(null), BUBBLE_MS);
|
||||
}, []);
|
||||
const setBubble = useCallback(
|
||||
(text: string) => {
|
||||
if (!text) return;
|
||||
burst.current = null;
|
||||
putBubble({ text });
|
||||
},
|
||||
[putBubble]
|
||||
);
|
||||
|
||||
const pat = useCallback(() => {
|
||||
playMoment("jump");
|
||||
update((prev) => ({ ...prev, happyUntil: Date.now() + MOMENT_MS }));
|
||||
clearTimeout(reactionTimer.current);
|
||||
setReaction({ text: pickLine("pat"), key: ++momentKey.current });
|
||||
reactionTimer.current = setTimeout(() => setReaction(null), MOMENT_MS);
|
||||
}, [playMoment, update]);
|
||||
|
||||
return {
|
||||
mood,
|
||||
status,
|
||||
attention,
|
||||
bubble,
|
||||
dismissBubble,
|
||||
setBubble,
|
||||
moment,
|
||||
playMoment,
|
||||
pat,
|
||||
reaction,
|
||||
muted,
|
||||
toggleMute,
|
||||
clearAlerts,
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* @description Generic styled dropdown that replaces native `<select>` elements
|
||||
* for consistent cross-platform rendering. Native macOS/Chromium selects reserve
|
||||
* checkmark space inconsistently; this control aligns labels, flips above the
|
||||
* trigger when viewport space is tight, and marks the chosen option with accent
|
||||
* trigger when space is tight — in its split pane (`data-menu-bounds`, see
|
||||
* `menuRoom`) or else the viewport — and marks the chosen option with accent
|
||||
* color plus a Lucide check.
|
||||
*
|
||||
* ## Accessibility — WAI-ARIA "listbox button"
|
||||
@@ -74,6 +75,14 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, Check } from "lucide-react";
|
||||
|
||||
/** Room above and below `r` (the trigger's rect) for a menu it opens: inside
|
||||
* the nearest `data-menu-bounds` box — a split pane, whose neighbour paints
|
||||
* over anything crossing its edge — else the viewport. */
|
||||
export function menuRoom(el: Element, r: DOMRect): { above: number; below: number } {
|
||||
const box = el.closest("[data-menu-bounds]")?.getBoundingClientRect();
|
||||
return { above: r.top - (box?.top ?? 0), below: (box?.bottom ?? window.innerHeight) - r.bottom };
|
||||
}
|
||||
|
||||
/** Single option in a {@link Select} list. */
|
||||
export interface SelectOption<T extends string> {
|
||||
/** Stored value emitted via `onChange`. */
|
||||
@@ -84,6 +93,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}. */
|
||||
@@ -142,9 +153,11 @@ export function Select<T extends string>({
|
||||
const idx = options.findIndex((o) => o.value === value);
|
||||
setActive(idx >= 0 ? idx : 0);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const below = window.innerHeight - rect.bottom;
|
||||
setOpenUp(below < 288 && rect.top > below);
|
||||
const { above, below } = menuRoom(
|
||||
buttonRef.current,
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
);
|
||||
setOpenUp(below < 288 && above > below);
|
||||
}
|
||||
listRef.current?.focus({ preventScroll: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only on open
|
||||
@@ -272,6 +285,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -49,12 +49,15 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { settingsHref } from "../lib/settingsLink";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Gem,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Wifi,
|
||||
@@ -69,10 +72,13 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
TableProperties,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -84,16 +90,12 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
const NAV_KEYS = [
|
||||
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
{ to: "/query", icon: TableProperties, key: "nav:query" },
|
||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
|
||||
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -190,6 +192,16 @@ export function Sidebar({
|
||||
resizing = false,
|
||||
}: SidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuth();
|
||||
// On a workspace Settings opens as its tab (lib/settingsLink.ts), so the
|
||||
// link's own match (the path) cannot tell when it is the one on screen.
|
||||
const location = useLocation();
|
||||
const settingsOn =
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const perms = usePermissions();
|
||||
const canUpdate = perms.isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -463,7 +475,16 @@ export function Sidebar({
|
||||
user knows there's more to reach without inspecting the scrollbar. */}
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{NAV_KEYS.map(({ to, icon: Icon, key }) => {
|
||||
{[
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
return (
|
||||
<NavLink
|
||||
@@ -484,6 +505,7 @@ export function Sidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
data-testid="sidebar-tree-rail"
|
||||
title={t("lanes:tree.title")}
|
||||
aria-label={t("lanes:tree.title")}
|
||||
className="flex w-full items-center justify-center rounded-md px-2 py-2 text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
@@ -526,13 +548,29 @@ export function Sidebar({
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to="/settings"
|
||||
to={settingsHref("", location)}
|
||||
title={collapsed ? t("nav:settings") : undefined}
|
||||
className={(p) => `${navClass(p)} ${collapsed ? "" : "flex-1"}`}
|
||||
className={() => `${navClass({ isActive: settingsOn })} ${collapsed ? "" : "flex-1"}`}
|
||||
>
|
||||
<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.displayName || 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}
|
||||
@@ -567,7 +605,7 @@ export function Sidebar({
|
||||
checking,
|
||||
available: updateAvailable,
|
||||
failed: checkError,
|
||||
onCheck: onCheckUpdates,
|
||||
onCheck: canUpdate ? onCheckUpdates : undefined,
|
||||
}}
|
||||
onResetStats={() => {
|
||||
eventCountRef.current = 0;
|
||||
@@ -609,7 +647,8 @@ interface ConnectionStatusModalProps {
|
||||
checking: boolean;
|
||||
available: boolean;
|
||||
failed: boolean;
|
||||
onCheck: () => void;
|
||||
/** Absent for a user who may not update (server mode, not an admin). */
|
||||
onCheck?: () => void;
|
||||
};
|
||||
onResetStats: () => void;
|
||||
}
|
||||
@@ -751,18 +790,20 @@ function ConnectionStatusModal({
|
||||
{update.title}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
{update.onCheck && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={update.onCheck}
|
||||
disabled={update.checking}
|
||||
className="btn-ghost shrink-0 text-xs disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${update.checking ? "animate-spin" : ""}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{t("nav:checkForUpdates")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
* ## Public surface
|
||||
* - `Skeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `StatValueSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TextSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
* - `TableRowSkeleton` — exported API; see TSDoc on the symbol for behavior.
|
||||
*
|
||||
* ## Testing pointers
|
||||
@@ -52,11 +51,6 @@
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TextSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
* When behavior changes, update the `@file` overview and relevant tests.
|
||||
*
|
||||
* **TableRowSkeleton**
|
||||
* Part of this module's public contract. Downstream imports should treat
|
||||
* the signature and return type as stable unless release notes say otherwise.
|
||||
@@ -112,19 +106,6 @@ export function StatValueSkeleton({ className = "" }: { className?: string }) {
|
||||
return <Skeleton className={`h-7 w-20 align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-line text placeholder. Good for trend/sub labels.
|
||||
*/
|
||||
export function TextSkeleton({
|
||||
className = "",
|
||||
width = "w-16",
|
||||
}: {
|
||||
className?: string;
|
||||
width?: string;
|
||||
}) {
|
||||
return <Skeleton className={`h-3 ${width} align-middle ${className}`} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full table-row skeleton for tabular pages. Caller passes the column count so
|
||||
* the skeleton lines up under existing headers.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SplashScreen.tsx
|
||||
* @description Branding splash shown once per browser session on app load. A
|
||||
* dark-tech "constellation" overlay built around the node-graph brand mark:
|
||||
* dark-tech "constellation" overlay built around the SmartGift brand mark:
|
||||
* a time-aware greeting, a bold (localized) tagline, and two subtexts reveal
|
||||
* in a staggered cascade. The tagline and the subtext pair are picked at
|
||||
* random (per mount) from localized pools in `splash.json`, so the copy is
|
||||
@@ -143,7 +143,7 @@ export function SplashScreen() {
|
||||
<div className="splash-grain" aria-hidden="true" />
|
||||
|
||||
<div className="splash-content">
|
||||
{/* Brand mark - the node-graph hexagon, enlarged and animated */}
|
||||
{/* Brand mark - the SmartGift logo */}
|
||||
<div className="splash-mark" aria-hidden="true">
|
||||
<span className="splash-mark-glow" />
|
||||
<BrandMark />
|
||||
@@ -167,74 +167,10 @@ export function SplashScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
/** The hexagon node-graph brand mark (mirrors public/favicon.svg), scaled up
|
||||
* with animated connector lines and pulsing outer nodes. */
|
||||
/** The SmartGift mark — the same file as the favicon, local so the splash
|
||||
* needs no network. */
|
||||
function BrandMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 32 32" width="96" height="96" className="splash-svg">
|
||||
<defs>
|
||||
<linearGradient id="splashBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#6366f1" />
|
||||
<stop offset="100%" stopColor="#818cf8" />
|
||||
</linearGradient>
|
||||
<linearGradient id="splashGlow" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#a5b4fc" stopOpacity="0.95" />
|
||||
<stop offset="100%" stopColor="#c7d2fe" stopOpacity="0.6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon
|
||||
className="splash-hex"
|
||||
points="16,2 28,9 28,23 16,30 4,23 4,9"
|
||||
fill="url(#splashBg)"
|
||||
/>
|
||||
<circle cx="16" cy="16" r="3" fill="white" opacity="0.95" />
|
||||
<line
|
||||
className="splash-line"
|
||||
x1="16"
|
||||
y1="13"
|
||||
x2="16"
|
||||
y2="7"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-2"
|
||||
x1="18.6"
|
||||
y1="17.5"
|
||||
x2="24"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
className="splash-line splash-line-3"
|
||||
x1="13.4"
|
||||
y1="17.5"
|
||||
x2="8"
|
||||
y2="20.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle className="splash-node" cx="16" cy="6" r="1.8" fill="url(#splashGlow)" />
|
||||
<circle
|
||||
className="splash-node splash-node-2"
|
||||
cx="24.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
<circle
|
||||
className="splash-node splash-node-3"
|
||||
cx="7.5"
|
||||
cy="21"
|
||||
r="1.8"
|
||||
fill="url(#splashGlow)"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
return <img src="/favicon.svg" alt="" width={96} height={96} className="splash-svg" />;
|
||||
}
|
||||
|
||||
/** Faint background constellation: a handful of nodes joined by thin lines that
|
||||
@@ -351,13 +287,6 @@ const SPLASH_CSS = `
|
||||
animation: splashGlowPulse 2.6s ease-in-out infinite;
|
||||
}
|
||||
.splash-svg { position: relative; display: block; filter: drop-shadow(0 8px 28px rgba(99, 102, 241, 0.45)); }
|
||||
.splash-hex { transform-origin: center; animation: splashHexBreathe 3.4s ease-in-out infinite; }
|
||||
.splash-line { stroke-dasharray: 8; stroke-dashoffset: 8; opacity: 0.75; animation: splashDraw 0.7s ease 0.5s forwards; }
|
||||
.splash-line-2 { animation-delay: 0.62s; }
|
||||
.splash-line-3 { animation-delay: 0.74s; }
|
||||
.splash-node { opacity: 0; animation: splashNodePop 0.5s ease 0.85s forwards, splashNodePulse 2.4s ease-in-out 1.4s infinite; }
|
||||
.splash-node-2 { animation-delay: 0.98s, 1.6s; }
|
||||
.splash-node-3 { animation-delay: 1.1s, 1.8s; }
|
||||
|
||||
.splash-greeting {
|
||||
display: flex;
|
||||
@@ -435,11 +364,7 @@ const SPLASH_CSS = `
|
||||
@keyframes splashDrift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-24px, -16px, 0) scale(1.05); } }
|
||||
@keyframes splashTwinkle { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }
|
||||
@keyframes splashMark { from { opacity: 0; transform: translateY(14px) scale(0.82); } to { opacity: 1; transform: translateY(0) scale(1); } }
|
||||
@keyframes splashHexBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.04); } }
|
||||
@keyframes splashGlowPulse { 0%, 100% { opacity: 0.55; transform: scale(0.96); } 50% { opacity: 1; transform: scale(1.06); } }
|
||||
@keyframes splashDraw { to { stroke-dashoffset: 0; } }
|
||||
@keyframes splashNodePop { from { opacity: 0; transform: scale(0); } to { opacity: 1; transform: scale(1); } }
|
||||
@keyframes splashNodePulse { 0%, 100% { opacity: 0.7; } 50% { opacity: 1; } }
|
||||
@keyframes splashRise { from { opacity: 0; transform: translateY(18px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -449,9 +374,6 @@ const SPLASH_CSS = `
|
||||
.splash-star,
|
||||
.splash-mark,
|
||||
.splash-mark-glow,
|
||||
.splash-hex,
|
||||
.splash-line,
|
||||
.splash-node,
|
||||
.splash-dot,
|
||||
.splash-greeting,
|
||||
.splash-tagline,
|
||||
@@ -460,7 +382,6 @@ const SPLASH_CSS = `
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
opacity: 1 !important;
|
||||
stroke-dashoffset: 0 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ interface StatCardProps {
|
||||
loading?: boolean;
|
||||
/** Drill-in: the whole card links here (e.g. the list the number counts). */
|
||||
to?: string;
|
||||
/** Plain-language explanation of the metric, shown as the label's tooltip. */
|
||||
help?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,11 +94,15 @@ export function StatCard({
|
||||
raw,
|
||||
loading = false,
|
||||
to,
|
||||
help,
|
||||
}: StatCardProps) {
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-xs font-medium text-fg-muted uppercase tracking-wider truncate">
|
||||
<span
|
||||
className={`text-xs font-medium text-fg-muted uppercase tracking-wider truncate${help ? " cursor-help underline decoration-dotted underline-offset-2" : ""}`}
|
||||
title={help}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${accentColor}`} />
|
||||
|
||||
@@ -24,17 +24,26 @@ import { Fragment, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
|
||||
import { RefreshCw, Wifi, WifiOff } from "lucide-react";
|
||||
import { api, type RunHandle } from "../lib/api";
|
||||
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
|
||||
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||
|
||||
const COST_POLL_MS = 30_000;
|
||||
const QUOTA_POLL_MS = 5 * 60_000;
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = { claude: "Claude", codex: "Codex" };
|
||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
codex: "Codex",
|
||||
antigravity: "Antigravity",
|
||||
cursor: "Cursor",
|
||||
grok: "Grok",
|
||||
kimi: "Kimi",
|
||||
opencode: "OpenCode Go",
|
||||
};
|
||||
|
||||
function localDate(d = new Date()): string {
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
@@ -121,6 +130,8 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
ETOKENEXPIRED: "statusBar.quotaTokenExpired",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
@@ -131,6 +142,14 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
<div data-testid="status-bar-quota-account" className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{q.label || q.account}</span>
|
||||
{q.plan && (
|
||||
<span
|
||||
data-testid="status-bar-quota-plan"
|
||||
className="shrink-0 rounded bg-surface-4 px-1 text-[10px] capitalize leading-4 text-fg-secondary"
|
||||
>
|
||||
{q.plan}
|
||||
</span>
|
||||
)}
|
||||
{many && q.active !== false && (
|
||||
<span className="shrink-0 rounded bg-accent-solid/15 px-1 text-[10px] leading-4 text-accent">
|
||||
{t("statusBar.quotaActive")}
|
||||
@@ -143,12 +162,17 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
{t(QUOTA_ERROR_KEY[q.error] ?? "statusBar.quotaFailed")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_4rem] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w) => {
|
||||
<div className="mt-1 grid grid-cols-[2.75rem_1fr_2.25rem_auto] items-center gap-x-2 gap-y-0.5 tabular-nums">
|
||||
{q.windows.map((w, i) => {
|
||||
const p = Math.round(w.usedPercent ?? 0);
|
||||
const resets = untilText(w.resetsAt);
|
||||
return (
|
||||
<Fragment key={w.label}>
|
||||
<Fragment key={`${w.group ?? ""}-${w.label}`}>
|
||||
{w.group && w.group !== q.windows[i - 1]?.group && (
|
||||
<span className="col-span-4 mt-0.5 truncate text-[10px] text-fg-muted">
|
||||
{w.group}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-fg-muted">
|
||||
{t(`statusBar.quotaWindow.${w.label}`, { defaultValue: w.label })}
|
||||
</span>
|
||||
@@ -159,16 +183,9 @@ function QuotaAccount({ q, many }: { q: ToolQuota; many: boolean }) {
|
||||
/>
|
||||
</span>
|
||||
<span className={`text-right ${quotaTone(p)}`}>{p}%</span>
|
||||
<span className="flex items-center justify-end gap-1 text-fg-muted">
|
||||
{resets && (
|
||||
<>
|
||||
<RotateCcw className="h-3 w-3" aria-hidden />
|
||||
<span aria-hidden>{resets}</span>
|
||||
<span className="sr-only">
|
||||
{t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* In words: a ↻ glyph here read as a Refresh button. */}
|
||||
<span className="text-right text-fg-muted">
|
||||
{resets && t("statusBar.quotaResets", { time: resets })}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
@@ -208,7 +225,7 @@ function QuotaPanel({
|
||||
onMouseLeave={onLeave}
|
||||
onFocus={onEnter}
|
||||
onBlur={onLeave}
|
||||
className="fixed bottom-7 right-2 z-[9999] w-[19rem] max-w-[calc(100vw-1rem)] rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
className="fixed bottom-7 right-2 z-[9999] max-h-[calc(100vh-2.5rem)] w-[19rem] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20"
|
||||
>
|
||||
<div className="mb-2 font-semibold">{t("statusBar.quotaTitle")}</div>
|
||||
{groups.map(([id, list]) => (
|
||||
@@ -264,15 +281,15 @@ function QuotaChips() {
|
||||
closeTimer.current = window.setTimeout(() => setOpen(false), 200);
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimer.current), []);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const load = useCallback((force = false) => {
|
||||
if (force) setRefreshing(true);
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined)
|
||||
.finally(() => force && setRefreshing(false));
|
||||
}, []);
|
||||
const load = useCallback(
|
||||
(force = false) =>
|
||||
api.pricing
|
||||
.quota(force)
|
||||
.then((r) => setTools(r.tools))
|
||||
.catch(() => undefined),
|
||||
[]
|
||||
);
|
||||
const [refreshing, refresh] = useSpin(() => load(true));
|
||||
useVisiblePoll(load, QUOTA_POLL_MS, [load]);
|
||||
if (!tools.length) return null;
|
||||
return (
|
||||
@@ -281,7 +298,7 @@ function QuotaChips() {
|
||||
type="button"
|
||||
data-testid="status-bar-quota"
|
||||
data-tip-off
|
||||
onClick={() => load(true)}
|
||||
onClick={refresh}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
@@ -313,7 +330,7 @@ function QuotaChips() {
|
||||
tools={tools}
|
||||
onEnter={show}
|
||||
onLeave={hide}
|
||||
onRefresh={() => load(true)}
|
||||
onRefresh={refresh}
|
||||
refreshing={refreshing}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
*
|
||||
* ## State sources
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
* - Background re-check via `api.updates.check()` every hour
|
||||
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
|
||||
* - No client-side background re-check: the server checks on its own
|
||||
* schedule and broadcasts `update_status`; "Check now" stays manual.
|
||||
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
|
||||
*
|
||||
* ## Dismissal persistence
|
||||
@@ -85,15 +85,14 @@ 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";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||
|
||||
/** How often to silently re-check for updates in the background. */
|
||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Situations `POST /api/updates/apply` will actually act on. Mirrors the
|
||||
* server: `release_available` / `tracking_canonical` / `fork_or_diverged_tracking`
|
||||
* go through a git fast-forward, and `plugin_managed` goes through the detached
|
||||
@@ -127,6 +126,7 @@ function loadDismissedSha(): string | null {
|
||||
* @returns `null` when no update is available or this release was dismissed.
|
||||
*/
|
||||
export function UpdateNotifier() {
|
||||
const { isAdmin } = usePermissions();
|
||||
const { t } = useTranslation("updates");
|
||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||
@@ -171,19 +171,6 @@ export function UpdateNotifier() {
|
||||
});
|
||||
}, [syncFromPayload]);
|
||||
|
||||
// Background re-check every hour, on top of the initial mount fetch and the
|
||||
// manual "Check now" button — so a long-lived tab notices an update without
|
||||
// the user having to click anything.
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
api.updates
|
||||
.check()
|
||||
.then(syncFromPayload)
|
||||
.catch(() => {});
|
||||
}, AUTO_CHECK_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [syncFromPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
// An explicit "Check for updates" undoes both kinds of close.
|
||||
const handler = () => {
|
||||
@@ -205,7 +192,9 @@ export function UpdateNotifier() {
|
||||
status?.latest_release ||
|
||||
status?.remote_sha ||
|
||||
(installed ? `installed:${installed.root}` : null);
|
||||
// Updating replaces the host's install: only an admin is offered it.
|
||||
const show = Boolean(
|
||||
isAdmin &&
|
||||
status?.update_available &&
|
||||
dismissKey &&
|
||||
dismissedSha !== dismissKey &&
|
||||
@@ -264,22 +253,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 +300,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(
|
||||
@@ -330,7 +338,7 @@ export function UpdateNotifier() {
|
||||
longest label is a whole sentence ("restarting the dashboard…"). */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="w-full max-w-2xl card shadow-2xl animate-slide-up overflow-hidden"
|
||||
className="w-full max-w-2xl max-h-full flex flex-col card shadow-2xl animate-slide-up overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
@@ -370,7 +378,7 @@ export function UpdateNotifier() {
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto px-5 py-4 space-y-3">
|
||||
<p className="text-sm text-fg-secondary leading-relaxed">
|
||||
{pluginManaged ? t("pluginAutoNote") : release ? t("releaseLead") : t("lead")}
|
||||
</p>
|
||||
@@ -455,7 +463,10 @@ export function UpdateNotifier() {
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="text-xs text-status-danger" role="alert">
|
||||
<p
|
||||
className="text-xs text-status-danger whitespace-pre-wrap break-words max-h-48 overflow-auto"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -6,21 +6,37 @@
|
||||
* 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
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`).
|
||||
* 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`). Server mode, admin or
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
|
||||
* else `owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Plus, Search } from "lucide-react";
|
||||
import { api, type CwdSuggestion, type RunHandle } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useThrottledValue } from "../hooks/useThrottledValue";
|
||||
import {
|
||||
insertionIndex,
|
||||
moveBefore,
|
||||
@@ -33,11 +49,21 @@ 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). */
|
||||
/** An owner as the tree shows it: their display name, else their username. */
|
||||
const ownerShown = (l: Lane): string => l.owner_display_name || l.owner_name || "";
|
||||
|
||||
export function selectedFrom(pathname: string, search: string) {
|
||||
if (!pathname.startsWith("/workspace")) return { lane: null, run: null, session: null };
|
||||
const p = new URLSearchParams(search);
|
||||
@@ -54,6 +80,11 @@ export function WorkspaceTree() {
|
||||
const location = useLocation();
|
||||
const selected = selectedFrom(location.pathname, location.search);
|
||||
const { lanes, refresh: refreshLanes } = useLanes();
|
||||
const { state: auth } = useAuth();
|
||||
const { seesAll, canEditLane } = usePermissions();
|
||||
const meId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
||||
// Server mode staff see every user's workspaces, so the list says whose.
|
||||
const grouped = auth.mode === "server" && seesAll;
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [liveRuns, setLiveRuns] = useState<RunHandle[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -72,8 +103,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
|
||||
@@ -111,10 +146,17 @@ export function WorkspaceTree() {
|
||||
const tools = useMemo(() => [...new Set(lanes.map((l) => l.tool))], [lanes]);
|
||||
const toolFilter =
|
||||
tools.length > 1 && prefs.tool && tools.includes(prefs.tool) ? prefs.tool : null;
|
||||
const sorted = useMemo(
|
||||
() => sortByStoredOrder(lanes, order).filter((l) => !toolFilter || l.tool === toolFilter),
|
||||
[lanes, order, toolFilter]
|
||||
);
|
||||
const sorted = useMemo(() => {
|
||||
const list = sortByStoredOrder(lanes, order).filter(
|
||||
(l) => !toolFilter || l.tool === toolFilter
|
||||
);
|
||||
if (!grouped) return list;
|
||||
// Stable: each owner's workspaces keep the stored order inside the group.
|
||||
const rank = (l: Lane) => (l.owner_id == null ? 2 : l.owner_id === meId ? 0 : 1);
|
||||
return [...list].sort(
|
||||
(a, b) => rank(a) - rank(b) || ownerShown(a).localeCompare(ownerShown(b))
|
||||
);
|
||||
}, [lanes, order, toolFilter, grouped, meId]);
|
||||
const q = query.trim().toLowerCase();
|
||||
const nameHit = (l: Lane) =>
|
||||
workspaceName(l).toLowerCase().includes(q) || l.cwd.toLowerCase().includes(q);
|
||||
@@ -291,42 +333,90 @@ export function WorkspaceTree() {
|
||||
)}
|
||||
|
||||
<div ref={listRef} className="space-y-px px-1">
|
||||
{sorted.map((lane, i) => (
|
||||
<div key={lane.id} className="relative">
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sorted.map((lane, i) => {
|
||||
const owner = ownerKey(lane);
|
||||
// A search opens every group, like it opens every workspace.
|
||||
const folded = grouped && !q && !!prefs.collapsedOwners[owner];
|
||||
const readOnly = !canEditLane(lane);
|
||||
const ownerLabel =
|
||||
lane.owner_id == null
|
||||
? t("tree.noOwner")
|
||||
: lane.owner_id === meId
|
||||
? t("tree.ownerYou", { name: ownerShown(lane) || `#${lane.owner_id}` })
|
||||
: ownerShown(lane) || `#${lane.owner_id}`;
|
||||
const groupStart = grouped && (i === 0 || ownerKey(sorted[i - 1] ?? lane) !== owner);
|
||||
const groupShown = sorted.some((l) => ownerKey(l) === owner && visible(l));
|
||||
return (
|
||||
<div key={lane.id} className="relative">
|
||||
{groupStart && groupShown && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-owner-${owner}`}
|
||||
aria-expanded={!folded}
|
||||
title={t("tree.ownerToggle", { name: ownerLabel })}
|
||||
onClick={() =>
|
||||
writeProjectTreePrefs({
|
||||
collapsedOwners: { ...prefs.collapsedOwners, [owner]: !folded },
|
||||
})
|
||||
}
|
||||
className={`flex w-full items-center gap-1 rounded px-1 py-1 text-left text-[11px] font-medium text-fg-muted hover:text-fg-primary ${i === 0 ? "" : "mt-2"}`}
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{ownerShown(lane)[0] ?? "?"}
|
||||
</span>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
{sorted.filter((l) => ownerKey(l) === owner).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{dropAt === i && <DropLine />}
|
||||
{visible(lane) && !folded && (
|
||||
<WorkspaceRow
|
||||
lane={lane}
|
||||
index={i}
|
||||
readOnly={readOnly ? t("tree.readOnly", { name: ownerLabel }) : undefined}
|
||||
selected={selected.lane === lane.id && !selected.run && !selected.session}
|
||||
expanded={expanded(lane.id)}
|
||||
renaming={renamingId === lane.id}
|
||||
onSelect={() => {
|
||||
// A read-only workspace has no home to open: it only unfolds.
|
||||
if (readOnly) return setExpanded(lane.id, !expanded(lane.id));
|
||||
select(lane.id);
|
||||
if (!expanded(lane.id)) setExpanded(lane.id, true);
|
||||
}}
|
||||
onToggle={() => setExpanded(lane.id, !expanded(lane.id))}
|
||||
onNewSession={() => select(lane.id)}
|
||||
onReorder={(from, to) => reorder((ids) => moveLane(ids, from, to))}
|
||||
onMenu={(x, y) => setMenu({ lane, x, y })}
|
||||
onRenameStart={() => setRenamingId(lane.id)}
|
||||
onRenameEnd={(title) => void rename(lane, title)}
|
||||
/>
|
||||
)}
|
||||
{expanded(lane.id) && !folded && (
|
||||
<WorkspaceSessionList
|
||||
lane={lane}
|
||||
liveRuns={liveRuns}
|
||||
readOnly={readOnly}
|
||||
// A workspace found by its name shows all of its sessions.
|
||||
query={q && nameHit(lane) ? "" : query}
|
||||
selectedRunId={selected.lane === lane.id ? selected.run : null}
|
||||
selectedSessionId={selected.lane === lane.id ? selected.session : null}
|
||||
onMatches={q ? (n) => onMatches(lane.id, n) : undefined}
|
||||
hidden={!visible(lane)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dropAt === sorted.length && (
|
||||
<div className="relative">
|
||||
<DropLine />
|
||||
@@ -349,32 +439,39 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** A workspace's owner group: its user id, or "none". */
|
||||
const ownerKey = (l: Lane) => (l.owner_id == null ? "none" : String(l.owner_id));
|
||||
|
||||
/** Where a dragged workspace would land: a line on its parent's top edge. */
|
||||
function DropLine() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @file AlertsNotifications.roles.test.tsx
|
||||
* @description Server mode, not an admin: alert rules and channels fire for
|
||||
* every user, so neither tab is shown nor loaded, and an analyst's feed offers
|
||||
* Acknowledge only on alerts the server marks theirs (`can_ack`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const rulesList = vi.fn(async () => ({ rules: [] }));
|
||||
const alert = (id: number, can_ack: boolean) => ({
|
||||
id,
|
||||
rule_id: "r1",
|
||||
rule_name: `rule ${id}`,
|
||||
rule_type: "error_rate",
|
||||
session_id: null,
|
||||
agent_id: null,
|
||||
message: `boom ${id}`,
|
||||
details: null,
|
||||
triggered_at: "2026-10-08T00:00:00.000Z",
|
||||
acknowledged_at: null,
|
||||
can_ack,
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
alerts: {
|
||||
rules: { list: () => rulesList() },
|
||||
list: vi.fn(async () => ({
|
||||
alerts: [alert(1, true), alert(2, false)],
|
||||
total: 2,
|
||||
unacked: 2,
|
||||
})),
|
||||
ack: vi.fn(),
|
||||
ackAll: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../AuthGate", () => ({
|
||||
usePermissions: () => ({ isAdmin: false, seesAll: true, canEditLane: () => false }),
|
||||
}));
|
||||
|
||||
import { AlertsNotifications } from "../AlertsNotifications";
|
||||
|
||||
describe("AlertsNotifications for a non-admin", () => {
|
||||
it("shows only the feed, and Acknowledge only where the server allows it", async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AlertsNotifications />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(await screen.findByText("boom 2")).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /^acknowledge$/i })).toHaveLength(1);
|
||||
expect(screen.queryByRole("button", { name: /rules/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /channels/i })).toBeNull();
|
||||
expect(rulesList).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @file AuthGate.test.tsx
|
||||
* @description The login gate: local mode renders the app untouched, server
|
||||
* mode shows the login page until a login succeeds, a wrong password says so,
|
||||
* and a later 401 `EAUTH` brings the login page back. `usePermissions` and
|
||||
* `useWorkLanes` mirror the server's roles: an admin changes everything, an
|
||||
* analyst reads everything but works only in their own workspaces, a
|
||||
* developer only ever sees (and so changes) their own. The login page wears the
|
||||
* OS's light/dark; a sign-in swaps in the account's saved appearance.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../i18n";
|
||||
|
||||
const me = vi.fn();
|
||||
const login = vi.fn();
|
||||
const prefs = vi.fn();
|
||||
const LANES = [
|
||||
{ id: 1, owner_id: 5 },
|
||||
{ id: 2, owner_id: 6 },
|
||||
];
|
||||
vi.mock("../../lib/api", () => ({
|
||||
AUTH_REQUIRED_EVENT: "agenthub:auth-required",
|
||||
api: {
|
||||
auth: { me: () => me(), login: (u: string, p: string) => login(u, p), logout: vi.fn() },
|
||||
lanes: { list: () => Promise.resolve({ lanes: LANES }) },
|
||||
me: { prefs: () => prefs(), savePrefs: () => Promise.resolve({ prefs: {} }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { AuthGate, usePermissions } from "../AuthGate";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
function Probe() {
|
||||
const p = usePermissions();
|
||||
const { lanes } = useWorkLanes();
|
||||
return (
|
||||
<p>
|
||||
{[
|
||||
`admin:${p.isAdmin}`,
|
||||
`all:${p.seesAll}`,
|
||||
`mine:${p.canEditLane({ owner_id: 5 })}`,
|
||||
`theirs:${p.canEditLane({ owner_id: 6 })}`,
|
||||
`work:${lanes.map((l) => l.id).join(",")}`,
|
||||
].join(" ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const user = { id: 1, username: "mia", role: "developer" };
|
||||
|
||||
beforeEach(() => {
|
||||
me.mockReset();
|
||||
login.mockReset();
|
||||
prefs.mockReset().mockResolvedValue({ prefs: {} });
|
||||
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", { name: /sign in/i }));
|
||||
expect(await screen.findByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.queryByText("app")).toBeNull();
|
||||
|
||||
await userEvent.type(pass, "member-pass-1");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
expect(await screen.findByText("app")).toBeInTheDocument();
|
||||
expect(login).toHaveBeenLastCalledWith("mia", "member-pass-1");
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("agenthub:auth-required"));
|
||||
});
|
||||
expect(await screen.findByLabelText(/username/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthGate theme", () => {
|
||||
it("wears the OS theme while signed out and the account's own once signed in", async () => {
|
||||
const html = document.documentElement;
|
||||
// The previous user's choice, left in this browser.
|
||||
localStorage.setItem("theme", "dark");
|
||||
localStorage.setItem("agenthub.theme.dark", "darcula");
|
||||
const mq = { matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() };
|
||||
vi.stubGlobal("matchMedia", () => mq); // the OS is light
|
||||
me.mockResolvedValue({ mode: "server", user: null });
|
||||
prefs.mockResolvedValue({ prefs: { theme: "dark", "agenthub.theme.dark": "orca-dark" } });
|
||||
login.mockResolvedValue({ user });
|
||||
render(<AuthGate>app</AuthGate>);
|
||||
|
||||
await userEvent.type(await screen.findByLabelText(/username/i), "mia");
|
||||
expect(html.dataset.mode).toBe("light");
|
||||
expect(html.hasAttribute("data-theme")).toBe(false);
|
||||
|
||||
await userEvent.type(screen.getByLabelText(/^password$/i), "member-pass-1");
|
||||
await userEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => expect(html.dataset.theme).toBe("orca-dark"));
|
||||
expect(html.dataset.mode).toBe("dark");
|
||||
expect(localStorage.getItem("agenthub.theme.dark")).toBe("orca-dark");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePermissions / useWorkLanes", () => {
|
||||
const as = async (state: unknown) => {
|
||||
cleanup();
|
||||
me.mockResolvedValue(state);
|
||||
render(
|
||||
<AuthGate>
|
||||
<Probe />
|
||||
</AuthGate>
|
||||
);
|
||||
return (await screen.findByText(/work:\d/)).textContent;
|
||||
};
|
||||
|
||||
it("lets an admin and local mode do everything", async () => {
|
||||
for (const state of [
|
||||
{ mode: "local" },
|
||||
{ mode: "server", user: { id: 9, username: "boss", role: "admin" } },
|
||||
]) {
|
||||
expect(await as(state)).toBe("admin:true all:true mine:true theirs:true work:1,2");
|
||||
}
|
||||
});
|
||||
|
||||
it("lets an analyst read everything but work only in their own workspaces", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "ana", role: "analyst" } })).toBe(
|
||||
"admin:false all:true mine:true theirs:false work:1"
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a developer act on everything they can see, and nothing host-wide", async () => {
|
||||
expect(await as({ mode: "server", user: { id: 5, username: "mia", role: "developer" } })).toBe(
|
||||
"admin:false all:false mine:true theirs:true work:1,2"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,21 @@
|
||||
/**
|
||||
* @file PathPickButton.test.tsx
|
||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||
* path fills the field, a Cancel changes nothing, a folder falls back to the
|
||||
* in-app browser on ANY native-picker failure, and a file falls back to a
|
||||
* "type the path" hint only when no picker exists for this browser.
|
||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||
* EREMOTE included), where a double-click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
||||
}));
|
||||
vi.mock("../../lib/toast", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
@@ -55,15 +55,24 @@ describe("PathPickButton", () => {
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hints to type a file path when no picker exists, and reports any other failure", async () => {
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("ENODIALOG"));
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValueOnce(fail("EDIALOG"));
|
||||
render(<PathPickButton kind="file" onPick={vi.fn()} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.info).toHaveBeenCalledTimes(1));
|
||||
await userEvent.click(button);
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith("EDIALOG"));
|
||||
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||
const onPick = vi.fn();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/home/u/.ssh",
|
||||
parent: "/home/u",
|
||||
entries: [
|
||||
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
|
||||
],
|
||||
});
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* @file Select.test.tsx
|
||||
* @description The shared Select as a WAI-ARIA listbox button: expanded state,
|
||||
* listbox/option roles with aria-selected, aria-activedescendant tracking,
|
||||
* Arrow/Home/End/Enter/Escape/type-ahead, and focus back on the trigger.
|
||||
* Arrow/Home/End/Enter/Escape/type-ahead, focus back on the trigger, and a
|
||||
* list that flips up by the room in its split pane, not the window's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -90,4 +91,21 @@ describe("Select (listbox button)", () => {
|
||||
fireEvent.click(screen.getByText("Apple"));
|
||||
expect(onChange).toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("opens upward when its pane (data-menu-bounds) has no room below, whatever the window has", () => {
|
||||
const rect = (top: number, bottom: number) => ({ top, bottom }) as DOMRect;
|
||||
render(
|
||||
<div data-menu-bounds data-testid="pane">
|
||||
<Select value="b" onChange={() => {}} options={OPTIONS} label="Fruit" />
|
||||
</div>
|
||||
);
|
||||
// A top pane 0–400 in a 2000px window; the trigger near its bottom.
|
||||
vi.spyOn(screen.getByTestId("pane"), "getBoundingClientRect").mockReturnValue(rect(0, 400));
|
||||
const button = screen.getByRole("button", { name: "Fruit" });
|
||||
vi.spyOn(button, "getBoundingClientRect").mockReturnValue(rect(360, 380));
|
||||
vi.stubGlobal("innerHeight", 2000);
|
||||
fireEvent.click(button);
|
||||
expect(screen.getByRole("listbox").className).toContain("bottom-full");
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
|
||||
expect(hrefs).toContain("/sessions");
|
||||
expect(hrefs).toContain("/settings");
|
||||
expect(hrefs).not.toContain("/activity");
|
||||
// The file manager serves a shared server; a local dashboard has none.
|
||||
expect(hrefs).not.toContain("/files");
|
||||
});
|
||||
|
||||
describe("update status line, in the connection details modal", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ function today() {
|
||||
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: { iconUrl: (id: string) => `/api/ai-tools/${id}/icon` },
|
||||
sessions: {
|
||||
// 3 active sessions, 1 of them waiting on the user.
|
||||
list: vi.fn().mockImplementation(async (p: { awaiting?: boolean }) => ({
|
||||
@@ -76,6 +77,7 @@ vi.mock("../../lib/api", () => ({
|
||||
active: true,
|
||||
label: "Work",
|
||||
account: "me@example.com",
|
||||
plan: "max",
|
||||
expiresAt: new Date(Date.now() + 3 * 3600_000 + 30_000).toISOString(),
|
||||
windows: [
|
||||
{
|
||||
@@ -102,6 +104,22 @@ vi.mock("../../lib/api", () => ({
|
||||
windows: [],
|
||||
error: "EUPSTREAM",
|
||||
},
|
||||
{
|
||||
id: "antigravity",
|
||||
active: true,
|
||||
windows: [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: null },
|
||||
{ group: "Gemini Models", label: "wk", usedPercent: 25, resetsAt: null },
|
||||
{ group: "Claude and GPT models", label: "5h", usedPercent: 50, resetsAt: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@example.com",
|
||||
plan: "pro",
|
||||
windows: [{ label: "mo", usedPercent: 40, resetsAt: null }],
|
||||
},
|
||||
],
|
||||
checkedAt: "",
|
||||
}),
|
||||
@@ -214,10 +232,10 @@ describe("StatusBar", () => {
|
||||
expect(screen.getByText("95% used")).toHaveClass("text-status-danger");
|
||||
await user.hover(chips);
|
||||
const panel = screen.getByTestId("status-bar-quota-panel");
|
||||
const [work, personal, codex] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workin use");
|
||||
const [work, personal, codex, agy, cursor] = screen.getAllByTestId("status-bar-quota-account");
|
||||
expect(work).toHaveTextContent("Workmaxin use");
|
||||
expect(work).toHaveTextContent("me@example.com · Sign-in expires in 3h 0m");
|
||||
expect(work).toHaveTextContent("5h95%41m");
|
||||
expect(work).toHaveTextContent("5h95%Resets in 41m");
|
||||
expect(work).toHaveTextContent("Week32%");
|
||||
expect(personal).toHaveTextContent("home@example.com · Sign-in expired");
|
||||
expect(personal).toHaveTextContent("Check quota in Claude Code → Profiles");
|
||||
@@ -225,6 +243,11 @@ describe("StatusBar", () => {
|
||||
expect(codex).toHaveTextContent("dev@example.com");
|
||||
expect(codex).toHaveTextContent("Sign-in expired");
|
||||
expect(panel).toHaveTextContent("Could not read the quota");
|
||||
expect(agy).toHaveTextContent("Gemini Models5h0%Week25%Claude and GPT models5h50%");
|
||||
expect(chips).toHaveTextContent("Antigravity50% used");
|
||||
expect(cursor).toHaveTextContent("c@example.compro");
|
||||
expect(cursor).toHaveTextContent("Month40%");
|
||||
expect(chips).toHaveTextContent("Cursor40% used");
|
||||
// Moving onto the panel keeps it open, so it can be read; leaving it closes it.
|
||||
await user.unhover(chips);
|
||||
await user.hover(panel);
|
||||
@@ -244,7 +267,11 @@ describe("StatusBar", () => {
|
||||
await user.click(refresh);
|
||||
expect(api.pricing.quota).toHaveBeenCalledTimes(1);
|
||||
await act(async () => answer(same));
|
||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent("Refresh");
|
||||
// The spin outlasts an instant answer (a throttled refresh is served from
|
||||
// cache), so the label returns after the minimum spin.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("status-bar-quota-refresh")).toHaveTextContent(/^Refresh$/)
|
||||
);
|
||||
expect(screen.getByTestId("status-bar-quota-panel")).toBeInTheDocument();
|
||||
await user.unhover(panel);
|
||||
await waitFor(() => expect(screen.queryByTestId("status-bar-quota-panel")).toBeNull());
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* @file WorkspaceTree.owners.test.tsx
|
||||
* @description Server mode, the sidebar workspace list for an analyst: every
|
||||
* user's workspaces, grouped under their owner (yours first, no owner last),
|
||||
* groups folding and remembered; another user's workspace is read only — no
|
||||
* menu, no new session, and a session opens its analysis page, never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, 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";
|
||||
|
||||
const ME = { id: 2, username: "ana", role: "analyst" as const };
|
||||
|
||||
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
|
||||
({
|
||||
id,
|
||||
title,
|
||||
cwd: `/w/${id}`,
|
||||
kind: "adopted",
|
||||
tool: "claude",
|
||||
status: "idle",
|
||||
liveness: "idle",
|
||||
pipeline_nodes: [],
|
||||
owner_id: owner,
|
||||
owner_name: name,
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
];
|
||||
|
||||
vi.mock("../AuthGate", () => ({
|
||||
useAuth: () => ({ state: { mode: "server", user: ME } }),
|
||||
usePermissions: () => ({
|
||||
isAdmin: false,
|
||||
seesAll: true,
|
||||
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
|
||||
sessions: {
|
||||
list: vi.fn(async () => ({
|
||||
sessions: [
|
||||
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
|
||||
] as Session[],
|
||||
total: 1,
|
||||
limit: 6,
|
||||
offset: 0,
|
||||
})),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { WorkspaceTree } from "../WorkspaceTree";
|
||||
|
||||
function Where() {
|
||||
const loc = useLocation();
|
||||
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<>
|
||||
<WorkspaceTree />
|
||||
<Where />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe("WorkspaceTree for an analyst", () => {
|
||||
it("groups every workspace under its owner: yours first, no owner last", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
const order = screen
|
||||
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
|
||||
.map((el) => el.dataset.testid);
|
||||
expect(order).toEqual([
|
||||
"workspace-owner-2",
|
||||
"workspace-row-3",
|
||||
"workspace-owner-1",
|
||||
"workspace-row-4",
|
||||
"workspace-owner-3",
|
||||
"workspace-row-1",
|
||||
"workspace-owner-none",
|
||||
"workspace-row-2",
|
||||
]);
|
||||
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
||||
});
|
||||
|
||||
it("names an owner by their display name when they set one", async () => {
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
||||
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
||||
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
it("folds a group and remembers it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-owner-3"));
|
||||
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
|
||||
"3": true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps another user's workspace read only", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
// Yours keeps its controls; theirs has none.
|
||||
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
|
||||
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
|
||||
// Picking it unfolds it instead of opening a workspace home.
|
||||
await user.click(screen.getByTestId("workspace-open-1"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
|
||||
await user.click(within(list).getByTestId("workspace-session-s-1"));
|
||||
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
|
||||
});
|
||||
});
|
||||
@@ -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: [] })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -156,7 +154,12 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||
// Its OWN sessions only — never the rest of its folder.
|
||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
@@ -177,9 +180,23 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
||||
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
||||
await user.click(screen.getByTestId("workspace-sessions-more-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 9,
|
||||
});
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("renames a session on double-click + Enter, and archives one from its row", async () => {
|
||||
@@ -195,6 +212,38 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
session("s-live", "Live one"),
|
||||
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
await screen.findByTestId("workspace-session-pinned-s-old");
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByTestId(/^workspace-session-s-/)
|
||||
.map((el) => el.dataset.testid)
|
||||
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("workspace-session-s-old"),
|
||||
});
|
||||
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
|
||||
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
@@ -309,6 +358,23 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.lanes.action).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lists live sessions above ended ones, even an ended one active more recently", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [session("s-old", "Old one"), session("s-live", "Live one")],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("workspace-session-s-live")).toHaveAttribute("data-live", "true")
|
||||
);
|
||||
const ids = screen.getAllByTestId(/^workspace-session-s-/).map((el) => el.dataset.testid);
|
||||
expect(ids).toEqual(["workspace-session-s-live", "workspace-session-s-old"]);
|
||||
});
|
||||
|
||||
it("shows a live tab whose session has no row yet as a New session, and a shell as Terminal", async () => {
|
||||
vi.mocked(api.run.list).mockImplementation(
|
||||
async () =>
|
||||
@@ -351,97 +417,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");
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ConfirmDeleteModal<
|
||||
aria-modal="true"
|
||||
aria-labelledby="cc-confirm-delete-title"
|
||||
aria-describedby="cc-confirm-delete-body"
|
||||
className="relative w-full max-w-lg rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
className="relative flex max-h-full w-full max-w-lg flex-col rounded-xl border border-status-danger/40 bg-surface-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
|
||||
@@ -76,7 +76,7 @@ export function ConfirmDeleteModal<
|
||||
{t("edit.confirmDelete")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="min-h-0 overflow-y-auto p-4 space-y-3">
|
||||
<p id="cc-confirm-delete-body" className="text-xs text-fg-secondary leading-relaxed">
|
||||
{t("edit.confirmDeleteBody")}
|
||||
</p>
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
* the message stream via MessageList. Combines a WebSocket subscription, a
|
||||
* visibility-gated polling fallback, and a manual refresh button so the view
|
||||
* stays caught up even when hooks miss frames or the user is mid-text-only
|
||||
* turn (no PreToolUse fires until Stop).
|
||||
* turn (no PreToolUse fires until Stop). The view sticks to its end while
|
||||
* the reader is there: anything that makes it taller — a new line, the live
|
||||
* step, an image loading, the pane shown again after being hidden by CSS
|
||||
* (a `display:none` pane cannot scroll) — keeps the newest in sight, until the
|
||||
* reader scrolls up; `follow` (a chat's send) sends it back to the end. A
|
||||
* chat passes in whether the CLI waits on the user (`waiting`, with
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -53,12 +61,15 @@
|
||||
*
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { memo, useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Loader2, ArrowDown, MessagesSquare, RefreshCw } from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||
import { MessageList } from "./MessageList";
|
||||
import { MessageList, promptTokens } from "./MessageList";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||
import { throttle } from "../../lib/throttle";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/types";
|
||||
|
||||
@@ -71,15 +82,64 @@ const POLL_INTERVAL_MS = 3000;
|
||||
// Rescan the transcripts list periodically so new subagents that spawn
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
// A hook burst (Pre + Post per tool call, several sessions) refetches the tail
|
||||
// at most this often: the first event still fetches at once, the last is never
|
||||
// lost, and a long transcript is not re-read for every single frame.
|
||||
const EVENT_FETCH_MS = 1500;
|
||||
|
||||
interface ConversationViewProps {
|
||||
sessionId: string;
|
||||
initialTranscriptId?: string | null;
|
||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
||||
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||
fill?: boolean;
|
||||
/** Opens a file named in a turn's "Edited N files" card. */
|
||||
onOpenFile?: (path: string) => void;
|
||||
/** The session runs in a pane: show the step in progress. */
|
||||
live?: boolean;
|
||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||
follow?: number;
|
||||
/** The CLI is asking the user something (a permission prompt). */
|
||||
waiting?: boolean;
|
||||
/** Shows the pane's terminal, where that question is answered. */
|
||||
onOpenTerminal?: () => void;
|
||||
/** Bumped by the chat's Stop: the live line ends with the turn on screen. */
|
||||
stopped?: number;
|
||||
/** The newest reply's prompt size (input + cache) and model: how full the
|
||||
* context is. */
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
}
|
||||
|
||||
export function ConversationView({ sessionId, initialTranscriptId }: ConversationViewProps) {
|
||||
function ConversationViewPane({
|
||||
sessionId,
|
||||
initialTranscriptId,
|
||||
fill = false,
|
||||
onOpenFile,
|
||||
live = false,
|
||||
follow = 0,
|
||||
waiting = false,
|
||||
onOpenTerminal,
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
// A live pane's session has no row until its first hook — and Claude Code
|
||||
// runs no hook before the folder is trusted, so a trust prompt (or any
|
||||
// other question before the session starts) waits unseen in the terminal.
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||
useEffect(() => {
|
||||
if (!onContext) return;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if (m.type === "assistant" && m.usage)
|
||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||
}
|
||||
}, [messages, onContext]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||
@@ -90,11 +150,14 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [transcripts, setTranscripts] = useState<TranscriptInfo[]>([]);
|
||||
const [showNewMsg, setShowNewMsg] = useState(false);
|
||||
// Scrolled up from the end: a chat (`fill`) offers the way back down.
|
||||
const [awayFromEnd, setAwayFromEnd] = useState(false);
|
||||
|
||||
// Track JSONL line numbers for incremental requests and history loading
|
||||
const lastLineRef = useRef(0);
|
||||
const firstLineRef = useRef(0);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const fetchingRef = useRef(false);
|
||||
// When a fetch is in flight and a new trigger arrives (WS event, poll,
|
||||
@@ -104,9 +167,12 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Bumped whenever the session or the selected transcript changes: a tail
|
||||
// fetch started for the previous transcript must not append to the new one.
|
||||
const genRef = useRef(0);
|
||||
// A pane of another workspace or tab stays mounted but hidden: it neither
|
||||
// polls nor follows events, and catches up once when it is shown again.
|
||||
const onShowRef = useRef<() => void>(() => {});
|
||||
const onScreen = useOnScreen(scrollContainerRef, () => onShowRef.current());
|
||||
// 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).
|
||||
@@ -123,7 +189,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") loadTranscripts();
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -155,6 +221,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
limit: 50,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setNotStarted(false);
|
||||
setMessages(result.messages);
|
||||
setTotal(result.total);
|
||||
setHasMore(result.has_more);
|
||||
@@ -162,7 +229,11 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
firstLineRef.current = result.first_line;
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
// A live pane's new session has no row until its first hook: nothing
|
||||
// to show yet, and the poll below picks it up once it exists.
|
||||
const notYet = live && (err as { code?: string }).code === "NOT_FOUND";
|
||||
if (!notYet) setError(err instanceof Error ? err.message : t("conversation.failedLoad"));
|
||||
setNotStarted(notYet);
|
||||
setMessages([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
@@ -202,6 +273,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
limit: 50,
|
||||
});
|
||||
if (gen !== genRef.current) return; // another transcript is shown now
|
||||
setNotStarted(false);
|
||||
if (result.messages.length === 0) return;
|
||||
|
||||
lastLineRef.current = result.last_line;
|
||||
@@ -237,22 +309,33 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, selectedTranscript]);
|
||||
|
||||
// WebSocket subscription: refetch on every new_event for this session.
|
||||
// WebSocket subscription: refetch on new_event for this session, throttled.
|
||||
// Hook coverage isn't complete (a user-typed message fires no hook), so we
|
||||
// also poll below to catch what WS misses.
|
||||
const fetchSoon = useMemo(
|
||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||
[fetchNewMessages]
|
||||
);
|
||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||
if (!isShown(onScreen)) return;
|
||||
if (isRemoteDataRefreshMessage(msg)) {
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
return;
|
||||
}
|
||||
if (msg.type !== "new_event") return;
|
||||
const data = msg.data as { session_id?: string };
|
||||
if (data.session_id !== sessionId) return;
|
||||
fetchNewMessages();
|
||||
fetchSoon();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [sessionId, fetchNewMessages]);
|
||||
}, [sessionId, fetchSoon, onScreen]);
|
||||
|
||||
onShowRef.current = () => {
|
||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||
};
|
||||
|
||||
// Resync on WebSocket reconnect: events that landed during a transient
|
||||
// disconnect are gone from the bus, but the JSONL still has them, so a
|
||||
@@ -273,7 +356,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") fetchNewMessages();
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
function stop() {
|
||||
@@ -299,18 +382,11 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages]);
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
const refresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetchNewMessages();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fetchNewMessages]);
|
||||
const [refreshing, refresh] = useSpin(fetchNewMessages);
|
||||
|
||||
// Scroll-up to load history
|
||||
const loadHistory = useCallback(async () => {
|
||||
@@ -375,6 +451,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
// Detect if at bottom
|
||||
const atBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
isAtBottomRef.current = atBottom;
|
||||
setAwayFromEnd(!atBottom);
|
||||
|
||||
// Hide "new messages" indicator when scrolled to bottom
|
||||
if (atBottom) {
|
||||
@@ -387,6 +464,30 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}
|
||||
}, [hasMore, loadingHistory, loadHistory]);
|
||||
|
||||
// Stick to the end while the reader is there. Scroll events say whether
|
||||
// they are; a size change — of the content, or of the box itself when a
|
||||
// hidden pane is shown — never fires one, so it is watched here.
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isAtBottomRef.current) container.scrollTop = container.scrollHeight;
|
||||
});
|
||||
observer.observe(container);
|
||||
observer.observe(content);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// The reader sent a message: show it, and what follows, as it lands.
|
||||
useEffect(() => {
|
||||
if (!follow) return;
|
||||
isAtBottomRef.current = true;
|
||||
setAwayFromEnd(false);
|
||||
setShowNewMsg(false);
|
||||
scrollToBottom();
|
||||
}, [follow, scrollToBottom]);
|
||||
|
||||
// Auto-scroll to bottom after initial load
|
||||
useEffect(() => {
|
||||
if (!loading && messages.length > 0) {
|
||||
@@ -395,10 +496,10 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col" style={{ minHeight: 0 }}>
|
||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||
{/* Toolbar - always rendered after the initial load so users can
|
||||
refresh even when no messages have streamed yet. */}
|
||||
{!loading && (
|
||||
{!loading && !fill && (
|
||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||
{transcripts.length > 1 && (
|
||||
<div className="relative">
|
||||
@@ -445,42 +546,92 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
||||
>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||
(the outline's file button floats there). */}
|
||||
<div ref={contentRef} className={fill ? "pt-6" : undefined}>
|
||||
{/* History loading indicator */}
|
||||
{loadingHistory && (
|
||||
<div className="flex justify-center py-3">
|
||||
<Loader2 className="w-4 h-4 text-fg-muted animate-spin" />
|
||||
<span className="text-xs text-fg-muted ml-2">{t("conversation.loadingHistory")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">↑ {t("conversation.scrollForOlder")}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Scroll-up for history hint */}
|
||||
{hasMore && !loadingHistory && !loading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="text-[11px] text-fg-muted">
|
||||
↑ {t("conversation.scrollForOlder")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList messages={messages} loading={false} />
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
||||
{t("conversation.loading")}
|
||||
</div>
|
||||
) : messages.length === 0 && notStarted ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.notStartedBody")}
|
||||
</p>
|
||||
{onOpenTerminal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenTerminal}
|
||||
className="btn-secondary mt-3 text-xs"
|
||||
>
|
||||
{t("conversation.openTerminal")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||
{t("conversation.emptyBody")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
live={live}
|
||||
waiting={waiting}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
stopped={stopped}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Desktop's round "back to the end" button */}
|
||||
{fill && awayFromEnd && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="conversation-to-end"
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
setShowNewMsg(false);
|
||||
}}
|
||||
aria-label={t("conversation.newMessages")}
|
||||
title={t("conversation.newMessages")}
|
||||
className="absolute bottom-3 left-1/2 z-10 flex h-8 w-8 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-surface-1 text-fg-secondary shadow-md transition-colors hover:text-fg-primary"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
{showNewMsg && (
|
||||
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-accent-solid" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* New messages indicator */}
|
||||
{showNewMsg && (
|
||||
{showNewMsg && !fill && (
|
||||
<button
|
||||
onClick={() => {
|
||||
scrollToBottom();
|
||||
@@ -495,3 +646,7 @@ export function ConversationView({ sessionId, initialTranscriptId }: Conversatio
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoized: a chat's prompt box lives in its parent (PaneChat), and every
|
||||
* keystroke there re-rendered the whole conversation. */
|
||||
export const ConversationView = memo(ConversationViewPane);
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs.
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
*
|
||||
* Output is built as a React element tree (no dangerouslySetInnerHTML) so user content
|
||||
* is escaped by React. Code blocks delegate to <CodeBlock /> for syntax highlighting and
|
||||
@@ -244,6 +251,110 @@ function parseBlocks(src: string): Block[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Opens a file a message names; provided by a view that can show one. */
|
||||
export const OpenFileContext = React.createContext<((path: string) => void) | undefined>(undefined);
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
|
||||
// A relative path needs a folder AND an extension starting with a letter
|
||||
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
// 2026-10-12T08:00:00Z. It must stand alone, so "abc123", "file.2", "#67" and a sha like
|
||||
// "2fd0c903" stay text.
|
||||
const NUM_RE =
|
||||
/^(?:[-+−]?[$€£¥]?|v)\d+(?:[.,:/-]\d+)*(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?(?:%|×|x|k|K|M|B|KB|MB|GB|TB|ms|s|m|h|d|px|đ)?(?![\p{L}\p{N}_])/u;
|
||||
const numStart = (text: string, i: number) =>
|
||||
i === 0 || !/[\p{L}\p{N}_.\/\\#@-]/u.test(text[i - 1]!);
|
||||
// A label that flags what follows — "Lưu ý:", "Warning:" — coloured by how serious it is.
|
||||
const LABEL_RE =
|
||||
/^(Error|Lỗi|Caution|Danger|Breaking|Warning|Cảnh báo|Important|Quan trọng|Chú ý|Note|Lưu ý|Tip|Mẹo):/i;
|
||||
const LABEL_CLASS = (word: string) =>
|
||||
/^(error|lỗi|caution|danger|breaking)$/i.test(word)
|
||||
? "text-status-danger"
|
||||
: /^(warning|cảnh báo|important|quan trọng|chú ý)$/i.test(word)
|
||||
? "text-status-warning"
|
||||
: "text-accent";
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||
function decodeHref(href: string): string {
|
||||
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
/* a stray % stays as written */
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A path as a button that opens it, or as it was when nothing can. */
|
||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||
const open = React.useContext(OpenFileContext);
|
||||
if (!open) return <>{children}</>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(stripLine(path))}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Plain text with its URLs and absolute paths as links — nothing else parsed. */
|
||||
export function linkify(text: string): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
let buf = "";
|
||||
for (let i = 0; i < text.length;) {
|
||||
const rest = text.slice(i);
|
||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && matchPath(rest);
|
||||
const hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
continue;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
buf = "";
|
||||
out.push(
|
||||
url ? (
|
||||
<UrlLink key={i} url={url} />
|
||||
) : (
|
||||
<PathLink key={i} path={hit}>
|
||||
{hit}
|
||||
</PathLink>
|
||||
)
|
||||
);
|
||||
i += hit.length;
|
||||
}
|
||||
if (buf) out.push(buf);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render inline markdown (bold/italic/code/strikethrough/links/auto-links). */
|
||||
function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const out: React.ReactNode[] = [];
|
||||
@@ -267,11 +378,18 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Inline code: `...`
|
||||
const codeM = rest.match(/^`([^`\n]+)`/);
|
||||
if (codeM) {
|
||||
push(
|
||||
const code = (
|
||||
<code className="rounded bg-surface-4 border border-surface-3 px-1.5 py-0.5 font-mono text-[12.5px] text-status-warning">
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
matchPath(codeM[1]!) === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -280,7 +398,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
const boldM = rest.match(/^(\*\*|__)(.+?)\1/);
|
||||
if (boldM) {
|
||||
push(
|
||||
<strong className="font-semibold text-fg-primary">
|
||||
<strong className="font-bold text-accent">
|
||||
{renderInline(boldM[2]!, `${baseKey}-b${n}`)}
|
||||
</strong>
|
||||
);
|
||||
@@ -315,9 +433,23 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
const href = linkM[2]!;
|
||||
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||
if (
|
||||
/^file:\/\//i.test(href) ||
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
@@ -330,22 +462,39 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// Auto-link
|
||||
const urlM = rest.match(/^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/);
|
||||
const urlM = rest.match(URL_RE);
|
||||
if (urlM) {
|
||||
push(
|
||||
<a
|
||||
href={urlM[0]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
|
||||
>
|
||||
{urlM[0]}
|
||||
</a>
|
||||
);
|
||||
push(<UrlLink url={urlM[0]} />);
|
||||
i += urlM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||||
i += pathM.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const labelM = atWordStart(text, i) ? LABEL_RE.exec(rest) : null;
|
||||
if (labelM) {
|
||||
push(<span className={`font-bold ${LABEL_CLASS(labelM[1]!)}`}>{labelM[0]}</span>);
|
||||
i += labelM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const numM = numStart(text, i) ? NUM_RE.exec(rest) : null;
|
||||
if (numM) {
|
||||
push(
|
||||
<span className="font-semibold tabular-nums text-teal-700 dark:text-teal-300">
|
||||
{numM[0]}
|
||||
</span>
|
||||
);
|
||||
i += numM[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
buf += text[i]!;
|
||||
i++;
|
||||
}
|
||||
@@ -381,6 +530,8 @@ interface MarkdownContentProps {
|
||||
text: string;
|
||||
/** Tighter spacing for nested contexts (list items, quotes). */
|
||||
dense?: boolean;
|
||||
/** The assistant's reply in a chat: larger serif body text, as Claude Desktop. */
|
||||
prose?: boolean;
|
||||
}
|
||||
|
||||
const HEADING_STYLES = [
|
||||
@@ -392,12 +543,16 @@ const HEADING_STYLES = [
|
||||
"text-xs font-medium text-fg-secondary uppercase tracking-wider",
|
||||
];
|
||||
|
||||
export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
export function MarkdownContent({ text, dense = false, prose = false }: MarkdownContentProps) {
|
||||
const blocks = parseBlocks(text);
|
||||
const gap = dense ? "space-y-1.5" : "space-y-2.5";
|
||||
// A paragraph or list item takes the root's size and colour in prose.
|
||||
const bodyText = prose ? "" : "text-sm text-fg-secondary";
|
||||
|
||||
return (
|
||||
<div className={`text-sm text-fg-secondary leading-relaxed ${gap}`}>
|
||||
<div
|
||||
className={`${prose ? "font-serif text-[15px] leading-7 text-fg-primary" : "text-sm text-fg-secondary leading-relaxed"} ${gap}`}
|
||||
>
|
||||
{blocks.map((b, idx) => {
|
||||
switch (b.kind) {
|
||||
case "code":
|
||||
@@ -420,7 +575,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-decimal pl-5 space-y-1 marker:text-fg-muted marker:font-mono marker:text-xs"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -433,7 +588,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
|
||||
>
|
||||
{b.items.map((item, i) => (
|
||||
<li key={i} className="text-sm text-fg-secondary">
|
||||
<li key={i} className={bodyText}>
|
||||
{renderListItem(item, `li${idx}-${i}`)}
|
||||
</li>
|
||||
))}
|
||||
@@ -503,7 +658,7 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
|
||||
|
||||
case "para":
|
||||
return (
|
||||
<p key={idx} className="text-sm text-fg-secondary whitespace-pre-wrap break-words">
|
||||
<p key={idx} className={`${bodyText} whitespace-pre-wrap break-words`}>
|
||||
{renderInline(b.text, `p${idx}`)}
|
||||
</p>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -94,7 +94,7 @@ function langFromPath(path: string): string {
|
||||
}
|
||||
|
||||
/** Build a one-line summary of the tool call to show in the collapsed header. */
|
||||
function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
export function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const input = toolUse.input;
|
||||
if (!input || typeof input !== "object" || "_truncated" in input) return null;
|
||||
const obj = input as Record<string, unknown>;
|
||||
@@ -112,7 +112,7 @@ function buildSummary(toolUse: TranscriptContent): string | null {
|
||||
const tt = (key: string) => i18n.t(`sessions:conversation.tool.${key}`);
|
||||
|
||||
/** Render the input pane with tool-aware formatting. */
|
||||
function renderInput(toolUse: TranscriptContent) {
|
||||
export function renderInput(toolUse: TranscriptContent) {
|
||||
const input = toolUse.input;
|
||||
if (!input) return null;
|
||||
|
||||
@@ -231,7 +231,7 @@ function renderInput(toolUse: TranscriptContent) {
|
||||
}
|
||||
|
||||
/** Render the result pane: detect diff/json/text. */
|
||||
function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
export function renderResult(toolResult: TranscriptContent, toolName: string) {
|
||||
const text = toolResult.output ?? "";
|
||||
if (text.length === 0)
|
||||
return <div className="text-xs text-fg-muted italic px-1">({tt("empty")})</div>;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @file ConversationView.follow.test.tsx
|
||||
* @description A chat sticks to its end: when it grows or is shown again
|
||||
* after being hidden (a size change, which fires no scroll event) it scrolls
|
||||
* to the newest while the reader is at the end, leaves a reader who scrolled
|
||||
* up alone, and a send (`follow`) brings it back to the end.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let resize: (() => void) | null = null;
|
||||
const original = globalThis.ResizeObserver;
|
||||
beforeEach(() => {
|
||||
resize = null;
|
||||
// @ts-expect-error test stub
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
resize = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = original;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** The scroll box, with the layout jsdom does not compute. */
|
||||
function box(height: number) {
|
||||
const el = screen.getByText("hello").closest(".overflow-y-auto") as HTMLElement;
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: height });
|
||||
Object.defineProperty(el, "clientHeight", { configurable: true, value: 200 });
|
||||
Object.defineProperty(el, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
return el;
|
||||
}
|
||||
|
||||
describe("ConversationView sticks to its end", () => {
|
||||
it("scrolls to the newest when it grows or is shown again, unless the reader scrolled up", async () => {
|
||||
const view = render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const el = box(1000);
|
||||
// Shown again after display:none, or taller: only a resize says so.
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(1000);
|
||||
|
||||
// The reader scrolls up: growing no longer pulls them down.
|
||||
el.scrollTop = 100;
|
||||
fireEvent.scroll(el);
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1400 });
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(100);
|
||||
|
||||
// They send a message: back to the end, and it sticks again.
|
||||
view.rerender(<ConversationView sessionId="s1" fill live follow={1} />);
|
||||
expect(el.scrollTop).toBe(1400);
|
||||
Object.defineProperty(el, "scrollHeight", { configurable: true, value: 1600 });
|
||||
act(() => resize?.());
|
||||
expect(el.scrollTop).toBe(1600);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @file ConversationView.hidden.test.tsx
|
||||
* @description A chat kept mounted in a hidden pane (another workspace or
|
||||
* tab, `display:none`) does not follow the live stream: a new_event for its
|
||||
* session fetches nothing while its box is off screen, and showing it again
|
||||
* catches the tail up once. On screen, a burst of events is throttled to one
|
||||
* fetch at once plus one after the burst.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
import { api } from "../../../lib/api";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
sessions: {
|
||||
transcripts: vi.fn(async () => ({ transcripts: [] })),
|
||||
transcript: vi.fn(async () => ({
|
||||
messages: [
|
||||
{
|
||||
type: "user",
|
||||
content: [{ type: "text", text: "hello" }],
|
||||
timestamp: "2026-10-09T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
has_more: false,
|
||||
first_line: 1,
|
||||
last_line: 1,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let intersect: ((visible: boolean) => void) | null = null;
|
||||
const original = globalThis.IntersectionObserver;
|
||||
beforeEach(() => {
|
||||
intersect = null;
|
||||
// @ts-expect-error test stub
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(cb: (e: { isIntersecting: boolean }[]) => void) {
|
||||
intersect = (visible) => cb([{ isIntersecting: visible }]);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
});
|
||||
afterEach(() => {
|
||||
globalThis.IntersectionObserver = original;
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const event = () =>
|
||||
act(() => eventBus.publish({ type: "new_event", data: { session_id: "s1" } } as never));
|
||||
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||
|
||||
describe("ConversationView in a hidden pane", () => {
|
||||
it("fetches nothing while hidden and catches up once when shown", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
const before = calls();
|
||||
|
||||
act(() => intersect?.(false));
|
||||
event();
|
||||
event();
|
||||
expect(calls()).toBe(before);
|
||||
|
||||
act(() => intersect?.(true));
|
||||
await act(async () => {});
|
||||
expect(calls()).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("throttles a burst of events on screen", async () => {
|
||||
render(<ConversationView sessionId="s1" fill live />);
|
||||
await screen.findByText("hello");
|
||||
vi.useFakeTimers();
|
||||
const before = calls();
|
||||
event();
|
||||
event();
|
||||
event();
|
||||
// The first fetches at once; the rest wait for the throttle window.
|
||||
expect(calls()).toBe(before + 1);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1600);
|
||||
});
|
||||
expect(calls()).toBe(before + 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @file ConversationView.notYet.test.tsx
|
||||
* @description A live pane's new session has no row until its first hook:
|
||||
* its 404 shows no "Session not found" (a replay still shows it) but says the
|
||||
* session has not started — the CLI may be asking in the terminal, e.g. to
|
||||
* trust the folder — offers the terminal, and tells the chat (`onNotStarted`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ConversationView } from "../ConversationView";
|
||||
|
||||
const transcript = vi.hoisted(() =>
|
||||
vi.fn(async () => {
|
||||
throw Object.assign(new Error("Session not found"), { code: "NOT_FOUND", status: 404 });
|
||||
})
|
||||
);
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { sessions: { transcripts: vi.fn(async () => ({ transcripts: [] })), transcript } },
|
||||
};
|
||||
});
|
||||
|
||||
describe("ConversationView before a session's first hook", () => {
|
||||
it("shows no error for a live pane", async () => {
|
||||
render(<ConversationView sessionId="new" fill live />);
|
||||
await waitFor(() => expect(transcript).toHaveBeenCalled());
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(screen.queryByText("Session not found")).toBeNull();
|
||||
});
|
||||
|
||||
it("still reports it when not live", async () => {
|
||||
render(<ConversationView sessionId="gone" fill />);
|
||||
expect(await screen.findByText("Session not found")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says the session has not started and offers the terminal (a trust prompt)", async () => {
|
||||
const onOpenTerminal = vi.fn();
|
||||
const onNotStarted = vi.fn();
|
||||
render(
|
||||
<ConversationView
|
||||
sessionId="new"
|
||||
fill
|
||||
live
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
onNotStarted={onNotStarted}
|
||||
/>
|
||||
);
|
||||
expect(await screen.findByText("The session has not started yet.")).toBeTruthy();
|
||||
expect(screen.queryByText("No conversation records found.")).toBeNull();
|
||||
fireEvent.click(screen.getByText("Open terminal"));
|
||||
expect(onOpenTerminal).toHaveBeenCalled();
|
||||
expect(onNotStarted).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,52 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
|
||||
import { resolveIn } from "../../../lib/resolvePath";
|
||||
|
||||
function withOpen(text: string) {
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<OpenFileContext.Provider value={open}>
|
||||
<MarkdownContent text={text} />
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
return open;
|
||||
}
|
||||
|
||||
describe("file links", () => {
|
||||
it("opens a relative path with its line suffix stripped", () => {
|
||||
const open = withOpen("See server/lib/x.js:42 for it.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
|
||||
expect(open).toHaveBeenCalledWith("server/lib/x.js");
|
||||
});
|
||||
|
||||
it("opens an absolute path with a line suffix and one in inline code", () => {
|
||||
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
|
||||
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
|
||||
});
|
||||
|
||||
it("leaves prose with slashes as text", () => {
|
||||
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves a relative path against the pane's folder", () => {
|
||||
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
|
||||
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MarkdownContent />", () => {
|
||||
it("renders fenced code blocks with the language label", () => {
|
||||
@@ -66,6 +110,43 @@ describe("<MarkdownContent />", () => {
|
||||
expect(screen.getByText("just a normal sentence.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights standalone numbers, never ones inside a word, sha or issue ref", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"Took 3.5s, 87% done, $1.20, v2.7.1 on 2026-10-12 — abc123 file.2 #67 2fd0c903 x9"}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual(["3.5s", "87%", "$1.20", "v2.7.1", "2026-10-12"]);
|
||||
});
|
||||
|
||||
it("highlights an ISO date-time as one number", () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent
|
||||
text={"At 2026-10-12T08:00, 2026-10-12T08:00:00Z and 2026-10-12T08:00:00.123+07:00."}
|
||||
/>
|
||||
);
|
||||
const nums = [...container.querySelectorAll("span.tabular-nums")].map((n) => n.textContent);
|
||||
expect(nums).toEqual([
|
||||
"2026-10-12T08:00",
|
||||
"2026-10-12T08:00:00Z",
|
||||
"2026-10-12T08:00:00.123+07:00",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not highlight numbers inside inline code", () => {
|
||||
const { container } = render(<MarkdownContent text={"Run `sleep 5` now"} />);
|
||||
expect(container.querySelector("span.tabular-nums")).toBeNull();
|
||||
});
|
||||
|
||||
it("colours a flag label by how serious it is", () => {
|
||||
render(<MarkdownContent text={"Lưu ý: a. Warning: b. Lỗi: c. Note that x"} />);
|
||||
expect(screen.getByText("Lưu ý:").className).toContain("text-accent");
|
||||
expect(screen.getByText("Warning:").className).toContain("text-status-warning");
|
||||
expect(screen.getByText("Lỗi:").className).toContain("text-status-danger");
|
||||
expect(screen.queryByText("Note")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles empty input safely", () => {
|
||||
const { container } = render(<MarkdownContent text="" />);
|
||||
// Wrapper exists but no block elements
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @file MessageList.ask.test.tsx
|
||||
* @description An AskUserQuestion call is not folded into the collapsed work
|
||||
* line: the chat shows it as its own card — each question with the answer the
|
||||
* user picked or typed (without the option's " (Recommended)" marker), "declined" when they refused it, nothing yet while the
|
||||
* CLI still waits.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = "2026-10-13T08:00:00.000Z";
|
||||
const user = (text: string): TranscriptMessage =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: at,
|
||||
content: [{ type: "text", text }],
|
||||
}) as TranscriptMessage;
|
||||
const ask = (id: string, questions: string[]): TranscriptMessage =>
|
||||
({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [
|
||||
{ type: "tool_use", id: `${id}r`, name: "Read", input: { file_path: "/w/a.ts" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id,
|
||||
name: "AskUserQuestion",
|
||||
input: {
|
||||
questions: questions.map((question) => ({
|
||||
question,
|
||||
header: "Pick",
|
||||
options: [{ label: "Yes" }, { label: "No" }],
|
||||
})),
|
||||
},
|
||||
},
|
||||
],
|
||||
}) as TranscriptMessage;
|
||||
const result = (id: string, extra: Record<string, unknown>) =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id, output: "x", ...extra }],
|
||||
}) as TranscriptMessage;
|
||||
|
||||
describe("an AskUserQuestion card", () => {
|
||||
it("shows each question with the user's answer, outside the work line", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Push?"]),
|
||||
result("q1", { answers: { "Commit?": "Yes", "Push?": "only after review" } }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("only after review")).toBeTruthy();
|
||||
expect(within(screen.getByTestId("chat-tools")).queryByText(/Commit\?/)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops the (Recommended) marker from a picked answer", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("go"),
|
||||
ask("q1", ["Commit?", "Tools?"]),
|
||||
result("q1", {
|
||||
answers: { "Commit?": "Yes (Recommended)", "Tools?": "Lint (Recommended), Test" },
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Yes")).toBeTruthy();
|
||||
expect(within(card).getByText("Lint, Test")).toBeTruthy();
|
||||
expect(within(card).queryByText(/Recommended/)).toBeNull();
|
||||
});
|
||||
|
||||
it("says declined when refused, and shows no answer while still asked", () => {
|
||||
const { rerender } = render(
|
||||
<MessageList loading={false} messages={[user("go"), ask("q1", ["Commit?"])]} />
|
||||
);
|
||||
const card = screen.getByTestId("chat-ask");
|
||||
expect(within(card).getByText("Commit?")).toBeTruthy();
|
||||
expect(within(card).queryByText(/declined/i)).toBeNull();
|
||||
rerender(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[user("go"), ask("q1", ["Commit?"]), result("q1", { is_error: true })]}
|
||||
/>
|
||||
);
|
||||
expect(within(screen.getByTestId("chat-ask")).getByText(/declined/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @file MessageList.edits.test.tsx
|
||||
* @description Each assistant turn that changed files ends with Claude
|
||||
* Desktop's "Edited N files" card: one row per file with the lines added and
|
||||
* removed summed over the turn, a failed call left out, a row click handed
|
||||
* to the caller, and a row (or the header, every file) dragged as `@path`
|
||||
* attachments for a prompt box.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const at = "2026-10-09T08:00:00.000Z";
|
||||
const user = (text: string): TranscriptMessage =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: at,
|
||||
content: [{ type: "text", text }],
|
||||
}) as TranscriptMessage;
|
||||
const call = (id: string, name: string, file_path: string): TranscriptMessage =>
|
||||
({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_use", id, name, input: { file_path } }],
|
||||
}) as TranscriptMessage;
|
||||
const result = (id: string, diff?: { added: number; removed: number }, is_error = false) =>
|
||||
({
|
||||
type: "user",
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id, output: "ok", is_error, ...(diff ? { diff } : {}) }],
|
||||
}) as TranscriptMessage;
|
||||
|
||||
describe("the Edited N files card", () => {
|
||||
it("sums each file's lines over the turn and opens a file on click", () => {
|
||||
const onOpenFile = vi.fn();
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
onOpenFile={onOpenFile}
|
||||
messages={[
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 3, removed: 1 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
result("2", { added: 10, removed: 0 }),
|
||||
call("3", "Edit", "/w/a.ts"),
|
||||
result("3", { added: 2, removed: 2 }),
|
||||
call("4", "Edit", "/w/c.ts"),
|
||||
result("4", undefined, true),
|
||||
user("thanks"),
|
||||
call("5", "Read", "/w/a.ts"),
|
||||
result("5"),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const cards = screen.getAllByTestId("edited-files");
|
||||
expect(cards).toHaveLength(1); // the second turn only read
|
||||
const card = cards[0]!;
|
||||
expect(within(card).getByText("Edited 2 files")).toBeTruthy();
|
||||
expect(within(card).getByText("+15")).toBeTruthy();
|
||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
||||
});
|
||||
|
||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
user("fix it"),
|
||||
call("1", "Edit", "/w/a.ts"),
|
||||
result("1", { added: 1, removed: 0 }),
|
||||
call("2", "Write", "/w/b.md"),
|
||||
result("2", { added: 2, removed: 0 }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const card = screen.getByTestId("edited-files");
|
||||
const drag = (el: Element) => {
|
||||
const data: Record<string, string> = {};
|
||||
fireEvent.dragStart(el, {
|
||||
dataTransfer: { setData: (k: string, v: string) => (data[k] = v), effectAllowed: "" },
|
||||
});
|
||||
return data;
|
||||
};
|
||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||
{ path: "@/w/b.md", name: "b.md" },
|
||||
]);
|
||||
expect(one["text/plain"]).toBe("/w/b.md");
|
||||
const all = drag(within(card).getByText("Edited 2 files"));
|
||||
expect(
|
||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file MessageList.images.test.tsx
|
||||
* @description An image the prompt box attached reaches the transcript as its
|
||||
* saved drop path; the user's turn shows the image (served by
|
||||
* `GET /api/run/drop/:name`), opens it full size on the page; any other
|
||||
* dropped file is a chip that opens its preview, and any other path stays as typed. Claude Code turns a pasted drop path into an image
|
||||
* block plus an `[Image: source: <path>]` meta line: the image joins the turn.
|
||||
* URLs and absolute paths in a turn are links.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MessageList, splitDropImages } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
const SHOT = "366d0a87d12176a2-image.png";
|
||||
|
||||
describe("dropped images in a user turn", () => {
|
||||
it("splitDropImages takes drop-dir image paths out of the text", () => {
|
||||
expect(
|
||||
splitDropImages(`Đây là cli và version đã có. /tmp/agenthub-drops-1001/${SHOT}`)
|
||||
).toEqual({ text: "Đây là cli và version đã có.", images: [SHOT], files: [] });
|
||||
// Windows' temp dir, no prefix name.
|
||||
expect(splitDropImages("C:\\Temp\\agenthub-drops--1\\0123456789abcdef.webp").images).toEqual([
|
||||
"0123456789abcdef.webp",
|
||||
]);
|
||||
// The drop dir since the data-dir move.
|
||||
expect(splitDropImages(`[Image #1]fix this /home/me/.claude/agenthub/drops/${SHOT}`)).toEqual({
|
||||
text: "fix this",
|
||||
images: [SHOT],
|
||||
files: [],
|
||||
});
|
||||
// Any other dropped file comes out by its full path.
|
||||
const pdf = "/tmp/agenthub-drops-1001/0123456789abcdef-spec.pdf";
|
||||
expect(splitDropImages(`read ${pdf}`)).toEqual({ text: "read", images: [], files: [pdf] });
|
||||
// Not a drop: left as typed.
|
||||
for (const text of ["/home/me/shot.png", "/home/me/0123456789abcdef-spec.pdf"]) {
|
||||
expect(splitDropImages(text)).toEqual({ text, images: [], files: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("shows a dropped file as a chip that opens its preview", () => {
|
||||
const pdf = "/home/me/.claude/agenthub/drops/0123456789abcdef-spec.pdf";
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `summarise ${pdf}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).not.toHaveTextContent("0123456789abcdef");
|
||||
fireEvent.click(screen.getByRole("button", { name: /spec\.pdf/ }));
|
||||
expect(opened).toEqual([pdf]);
|
||||
});
|
||||
|
||||
it("shows the image instead of its path, full size on click", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T08:00:00.000Z",
|
||||
content: [{ type: "text", text: `look at this /tmp/agenthub-drops-1001/${SHOT}` }],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("look at this");
|
||||
expect(bubble).not.toHaveTextContent("agenthub-drops");
|
||||
const img = screen.getByRole("img", { name: SHOT });
|
||||
expect(img.getAttribute("src")).toBe(`/api/run/drop/${SHOT}`);
|
||||
|
||||
fireEvent.click(img);
|
||||
expect(screen.getByTestId("image-lightbox").parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it("folds Claude Code's [Image: source:] meta line into the turn", () => {
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [{ type: "text", text: "[Image #1]Hãy giúp tôi fix lỗi" }],
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sender: "system",
|
||||
timestamp: "2026-10-09T09:43:11.000Z",
|
||||
content: [
|
||||
{ type: "text", text: `[Image: source: /home/me/.claude/agenthub/drops/${SHOT}]` },
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.queryByText("System")).toBeNull();
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("Hãy giúp tôi fix lỗi");
|
||||
expect(bubble).not.toHaveTextContent("[Image #1]");
|
||||
expect(screen.getByRole("img", { name: SHOT }).getAttribute("src")).toBe(
|
||||
`/api/run/drop/${SHOT}`
|
||||
);
|
||||
});
|
||||
|
||||
it("makes URLs and absolute paths clickable", () => {
|
||||
const opened: string[] = [];
|
||||
const messages = [
|
||||
{
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: "2026-10-09T09:43:00.000Z",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "xem (https://git.example.com/a/issues/69) và /home/me/repo/server/x.js.",
|
||||
},
|
||||
],
|
||||
},
|
||||
] as TranscriptMessage[];
|
||||
render(<MessageList messages={messages} loading={false} onOpenFile={(p) => opened.push(p)} />);
|
||||
|
||||
const link = screen.getByRole("link", { name: "https://git.example.com/a/issues/69" });
|
||||
expect(link.getAttribute("href")).toBe("https://git.example.com/a/issues/69");
|
||||
expect(link.getAttribute("target")).toBe("_blank");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/home/me/repo/server/x.js" }));
|
||||
expect(opened).toEqual(["/home/me/repo/server/x.js"]);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* @file MessageList.sender.test.tsx
|
||||
* @description Verifies the transcript renders each message under its TRUE
|
||||
* sender label — User / Assistant / Main agent / System — instead of labeling
|
||||
* every `type:"user"` line "User" (reported transcript mis-attribution).
|
||||
* @description Verifies the transcript renders each message by its TRUE
|
||||
* sender — the human as a bubble, the assistant's prose bare (Claude Desktop's
|
||||
* layout), Main agent / System under their own label — instead of treating
|
||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
||||
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||
* shows as a chip.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MessageList } from "../MessageList";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
||||
import type { TranscriptMessage } from "../../../lib/types";
|
||||
|
||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
@@ -20,6 +23,21 @@ function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||
}
|
||||
|
||||
describe("MessageList — sender attribution", () => {
|
||||
it("shows a typed /command and @path as chips in the user's bubble", () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
msg({ sender: "user", content: [{ type: "text", text: "/review @src/a.ts now" }] }),
|
||||
]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
const bubble = screen.getByTestId("chat-user");
|
||||
expect(bubble).toHaveTextContent("/review @src/a.ts now");
|
||||
expect(within(bubble).getByText("/review").className).toContain("text-accent");
|
||||
expect(within(bubble).getByText("@src/a.ts").className).toContain("text-violet");
|
||||
});
|
||||
|
||||
it("labels each row by its sender, not blanket 'User'", () => {
|
||||
const messages: TranscriptMessage[] = [
|
||||
msg({ sender: "user", content: [{ type: "text", text: "spin up a team" }] }),
|
||||
@@ -36,8 +54,8 @@ describe("MessageList — sender attribution", () => {
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("spin up a team");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("on it");
|
||||
expect(screen.getByText("System")).toBeInTheDocument();
|
||||
expect(screen.getByText("Main agent")).toBeInTheDocument();
|
||||
});
|
||||
@@ -48,7 +66,281 @@ describe("MessageList — sender attribution", () => {
|
||||
msg({ type: "assistant", content: [{ type: "text", text: "hello" }] }), // → "Assistant"
|
||||
];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
expect(screen.getByText("User")).toBeInTheDocument();
|
||||
expect(screen.getByText("Assistant")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("chat-user")).toHaveTextContent("hi there");
|
||||
expect(screen.getByTestId("chat-assistant")).toHaveTextContent("hello");
|
||||
});
|
||||
|
||||
it("folds consecutive tool calls — one per transcript line — into one line", () => {
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||
});
|
||||
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
const messages = [call("a"), result("a"), call("b"), result("b"), call("c")];
|
||||
render(<MessageList messages={messages} loading={false} />);
|
||||
const group = screen.getAllByTestId("chat-tools");
|
||||
expect(group).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Ran 3 commands/ }));
|
||||
expect(group[0]).toHaveTextContent("echo b");
|
||||
});
|
||||
|
||||
it("names a mixed run by what it did, like Claude Desktop", () => {
|
||||
const use = (name: string) => ({ type: "tool_use" as const, name });
|
||||
expect(workLabel([use("Bash"), use("Bash"), use("Edit"), use("Read")])).toBe(
|
||||
"Ran 2 commands, edited a file, read a file"
|
||||
);
|
||||
expect(workLabel([{ type: "thinking", text: "hm" }])).toBe("Thinking");
|
||||
// Desktop's fixed order, whatever ran first: other tools always last.
|
||||
expect(workLabel([use("mcp__x__y"), use("Read"), use("Bash")])).toBe(
|
||||
"Ran 1 command, read a file, used a tool"
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a thinking block's text as narration, between two groups", () => {
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: `echo ${id}` } }],
|
||||
});
|
||||
const result = (id: string) => msg({ content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
const say = (text: string) =>
|
||||
msg({ type: "assistant", sender: "assistant", content: [{ type: "thinking", text }] });
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[call("a"), result("a"), say("Found an issue."), say(""), call("b"), result("b")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByTestId("chat-tools")).toHaveLength(2);
|
||||
expect(screen.getByText("Found an issue.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the whole prompt as ↓, not just its uncached tail", () => {
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
usage: {
|
||||
input_tokens: 2,
|
||||
output_tokens: 40,
|
||||
cache_read_input_tokens: 120000,
|
||||
cache_creation_input_tokens: 3000,
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ends a live chat with the step still running", () => {
|
||||
const messages = [
|
||||
msg({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 65_000).toISOString(),
|
||||
content: [{ type: "text", text: "test it" }],
|
||||
}),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date(Date.now() - 5_000).toISOString(),
|
||||
usage: { input_tokens: 2, output_tokens: 3500 },
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "t1",
|
||||
name: "Bash",
|
||||
input: { command: "npm test", description: "Run the full client suite" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
const { rerender } = render(<MessageList loading={false} messages={messages} live />);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Run the full client suite");
|
||||
// Like the terminal: the turn's time since the prompt, and its output.
|
||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
||||
// Its result arrived: nothing is running any more.
|
||||
rerender(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
messages={[
|
||||
...messages,
|
||||
msg({ content: [{ type: "tool_result", id: "t1", output: "ok" }] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
});
|
||||
|
||||
// Issue #74: Esc before the model wrote anything takes the prompt back into
|
||||
// Claude Code's input box and writes NO "[Request interrupted…]" line — the
|
||||
// transcript still ends on the prompt, which read as "Thinking…" for 15 min.
|
||||
it("drops Thinking… once Stop was pressed, until the transcript moves on", () => {
|
||||
const prompt = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date(Date.now() - 14_000).toISOString(),
|
||||
content: [{ type: "text", text: "Phần đề xuất trong báo cáo" }],
|
||||
});
|
||||
const { rerender } = render(<MessageList loading={false} messages={[prompt]} live />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
rerender(<MessageList loading={false} messages={[prompt]} live stopped={1} />);
|
||||
expect(screen.queryByTestId("chat-running-step")).toBeNull();
|
||||
// The next prompt is a new turn: its Thinking… shows.
|
||||
const next = msg({
|
||||
sender: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [{ type: "text", text: "again" }],
|
||||
});
|
||||
rerender(<MessageList loading={false} messages={[prompt, next]} live stopped={1} />);
|
||||
expect(screen.getByTestId("chat-running-step")).toHaveTextContent("Thinking");
|
||||
});
|
||||
|
||||
it("counts a turn's output once per API call, since its prompt", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const line = (cache: number, out: number) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
content: [{ type: "text", text: "x" }],
|
||||
usage: { input_tokens: 2, output_tokens: out, cache_read_input_tokens: cache },
|
||||
});
|
||||
const messages = [
|
||||
msg({ sender: "user" }),
|
||||
line(10, 999), // an earlier turn
|
||||
msg({ sender: "user", timestamp: at }),
|
||||
line(100, 40),
|
||||
line(100, 40), // the same call's second block
|
||||
msg({ sender: "tool", content: [{ type: "tool_result", id: "t", output: "ok" }] }),
|
||||
line(200, 25),
|
||||
];
|
||||
expect(turnStats(messages)).toEqual({ start: at, output: 65 });
|
||||
});
|
||||
|
||||
it("keeps every parallel call of the turn running until its own result", () => {
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const call = (id: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id, name: "Bash", input: { command: id } }],
|
||||
});
|
||||
const done = (id: string) =>
|
||||
msg({ sender: "tool", timestamp: at, content: [{ type: "tool_result", id, output: "ok" }] });
|
||||
// Claude Code writes each parallel call on its own line; one result is in.
|
||||
const m = [msg({ sender: "user", timestamp: at }), call("a"), call("b"), call("c"), done("b")];
|
||||
const results = new Map([["b", m[4]!.content[0]!]]);
|
||||
expect(workingOn(m, results, now)?.blocks.map((b) => b.id)).toEqual(["a", "c"]);
|
||||
// A call left unpaired in an EARLIER turn is not this one's.
|
||||
const next = [
|
||||
...m,
|
||||
msg({ sender: "user", timestamp: at, content: [{ type: "text", text: "go" }] }),
|
||||
];
|
||||
expect(workingOn(next, results, now)?.blocks).toEqual([]);
|
||||
});
|
||||
|
||||
it("says when the CLI waits on the user, and offers the terminal", () => {
|
||||
let opened = 0;
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
live
|
||||
waiting
|
||||
onOpenTerminal={() => opened++}
|
||||
messages={[
|
||||
msg({ sender: "user", timestamp: new Date().toISOString() }),
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content: [
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: { description: "Delete build" } },
|
||||
],
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
const row = screen.getByTestId("chat-running-step");
|
||||
expect(row).toHaveTextContent("Waiting for your approval: Delete build");
|
||||
fireEvent.click(screen.getByTestId("chat-open-terminal"));
|
||||
expect(opened).toBe(1);
|
||||
});
|
||||
|
||||
it("closes an ended turn with its time and output", () => {
|
||||
const reply = (out: number, stop_reason: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: "2026-06-26T08:16:08.000Z",
|
||||
stop_reason,
|
||||
usage: { input_tokens: 5, output_tokens: out, cache_read_input_tokens: out },
|
||||
content: [{ type: "text", text: "done" }],
|
||||
});
|
||||
render(
|
||||
<MessageList
|
||||
loading={false}
|
||||
messages={[msg({ sender: "user" }), reply(1000, "tool_use"), reply(2500, "end_turn")]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in 2m 8s · ↑ 3.5K tokens");
|
||||
});
|
||||
|
||||
it("knows what a live turn is doing until it ends", () => {
|
||||
const now = Date.parse("2026-06-26T08:20:00.000Z");
|
||||
const at = "2026-06-26T08:19:00.000Z";
|
||||
const prompt = msg({ type: "user", sender: "user", timestamp: at });
|
||||
const call = msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason: "tool_use",
|
||||
content: [{ type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }],
|
||||
});
|
||||
const result = msg({
|
||||
sender: "tool",
|
||||
timestamp: at,
|
||||
content: [{ type: "tool_result", id: "t1", output: "ok" }],
|
||||
});
|
||||
const reply = (stop_reason?: string) =>
|
||||
msg({
|
||||
type: "assistant",
|
||||
sender: "assistant",
|
||||
timestamp: at,
|
||||
stop_reason,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
});
|
||||
const results = (m: ReturnType<typeof msg>[]) =>
|
||||
new Map(
|
||||
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||
);
|
||||
const state = (m: ReturnType<typeof msg>[]) => workingOn(m, results(m), now);
|
||||
|
||||
const thinking = { blocks: [], since: null, last: at };
|
||||
expect(state([prompt])).toEqual(thinking); // thinking on the prompt
|
||||
expect(state([prompt, call])?.blocks.map((b) => b.id)).toEqual(["t1"]); // the call is running
|
||||
expect(state([prompt, call, result])).toEqual(thinking); // thinking again
|
||||
expect(state([prompt, call, result, reply("tool_use")])?.blocks).toEqual([]); // narration, a call follows
|
||||
expect(state([prompt, call, result, reply("end_turn")])).toBeNull(); // turn over
|
||||
expect(state([prompt, call, result, reply()])).toBeNull(); // an older server: no stop_reason
|
||||
const stopped = msg({
|
||||
type: "user",
|
||||
sender: "user",
|
||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||
});
|
||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||
// Silent for 15 minutes with nothing running: the turn died.
|
||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* @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
|
||||
* @description The "+ Add lane" flow, in one of three modes chosen with a
|
||||
* 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
|
||||
* manually-typed branch name. The user also picks which CLI drives the lane
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be).
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||
* which a click into the field opens too.
|
||||
* The worktree lane returned is `status: "provisioning"`:
|
||||
* the route answers before the actual `git worktree add` runs, so this
|
||||
* component polls `GET /api/lanes/:id` until that finishes before running the
|
||||
@@ -19,7 +25,7 @@
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
@@ -27,7 +33,7 @@ import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
* leaves "provisioning"), or gives up after `timeoutMs`. Returns the final
|
||||
@@ -45,6 +51,17 @@ async function waitForProvisioned(
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
|
||||
export function cloneDirName(url: string): string {
|
||||
return (
|
||||
url
|
||||
.trim()
|
||||
.replace(/[/:]+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop() || ""
|
||||
).replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||
function Seg({
|
||||
active,
|
||||
@@ -94,7 +111,18 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("worktree");
|
||||
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
|
||||
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
remember: boolean;
|
||||
} | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -107,20 +135,18 @@ export function AddLaneModal({
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
// No picker: a workspace's tool is only its DEFAULT — each new session
|
||||
// picks its own AI on the workspace home. This is the default AI tool
|
||||
// (Settings → AI Tools), the account's own pick first while it is
|
||||
// installed and on.
|
||||
const aiTools = useAiTools();
|
||||
const defaultTool = aiTools.defaultId ?? "claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled)
|
||||
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
|
||||
const [toolListOpen, setToolListOpen] = useState(false);
|
||||
const pickTool = (id: string) => {
|
||||
setPickedTool(id === defaultTool ? null : id);
|
||||
setToolListOpen(false);
|
||||
};
|
||||
const ownTool = readDefaultTool();
|
||||
const tool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -130,7 +156,11 @@ export function AddLaneModal({
|
||||
} | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setMode("worktree");
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -138,7 +168,6 @@ export function AddLaneModal({
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -151,6 +180,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");
|
||||
}
|
||||
@@ -201,6 +232,52 @@ export function AddLaneModal({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (mode === "clone") {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || url;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "repo") {
|
||||
try {
|
||||
const result = await api.lanes.ensure({
|
||||
@@ -325,6 +402,8 @@ export function AddLaneModal({
|
||||
const disabled =
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "clone" &&
|
||||
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
@@ -352,6 +431,7 @@ export function AddLaneModal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-tour="add-lane"
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-border px-5 py-3.5">
|
||||
<h3 id={titleId} className="min-w-0 flex-1 text-sm font-semibold text-fg-primary">
|
||||
@@ -380,73 +460,57 @@ export function AddLaneModal({
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "clone"}
|
||||
label={t("mode.clone")}
|
||||
title={t("mode.cloneHint")}
|
||||
onClick={() => setMode("clone")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-url"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => {
|
||||
setCloneUrl(e.target.value);
|
||||
// A login belongs to the host it was asked for.
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noTools && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
{mode === "repo"
|
||||
? t("addLaneRepoLabelAdopt")
|
||||
: mode === "clone"
|
||||
? t("clone.parentLabel")
|
||||
: t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path only focuses it — the picker opens from
|
||||
Browse alone (#77). */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<CwdAutocomplete
|
||||
inputId="add-lane-repo"
|
||||
@@ -458,10 +522,67 @@ export function AddLaneModal({
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
{mode === "clone"
|
||||
? t("clone.parentHint", {
|
||||
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
|
||||
cloneName.trim() || cloneDirName(cloneUrl) || "…"
|
||||
}`,
|
||||
})
|
||||
: mode === "repo"
|
||||
? t("addLaneRepoHintAdopt")
|
||||
: t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-clone-name"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
placeholder={cloneDirName(cloneUrl)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "clone" && login && (
|
||||
<div
|
||||
data-testid="add-lane-clone-login"
|
||||
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="off"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="new-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
@@ -675,6 +796,14 @@ export function AddLaneModal({
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
{sshRefused && (
|
||||
<>
|
||||
{" "}
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -692,9 +821,14 @@ export function AddLaneModal({
|
||||
type="button"
|
||||
onClick={done ? handleCancel : submit}
|
||||
disabled={busy || disabled}
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
onAdded,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||
parent: string;
|
||||
onAdded: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [text, setText] = useState("");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
s
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
row.state = "failed";
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
asked ??= {
|
||||
host,
|
||||
username: login?.username ?? "",
|
||||
token: "",
|
||||
remember: login?.remember ?? true,
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
// What is left to do stays in the box; what was added leaves it.
|
||||
setText(
|
||||
next
|
||||
.filter((r) => r.state !== "done")
|
||||
.map((r) => r.url)
|
||||
.join("\n")
|
||||
);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
<textarea
|
||||
data-testid="workspace-clone-input"
|
||||
aria-label={t("home.cloneFolders")}
|
||||
placeholder={t("home.clonePlaceholder")}
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{failed.length > 0 && !busy && (
|
||||
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||
{t("home.cloneRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-clone-run"
|
||||
disabled={busy || !urls(text).length}
|
||||
onClick={() => void cloneAll(urls(text))}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||
</button>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||
{rows.map((r) => (
|
||||
<li key={r.url} className="text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{r.state === "busy" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||
) : r.state === "done" ? (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-status-success"
|
||||
aria-label={t("home.cloneDone")}
|
||||
/>
|
||||
) : r.state === "failed" ? (
|
||||
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||
) : (
|
||||
<span className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,27 +13,55 @@
|
||||
* text), its edit in a stronger blue — the same scheme as the read-only diff in
|
||||
* LaneDiffView. Syntax colours come from the dashboard's own per-line
|
||||
* tokenizer (`lib/highlight.ts`), like every other code view here.
|
||||
* Given `onBlock`, each changed block also gets the read-only diff's bar under
|
||||
* its last line — Undo (asks twice) and the block's commit checkbox
|
||||
* (`blockIncluded`) — in place of its Revert; the panel turns it off while
|
||||
* the editor holds unsaved text, because git acts on the file on disk.
|
||||
* Given `blame`, a gutter left of the line numbers names each line's last
|
||||
* commit (Annotate); a click hands its sha to `onBlameClick`. `navRef` gets
|
||||
* a function that moves the cursor to the next / previous changed block.
|
||||
* Given `onAddToChat`, a selection in the editable file gets Cursor's Add to
|
||||
* Chat chip floating above its last line, at the column where it ends,
|
||||
* which hands the panel the selection's 1-based lines.
|
||||
* Lazy-loaded by LaneChangesPanel, which keys it by file and shape:
|
||||
* CodeMirror stays out of the main bundle, and one mount is one view.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Compartment,
|
||||
EditorSelection,
|
||||
EditorState,
|
||||
RangeSetBuilder,
|
||||
StateEffect,
|
||||
StateField,
|
||||
Text,
|
||||
} from "@codemirror/state";
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
WidgetType,
|
||||
drawSelection,
|
||||
gutter,
|
||||
GutterMarker,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
showTooltip,
|
||||
type Tooltip,
|
||||
type DecorationSet,
|
||||
type ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { MergeView, getChunks, unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { unifiedMergeView, type Chunk } from "@codemirror/merge";
|
||||
import { chunksOf, splitDiff } from "./splitDiff";
|
||||
import { highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneBlame } from "../../lib/types";
|
||||
|
||||
type Blame = Extract<LaneBlame, { available: true }>;
|
||||
|
||||
/**
|
||||
* True when a letter or digit of a line (`text`, starting at document offset
|
||||
@@ -60,14 +90,14 @@ const mark = (cls: string) => {
|
||||
* either side of a split view (`a` is the original). */
|
||||
function decorate(view: EditorView, lang: string): DecorationSet {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
const info = getChunks(view.state);
|
||||
const info = chunksOf(view.state);
|
||||
const a = info?.side === "a";
|
||||
const span = (k: Chunk) => (a ? [k.fromA, k.toA, k.endA] : [k.fromB, k.toB, k.endB]);
|
||||
const chunks = info?.chunks ?? [];
|
||||
const doc = view.state.doc;
|
||||
let last = 0;
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (let pos = from; pos <= to; ) {
|
||||
for (let pos = from; pos <= to;) {
|
||||
const line = doc.lineAt(pos);
|
||||
pos = line.to + 1;
|
||||
if (line.number <= last) continue;
|
||||
@@ -139,6 +169,159 @@ const diffColours = (lang: string) =>
|
||||
{ decorations: (p) => p.decorations }
|
||||
);
|
||||
|
||||
/** A changed block by line (1-based; a count of 0 is a pure insertion or
|
||||
* removal): what the panel matches against git's own change blocks. */
|
||||
export type EditorBlock = { oldFrom: number; oldCount: number; newFrom: number; newCount: number };
|
||||
export type BlockAction = "include" | "undo";
|
||||
|
||||
/** One Annotate cell: the line's commit date and author, its subject on hover. */
|
||||
class BlameMarker extends GutterMarker {
|
||||
constructor(
|
||||
readonly text: string,
|
||||
readonly title: string
|
||||
) {
|
||||
super();
|
||||
}
|
||||
override eq(other: BlameMarker) {
|
||||
return other.text === this.text && other.title === this.title;
|
||||
}
|
||||
override toDOM() {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = this.text;
|
||||
el.title = this.title;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
/** The Annotate gutter for the editable file's lines (the B side). ponytail:
|
||||
* lines are matched by number, so unsaved edits above a line shift its
|
||||
* annotation until the file is saved and re-annotated. */
|
||||
function blameGutter(blame: Blame, notCommitted: string, onClick: (sha: string) => void) {
|
||||
const shaAt = (state: EditorState, pos: number) => blame.lines[state.doc.lineAt(pos).number - 1];
|
||||
return gutter({
|
||||
class: "cm-ah-blame",
|
||||
lineMarker(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha === undefined) return null;
|
||||
const c = sha ? blame.commits[sha] : undefined;
|
||||
return c
|
||||
? new BlameMarker(`${c.date.slice(0, 10)} ${c.author}`, `${c.short} ${c.summary}`)
|
||||
: new BlameMarker(notCommitted, notCommitted);
|
||||
},
|
||||
initialSpacer: () => new BlameMarker("0000-00-00 Author Name", ""),
|
||||
domEventHandlers: {
|
||||
mousedown(view, line) {
|
||||
const sha = shaAt(view.state, line.from);
|
||||
if (sha) onClick(sha);
|
||||
return !!sha;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A chunk side's first line and line count. `to` is the start of the line
|
||||
* after the chunk (or the end + 1), so its last line holds `to - 1`. */
|
||||
const lineSpan = (doc: Text, from: number, to: number): [number, number] => {
|
||||
const first = doc.lineAt(Math.min(from, doc.length)).number;
|
||||
return [first, from >= to ? 0 : doc.lineAt(Math.min(to - 1, doc.length)).number - first + 1];
|
||||
};
|
||||
|
||||
class BlockBar extends WidgetType {
|
||||
constructor(
|
||||
readonly block: EditorBlock,
|
||||
readonly build: (b: EditorBlock) => HTMLElement
|
||||
) {
|
||||
super();
|
||||
}
|
||||
eq(o: BlockBar) {
|
||||
const a = this.block;
|
||||
const b = o.block;
|
||||
return (
|
||||
a.oldFrom === b.oldFrom &&
|
||||
a.oldCount === b.oldCount &&
|
||||
a.newFrom === b.newFrom &&
|
||||
a.newCount === b.newCount
|
||||
);
|
||||
}
|
||||
toDOM() {
|
||||
return this.build(this.block);
|
||||
}
|
||||
}
|
||||
|
||||
const barsOn = StateEffect.define<boolean>();
|
||||
|
||||
/** One bar per chunk, under its last line (above a pure removal), rebuilt
|
||||
* whenever the chunks are — and dropped while `barsOn` is false. */
|
||||
function blockBars(orig: Text, build: (b: EditorBlock) => HTMLElement) {
|
||||
const decos = (doc: Text, chunks: readonly Chunk[]) => {
|
||||
const out = new RangeSetBuilder<Decoration>();
|
||||
for (const c of chunks) {
|
||||
const [oldFrom, oldCount] = lineSpan(orig, c.fromA, c.toA);
|
||||
const [newFrom, newCount] = lineSpan(doc, c.fromB, c.toB);
|
||||
const pos = newCount ? doc.line(newFrom + newCount - 1).to : Math.min(c.fromB, doc.length);
|
||||
out.add(
|
||||
pos,
|
||||
pos,
|
||||
Decoration.widget({
|
||||
block: true,
|
||||
side: newCount ? 1 : -1,
|
||||
widget: new BlockBar({ oldFrom, oldCount, newFrom, newCount }, build),
|
||||
})
|
||||
);
|
||||
}
|
||||
return out.finish();
|
||||
};
|
||||
return StateField.define<{ on: boolean; deco: DecorationSet }>({
|
||||
create: () => ({ on: false, deco: Decoration.none }),
|
||||
update(v, tr) {
|
||||
let on = v.on;
|
||||
for (const e of tr.effects) if (e.is(barsOn)) on = e.value;
|
||||
const chunks = chunksOf(tr.state)?.chunks;
|
||||
if (on === v.on && chunks === chunksOf(tr.startState)?.chunks) {
|
||||
return tr.docChanged ? { on, deco: v.deco.map(tr.changes) } : v;
|
||||
}
|
||||
return { on, deco: on && chunks ? decos(tr.state.doc, chunks) : Decoration.none };
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
|
||||
});
|
||||
}
|
||||
|
||||
/** Add to Chat floating just above the selection's last line, at the column
|
||||
* where the selection ends — so a long line never pushes it out of view.
|
||||
* `build` makes its DOM from the selection's first and last 1-based line. A
|
||||
* selection ending at a line's start (a whole-line drag) does not take that
|
||||
* line, and the chip then sits at the start of the last line it does take. */
|
||||
function chatChip(build: (start: number, end: number) => HTMLElement) {
|
||||
return StateField.define<Tooltip | null>({
|
||||
create: () => null,
|
||||
update(tip, tr) {
|
||||
if (!tr.docChanged && !tr.selection) return tip;
|
||||
const { from, to, empty } = tr.state.selection.main;
|
||||
if (empty) return null;
|
||||
const doc = tr.state.doc;
|
||||
const first = doc.lineAt(from);
|
||||
const last = doc.lineAt(to);
|
||||
const wholeLine = to === last.from && last.number > first.number;
|
||||
const end = wholeLine ? last.number - 1 : last.number;
|
||||
return {
|
||||
pos: wholeLine ? doc.line(end).from : to,
|
||||
above: true,
|
||||
create: () => {
|
||||
const dom = build(first.number, end);
|
||||
dom.classList.add("cm-chat-chip");
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
},
|
||||
provide: (f) => showTooltip.from(f),
|
||||
});
|
||||
}
|
||||
|
||||
const CHIP = "flex shrink-0 items-center gap-0.5 rounded border px-1 leading-[14px] cursor-pointer";
|
||||
const UNDO =
|
||||
"border-border-light text-fg-secondary hover:border-status-danger/60 hover:text-status-danger";
|
||||
const UNDO_ARMED = "border-status-danger bg-status-danger/20 text-status-danger";
|
||||
|
||||
const GREEN = "rgb(var(--status-success) / 0.18)";
|
||||
const RED = "rgb(var(--status-danger) / 0.18)";
|
||||
const BLUE = "rgb(var(--diff-mod) / 0.18)";
|
||||
@@ -167,9 +350,19 @@ const theme = EditorView.theme({
|
||||
".cm-activeLine": { backgroundColor: "rgb(var(--fg-primary) / 0.04)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "rgb(var(--fg-primary) / 0.08)" },
|
||||
".cm-cursor": { borderLeftColor: "rgb(var(--fg-primary))" },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "rgb(var(--accent) / 0.3)",
|
||||
// Doubled classes: drawSelection's base theme scopes its light/dark colours
|
||||
// as `.ͼ1.ͼ2.cm-focused > .cm-scroller > .cm-selectionLayer …`, which a
|
||||
// plain `.cm-selectionBackground` never outranks.
|
||||
"&.cm-focused.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionLayer .cm-selectionBackground.cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "rgb(var(--accent) / 0.3)",
|
||||
},
|
||||
// Doubled class: outranks the base theme's light/dark-scoped tooltip frame.
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip": {
|
||||
border: "1px solid rgb(var(--accent) / 0.6)",
|
||||
backgroundColor: "rgb(var(--surface-1))",
|
||||
},
|
||||
".cm-tooltip.cm-chat-chip.cm-chat-chip:hover": { backgroundColor: "rgb(var(--surface-2))" },
|
||||
[`${M} .cm-changedLine`]: { backgroundColor: GREEN },
|
||||
[`${M} .cm-changedLine.cm-ah-mod`]: { backgroundColor: BLUE },
|
||||
[`${M} .cm-changedText`]: { background: "none" },
|
||||
@@ -192,14 +385,21 @@ const theme = EditorView.theme({
|
||||
background: "rgb(var(--surface-2))",
|
||||
},
|
||||
".cm-chunkButtons": { zIndex: "1" },
|
||||
".cm-ah-blame .cm-gutterElement": {
|
||||
color: "rgb(var(--fg-muted))",
|
||||
cursor: "pointer",
|
||||
fontSize: "10px",
|
||||
paddingRight: "6px",
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
});
|
||||
|
||||
/** The Revert button on a changed block; @codemirror/merge wires the click.
|
||||
* Between split columns there is room for an arrow only (`→`: left onto right). */
|
||||
function revertButton(label: string, arrow = false) {
|
||||
/** The Revert button on a changed block (unified); @codemirror/merge wires
|
||||
* the click. The split layout draws its own » in the gutter. */
|
||||
function revertButton(label: string) {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.textContent = arrow ? "→" : label;
|
||||
b.textContent = label;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.className =
|
||||
@@ -217,6 +417,12 @@ export default function DiffEditor({
|
||||
revertLabel,
|
||||
onChange,
|
||||
onSave,
|
||||
onBlock,
|
||||
blockIncluded,
|
||||
blame,
|
||||
onBlameClick,
|
||||
navRef,
|
||||
onAddToChat,
|
||||
}: {
|
||||
value: string;
|
||||
/** The text the diff is taken against; read once, at mount. */
|
||||
@@ -231,15 +437,85 @@ export default function DiffEditor({
|
||||
revertLabel: string;
|
||||
onChange: (text: string) => void;
|
||||
onSave: () => void;
|
||||
/** Undo one changed block in git, or flip its commit checkbox; absent = no bars. */
|
||||
onBlock?: (action: BlockAction, block: EditorBlock) => void;
|
||||
/** Whether a block is ticked for the commit (its checkbox's state). */
|
||||
blockIncluded?: (block: EditorBlock) => boolean;
|
||||
/** Annotate: each line's last commit; absent = no gutter. */
|
||||
blame?: Blame;
|
||||
onBlameClick?: (sha: string) => void;
|
||||
/** Set to "go to the next (1) / previous (-1) changed block". */
|
||||
navRef?: { current: ((dir: 1 | -1) => void) | null };
|
||||
/** Add the selected lines (1-based, inclusive) to the chat; absent = no chip. */
|
||||
onAddToChat?: (start: number, end: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const cb = useRef({ onChange, onSave });
|
||||
cb.current = { onChange, onSave };
|
||||
const cb = useRef({ onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t });
|
||||
cb.current = { onChange, onSave, onBlock, blockIncluded, onBlameClick, onAddToChat, t };
|
||||
const blameSlot = useRef(new Compartment()).current;
|
||||
|
||||
/** The diff view's review bar, as DOM: pinned to the visible right edge,
|
||||
* zero height so it never pushes lines (or a split view's alignment). */
|
||||
const buildBar = (block: EditorBlock) => {
|
||||
const tr = cb.current.t;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "flex h-0";
|
||||
const pin = wrap.appendChild(document.createElement("span"));
|
||||
pin.className = "sticky right-2 ml-auto h-0 w-0 self-start";
|
||||
const bar = pin.appendChild(document.createElement("span"));
|
||||
bar.dataset.testid = "lane-editor-block-bar";
|
||||
bar.className =
|
||||
"absolute right-0 top-0.5 z-10 flex items-center gap-1 whitespace-nowrap rounded border border-border-light bg-surface-1 px-1 py-0.5 font-sans text-[10px] leading-[14px] text-fg-secondary shadow-card";
|
||||
const chip = (text: string, title: string, cls: string) => {
|
||||
const b = bar.appendChild(document.createElement("button"));
|
||||
b.type = "button";
|
||||
b.textContent = text;
|
||||
b.title = title;
|
||||
b.setAttribute("aria-label", title);
|
||||
b.className = `${CHIP} ${cls}`;
|
||||
return b;
|
||||
};
|
||||
const undo = chip(tr("changes.undoHunkShort"), tr("changes.undoHunk"), UNDO);
|
||||
// The block's commit checkbox, a real one so it reads like the diff's.
|
||||
const include = bar.appendChild(document.createElement("input"));
|
||||
include.type = "checkbox";
|
||||
include.checked = cb.current.blockIncluded?.(block) ?? true;
|
||||
include.title = tr("changes.includeBlock");
|
||||
include.setAttribute("aria-label", tr("changes.includeBlock"));
|
||||
include.className = "h-3 w-3 cursor-pointer";
|
||||
// Two clicks, like every undo git cannot give back; disarms after 3 s.
|
||||
let timer = 0;
|
||||
const arm = (on: boolean) => {
|
||||
undo.dataset.armed = on ? "1" : "";
|
||||
undo.textContent = tr(on ? "changes.undoHunkShortConfirm" : "changes.undoHunkShort");
|
||||
const title = tr(on ? "changes.undoHunkConfirm" : "changes.undoHunk");
|
||||
undo.title = title;
|
||||
undo.setAttribute("aria-label", title);
|
||||
undo.className = `${CHIP} ${on ? UNDO_ARMED : UNDO}`;
|
||||
};
|
||||
undo.onclick = () => {
|
||||
window.clearTimeout(timer);
|
||||
if (undo.dataset.armed) {
|
||||
arm(false);
|
||||
cb.current.onBlock?.("undo", block);
|
||||
return;
|
||||
}
|
||||
arm(true);
|
||||
timer = window.setTimeout(() => arm(false), 3000);
|
||||
};
|
||||
include.onchange = () => cb.current.onBlock?.("include", block);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const folded = collapse ? { margin: 3, minSize: 4 } : undefined;
|
||||
const common = [lineNumbers(), diffColours(lang), theme];
|
||||
// A CRLF file stays CRLF: CodeMirror would hand `onChange` LF text, the
|
||||
// panel would read that as an unsaved edit (no Undo / Keep bars) and Save
|
||||
// would rewrite every line ending.
|
||||
const eol = value.includes("\r\n") ? [EditorState.lineSeparator.of("\r\n")] : [];
|
||||
const common = [lineNumbers(), diffColours(lang), theme, ...eol];
|
||||
const editing = [
|
||||
...common,
|
||||
highlightActiveLineGutter(),
|
||||
@@ -261,25 +537,41 @@ export default function DiffEditor({
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) cb.current.onChange(u.state.doc.toString());
|
||||
if (u.docChanged) cb.current.onChange(u.state.sliceDoc());
|
||||
}),
|
||||
blockBars(Text.of(original.split(/\r?\n/)), buildBar),
|
||||
blameSlot.of([]),
|
||||
...(onAddToChat
|
||||
? [
|
||||
chatChip((start, end) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.dataset.testid = "lane-editor-add-to-chat";
|
||||
b.textContent = cb.current.t("changes.addToChat");
|
||||
b.className =
|
||||
"rounded border border-accent/60 bg-surface-1 px-1.5 font-sans text-[10px] leading-[14px] text-accent hover:bg-surface-2";
|
||||
// Keep the editor's focus (and so its selection) through the click.
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
b.onclick = () => cb.current.onAddToChat?.(start, end);
|
||||
return b;
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
let v: EditorView;
|
||||
let destroy: () => void;
|
||||
if (layout === "split") {
|
||||
const mv = new MergeView({
|
||||
const sd = splitDiff({
|
||||
parent: host.current!,
|
||||
a: {
|
||||
doc: original,
|
||||
extensions: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
},
|
||||
b: { doc: value, extensions: editing },
|
||||
revertControls: "a-to-b",
|
||||
renderRevertControl: () => revertButton(revertLabel, true),
|
||||
collapseUnchanged: folded,
|
||||
original,
|
||||
value,
|
||||
extA: [...common, EditorState.readOnly.of(true), EditorView.editable.of(false)],
|
||||
extB: editing,
|
||||
collapse: folded,
|
||||
revertLabel,
|
||||
});
|
||||
v = mv.b;
|
||||
destroy = () => mv.destroy();
|
||||
v = sd.b;
|
||||
destroy = sd.destroy;
|
||||
} else {
|
||||
v = new EditorView({
|
||||
parent: host.current!,
|
||||
@@ -306,15 +598,56 @@ export default function DiffEditor({
|
||||
}
|
||||
v.focus();
|
||||
view.current = v;
|
||||
return destroy;
|
||||
if (navRef) {
|
||||
// The B side's chunks, unified or split: the cursor goes to the start
|
||||
// of the next / previous one and it scrolls into view.
|
||||
navRef.current = (dir) => {
|
||||
const ed = view.current;
|
||||
const chunks = ed ? (chunksOf(ed.state)?.chunks ?? []) : [];
|
||||
if (!ed || !chunks.length) return;
|
||||
const at = ed.state.selection.main.head;
|
||||
const next =
|
||||
dir === 1
|
||||
? chunks.find((c) => c.fromB > at)
|
||||
: [...chunks].reverse().find((c) => c.fromB < ed.state.doc.lineAt(at).from);
|
||||
if (!next) return;
|
||||
ed.dispatch({
|
||||
selection: EditorSelection.cursor(next.fromB),
|
||||
effects: EditorView.scrollIntoView(next.fromB, { y: "center" }),
|
||||
});
|
||||
ed.focus();
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
if (navRef) navRef.current = null;
|
||||
destroy();
|
||||
};
|
||||
// One mount per view: the parent keys this component by file and shape.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Annotate on / off without remounting the view.
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({
|
||||
effects: blameSlot.reconfigure(
|
||||
blame
|
||||
? blameGutter(blame, cb.current.t("changes.notCommitted"), (sha) =>
|
||||
cb.current.onBlameClick?.(sha)
|
||||
)
|
||||
: []
|
||||
),
|
||||
});
|
||||
}, [blame, blameSlot]);
|
||||
|
||||
const bars = !!onBlock;
|
||||
useEffect(() => {
|
||||
view.current?.dispatch({ effects: barsOn.of(bars) });
|
||||
}, [bars]);
|
||||
|
||||
// A value set from outside (Cancel's revert, a save's fresh text).
|
||||
useEffect(() => {
|
||||
const v = view.current;
|
||||
if (v && v.state.doc.toString() !== value) {
|
||||
if (v && v.state.sliceDoc() !== value) {
|
||||
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: value } });
|
||||
}
|
||||
}, [value]);
|
||||
@@ -324,7 +657,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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,46 +1,178 @@
|
||||
/**
|
||||
* @file A server-backed folder browser for the Add Lane modal's path inputs.
|
||||
* Browsers refuse to expose an absolute filesystem path from a native folder
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path) instead — breadcrumb-free, just an
|
||||
* up-one-level button and a click-to-descend list, since this tool is
|
||||
* local-first and the server already trusts arbitrary local paths.
|
||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||
* absolute filesystem path from a native picker, so path selection here is
|
||||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||
* Backspace goes up and typing jumps to a name.
|
||||
* An admin with a Linux account of their own (S3; the listing carries
|
||||
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
|
||||
* browser to the host account's home, where a workspace runs as the shared
|
||||
* host account. It is never remembered: every open starts at the admin's own.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, FolderGit2, ArrowUp } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Clock,
|
||||
Eye,
|
||||
File,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
Home,
|
||||
Pencil,
|
||||
Server,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useWorkLanes } from "../../hooks/useLanes";
|
||||
|
||||
export function FolderBrowseModal({
|
||||
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||||
type Entry = Listing["entries"][number];
|
||||
|
||||
const RECENT_KEY = "agenthub.browse.recent";
|
||||
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||||
const RECENT_MAX = 8;
|
||||
|
||||
function readStored<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStored(key: string, value: unknown) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// A private window or blocked storage: the preference just isn't kept.
|
||||
}
|
||||
}
|
||||
|
||||
/** `[label, path]` for each level of `p`, root first. */
|
||||
function crumbs(p: string): [string, string][] {
|
||||
const win = /^[A-Za-z]:\\/.test(p);
|
||||
const sep = win ? "\\" : "/";
|
||||
const parts = p.split(sep).filter(Boolean);
|
||||
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||||
out.push([part, acc]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mounted only while open, so a closed picker (one behind every Browse
|
||||
* button) asks the server for nothing, and each opening starts fresh.
|
||||
*/
|
||||
export function FolderBrowseModal(props: Props) {
|
||||
return props.open ? <BrowseDialog {...props} /> : null;
|
||||
}
|
||||
|
||||
function BrowseDialog({
|
||||
open,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
/** `file` lists files too, and picking one is the only way to select. */
|
||||
kind?: "dir" | "file";
|
||||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||
initialPath?: string;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
||||
const { lanes } = useWorkLanes();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pathText, setPathText] = useState("");
|
||||
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||||
key: "name",
|
||||
dir: 1,
|
||||
});
|
||||
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||||
// The highest folder this user may open (a member's own folder answers no
|
||||
// parent) — breadcrumbs above it are not links.
|
||||
const [floor, setFloor] = useState<string | null>(null);
|
||||
const [home, setHome] = useState<string | null>(null);
|
||||
// An admin's "Server folder" tick (S3): browse the host account's home.
|
||||
const [hostHome, setHostHome] = useState<string | null>(null);
|
||||
const [onHost, setOnHost] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const crumbRef = useRef<HTMLDivElement>(null);
|
||||
const typeAhead = useRef({ text: "", at: 0 });
|
||||
|
||||
const load = async (path?: string) => {
|
||||
const load = async (
|
||||
target?: string,
|
||||
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await api.lanes.browse(path));
|
||||
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||||
setListing(next);
|
||||
setSelected(select);
|
||||
setFilter("");
|
||||
setEditing(false);
|
||||
if (next.home) setHome(next.home);
|
||||
setHostHome(next.hostHome ?? null);
|
||||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||
if (push) {
|
||||
setHistory((h) => {
|
||||
if (h.stack[h.at] === next.path) return h;
|
||||
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||
return { stack, at: stack.length - 1 };
|
||||
});
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) void load(initialPath);
|
||||
if (!open) return;
|
||||
setHistory({ stack: [], at: -1 });
|
||||
setCreating(false);
|
||||
setOnHost(false);
|
||||
void load(initialPath, { host: false });
|
||||
// Only re-run when the modal actually opens - not on every initialPath
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -51,8 +183,171 @@ export function FolderBrowseModal({
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, panel, onClose);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const needle = filter.trim().toLowerCase();
|
||||
const list = (listing?.entries ?? []).filter(
|
||||
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||||
);
|
||||
const by = (a: Entry, b: Entry) =>
|
||||
sort.key === "mtime"
|
||||
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||||
sort.dir;
|
||||
// Folders first, as every file picker lists them.
|
||||
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||||
}, [listing, filter, sort]);
|
||||
|
||||
const places = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
return lanes
|
||||
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||||
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||||
}, [lanes]);
|
||||
|
||||
// A deep path keeps its LAST levels in view, like a native path bar.
|
||||
useEffect(() => {
|
||||
const el = crumbRef.current;
|
||||
if (el) el.scrollLeft = el.scrollWidth;
|
||||
}, [listing?.path, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||||
(x) => x.dataset.path === selected
|
||||
);
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selected]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const finish = (picked: string) => {
|
||||
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||||
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||||
setRecent(next);
|
||||
writeStored(RECENT_KEY, next);
|
||||
onSelect(picked);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const activate = (entry: Entry) => {
|
||||
if (entry.file) finish(entry.path);
|
||||
else void load(entry.path);
|
||||
};
|
||||
|
||||
const go = (delta: -1 | 1) => {
|
||||
const at = history.at + delta;
|
||||
const target = history.stack[at];
|
||||
if (target === undefined) return;
|
||||
void load(target, { push: false }).then((ok) => {
|
||||
if (ok) setHistory((h) => ({ ...h, at }));
|
||||
});
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
if (!listing?.parent) return;
|
||||
const from = listing.path;
|
||||
void load(listing.parent, { select: from });
|
||||
};
|
||||
|
||||
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||||
const target =
|
||||
kind === "file"
|
||||
? selectedEntry?.file
|
||||
? selectedEntry.path
|
||||
: null
|
||||
: selectedEntry && !selectedEntry.file
|
||||
? selectedEntry.path
|
||||
: (listing?.path ?? null);
|
||||
|
||||
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const idx = entries.findIndex((x) => x.path === selected);
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||||
const next =
|
||||
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||
if (entries[next]) setSelected(entries[next].path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Home" || e.key === "End") {
|
||||
e.preventDefault();
|
||||
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||||
if (pick) setSelected(pick.path);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (selectedEntry) activate(selectedEntry);
|
||||
else if (kind === "dir" && target) finish(target);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
goUp();
|
||||
return;
|
||||
}
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Typing jumps to the first name that starts with what was typed.
|
||||
const now = Date.now();
|
||||
const ta = typeAhead.current;
|
||||
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||||
ta.at = now;
|
||||
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||||
if (hit) setSelected(hit.path);
|
||||
}
|
||||
};
|
||||
|
||||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||||
e.preventDefault();
|
||||
setPathText(listing?.path ?? "");
|
||||
setEditing(true);
|
||||
} else if (e.altKey && e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
go(-1);
|
||||
} else if (e.altKey && e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
|
||||
const createFolder = async () => {
|
||||
const name = newName.trim();
|
||||
if (!name || !listing) return;
|
||||
setError(null);
|
||||
try {
|
||||
const made = await api.lanes.mkdir(listing.path, name);
|
||||
setCreating(false);
|
||||
setNewName("");
|
||||
await load(listing.path, { push: false, select: made.path });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Switch between the admin's own home and the host account's, and open it.
|
||||
const toggleHost = () => {
|
||||
const next = !onHost;
|
||||
setOnHost(next);
|
||||
void load(undefined, { host: next });
|
||||
};
|
||||
|
||||
const toggleHidden = () => {
|
||||
const next = !showHidden;
|
||||
setShowHidden(next);
|
||||
writeStored(HIDDEN_KEY, next);
|
||||
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||||
};
|
||||
|
||||
const iconBtn =
|
||||
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
const placeBtn = (active: boolean) =>
|
||||
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||||
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||
}`;
|
||||
const path = listing?.path ?? "";
|
||||
const floorDepth = floor ? crumbs(floor).length : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -61,73 +356,362 @@ export function FolderBrowseModal({
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={onPanelKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("browse.title")}
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(-1)}
|
||||
disabled={history.at <= 0}
|
||||
title={t("browse.back")}
|
||||
aria-label={t("browse.back")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={() => go(1)}
|
||||
disabled={history.at >= history.stack.length - 1}
|
||||
title={t("browse.forward")}
|
||||
aria-label={t("browse.forward")}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onClick={goUp}
|
||||
disabled={!listing?.parent}
|
||||
title={t("browse.up")}
|
||||
aria-label={t("browse.up")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={pathText}
|
||||
onChange={(e) => setPathText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
if (pathText.trim()) void load(pathText.trim());
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
aria-label={t("browse.editPath")}
|
||||
spellCheck={false}
|
||||
className="input min-w-0 flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
ref={crumbRef}
|
||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||||
onDoubleClick={() => {
|
||||
setPathText(path);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{crumbs(path).map(([label, p], i, all) => {
|
||||
const last = i === all.length - 1;
|
||||
const locked = i + 1 < floorDepth;
|
||||
return (
|
||||
<span key={p} className="flex shrink-0 items-center">
|
||||
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||||
<span className="px-0.5 text-fg-muted">›</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={last || locked}
|
||||
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||||
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||||
last
|
||||
? "font-medium text-fg-primary"
|
||||
: locked
|
||||
? "text-fg-muted"
|
||||
: "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtn}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => {
|
||||
setPathText(path);
|
||||
setEditing((v) => !v);
|
||||
}}
|
||||
title={t("browse.editPath")}
|
||||
aria-label={t("browse.editPath")}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Places */}
|
||||
<nav
|
||||
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||||
aria-label={t("browse.places")}
|
||||
>
|
||||
{home && (
|
||||
<button
|
||||
type="button"
|
||||
className={placeBtn(path === home)}
|
||||
onClick={() => void load(home)}
|
||||
title={home}
|
||||
>
|
||||
<Home className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{t("browse.home")}</span>
|
||||
</button>
|
||||
)}
|
||||
{places.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Star className="h-3 w-3" />
|
||||
{t("browse.workspaces")}
|
||||
</div>
|
||||
{places.map((pl) => (
|
||||
<button
|
||||
key={pl.path}
|
||||
type="button"
|
||||
className={placeBtn(path === pl.path)}
|
||||
onClick={() => void load(pl.path)}
|
||||
title={pl.path}
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||
<span className="truncate">{pl.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
<Clock className="h-3 w-3" />
|
||||
{t("browse.recent")}
|
||||
</div>
|
||||
{recent.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={placeBtn(path === r)}
|
||||
onClick={() => void load(r)}
|
||||
title={r}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{baseName(r)}</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Listing */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-2">
|
||||
<input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (entries[0]) setSelected(entries[0].path);
|
||||
listRef.current?.focus();
|
||||
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||||
e.preventDefault();
|
||||
activate(entries[0]);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.filter")}
|
||||
aria-label={t("browse.filter")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||
{(["name", "mtime"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||||
}
|
||||
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||||
>
|
||||
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||||
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
tabIndex={0}
|
||||
role="listbox"
|
||||
aria-label={path}
|
||||
onKeyDown={onListKey}
|
||||
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{creating && (
|
||||
<div className="flex items-center gap-2 px-2 py-1">
|
||||
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void createFolder();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
placeholder={t("browse.newFolderName")}
|
||||
aria-label={t("browse.newFolderName")}
|
||||
className="input min-w-0 flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void createFolder()}
|
||||
disabled={!newName.trim()}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const isSel = entry.path === selected;
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
data-path={entry.path}
|
||||
role="option"
|
||||
aria-selected={isSel}
|
||||
onClick={() => setSelected(entry.path)}
|
||||
onDoubleClick={() => activate(entry)}
|
||||
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||||
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{entry.file ? (
|
||||
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||||
) : entry.isGitRepo ? (
|
||||
<FolderGit2
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||||
/>
|
||||
) : (
|
||||
<FolderOpen
|
||||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||||
/>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||
<span
|
||||
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||||
>
|
||||
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{listing && entries.length === 0 && (
|
||||
<p className="p-2 text-xs text-fg-muted">
|
||||
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{listing?.parent && (
|
||||
{/* Footer: what will be picked, and the actions. */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div
|
||||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||
title={target ?? undefined}
|
||||
>
|
||||
<bdi>{target ?? "…"}</bdi>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(listing.parent!)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
||||
onClick={() => {
|
||||
setNewName("");
|
||||
setCreating(true);
|
||||
}}
|
||||
disabled={!listing}
|
||||
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||
>
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
..
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
{t("browse.newFolder")}
|
||||
</button>
|
||||
)}
|
||||
{listing?.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => load(entry.path)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
||||
>
|
||||
{entry.isGitRepo ? (
|
||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
||||
) : (
|
||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
}}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{t("browse.select")}
|
||||
</button>
|
||||
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
{t("browse.showHidden")}
|
||||
</label>
|
||||
{hostHome && (
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||||
title={t("browse.hostFolderHint", { path: hostHome })}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={onHost}
|
||||
onChange={toggleHost}
|
||||
data-testid="browse-host-folder"
|
||||
/>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
{t("browse.hostFolder")}
|
||||
</label>
|
||||
)}
|
||||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||
{t("browse.hint")}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-ghost border border-border text-xs"
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!target}
|
||||
onClick={() => target && finish(target)}
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* @file InterruptedSessionsPrompt.tsx
|
||||
* @description The app-wide "resume interrupted sessions?" dialog. A session
|
||||
* whose pane died under it — the machine shut down, tmux was killed, the CLI
|
||||
* crashed — is listed by `GET /api/run/interrupted`; the user ticks the ones
|
||||
* to resume (each in the first free tab of its workspace, with its run's
|
||||
* model and effort) or resumes none. Either answer dismisses the whole list,
|
||||
* so it is asked once; a resume that failed stays listed. Refetched on the
|
||||
* `runs_interrupted` broadcast, so a tmux that dies under an open dashboard
|
||||
* asks too. Resuming is the explicit click here, never the dialog opening.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, type InterruptedRun } from "../../lib/api";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { timeAgo } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
|
||||
const label = (it: InterruptedRun) => it.lane_title || it.lane_cwd;
|
||||
|
||||
export function InterruptedSessionsPrompt() {
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<InterruptedRun[]>([]);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
// A refetch keeps the user's ticks; a session not seen before starts ticked.
|
||||
const seen = useRef(new Set<string>());
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.run
|
||||
.interrupted()
|
||||
.then((r) => {
|
||||
const fresh = r.items.filter((i) => !seen.current.has(i.session_id));
|
||||
fresh.forEach((i) => seen.current.add(i.session_id));
|
||||
setItems(r.items);
|
||||
setPicked((p) => {
|
||||
const next = new Set(r.items.filter((i) => p.has(i.session_id)).map((i) => i.session_id));
|
||||
fresh.forEach((i) => next.add(i.session_id));
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => undefined); // a convenience: no prompt when it cannot be read
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "runs_interrupted") load();
|
||||
}),
|
||||
[load]
|
||||
);
|
||||
|
||||
const dismiss = async (sessionIds: string[]) => {
|
||||
if (sessionIds.length) await api.run.dismissInterrupted(sessionIds).catch(toast.error);
|
||||
};
|
||||
|
||||
const resumeNone = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
await dismiss(items.map((i) => i.session_id));
|
||||
setItems([]);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const resumePicked = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const failed = new Set<string>();
|
||||
let first: { runId: string; laneId: number } | null = null;
|
||||
for (const it of items.filter((i) => picked.has(i.session_id))) {
|
||||
try {
|
||||
const r = await startInFreeTabDetailed(it.lane_id, {
|
||||
resumeSessionId: it.session_id,
|
||||
...(it.model ? { model: it.model } : {}),
|
||||
...(it.effort ? { effort: it.effort } : {}),
|
||||
// Bypass is a per-run choice, never carried over.
|
||||
...(it.permission_mode && it.permission_mode !== "bypassPermissions"
|
||||
? { permissionMode: it.permission_mode }
|
||||
: {}),
|
||||
});
|
||||
first ??= r;
|
||||
} catch (err) {
|
||||
failed.add(it.session_id);
|
||||
toast.error(
|
||||
t("interrupted.failed", {
|
||||
name: label(it),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
await dismiss(items.map((i) => i.session_id).filter((id) => !failed.has(id)));
|
||||
setItems((cur) => cur.filter((i) => failed.has(i.session_id)));
|
||||
setBusy(false);
|
||||
if (first) navigate(`/workspace?lane=${first.laneId}&run=${encodeURIComponent(first.runId)}`);
|
||||
};
|
||||
|
||||
const toggle = (id: string, on: boolean) =>
|
||||
setPicked((p) => {
|
||||
const next = new Set(p);
|
||||
if (on) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const count = items.filter((i) => picked.has(i.session_id)).length;
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={items.length > 0}
|
||||
title={t("interrupted.title")}
|
||||
message={t("interrupted.message")}
|
||||
confirmLabel={t("interrupted.resume", { count })}
|
||||
cancelLabel={t("interrupted.none")}
|
||||
destructive={false}
|
||||
busy={busy}
|
||||
disabled={count === 0}
|
||||
onConfirm={() => void resumePicked()}
|
||||
onCancel={() => void resumeNone()}
|
||||
>
|
||||
<ul className="mt-3 max-h-80 space-y-1 overflow-y-auto">
|
||||
{items.map((it) => (
|
||||
<li key={it.session_id} className="rounded-md border border-border px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={picked.has(it.session_id)}
|
||||
onChange={(on) => toggle(it.session_id, on)}
|
||||
disabled={busy}
|
||||
className="w-full"
|
||||
labelClassName="min-w-0 flex-1"
|
||||
label={
|
||||
<span className="block min-w-0 text-left">
|
||||
<span className="block truncate text-xs text-fg-primary">
|
||||
{label(it)} · {it.tool}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">
|
||||
{it.session_name || it.prompt_preview || it.session_id}
|
||||
{it.ended_at ? ` · ${timeAgo(it.ended_at)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,10 @@ export function useLaneRuntime(laneId: number, bump: number): LaneRuntime | null
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, RUNTIME_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
RUNTIME_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
@@ -88,7 +91,10 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
});
|
||||
};
|
||||
read();
|
||||
const timer = setInterval(read, LOCKS_REFRESH_MS);
|
||||
const timer = setInterval(
|
||||
() => document.visibilityState === "visible" && read(),
|
||||
LOCKS_REFRESH_MS
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
@@ -98,13 +104,6 @@ function useLaneLocks(laneSlot: number | null): NamedLock[] {
|
||||
return locks;
|
||||
}
|
||||
|
||||
export const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||
active: "bg-status-success",
|
||||
// A ring: a surface-4 fill vanished on the light themes.
|
||||
idle: "border border-fg-muted",
|
||||
dead: "bg-status-danger",
|
||||
};
|
||||
|
||||
export function since(sec: number | null): string {
|
||||
if (sec === null) return "—";
|
||||
if (sec < 60) return `${sec}s`;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,7 @@ function Lines({ lines, className = "" }: { lines: string[]; className?: string
|
||||
export function LaneConflictView({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
path,
|
||||
onResolved,
|
||||
onEdit,
|
||||
@@ -45,6 +46,8 @@ export function LaneConflictView({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
path: string;
|
||||
/** The file left the conflict (resolved, or a whole side taken). */
|
||||
onResolved: () => void;
|
||||
@@ -65,7 +68,7 @@ export function LaneConflictView({
|
||||
setChoices([]);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.gitFile(gitTarget(laneId, session), path, "work")
|
||||
.gitFile(gitTarget(laneId, session, source), path, "work")
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setFile(
|
||||
@@ -78,7 +81,7 @@ export function LaneConflictView({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [laneId, session, path]);
|
||||
}, [laneId, session, source, path]);
|
||||
|
||||
const parts: ConflictPart[] | null = file?.usable ? parseConflicts(file.lines) : null;
|
||||
const total = parts ? parts.filter((p) => p.kind === "conflict").length : 0;
|
||||
@@ -102,15 +105,18 @@ export function LaneConflictView({
|
||||
run(async () => {
|
||||
if (!parts || !file?.sha) return;
|
||||
await api.lanes.gitSaveFile(
|
||||
gitTarget(laneId, session),
|
||||
gitTarget(laneId, session, source),
|
||||
path,
|
||||
applyChoices(parts, choices).join("\n"),
|
||||
file.sha
|
||||
);
|
||||
await api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side: "done" });
|
||||
await api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", {
|
||||
path,
|
||||
side: "done",
|
||||
});
|
||||
});
|
||||
const takeAll = (side: "ours" | "theirs") =>
|
||||
run(() => api.lanes.gitAction(gitTarget(laneId, session), "resolve", { path, side }));
|
||||
run(() => api.lanes.gitAction(gitTarget(laneId, session, source), "resolve", { path, side }));
|
||||
|
||||
const choose = (i: number, c: ConflictChoice | undefined) =>
|
||||
setChoices((prev) => {
|
||||
|
||||
@@ -10,9 +10,15 @@
|
||||
* - The intra-line diff is a common prefix/suffix trim, not a real LCS. It is
|
||||
* right for the case that matters (one edited span on a line), degrades to
|
||||
* "the whole line changed" otherwise, and costs nothing.
|
||||
* - Line pairing for split view happens here, on the rows the server already
|
||||
* numbered. The server stays the single source of what changed; this file
|
||||
* only decides how it looks.
|
||||
* - Side by side is JetBrains-shaped and laid out here, on the rows the
|
||||
* server already numbered: within a hunk each column lists only its own
|
||||
* lines, one after another, so the sides drift apart past a change and a
|
||||
* curved band in the middle gutter joins each block to its counterpart
|
||||
* (`lib/diffConnectors.ts`). Only a hunk's END is padded, so the `@@`
|
||||
* headers — and the two scrollers, kept in step 1:1 — stay level. Every
|
||||
* split row is ROW_PX tall, which makes a band's y pure arithmetic. The
|
||||
* server stays the single source of what changed; this file only decides
|
||||
* how it looks.
|
||||
* Past `WINDOW_THRESHOLD` rows a view renders only the rows on screen (plus an
|
||||
* overscan) between two spacers. Rows never wrap — the box scrolls sideways —
|
||||
* so every windowed row is pinned to `ROW_PX` and the range is pure arithmetic
|
||||
@@ -38,6 +44,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { canonicalLang, highlight, tokenClass } from "../../lib/highlight";
|
||||
import type { LaneChangeFile, LaneDiffHunk, LaneDiffLine } from "../../lib/types";
|
||||
import { bandPath } from "../../lib/diffConnectors";
|
||||
|
||||
export type DiffLayout = "unified" | "split";
|
||||
|
||||
@@ -220,29 +227,28 @@ function emphasis(
|
||||
};
|
||||
}
|
||||
|
||||
type PairRow = { left: LaneDiffLine | null; right: LaneDiffLine | null; emph: EmphPair | null };
|
||||
type EmphPair = { del: [number, number]; add: [number, number] };
|
||||
/** A split-view row: the index in `hunk.lines` each column shows, -1 for the
|
||||
* padding that closes the shorter side of a hunk. */
|
||||
type SplitRow = { left: number; right: number };
|
||||
/** A hunk's split-view rows plus each paired line's emphasis, keyed by its
|
||||
* index in `hunk.lines` (what the unified view renders by). */
|
||||
type Analysed = { rows: PairRow[]; emph: Map<number, [number, number]> };
|
||||
type Analysed = { rows: SplitRow[]; emph: Map<number, [number, number]> };
|
||||
|
||||
// Keyed by the hunk object: a hunk is immutable once fetched, so both split
|
||||
// columns and every re-render of the same data share one pass. A refetch
|
||||
// brings new objects and the old entries go with them.
|
||||
const analysed = new WeakMap<LaneDiffHunk, Analysed>();
|
||||
|
||||
/** Pair each run of removed lines with the run of added lines following it,
|
||||
* carrying indices so emphasis lands without searching the hunk again. */
|
||||
/** Pair each run of removed lines with the run of added lines following it
|
||||
* for emphasis, keyed by line index so it lands without searching the hunk
|
||||
* again; and pack each column's own lines into the split rows. */
|
||||
export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
const hit = analysed.get(hunk);
|
||||
if (hit) return hit;
|
||||
const lines = hunk.lines;
|
||||
const rows: PairRow[] = [];
|
||||
const emph = new Map<number, [number, number]>();
|
||||
for (let i = 0; i < lines.length; ) {
|
||||
const line = lines[i]!;
|
||||
if (line.type === "ctx") {
|
||||
rows.push({ left: line, right: line, emph: null });
|
||||
for (let i = 0; i < lines.length;) {
|
||||
if (lines[i]!.type === "ctx") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -260,9 +266,18 @@ export function analyseHunk(hunk: LaneDiffHunk): Analysed {
|
||||
emph.set(delStart + k, e.del);
|
||||
emph.set(addStart + k, e.add);
|
||||
}
|
||||
rows.push({ left, right, emph: e });
|
||||
}
|
||||
}
|
||||
const left: number[] = [];
|
||||
const right: number[] = [];
|
||||
lines.forEach((l, i) => {
|
||||
if (l.type !== "add") left.push(i);
|
||||
if (l.type !== "del") right.push(i);
|
||||
});
|
||||
const rows = Array.from({ length: Math.max(left.length, right.length) }, (_, i) => ({
|
||||
left: left[i] ?? -1,
|
||||
right: right[i] ?? -1,
|
||||
}));
|
||||
const out = { rows, emph };
|
||||
analysed.set(hunk, out);
|
||||
return out;
|
||||
@@ -351,9 +366,14 @@ export type LaneDiffChange = {
|
||||
/** First/last index in `hunk.lines` (unified). */
|
||||
firstLine: number;
|
||||
lastLine: number;
|
||||
/** First/last split-view row. */
|
||||
/** First split-view row (either side): the bar's arrows scroll here. */
|
||||
firstRow: number;
|
||||
/** The right column's row its review bar hangs under. */
|
||||
lastRow: number;
|
||||
/** Split-view rows `[from, to)` on each side; empty (`from === to`) is the
|
||||
* point between two rows where a pure insertion / removal sits. */
|
||||
left: [number, number];
|
||||
right: [number, number];
|
||||
};
|
||||
|
||||
const blocksOf = new WeakMap<LaneDiffHunk, LaneDiffChange[]>();
|
||||
@@ -363,23 +383,46 @@ export function changeBlocks(hunk: LaneDiffHunk): LaneDiffChange[] {
|
||||
const hit = blocksOf.get(hunk);
|
||||
if (hit) return hit;
|
||||
const out: LaneDiffChange[] = [];
|
||||
const owner = new Map<LaneDiffLine, LaneDiffChange>();
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (line.type === "ctx") return;
|
||||
let c = i > 0 && hunk.lines[i - 1]!.type !== "ctx" ? out[out.length - 1] : undefined;
|
||||
if (!c) {
|
||||
c = { hunk, block: out.length, firstLine: i, lastLine: i, firstRow: -1, lastRow: -1 };
|
||||
c = {
|
||||
hunk,
|
||||
block: out.length,
|
||||
firstLine: i,
|
||||
lastLine: i,
|
||||
firstRow: -1,
|
||||
lastRow: -1,
|
||||
left: [0, 0],
|
||||
right: [0, 0],
|
||||
};
|
||||
out.push(c);
|
||||
}
|
||||
c.lastLine = i;
|
||||
owner.set(line, c);
|
||||
});
|
||||
analyseHunk(hunk).rows.forEach((r, i) => {
|
||||
const c = (r.left && owner.get(r.left)) || (r.right && owner.get(r.right));
|
||||
if (!c) return;
|
||||
if (c.firstRow < 0) c.firstRow = i;
|
||||
c.lastRow = i;
|
||||
});
|
||||
// A side's rows are its lines in order, so a block's rows there are the
|
||||
// count of that side's lines before it, plus its own.
|
||||
for (const c of out) {
|
||||
let l = 0;
|
||||
let r = 0;
|
||||
hunk.lines.forEach((line, i) => {
|
||||
if (i >= c.firstLine) return;
|
||||
if (line.type !== "add") l += 1;
|
||||
if (line.type !== "del") r += 1;
|
||||
});
|
||||
let dl = 0;
|
||||
let dr = 0;
|
||||
for (let i = c.firstLine; i <= c.lastLine; i += 1) {
|
||||
if (hunk.lines[i]!.type === "del") dl += 1;
|
||||
else dr += 1;
|
||||
}
|
||||
c.left = [l, l + dl];
|
||||
c.right = [r, r + dr];
|
||||
c.firstRow = Math.min(l, r);
|
||||
// A pure removal hangs its bar under the right row its band points at.
|
||||
c.lastRow = dr ? r + dr - 1 : Math.max(0, r - 1);
|
||||
}
|
||||
blocksOf.set(hunk, out);
|
||||
return out;
|
||||
}
|
||||
@@ -397,7 +440,8 @@ const startAtRow = (hunk: LaneDiffHunk, i: number) =>
|
||||
* block's last line. A context rather than a prop because lines render from
|
||||
* four places; `changes` is what the bar's "n of N" and its arrows walk. */
|
||||
const HunkActions = createContext<{
|
||||
actions: (change: LaneDiffChange) => ReactNode;
|
||||
/** null = no bar; the anchors still mark each block for navigation. */
|
||||
actions: ((change: LaneDiffChange) => ReactNode) | null;
|
||||
changes: LaneDiffChange[];
|
||||
go: (index: number) => void;
|
||||
} | null>(null);
|
||||
@@ -414,7 +458,7 @@ function useAnchor(change?: LaneDiffChange) {
|
||||
function HunkBar({ change }: { change: LaneDiffChange }) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const ctx = useContext(HunkActions);
|
||||
if (!ctx) return null;
|
||||
if (!ctx?.actions) return null;
|
||||
const index = ctx.changes.indexOf(change);
|
||||
const total = ctx.changes.length;
|
||||
const nav =
|
||||
@@ -547,37 +591,36 @@ function longestInHunks(hunks: LaneDiffHunk[]) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One column's row i of a hunk: that side's i-th line, or the padding
|
||||
* that closes the hunk's shorter side. Always ROW_PX tall (see the header). */
|
||||
function SplitLine({
|
||||
row,
|
||||
hunk,
|
||||
i,
|
||||
lang,
|
||||
side,
|
||||
style,
|
||||
bar,
|
||||
start,
|
||||
}: {
|
||||
row: PairRow;
|
||||
hunk: LaneDiffHunk;
|
||||
i: number;
|
||||
lang: string;
|
||||
side: "left" | "right";
|
||||
style?: CSSProperties;
|
||||
bar?: LaneDiffChange;
|
||||
start?: LaneDiffChange;
|
||||
}) {
|
||||
const line = side === "left" ? row.left : row.right;
|
||||
const { rows, emph } = analyseHunk(hunk);
|
||||
const at = rows[i]![side];
|
||||
const line = at < 0 ? null : hunk.lines[at]!;
|
||||
const e = at < 0 ? undefined : emph.get(at);
|
||||
const anchor = useAnchor(start);
|
||||
return (
|
||||
<Row tint={rowTint(line?.type ?? null, !!row.emph)} style={style} anchor={anchor}>
|
||||
<Row tint={rowTint(line?.type ?? null, !!e)} style={FIXED} anchor={anchor}>
|
||||
<Gutter
|
||||
n={(side === "left" ? line?.oldLine : line?.newLine) ?? null}
|
||||
commentable={side === "right"}
|
||||
/>
|
||||
<span className="whitespace-pre px-1">
|
||||
{line ? (
|
||||
<Code
|
||||
text={line.text}
|
||||
lang={lang}
|
||||
emph={side === "left" ? row.emph?.del : row.emph?.add}
|
||||
/>
|
||||
) : null}
|
||||
{line ? <Code text={line.text} lang={lang} emph={e} /> : null}
|
||||
</span>
|
||||
{bar && <HunkBar change={bar} />}
|
||||
</Row>
|
||||
@@ -598,11 +641,12 @@ function SplitColumn({
|
||||
<>
|
||||
{hunks.map((hunk, h) => (
|
||||
<div key={`${hunk.header}:${h}`}>
|
||||
<HunkHeader hunk={hunk} />
|
||||
{analyseHunk(hunk).rows.map((row, i) => (
|
||||
<HunkHeader hunk={hunk} fixed />
|
||||
{analyseHunk(hunk).rows.map((_, i) => (
|
||||
<SplitLine
|
||||
key={i}
|
||||
row={row}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
// One bar per change block: on the right (new) column only.
|
||||
@@ -638,10 +682,10 @@ function SplitSlice({
|
||||
) : (
|
||||
<SplitLine
|
||||
key={range.start + k}
|
||||
row={analyseHunk(hunk).rows[i]!}
|
||||
hunk={hunk}
|
||||
i={i}
|
||||
lang={lang}
|
||||
side={side}
|
||||
style={FIXED}
|
||||
bar={side === "right" ? barAtRow(hunk, i) : undefined}
|
||||
start={startAtRow(hunk, i)}
|
||||
/>
|
||||
@@ -657,8 +701,7 @@ export function clampScroll(v: number, scrollSize: number, clientSize: number) {
|
||||
}
|
||||
|
||||
type Axis =
|
||||
| ["scrollLeft", "scrollWidth", "clientWidth"]
|
||||
| ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
["scrollLeft", "scrollWidth", "clientWidth"] | ["scrollTop", "scrollHeight", "clientHeight"];
|
||||
const AXES: Axis[] = [
|
||||
["scrollLeft", "scrollWidth", "clientWidth"],
|
||||
["scrollTop", "scrollHeight", "clientHeight"],
|
||||
@@ -677,11 +720,48 @@ function follow(src: HTMLElement, dst: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes. */
|
||||
/** The middle gutter's width: room for an S-curve to read as one. */
|
||||
const GUTTER_PX = 36;
|
||||
|
||||
const BAND = {
|
||||
add: "fill-status-success/20 stroke-status-success/60",
|
||||
del: "fill-status-danger/20 stroke-status-danger/60",
|
||||
mod: "fill-diff-mod/20 stroke-diff-mod/60",
|
||||
} as const;
|
||||
|
||||
/** Every change block's band, in the columns' content coordinates: a hunk's
|
||||
* header is item `h`, so its row r starts at (h + 1 + r) × ROW_PX. */
|
||||
function bandsOf(items: Item[]) {
|
||||
const out: { d: string; kind: keyof typeof BAND; top: number; bottom: number }[] = [];
|
||||
items.forEach(({ hunk, i }, h) => {
|
||||
if (i >= 0) return;
|
||||
for (const c of changeBlocks(hunk)) {
|
||||
const [y0, y1, z0, z1] = [...c.left, ...c.right].map((r) => (h + 1 + r) * ROW_PX) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
const kind = y0 === y1 ? "add" : z0 === z1 ? "del" : "mod";
|
||||
out.push({
|
||||
d: bandPath(GUTTER_PX, y0, y1, z0, z1),
|
||||
kind,
|
||||
top: Math.min(y0, z0),
|
||||
bottom: Math.max(y1, z1),
|
||||
});
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Both sides, each its own scroller but kept in step on both axes, and the
|
||||
* gutter between them that draws each block's band, scrolled with them. */
|
||||
function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string; fill: boolean }) {
|
||||
const left = useRef<HTMLDivElement>(null);
|
||||
const right = useRef<HTMLDivElement>(null);
|
||||
const mid = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => flatten(hunks, "split"), [hunks]);
|
||||
const bands = useMemo(() => bandsOf(items), [items]);
|
||||
const minWidth = useMemo(() => `calc(${longestInHunks(hunks)}ch + 3rem)`, [hunks]);
|
||||
const win = useWindow(items.length);
|
||||
|
||||
@@ -695,8 +775,11 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
const dst = from === "left" ? right.current : left.current;
|
||||
if (!src || !dst) return;
|
||||
follow(src, dst);
|
||||
if (mid.current) mid.current.scrollTop = src.scrollTop;
|
||||
win.onScroll(src);
|
||||
};
|
||||
// Windowed, only the bands that reach the rendered rows are drawn.
|
||||
const [from, to] = [win.range.start * ROW_PX, win.range.end * ROW_PX];
|
||||
|
||||
const column = (side: "left" | "right") =>
|
||||
win.on ? (
|
||||
@@ -717,11 +800,33 @@ function SplitView({ hunks, lang, fill }: { hunks: LaneDiffHunk[]; lang: string;
|
||||
>
|
||||
{column("left")}
|
||||
</div>
|
||||
<div
|
||||
ref={mid}
|
||||
data-testid="lane-diff-connectors"
|
||||
aria-hidden
|
||||
className="shrink-0 overflow-hidden border-x border-border-light bg-surface-1"
|
||||
style={{ width: GUTTER_PX }}
|
||||
>
|
||||
{/* Taller than the columns by a scrollbar, so it can scroll as far. */}
|
||||
<svg width={GUTTER_PX} height={items.length * ROW_PX + 24} className="block">
|
||||
{bands
|
||||
.filter((b) => b.bottom >= from && b.top <= to)
|
||||
.map((b) => (
|
||||
<path
|
||||
key={`${b.top}:${b.d}`}
|
||||
d={b.d}
|
||||
data-kind={b.kind}
|
||||
className={BAND[b.kind]}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
ref={right}
|
||||
data-testid="lane-diff-split-right"
|
||||
onScroll={() => sync("right")}
|
||||
className="w-1/2 min-w-0 overflow-auto border-l border-border-light"
|
||||
className="w-1/2 min-w-0 overflow-auto"
|
||||
>
|
||||
{column("right")}
|
||||
</div>
|
||||
@@ -826,7 +931,9 @@ export function LaneDiffView({
|
||||
const at = items.findIndex((it) => it.hunk === target.hunk && it.i === target.firstLine);
|
||||
if (at >= 0 && layout === "unified") el.scrollTop = at * ROW_PX;
|
||||
};
|
||||
const review = hunkActions && hunks ? { actions: hunkActions, changes, go } : null;
|
||||
// Provided without actions too: every block's first row carries its
|
||||
// `data-change-index`, which the panel's F7 / Shift+F7 walk in any range.
|
||||
const review = hunks ? { actions: hunkActions ?? null, changes, go } : null;
|
||||
|
||||
if (file.binary)
|
||||
return <p className="px-2 py-1 text-[10px] text-fg-muted">{t("changes.binary")}</p>;
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
* is `EGITAUTH` (issue #45): the dashboard's git has no terminal to prompt
|
||||
* in, so a remote wanting a login opens a form here and the SAME action is
|
||||
* resent with the typed credential — the token is never rendered back.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge
|
||||
* asks through the themed confirm; the rows wrap in a narrow panel.
|
||||
* Fetch, pull, push and publish say so in a toast when they succeed; a merge,
|
||||
* a push, a publish and a branch delete ask through the themed confirm first; the rows wrap in a narrow panel.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -45,14 +45,7 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
import type { LaneGitFacts, LaneWorktree } from "../../lib/types";
|
||||
|
||||
type Action =
|
||||
| "fetch"
|
||||
| "pull"
|
||||
| "push"
|
||||
| "switch"
|
||||
| "branch-create"
|
||||
| "branch-delete"
|
||||
| "merge"
|
||||
| "publish";
|
||||
"fetch" | "pull" | "push" | "switch" | "branch-create" | "branch-delete" | "merge" | "publish";
|
||||
type RunBody = Parameters<typeof api.lanes.gitAction>[2];
|
||||
|
||||
const LOCAL_HOST = /^(localhost|127\.|\[::1\])/;
|
||||
@@ -95,6 +88,7 @@ function Btn({
|
||||
export function LaneGitBar({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -103,6 +97,8 @@ export function LaneGitBar({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts;
|
||||
/** Rendered at the start of the action row: the header's branch line, so
|
||||
* the facts and the buttons that act on them read as one row. */
|
||||
@@ -165,7 +161,7 @@ export function LaneGitBar({
|
||||
setBusy(action);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session), action, body || {});
|
||||
const res = await api.lanes.gitAction(gitTarget(lane, session, source), action, body || {});
|
||||
// Landed after a lane switch: every write below belongs to the lane
|
||||
// no longer shown.
|
||||
if (laneNow.current !== lane) return;
|
||||
@@ -218,7 +214,7 @@ export function LaneGitBar({
|
||||
|
||||
const refreshBranches = async () => {
|
||||
try {
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session));
|
||||
const r = await api.lanes.gitBranches(gitTarget(laneId, session, source));
|
||||
if (laneNow.current !== laneId) return;
|
||||
setBranches(r.branches);
|
||||
setWorktrees(r.worktrees || []);
|
||||
@@ -234,6 +230,60 @@ 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 = async () => {
|
||||
let ahead = git.ahead ?? 0;
|
||||
if (git.upstream && ahead === 0) {
|
||||
// The facts can be up to 30 s old (an agent may have just committed):
|
||||
// ask git again before saying there is nothing to push — and never
|
||||
// ask to confirm pushing zero commits.
|
||||
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
|
||||
const fresh = read?.available ? read : null;
|
||||
ahead = fresh?.ahead ?? 0;
|
||||
if (fresh?.upstream && ahead === 0) {
|
||||
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return confirmRun(
|
||||
git.upstream
|
||||
? {
|
||||
title: t("gitBar.push"),
|
||||
message: t("gitBar.pushConfirm", {
|
||||
count: ahead,
|
||||
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 +372,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 +392,7 @@ export function LaneGitBar({
|
||||
? t("gitBar.publishBranchHint")
|
||||
: t("gitBar.pushHint")
|
||||
}
|
||||
onClick={() => run("push")}
|
||||
onClick={() => void push()}
|
||||
/>
|
||||
{!git.hasRemote && (
|
||||
<Btn
|
||||
@@ -362,7 +416,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 +488,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 +538,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 +552,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>
|
||||
)}
|
||||
|
||||
@@ -36,7 +36,9 @@ const GIT_REFRESH_MS = 30_000;
|
||||
export function useLaneGitFacts(
|
||||
laneId: number,
|
||||
/** Read that session's own worktree instead (`?session=`). */
|
||||
session?: string
|
||||
session?: string,
|
||||
/** Read that extra folder's repo instead (`?source=`). */
|
||||
source?: number
|
||||
): [LaneGitFacts | null, () => void] {
|
||||
const [facts, setFacts] = useState<LaneGitFacts | null>(null);
|
||||
// Bumped by the git action bar: a commit or a pull changes every fact on
|
||||
@@ -54,7 +56,7 @@ export function useLaneGitFacts(
|
||||
let alive = true;
|
||||
const read = () => {
|
||||
api.lanes
|
||||
.git(gitTarget(laneId, session))
|
||||
.git(gitTarget(laneId, session, source))
|
||||
.then((f) => {
|
||||
if (alive) setFacts(keepIfSame(f));
|
||||
})
|
||||
@@ -65,12 +67,15 @@ 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);
|
||||
};
|
||||
}, [laneId, session, nonce]);
|
||||
}, [laneId, session, source, nonce]);
|
||||
|
||||
return [facts, () => setNonce((n) => n + 1)];
|
||||
}
|
||||
@@ -319,20 +324,23 @@ export function LaneGitCounts({
|
||||
export function LaneGitRailCounts({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
onShowAll,
|
||||
}: {
|
||||
laneId: number;
|
||||
session?: string;
|
||||
source?: number;
|
||||
/** Opens the Changes panel from the file list's "… more files". */
|
||||
onShowAll?: () => void;
|
||||
}) {
|
||||
const [git] = useLaneGitFacts(laneId, session);
|
||||
const [git] = useLaneGitFacts(laneId, session, source);
|
||||
return git?.available ? <LaneGitCounts git={git} vertical onShowAll={onShowAll} /> : null;
|
||||
}
|
||||
|
||||
export function LaneGitHeader({
|
||||
laneId,
|
||||
session,
|
||||
source,
|
||||
git,
|
||||
onChanged,
|
||||
onNewLane,
|
||||
@@ -340,6 +348,8 @@ export function LaneGitHeader({
|
||||
laneId: number;
|
||||
/** Work in that session's own worktree (see `gitTarget`). */
|
||||
session?: string;
|
||||
/** Work in that extra folder's repo instead (`lane_sources` id, see `gitTarget`). */
|
||||
source?: number;
|
||||
git: LaneGitFacts | null;
|
||||
/** Called after a git bar action: the facts AND the panel's diff moved. */
|
||||
onChanged: () => void;
|
||||
@@ -371,6 +381,7 @@ export function LaneGitHeader({
|
||||
<LaneGitBar
|
||||
laneId={laneId}
|
||||
session={session}
|
||||
source={source}
|
||||
git={git}
|
||||
onChanged={onChanged}
|
||||
onNewLane={onNewLane}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the sidebar's **New session** row). Every item is something the row already
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -29,6 +29,7 @@ export function SessionMenu({
|
||||
onRename,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -38,6 +39,8 @@ export function SessionMenu({
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -84,6 +87,11 @@ export function SessionMenu({
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||
{onPin && session && (
|
||||
<MenuItem onSelect={pick(onPin)}>
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -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.
|
||||
@@ -121,19 +121,23 @@ export function SessionReplay({
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="session-replay" className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
data-testid="session-replay"
|
||||
data-prompt-drop
|
||||
className="flex min-h-0 flex-1 flex-col gap-2"
|
||||
>
|
||||
{session && (
|
||||
<div className="flex justify-end">
|
||||
<SessionResumeButton session={session} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} />
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -148,7 +152,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="flex items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -160,7 +164,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-border p-2 transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
@@ -173,6 +177,7 @@ export function SessionReplay({
|
||||
rows={2}
|
||||
slash={tool === "claude"}
|
||||
fileCwd={session?.cwd || lane.cwd}
|
||||
lane={lane.id}
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="text-[11px] text-fg-muted">{t("replay.hint")}</span>
|
||||
|
||||
@@ -25,16 +25,22 @@ import { useLanes } from "../../hooks/useLanes";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
import { startInFreeTabDetailed } from "./startSession";
|
||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
|
||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
|
||||
/** The workspace of this tool whose folder holds `cwd` most deeply — the
|
||||
* same path-boundary rule the server binds hook sessions with. */
|
||||
export function owningLane(cwd: string, tool: string, lanes: Lane[]): Lane | null {
|
||||
let best: Lane | null = null;
|
||||
const target = strip(cwd);
|
||||
for (const l of lanes) {
|
||||
if (l.tool !== tool) continue;
|
||||
const root = l.cwd.replace(/\/+$/, "");
|
||||
if (cwd !== root && !cwd.startsWith(root + "/")) continue;
|
||||
if (!best || root.length > best.cwd.replace(/\/+$/, "").length) best = l;
|
||||
const root = strip(l.cwd);
|
||||
if (target !== root && !target.startsWith(root + "/") && !target.startsWith(root + "\\"))
|
||||
continue;
|
||||
if (!best || root.length > strip(best.cwd).length) best = l;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -47,6 +53,7 @@ export function SessionResumeButton({
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const { lanes } = useLanes();
|
||||
const { canEditLane } = usePermissions();
|
||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -72,9 +79,10 @@ export function SessionResumeButton({
|
||||
? null
|
||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||
owningLane(session.cwd, tool, lanes));
|
||||
if (!lane || runs === null) return null;
|
||||
// Server mode: an analyst may resume only in a workspace of their own.
|
||||
if (!lane || !canEditLane(lane) || runs === null) return null;
|
||||
const run = liveRunFor(lane, runs, session.id);
|
||||
const same = (a: string, b: string) => a.replace(/\/+$/, "") === b.replace(/\/+$/, "");
|
||||
const same = (a: string, b: string) => strip(a) === strip(b);
|
||||
// Its own worktree counts too: the server resumes it there (`claude
|
||||
// --worktree` sessions), and refuses one whose worktree is gone.
|
||||
const resumable =
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
/**
|
||||
* @file WorkspaceContext — a workspace's context: Instructions, Memory
|
||||
* (read-only), Context files and Folders (`GET/PUT/POST/DELETE
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, Folder, Pencil, Plus, X } from "lucide-react";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { dragProjectPath } from "../../lib/promptAttach";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { api, type LaneMemoryFile, type LaneSource, type LaneSourcesView } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { CloneSourcesForm } from "./CloneSourcesForm";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const message = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Keyed by workspace: an open edit or folder form belongs to ONE workspace. */
|
||||
export function WorkspaceContext(props: Parameters<typeof ContextBody>[0]) {
|
||||
return <ContextBody key={props.lane.id} {...props} />;
|
||||
}
|
||||
|
||||
function ContextBody({
|
||||
lane,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
compact = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere: the lists re-read. */
|
||||
sourcesVersion?: number;
|
||||
/** The Changes panel's narrow fold: no card, tighter rows. */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const [ctxDrag, setCtxDrag] = useState(false);
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourcesVersion) return;
|
||||
let live = true;
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- on a bump only
|
||||
}, [sourcesVersion]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
setError(null);
|
||||
try {
|
||||
const next = await fn();
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const addFiles = async (picked: File[]) => {
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
};
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const card = compact
|
||||
? "border-b border-border/60 px-1 py-2 last:border-b-0"
|
||||
: "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const heading = compact
|
||||
? "text-xs font-medium text-fg-secondary"
|
||||
: "text-sm font-medium text-fg-primary";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className={compact ? "" : "card w-80 shrink-0 self-start p-0"}
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="border-b border-border px-4 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p
|
||||
className={`mt-1 ${compact ? "line-clamp-2" : "line-clamp-3"} whitespace-pre-wrap text-xs text-fg-muted`}
|
||||
>
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={compact ? 4 : 6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<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={heading}>{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void addFiles(picked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!compact && <p className="mt-1 text-xs text-fg-muted">{t("home.contextSnapshot")}</p>}
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className={`mt-2 w-full rounded-lg border border-dashed border-border px-3 ${compact ? "py-2" : "py-5"} text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary`}
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
// Dragged onto a prompt box, it is attached as `@<path>`.
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, f.path)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={heading}>{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li
|
||||
className="flex items-start gap-2 text-xs"
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${lane.cwd}/`)}
|
||||
>
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
draggable
|
||||
onDragStart={(e) => dragProjectPath(e, `${f.path}/`)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{folderOpen && (
|
||||
<CloneSourcesForm
|
||||
laneId={lane.id}
|
||||
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
||||
onAdded={(next) => {
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,48 +5,41 @@
|
||||
* then opens it (`&run=<runId>`) — the one place a session starts WITH a
|
||||
* 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
|
||||
* the box, like Claude Desktop's: the `+` menu (`ChatAddMenu`: upload, or
|
||||
* attach a project file, a context file or a recent upload), AI (the session's own tool, the workspace's
|
||||
* 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,
|
||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
||||
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||
* any other its conversation (`&session=`, `SessionReplay`), nothing is resumed — and on the right its context: Instructions, Memory (read-only), Context files and
|
||||
* Folders (`GET/PUT/POST/DELETE /api/lanes/:id/sources…`, see
|
||||
* server/lib/lane-sources.js). Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* Folders (`WorkspaceContext.tsx`, also folded at the bottom of the Changes panel).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Checkbox } from "../Checkbox";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ArchiveRestore,
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
Folder,
|
||||
GitBranch,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
X,
|
||||
SquareTerminal,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type CodexApproval,
|
||||
type CodexSandbox,
|
||||
type EffortLevel,
|
||||
type LaneMemoryFile,
|
||||
type LaneSource,
|
||||
type LaneSourcesView,
|
||||
type ModelChoice,
|
||||
type PermissionMode,
|
||||
@@ -55,16 +48,16 @@ import {
|
||||
import type { Lane, Project, Session } from "../../lib/types";
|
||||
import { fmtCost, timeAgo } from "../../lib/format";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "../run/RunSetup";
|
||||
import { ChatAddMenu } from "../run/ChatAddMenu";
|
||||
import { addAttachments } from "../../lib/promptAttach";
|
||||
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";
|
||||
import { workspaceName } from "./WorkspaceRow";
|
||||
import { SessionMenu } from "./SessionMenu";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import { menuPoint, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
liveRunFor,
|
||||
SessionFold,
|
||||
@@ -82,14 +75,8 @@ export {
|
||||
startInFreeTabDetailed,
|
||||
} from "./startSession";
|
||||
import { startInFreeTab } from "./startSession";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
import { WorkspaceContext } from "./WorkspaceContext";
|
||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Recents' page size; Show more adds another. */
|
||||
const RECENT_PAGE = 30;
|
||||
@@ -101,6 +88,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} />;
|
||||
}
|
||||
@@ -109,17 +99,23 @@ function HomeBody({
|
||||
lane,
|
||||
onMenuAction,
|
||||
onRenamed,
|
||||
onSourcesChange,
|
||||
sourcesVersion = 0,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Row-menu items the page carries out (its modals); rename is done here. */
|
||||
onMenuAction: (action: WorkspaceMenuAction) => void;
|
||||
/** Called after a rename, so the page re-reads the lane list. */
|
||||
onRenamed?: () => void;
|
||||
/** Called with the sources after a folder or file is added or removed. */
|
||||
onSourcesChange?: (view: LaneSourcesView) => void;
|
||||
/** Bumped when the folder list changed elsewhere (the Changes panel's
|
||||
* Close repository): the Folder list re-reads. */
|
||||
sourcesVersion?: number;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
const aiTools = useAiTools();
|
||||
const prefs = useProjectTreePrefs();
|
||||
// The NEW session's AI tool — its own choice, the workspace's by default.
|
||||
const [tool, setTool] = useState(lane.tool);
|
||||
const entry = aiTools.byId(tool);
|
||||
@@ -143,32 +139,34 @@ 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 pickFiles = useRef<(() => void) | null>(null);
|
||||
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]);
|
||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
||||
// model only when its file names the flag, an effort never.
|
||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||
const [mode, setMode] = useState<PermissionMode>("auto");
|
||||
// The account's own default (first-run setup) — never bypass.
|
||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
||||
// Per session, never remembered.
|
||||
@@ -191,21 +189,9 @@ function HomeBody({
|
||||
// A Recents row being renamed (its session id) and the draft.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [view, setView] = useState<LaneSourcesView | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [memory, setMemory] = useState<LaneMemoryFile[] | null>(null);
|
||||
const [memoryOpen, setMemoryOpen] = useState(false);
|
||||
const [memoryTab, setMemoryTab] = useState(0);
|
||||
const [folderOpen, setFolderOpen] = useState(false);
|
||||
const [folderPath, setFolderPath] = useState("");
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const [menuAt, setMenuAt] = useState<{ x: number; y: number } | null>(null);
|
||||
const [title, setTitle] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const memoryPanel = useRef<HTMLDivElement>(null);
|
||||
useDialog(memoryOpen && !!memory, memoryPanel, () => setMemoryOpen(false));
|
||||
const recentCtx = useContextMenu<Session>();
|
||||
const sourceCtx = useContextMenu<LaneSource>();
|
||||
|
||||
// A new pane, a pane gone, a session id learned — all move these lane fields.
|
||||
const runKey = `${lane.status}:${lane.session_id}:${lane.run_id}`;
|
||||
@@ -234,25 +220,6 @@ function HomeBody({
|
||||
`${runKey}:${version}`
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setView(null);
|
||||
setMemory(null);
|
||||
setEditing(null);
|
||||
setError(null);
|
||||
api.lanes
|
||||
.sources(lane.id)
|
||||
.then((v) => live && setView(v))
|
||||
.catch((err) => live && setError(message(err)));
|
||||
api.lanes
|
||||
.memory(lane.id)
|
||||
.then((r) => live && setMemory(r.files))
|
||||
.catch(() => live && setMemory([]));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// The repository this workspace belongs to (Settings → Projects used to
|
||||
// show this): read once per visit, from the server's cached project list.
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
@@ -269,39 +236,21 @@ function HomeBody({
|
||||
};
|
||||
}, [lane.id]);
|
||||
|
||||
// Suggestions are read when the folder form opens, not on every visit.
|
||||
useEffect(() => {
|
||||
if (!folderOpen) return;
|
||||
let live = true;
|
||||
api.projects
|
||||
.list()
|
||||
.then(({ projects }) => {
|
||||
if (!live) return;
|
||||
const p = projects.find((x) => x.workspaces.includes(lane.id));
|
||||
const own = new Set((view?.sources ?? []).map((s) => s.path));
|
||||
setSuggestions(
|
||||
!p || !prefs.showUntracked
|
||||
? []
|
||||
: p.worktrees
|
||||
.filter((w) => w.workspace_id === null && !own.has(w.path))
|
||||
.filter((w) => !(w.source && prefs.hideSources.includes(w.source)))
|
||||
.map((w) => w.path)
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [folderOpen, lane.id, prefs.showUntracked, prefs.hideSources, view?.sources]);
|
||||
|
||||
const run = async (fn: () => Promise<LaneSourcesView>) => {
|
||||
// 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 {
|
||||
setView(await fn());
|
||||
return true;
|
||||
const runId = await startInFreeTab(lane.id, { shell: true });
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
setError(message(err));
|
||||
return false;
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -319,7 +268,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 +277,14 @@ function HomeBody({
|
||||
...(claude && inWorktree && project?.root ? { worktree: true } : {}),
|
||||
});
|
||||
setPrompt("");
|
||||
images.forEach((i) => URL.revokeObjectURL(i.url));
|
||||
// Here, not only through the effect: the navigate below unmounts this
|
||||
// home in the same batch, so the effect never sees the empty prompt.
|
||||
try {
|
||||
localStorage.removeItem(draftKey);
|
||||
} catch {
|
||||
/* storage unavailable: nothing was kept */
|
||||
}
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
setImages([]);
|
||||
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||
} catch (err) {
|
||||
@@ -409,10 +365,7 @@ function HomeBody({
|
||||
? t("run:hint.sandboxWarning")
|
||||
: null;
|
||||
|
||||
const files = (view?.sources ?? []).filter((s) => s.kind === "file");
|
||||
const folders = (view?.sources ?? []).filter((s) => s.kind === "folder");
|
||||
const name = workspaceName(lane);
|
||||
const card = "border-b border-border px-4 py-4 last:border-b-0";
|
||||
const iconBtn =
|
||||
"rounded p-1 text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40";
|
||||
|
||||
@@ -421,7 +374,7 @@ function HomeBody({
|
||||
data-testid="workspace-home"
|
||||
className="mx-auto flex w-full max-w-6xl flex-wrap gap-8 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 basis-[28rem]">
|
||||
<div data-prompt-drop className="min-w-0 flex-1 basis-[28rem]">
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("home.breadcrumb")} / <span className="text-fg-secondary">{name}</span>
|
||||
</p>
|
||||
@@ -500,31 +453,51 @@ function HomeBody({
|
||||
rows={3}
|
||||
slash={claude}
|
||||
fileCwd={lane.cwd}
|
||||
lane={lane.id}
|
||||
images={images}
|
||||
onImagesChange={setImages}
|
||||
onBusyChange={setUploading}
|
||||
pickFiles={pickFiles}
|
||||
bare
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<ChatAddMenu
|
||||
cwd={lane.cwd}
|
||||
laneId={lane.id}
|
||||
skills={claude}
|
||||
onUpload={() => pickFiles.current?.()}
|
||||
onInsert={(cmd) => setPrompt((p) => `${cmd} ${p.replace(/^\s+/, "")}`)}
|
||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||
/>
|
||||
<div data-testid="workspace-home-tool">
|
||||
<Select<string>
|
||||
variant="ghost"
|
||||
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 +506,7 @@ function HomeBody({
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
tool={tool}
|
||||
choices={routerModels}
|
||||
choices={proxyModels}
|
||||
{...(hasEffort ? { effort, onEffortChange: setEffort } : {})}
|
||||
/>
|
||||
)}
|
||||
@@ -556,12 +529,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>
|
||||
@@ -737,6 +710,16 @@ function HomeBody({
|
||||
{t("tree.showMore", { count: Math.min(sessionTotal - sessions.length, RECENT_PAGE) })}
|
||||
</button>
|
||||
)}
|
||||
{recentLimit > RECENT_PAGE && sessionTotal > RECENT_PAGE && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-recents-less"
|
||||
onClick={() => setRecentLimit(RECENT_PAGE)}
|
||||
className="ml-3 mt-1 text-xs text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<SessionFold
|
||||
testId={`workspace-archived-${lane.id}`}
|
||||
@@ -751,345 +734,11 @@ function HomeBody({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
data-testid="workspace-context"
|
||||
aria-label={t("home.contextPanel")}
|
||||
className="card w-80 shrink-0 self-start p-0"
|
||||
>
|
||||
{view && !view.supported && (
|
||||
<p className="border-b border-border px-4 py-3 text-xs text-status-warning">
|
||||
{t("home.unsupported", { tool: aiTools.label(lane.tool) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.instructions")}</h3>
|
||||
{editing === null && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.edit")}
|
||||
title={t("home.edit")}
|
||||
onClick={() => setEditing(view?.instructions ?? "")}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{editing === null ? (
|
||||
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-xs text-fg-muted">
|
||||
{view?.instructions || t("home.instructionsEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<textarea
|
||||
data-testid="workspace-instructions"
|
||||
aria-label={t("home.instructions")}
|
||||
autoFocus
|
||||
value={editing}
|
||||
maxLength={view?.maxInstructions}
|
||||
onChange={(e) => setEditing(e.target.value)}
|
||||
rows={6}
|
||||
className="w-full rounded border border-border bg-surface-1 p-2 text-xs text-fg-primary"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(null)}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{t("home.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (await run(() => api.lanes.setInstructions(lane.id, editing)))
|
||||
setEditing(null);
|
||||
}}
|
||||
className="btn-primary text-xs"
|
||||
>
|
||||
{t("home.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.memory")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMemoryTab(0);
|
||||
setMemoryOpen(true);
|
||||
}}
|
||||
disabled={!memory?.length}
|
||||
className="text-xs text-fg-secondary hover:text-fg-primary disabled:opacity-40"
|
||||
>
|
||||
{t("home.viewMemory")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-fg-muted">
|
||||
{memory?.length
|
||||
? t("home.memoryCount", { count: memory.length })
|
||||
: t("home.memoryEmpty")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.context")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFile")}
|
||||
title={t("home.addFile")}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
data-testid="workspace-file-input"
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
onChange={async (e) => {
|
||||
const picked = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
for (const f of picked) {
|
||||
if (!(await run(() => api.lanes.addFile(lane.id, f)))) break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{files.length === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
className="mt-2 w-full rounded-lg border border-dashed border-border px-3 py-5 text-center text-xs text-fg-muted hover:border-border-light hover:text-fg-secondary"
|
||||
>
|
||||
{t("home.contextEmpty")}
|
||||
</button>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">
|
||||
{f.missing ? t("home.missing") : fmtBytes(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-fg-primary">{t("home.folder")}</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.addFolder")}
|
||||
title={t("home.addFolder")}
|
||||
onClick={() => setFolderOpen((v) => !v)}
|
||||
disabled={!view}
|
||||
className={iconBtn}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
<li className="flex items-start gap-2 text-xs">
|
||||
<Folder className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0">
|
||||
<span title={lane.cwd} className="block truncate text-fg-secondary">
|
||||
{lane.cwd.split("/").filter(Boolean).pop() || lane.cwd}
|
||||
</span>
|
||||
<span className="block text-fg-muted">{t("home.ownFolder")}</span>
|
||||
</span>
|
||||
</li>
|
||||
{folders.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className="group flex items-center gap-2 text-xs"
|
||||
onContextMenu={(e) => sourceCtx.open(e, f)}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<span title={f.path} className="min-w-0 flex-1 truncate text-fg-secondary">
|
||||
{f.name}
|
||||
{f.missing && (
|
||||
<span className="ml-1 text-status-warning">{t("home.missing")}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("home.remove")} ${f.name}`}
|
||||
onClick={() => void run(() => api.lanes.removeSource(lane.id, f.id))}
|
||||
className={`${iconBtn} opacity-0 group-hover:opacity-100 focus:opacity-100`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{folderOpen && (
|
||||
<form
|
||||
className="mt-3 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (await run(() => api.lanes.addFolder(lane.id, folderPath.trim()))) {
|
||||
setFolderPath("");
|
||||
setFolderOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
data-testid="workspace-folder-input"
|
||||
aria-label={t("home.folderPath")}
|
||||
placeholder={t("home.folderPath")}
|
||||
autoFocus
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<PathPickButton start={folderPath || lane.cwd} onPick={setFolderPath} />
|
||||
<button type="submit" disabled={!folderPath.trim()} className="btn-ghost text-xs">
|
||||
{t("home.add")}
|
||||
</button>
|
||||
</div>
|
||||
{suggestions.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">
|
||||
{t("home.suggested")}
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{suggestions.map((p) => (
|
||||
<li key={p}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFolderPath(p)}
|
||||
title={p}
|
||||
className="w-full truncate rounded px-1 text-left font-mono text-[11px] text-fg-secondary hover:bg-surface-3"
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{memoryOpen && memory && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={memoryPanel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("home.memory")}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex max-h-[85vh] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("home.memory")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
aria-label={t("home.close")}
|
||||
onClick={() => setMemoryOpen(false)}
|
||||
className={iconBtn}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t("home.memory")}
|
||||
// Wraps, so every file is reachable without a horizontal wheel (a
|
||||
// hidden-scrollbar row cut the rest off); past ~3 rows it scrolls
|
||||
// with a visible bar. shrink-0: a long file must not squeeze it.
|
||||
className="mb-3 flex max-h-28 shrink-0 flex-wrap gap-1.5 overflow-y-auto border-b border-border/60 pb-2"
|
||||
>
|
||||
{memory.map((f, i) => (
|
||||
<button
|
||||
key={f.path}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`workspace-memory-tab-${i}`}
|
||||
aria-controls="workspace-memory-panel"
|
||||
tabIndex={i === memoryTab ? 0 : -1}
|
||||
title={f.path}
|
||||
aria-selected={i === memoryTab}
|
||||
onClick={() => setMemoryTab(i)}
|
||||
// APG tabs: arrows move (and select), one tab stop.
|
||||
onKeyDown={(e) => {
|
||||
const n = memory.length;
|
||||
const to =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % n
|
||||
: e.key === "ArrowLeft"
|
||||
? (i - 1 + n) % n
|
||||
: e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? n - 1
|
||||
: null;
|
||||
if (to === null) return;
|
||||
e.preventDefault();
|
||||
setMemoryTab(to);
|
||||
document.getElementById(`workspace-memory-tab-${to}`)?.focus();
|
||||
}}
|
||||
className={`flex-shrink-0 whitespace-nowrap rounded-lg border px-3 py-1.5 font-mono text-xs transition-colors ${
|
||||
i === memoryTab
|
||||
? "border-accent/30 bg-accent/15 text-accent"
|
||||
: "border-border text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{memory[memoryTab] && (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="workspace-memory-panel"
|
||||
aria-labelledby={`workspace-memory-tab-${memoryTab}`}
|
||||
className="min-h-0 flex-1 overflow-y-auto"
|
||||
>
|
||||
<MarkdownPreview text={memory[memoryTab].content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<WorkspaceContext
|
||||
lane={lane}
|
||||
onSourcesChange={onSourcesChange}
|
||||
sourcesVersion={sourcesVersion}
|
||||
/>
|
||||
|
||||
{recentCtx.menu && (
|
||||
<SessionMenu
|
||||
@@ -1107,34 +756,6 @@ function HomeBody({
|
||||
onArchive={() => void archiveSession(recentCtx.menu!.item)}
|
||||
/>
|
||||
)}
|
||||
{sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId={`workspace-source-menu-${sourceCtx.menu.item.id}`}
|
||||
>
|
||||
<MenuItem
|
||||
onSelect={() => {
|
||||
void copyText(sourceCtx.menu!.item.path);
|
||||
sourceCtx.close();
|
||||
}}
|
||||
>
|
||||
{t("menu.copyPath")}
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
danger
|
||||
onSelect={() => {
|
||||
const id = sourceCtx.menu!.item.id;
|
||||
sourceCtx.close();
|
||||
void run(() => api.lanes.removeSource(lane.id, id));
|
||||
}}
|
||||
>
|
||||
{t("home.remove")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
{menuAt && (
|
||||
<WorkspaceRowMenu
|
||||
lane={lane}
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
* preference kept in localStorage (`laneOrderStorage.ts`), never written to
|
||||
* the lane row. The row only STARTS a drag (`LANE_DRAG_TYPE`); the list
|
||||
* (`WorkspaceTree.tsx`) takes the drop anywhere, gaps included. F2 renames too; the
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring.
|
||||
* dot's meaning is screen-reader text, and an idle dot is a ring. A `readOnly`
|
||||
* row (another user's workspace, for an analyst) has no menu, rename or `+`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState, type MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MoreHorizontal, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Eye, MoreHorizontal, Plus } from "lucide-react";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
@@ -49,6 +50,7 @@ export function workspaceName(lane: Lane): string {
|
||||
export function WorkspaceRow({
|
||||
lane,
|
||||
index,
|
||||
readOnly,
|
||||
selected,
|
||||
expanded,
|
||||
renaming,
|
||||
@@ -63,6 +65,9 @@ export function WorkspaceRow({
|
||||
lane: Lane;
|
||||
/** Position in the list — the unit both reorder paths speak in. */
|
||||
index: number;
|
||||
/** Set (to its tooltip) for a workspace this user may only read: no menu,
|
||||
* no rename, no new session. */
|
||||
readOnly?: string;
|
||||
selected: boolean;
|
||||
expanded: boolean;
|
||||
renaming: boolean;
|
||||
@@ -82,7 +87,12 @@ export function WorkspaceRow({
|
||||
const openMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onMenu(e.clientX, e.clientY);
|
||||
if (!readOnly) onMenu(e.clientX, e.clientY);
|
||||
};
|
||||
const startRename = () => {
|
||||
if (readOnly) return;
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
};
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||
const hoverBtn =
|
||||
@@ -140,22 +150,18 @@ export function WorkspaceRow({
|
||||
aria-current={selected ? "true" : undefined}
|
||||
// A double-click's second click renames instead of opening again.
|
||||
onClick={(e) => e.detail < 2 && onSelect()}
|
||||
onDoubleClick={() => {
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
}}
|
||||
onDoubleClick={startRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
e.preventDefault();
|
||||
setDraft(lane.title || "");
|
||||
onRenameStart();
|
||||
startRename();
|
||||
return;
|
||||
}
|
||||
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
||||
e.preventDefault();
|
||||
onReorder(index, index + (e.key === "ArrowUp" ? -1 : 1));
|
||||
}}
|
||||
title={`${lane.cwd}\n${t("row.hint")}`}
|
||||
title={`${lane.cwd}\n${readOnly ?? t("row.hint")}`}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left"
|
||||
>
|
||||
<span aria-hidden className={`h-2 w-2 shrink-0 rounded-full ${workspaceDot(lane)}`} />
|
||||
@@ -163,7 +169,8 @@ export function WorkspaceRow({
|
||||
<span className="truncate text-[13px] font-medium text-fg-primary">{name}</span>
|
||||
</button>
|
||||
)}
|
||||
{!renaming && (
|
||||
{readOnly && <Eye aria-label={readOnly} className="mr-1 h-3 w-3 shrink-0 text-fg-muted" />}
|
||||
{!renaming && !readOnly && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,54 +1,53 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
|
||||
* pinned ones on top in pin order, then the ones with a live pane, then the
|
||||
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing); a right-click opens the same actions and more
|
||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
* (`?lane=<id>&run=<runId>`, its tab in the session view; a live tab whose
|
||||
* session has no row yet — a bare tab before its first prompt — is listed as
|
||||
* **New session** from the run itself, and a live Terminal tab (`tool: "shell"`,
|
||||
* which never has a session) as **Terminal** — and under the sessions the last
|
||||
* `CLOSED_TERMINALS` closed ones (`GET /api/run/history?tool=shell`), whose
|
||||
* click opens a NEW shell tab (a shell cannot be resumed) and whose hover X or
|
||||
* right-click menu forgets the row (`DELETE /api/run/history/:id`); Ctrl/Cmd+click opens
|
||||
* it in a split beside the focused pane) — attaching, never starting or
|
||||
* which never has a session) as **Terminal** while it is open — a closed one
|
||||
* leaves no row; Ctrl/Cmd+click opens
|
||||
* it in a split in the tab on screen) — attaching, never starting or
|
||||
* resuming anything;
|
||||
* any other session opens in its workspace too (`?lane=<id>&session=<id>`,
|
||||
* `SessionReplay`): its conversation with a prompt box, resumed only when a
|
||||
* message is SENT, because a resume re-reads the whole transcript into context.
|
||||
* The list shows `SESSION_LIMIT` rows and a **See all (N more)** under them that
|
||||
* loads every one. A
|
||||
* loads every one, then a **Show less** that goes back to `SESSION_LIMIT`. A
|
||||
* live tab whose session was archived is not brought back as a New session,
|
||||
* and archiving a live one asks first. F2 renames the focused row too; each
|
||||
* dot carries its meaning as screen-reader text.
|
||||
* dot carries its meaning as screen-reader text. `readOnly` (another user's
|
||||
* workspace, for an analyst): no live tabs, no rename/archive/menu, and a row
|
||||
* opens the session's analysis page (`/sessions/<id>`) — never a pane or a resume.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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, Pin, PinOff, 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
|
||||
@@ -66,9 +65,10 @@ export function liveRunFor(lane: Lane, runs: RunHandle[], sessionId: string): Ru
|
||||
/** The name of the git worktree a session ran in (`claude --worktree` puts it
|
||||
* at `<repo>/.claude/worktrees/<name>`), or null for one in the folder. */
|
||||
export function sessionWorktreeName(lane: Lane, s: Session): string | null {
|
||||
const strip = (p: string) => p.replace(/\/+$/, "");
|
||||
// Either separator: a Windows cwd is `D:\repo\.claude\worktrees\<name>`.
|
||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||
if (!s.cwd || strip(s.cwd) === strip(lane.cwd)) return null;
|
||||
return /\/\.claude\/worktrees\/([^/]+)\/?$/.exec(s.cwd)?.[1] ?? null;
|
||||
return /[\\/]\.claude[\\/]worktrees[\\/]([^\\/]+)[\\/]?$/.exec(s.cwd)?.[1] ?? null;
|
||||
}
|
||||
|
||||
/** A session row's dot: waiting for the user, failed, held by a live pane, or
|
||||
@@ -223,6 +223,7 @@ export function WorkspaceSessionList({
|
||||
selectedSessionId = null,
|
||||
onMatches,
|
||||
hidden = false,
|
||||
readOnly = false,
|
||||
}: {
|
||||
lane: Lane;
|
||||
/** Every live dashboard pane, from `GET /api/run`. */
|
||||
@@ -238,6 +239,8 @@ export function WorkspaceSessionList({
|
||||
onMatches?: (count: number) => void;
|
||||
/** Mounted only to load and count, during a search that hides the row. */
|
||||
hidden?: boolean;
|
||||
/** A workspace this user may only read. */
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -266,56 +269,31 @@ 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);
|
||||
}
|
||||
};
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
// 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, pinnedFirst: true, limit },
|
||||
refreshKey
|
||||
);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const shown = (sessions ?? []).filter(
|
||||
(s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q)
|
||||
);
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Pinned sessions first, then live ones, then ended ones — each group in
|
||||
// the server's order (pin order, else newest first; the sort is stable).
|
||||
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
@@ -347,7 +325,7 @@ export function WorkspaceSessionList({
|
||||
};
|
||||
}, [orphanIds]);
|
||||
const fresh =
|
||||
sessions === null || q
|
||||
sessions === null || q || readOnly
|
||||
? []
|
||||
: [
|
||||
...laneRuns.filter(
|
||||
@@ -363,7 +341,10 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
const patch = async (
|
||||
s: Session,
|
||||
body: { name?: string; archived?: boolean; pinned?: boolean }
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
@@ -432,9 +413,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>
|
||||
@@ -526,6 +505,7 @@ export function WorkspaceSessionList({
|
||||
// A double-click's second click renames; it must not
|
||||
// navigate (a second history entry) on top of the first.
|
||||
if (e.detail > 1) return;
|
||||
if (readOnly) return navigate(`/sessions/${encodeURIComponent(s.id)}`);
|
||||
navigate(
|
||||
run
|
||||
? `/workspace?lane=${lane.id}&run=${encodeURIComponent(run.id)}`
|
||||
@@ -535,19 +515,19 @@ export function WorkspaceSessionList({
|
||||
run && (e.ctrlKey || e.metaKey) ? { state: { split: true } } : undefined
|
||||
);
|
||||
}}
|
||||
onDoubleClick={() => startRename(s)}
|
||||
onDoubleClick={() => !readOnly && startRename(s)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "F2") {
|
||||
if (e.key === "F2" && !readOnly) {
|
||||
e.preventDefault();
|
||||
startRename(s);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => sessionCtx.open(e, s)}
|
||||
onContextMenu={readOnly ? undefined : (e) => sessionCtx.open(e, s)}
|
||||
title={`${s.name || s.id}\n${timeAgo(s.last_activity ?? s.started_at)}${
|
||||
sessionWorktreeName(lane, s)
|
||||
? `\n${t("tree.worktreeSession", { name: sessionWorktreeName(lane, s) })}`
|
||||
: ""
|
||||
}\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}
|
||||
}${readOnly ? "" : `\n${run ? t("tree.splitHint") : t("tree.transcriptHint")}`}`}
|
||||
className={`flex w-full items-center gap-2 rounded-md py-1 pl-2 pr-2 text-left text-[12px] ${
|
||||
selected
|
||||
? "bg-accent/10 text-fg-primary"
|
||||
@@ -561,6 +541,13 @@ export function WorkspaceSessionList({
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{s.pinned_at && (
|
||||
<Pin
|
||||
data-testid={`workspace-session-pinned-${s.id}`}
|
||||
aria-label={t("tree.pinned")}
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
data-testid={`workspace-session-worktree-${s.id}`}
|
||||
@@ -568,65 +555,49 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
<span
|
||||
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
>
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
tool={{ id: s.tool || "claude", label: tools.label(s.tool || "claude") }}
|
||||
className="h-3.5 w-3.5 shrink-0 !p-0 opacity-60 group-hover:invisible"
|
||||
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
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"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-pin-${s.id}`}
|
||||
onClick={() => void patch(s, { pinned: !s.pinned_at })}
|
||||
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
className="absolute right-6 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"
|
||||
>
|
||||
{s.pinned_at ? (
|
||||
<PinOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-archive-${s.id}`}
|
||||
onClick={() => void archive(s)}
|
||||
title={t("tree.archiveSession")}
|
||||
aria-label={t("tree.archiveSession")}
|
||||
// Invisible, not `hidden`: a keyboard user must be able to
|
||||
// Tab onto it, and it shows when focused.
|
||||
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"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</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
|
||||
@@ -638,6 +609,16 @@ export function WorkspaceSessionList({
|
||||
{t("tree.seeAll", { count: more })}
|
||||
</button>
|
||||
)}
|
||||
{more === 0 && !q && own && own.total > SESSION_LIMIT && limit > SESSION_LIMIT && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-sessions-less-${lane.id}`}
|
||||
onClick={() => setLimit(SESSION_LIMIT)}
|
||||
className="w-full py-1 pl-[42px] pr-2 text-left text-[11px] text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
{t("tree.showLess")}
|
||||
</button>
|
||||
)}
|
||||
{failure}
|
||||
{confirmArchive}
|
||||
{ctx.menu && (
|
||||
@@ -650,42 +631,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}
|
||||
@@ -696,6 +641,9 @@ export function WorkspaceSessionList({
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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";
|
||||
@@ -25,13 +25,16 @@ vi.mock("../../../lib/api", () => ({
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
|
||||
get: vi.fn(),
|
||||
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 +53,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 +65,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 +122,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 +137,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>,
|
||||
@@ -147,6 +157,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -158,7 +169,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 +184,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 +199,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 +222,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 +240,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 +264,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 +278,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -284,7 +295,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 +314,7 @@ describe("AddLaneModal — worktree mode (default)", () => {
|
||||
current: "main",
|
||||
worktrees: [],
|
||||
});
|
||||
renderModal();
|
||||
renderWorktree();
|
||||
const user = userEvent.setup();
|
||||
|
||||
const repoField = screen.getByLabelText("Source repository");
|
||||
@@ -328,7 +339,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 +371,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 +396,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 +421,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 +442,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 +474,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 +488,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();
|
||||
@@ -569,6 +586,17 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a click into the path field only focuses it — no picker opens (#77)", async () => {
|
||||
renderModal();
|
||||
const user = userEvent.setup();
|
||||
const field = document.getElementById("add-lane-repo")!;
|
||||
|
||||
await user.click(field);
|
||||
expect(field).toHaveFocus();
|
||||
expect(api.lanes.pickPath).not.toHaveBeenCalled();
|
||||
expect(api.lanes.browse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the in-app browser when no native picker is available", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
@@ -588,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
@@ -601,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
@@ -639,49 +667,21 @@ 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();
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — tool selection", () => {
|
||||
it("sends the picked tool when adopting a repo", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
// One button showing the default; it opens every installed + enabled tool.
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
|
||||
);
|
||||
it("offers no tool picker: a new session picks its own AI", async () => {
|
||||
renderModal({});
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /Claude Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Codex" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
@@ -727,7 +727,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");
|
||||
@@ -753,3 +753,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — Clone", () => {
|
||||
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, url);
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
}
|
||||
|
||||
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 5 }),
|
||||
created: true,
|
||||
path: "/home/an/code/repo",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("needs a login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "git.example.com" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const login = await screen.findByTestId("add-lane-clone-login");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "an");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
|
||||
username: "an",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("points to Settings → Git when the SSH key is refused", async () => {
|
||||
vi.mocked(api.lanes.clone).mockRejectedValue(
|
||||
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "git@github.com:o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||
if (body?.url.includes("private") && !body.credentials)
|
||||
throw Object.assign(new Error("login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "private.test" },
|
||||
});
|
||||
return view;
|
||||
});
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
|
||||
await user.type(screen.getByLabelText("Username"), "dev");
|
||||
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user