Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a6dc6a29e | |||
| c042519bc9 | |||
| 383b9ae231 | |||
| aee4fc190e | |||
| 0547488f13 | |||
| 49f87c4da3 | |||
| 1a6b550e89 | |||
| f8d07dab61 | |||
| 0434ffc750 | |||
| e2d8d314f6 | |||
| 0c8782ea75 | |||
| c042adc51b | |||
| 776ad6466f | |||
| 9c98638ec0 | |||
| aa8ac6cad9 | |||
| 3237ce4774 | |||
| 7c5f30df7d | |||
| 5e0a9b526b | |||
| 538aec3ed9 | |||
| 692f532972 | |||
| f460fff406 | |||
| 2c712b06d1 | |||
| 1b09371042 | |||
| 14595651fe | |||
| 8739294a72 | |||
| e07993c536 | |||
| 5cb70d702b | |||
| 9a4ed54cf7 | |||
| 57c423cdb1 | |||
| ecbbb4dd8f | |||
| a58e95a957 | |||
| 4d6067650a | |||
| 5bc3f7d34a | |||
| b058179177 | |||
| b6b40b0c1b | |||
| 758609775f | |||
| 09efb089cf | |||
| c83ddf62a0 | |||
| d8ab850295 | |||
| bac6c04a0b | |||
| 38c7ac6635 | |||
| 276cead826 | |||
| 1c5bd88747 | |||
| 0a1dcfeb7f | |||
| 58be04a4e7 | |||
| cd427d218b |
+13
-1
@@ -72,9 +72,21 @@
|
|||||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||||
|
|
||||||
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
# Hourly database upkeep: a non-blocking WAL checkpoint (the WAL then shrinks
|
||||||
# to 64 MB) and PRAGMA optimize. 0 turns it off. Never VACUUMs.
|
# to 64 MB), PRAGMA optimize, the opt-in retention below and a bounded
|
||||||
|
# incremental_vacuum. 0 turns it off. Never a full VACUUM (that is Settings →
|
||||||
|
# Data → Compact, on a click).
|
||||||
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
# DASHBOARD_DB_MAINTAIN_MS=3600000
|
||||||
|
|
||||||
|
# Automatic retention (OFF unless set): finished sessions that started more
|
||||||
|
# than N days ago (1–3650) are purged by the hourly upkeep. Usually set from
|
||||||
|
# Settings → Data → Session cleanup, which writes it to the settings file.
|
||||||
|
# AGENTHUB_RETENTION_DAYS=90
|
||||||
|
|
||||||
|
# Update check interval (ms, default 3600000 = 1 h, minimum 60000). A tick
|
||||||
|
# with no browser connected is skipped; the next browser to connect gets one.
|
||||||
|
# DASHBOARD_UPDATE_CHECK=0 turns the scheduled check off.
|
||||||
|
# DASHBOARD_UPDATE_CHECK_INTERVAL_MS=3600000
|
||||||
|
|
||||||
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
# Poll interval (ms) for Antigravity CLI sessions and tokens, read from agy's
|
||||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ client/package-lock.json
|
|||||||
*.md
|
*.md
|
||||||
*.yml
|
*.yml
|
||||||
*.yaml
|
*.yaml
|
||||||
|
server/vendor/
|
||||||
|
|||||||
+27
-15
@@ -310,7 +310,7 @@ graph TD
|
|||||||
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|---------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||||
| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit |
|
| `server/index.js` | Express app setup, middleware, route mounting, static file serving in production, HTTP server creation. Static middleware sets explicit `Cache-Control` headers — `immutable` for `/assets/*`, `no-cache, must-revalidate` for `index.html` / `sw.js` / `manifest.json`, a short revalidation window otherwise — so a rebuild always replaces the in-browser bundle without a hard refresh. Runs a periodic maintenance sweep — cadence derived from `DASHBOARD_STALE_MINUTES` (¼ of the threshold, clamped to 60 s – 5 min, default ~45 min) — that abandons stale sessions with transcript cache eviction and scans active sessions for new compaction entries by reading `sessions.transcript_path` directly (an O(active sessions) lookup; the previous `SELECT DISTINCT json_extract(events.data,'$.transcript_path')` scan grew with the events table and is gone). **Error detection watchdog** runs every 15 seconds: finds active sessions with no recent hook events (>10 s stale), re-reads their transcript files looking for API errors (401 auth, rate limits, quota exhaustion), derives transcript paths from session `cwd` for imported sessions, and marks sessions/agents as `error` when API errors are found — catches cases where the CLI doesn't fire a hook after API errors. The same watchdog also performs **user-interrupt (Esc) recovery**: `Esc` fires no hook, so a cancelled turn would otherwise leave the main agent stuck `working`. It detects this two ways — (a) the transcript's `[Request interrupted by user]` marker, surfaced as `result.pendingInterrupt` from `TranscriptCache` (computed from transcript ordering alone, immune to server/transcript clock skew), and (b) an idle-working fallback for an Esc pressed *before any output* (which writes no marker): when the main agent has been `working` with `current_tool` null and neither a hook event nor the transcript mtime has advanced for `DASHBOARD_WORKING_IDLE_SECONDS` (default 120) — and moves the session to **Waiting** (agent → `waiting`, `awaiting_input_since` stamped) with an `Interrupted` event. Triggers legacy session import (with active-session detection for recently-modified JSONL files) and compaction backfill on startup, plus a boot **liveness reap** — immediately for rows from a previous run and again ~5 s later for rows the startup sync just imported (see the `routes/hooks.js` row) — so sessions that died while the dashboard was down never render as Waiting. Also starts the **Remote Data Sources** sync poller (`startRemoteSourceSync`) that pulls each enabled remote source on an interval — `DASHBOARD_REMOTE_SYNC_MS` (default 60000; 0 disables) — reusing `server/lib/remote-sync.js`. **Graceful shutdown** (SIGTERM/SIGINT) tears down in order: drop realtime clients first (`closeWebSocket` terminates WS clients so their sockets release), then `httpServer.close()` to stop new connections, then `httpServer.closeAllConnections()` to drop lingering keep-alive sockets so `close()` fires promptly, and only **then** close SQLite — inside the `close()` callback, after the HTTP server has drained. Closing the DB before drain made in-flight requests throw `The database connection is not open`; leaving WS/keep-alive sockets open stalled the shutdown until the 5 s force-exit backstop (the "waiting for graceful termination" hang under `node --watch`). A second signal forces an immediate exit |
|
||||||
| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` |
|
| `server/openapi.js` | OpenAPI 3.0.3 document generator for the backend API (metadata, schemas, endpoint paths), merging supplementary fragments from `server/openapi-extra/` in `createOpenApiSpec()`. Feeds the raw spec endpoint (`/api/openapi.json`), Swagger UI (`/api/docs`), **ReDoc** (`/api/redoc`, served via `server/lib/redoc.js` with a self-hosted bundle — never a CDN), and the committed `openapi.yaml` regenerated by `npm run openapi:yaml` |
|
||||||
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (bundled via the `redoc` dependency, never a CDN) so the reference works fully offline / air-gapped |
|
| `server/lib/redoc.js` | Serves the **ReDoc** API reference (`/api/redoc`) as a self-hosted three-panel rendering of the OpenAPI spec, with the ReDoc bundle served locally from `/api/redoc/redoc.standalone.js` (vendored under `server/vendor/redoc/` — the `redoc` npm package is not a dependency — never a CDN) so the reference works fully offline / air-gapped |
|
||||||
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
|
| `server/openapi-extra/` | Supplementary OpenAPI path/schema fragments merged into the spec by `createOpenApiSpec()` — covers `cc-config.js`, `push.js`, `run.js`, and `misc.js` route groups |
|
||||||
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
|
| `server/db.js` | SQLite connection with WAL mode, schema migration (CREATE TABLE IF NOT EXISTS + ALTER TABLE for column additions), all prepared statements as a reusable `stmts` object. Tries `better-sqlite3` first, falls back to `node:sqlite` via `compat-sqlite.js`. Migrations use literal defaults for ALTER TABLE since SQLite does not support expressions like `strftime()` in column defaults added via ALTER TABLE |
|
||||||
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
|
| `server/compat-sqlite.js` | Compatibility wrapper that gives Node.js built-in `node:sqlite` (`DatabaseSync`) the same API as `better-sqlite3` — pragma, transaction, prepare. Used as automatic fallback when the native module is unavailable (Node 22+) Maps every better-sqlite3 API the server uses — `function` (the `trim_event_data` SQL function `db.js` registers at boot), `stmt.pluck()`, the `readonly`/`fileMustExist` open options — and `db.js` exports the chosen class as `Database` so other SQLite files (agy's conversations) open with the same driver. A plugin install has NO better-sqlite3 build, so this is production there; `server/__tests__/compat-sqlite.test.js` boots `db.js` with better-sqlite3 blocked because the rest of the suite never runs on it. |
|
||||||
@@ -1066,7 +1066,7 @@ flowchart TD
|
|||||||
- Uses Node.js `http` module directly — no dependencies.
|
- Uses Node.js `http` module directly — no dependencies.
|
||||||
- Resolution order is **env override → discovery file → default**:
|
- Resolution order is **env override → discovery file → default**:
|
||||||
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
|
- `CLAUDE_DASHBOARD_PORT` forces a single target (no fan-out, no discovery).
|
||||||
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
|
- Otherwise `server/lib/server-info.js` reads `~/.claude/.agenthub.json`, prunes dead-PID entries, and returns one live `port` per unique `dataDir` (lowest port wins when two instances share `~/.claude/agenthub`). The handler POSTs to each returned port in parallel. The other instance learns of a lane the hooks moved through `broadcastForeignLaneChanges` (`server/routes/lanes.js`): every `DASHBOARD_LANE_SYNC_MS` (2 s) — while `peersSharingDataDir()` finds another live dashboard on its data dir, which it checks every 30 s — it reads `PRAGMA data_version`, which moves only on another connection's commit, and re-broadcasts each lane whose `updated_at` changed (and `removed` for a vanished one) to its own browsers. Sessions and events are not re-broadcast this way — a page on that instance shows them on its next fetch.
|
||||||
- If neither yields anything, the handler falls back to `4820`.
|
- If neither yields anything, the handler falls back to `4820`.
|
||||||
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
|
- Per-target promises never reject — a dead listener can't starve the others, and the handler can wait on `Promise.all` for clean exit timing.
|
||||||
|
|
||||||
@@ -1223,7 +1223,12 @@ common "nothing changed" tick is just a handful of `stat` calls), funnels them
|
|||||||
through the same `parseSessionFile` + `importSession` pipeline as every other
|
through the same `parseSessionFile` + `importSession` pipeline as every other
|
||||||
path, and broadcasts `session_created` for newly imported sessions /
|
path, and broadcasts `session_created` for newly imported sessions /
|
||||||
`session_updated` for grown ones — the same events hooks emit, so the UI
|
`session_updated` for grown ones — the same events hooks emit, so the UI
|
||||||
refreshes live. All timers and watchers are `unref`'d and best-effort; nothing
|
refreshes live. Two kinds of transcript are not re-parsed: one whose session
|
||||||
|
hooks are feeding **live** (status `active`, newest event a hook's, a hook
|
||||||
|
within `DASHBOARD_SYNC_LIVE_HOOK_MS`, default 5 min) is left until it goes
|
||||||
|
quiet or ends — then caught up once — and one whose session a hook created
|
||||||
|
(no `imported` flag, which `importSession` skips anyway) only has its durable
|
||||||
|
snapshot refreshed, never a parse. All timers and watchers are `unref`'d and best-effort; nothing
|
||||||
here blocks shutdown or can take down the server.
|
here blocks shutdown or can take down the server.
|
||||||
|
|
||||||
### Upload request sequence
|
### Upload request sequence
|
||||||
@@ -1579,7 +1584,11 @@ re-point if that changes.
|
|||||||
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
|
`DASHBOARD_CODEX_POLL_MS` beat, a first pass over every journal in the
|
||||||
background at startup). Each file's read offset and parse state live in the
|
background at startup). Each file's read offset and parse state live in the
|
||||||
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
|
`codex_rollouts` table, so an unchanged file is never re-read and a grown one is
|
||||||
read from where it stopped. `token_count` events are summed per `turn_context`
|
read from where it stopped (a pass loads every row's size/mtime in one query).
|
||||||
|
The first pass and one every five minutes walk every journal; the poll's other
|
||||||
|
passes stat only the two newest day folders plus the journals the table saw
|
||||||
|
written in the last hour (or not yet read to the end), so a thread resumed
|
||||||
|
after an hour's quiet in an older folder is picked up by the next full pass. `token_count` events are summed per `turn_context`
|
||||||
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
|
model — `input_tokens` includes `cached_input_tokens` (stored as input − cached
|
||||||
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
|
plus cache_read) and `output_tokens` includes reasoning. A **fork** copies its
|
||||||
parent's whole history into its own file, token events included, so every total
|
parent's whole history into its own file, token events included, so every total
|
||||||
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
|
|||||||
|
|
||||||
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
|
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
|
||||||
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
|
`startAntigravityUsagePoll`, every `DASHBOARD_ANTIGRAVITY_POLL_MS`, default
|
||||||
15000, `0` disables). agy writes one SQLite file per conversation,
|
15000, `0` disables; after a pass that read nothing, wrote nothing and left no
|
||||||
|
fresh session to settle it slows to once a minute until one finds work —
|
||||||
|
`server/lib/backoff-poll.js`). agy writes one SQLite file per conversation,
|
||||||
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
|
`~/.gemini/antigravity-cli/conversations/<conversation id>.db`. Each is opened
|
||||||
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
|
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
|
||||||
`antigravity_conversations` holds the mtime read, so a session the user deleted
|
`antigravity_conversations` holds the mtime read, so a session the user deleted
|
||||||
@@ -1743,7 +1754,8 @@ is ever inferred.
|
|||||||
## Cursor Sessions
|
## Cursor Sessions
|
||||||
|
|
||||||
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
|
`server/lib/cursor-usage.js` (`startCursorUsagePoll`, every
|
||||||
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables) reads the agent
|
`DASHBOARD_CURSOR_POLL_MS`, default 15000, `0` disables; idle passes slow it to
|
||||||
|
once a minute like the Antigravity poll) reads the agent
|
||||||
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
|
transcripts Cursor writes — the CLI (`cursor-agent`) and the editor's agent both —
|
||||||
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
||||||
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
||||||
@@ -2225,7 +2237,7 @@ sequenceDiagram
|
|||||||
| Component | Responsibility |
|
| Component | Responsibility |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
| **`server/lib/update-check.js`** | `getUpdatesStatus(root?, { skipFetch?, latestRelease? })` + `applyUpdate(root?)`. Runs every git call via `execFile` (no shell, 10s–120s timeouts) and reads the newest release over `fetch` with an `AbortSignal.timeout` — `fetchLatestRelease` NEVER throws, resolving to `{ok:false, reason}` (`no_remote`, `remote_not_http`, `no_releases`, `unreachable`, `bad_payload`, `http_<status>`) so a status poll degrades instead of erroring. `releaseApiUrl` derives the endpoint from a repository URL (https, scp-style and `ssh://` forms) and returns null for anything not owner/repo shaped, which is what keeps local-path remotes off the network entirely. `fetchLatestRelease` finds that URL via the `origin` remote; `fetchReleaseFromUrl` takes one directly, which is how the **git-free plugin path** works: `pluginMarketplaceUrl(mkt, plugin)` reads `known_marketplaces.json` → the marketplace's `.claude-plugin/marketplace.json` → the entry's `source.url`, returning null (never a guessed host) for a `github` `owner/repo` shorthand and never throwing. **Order matters:** `detectPluginInstall` runs FIRST in `getUpdatesStatus`, ahead of the `.git` probe — a plugin cache unpacked from an archive has no `.git`, and probing first is the 1.5.1 bug that made `update_available` permanently false. `compareVersions` orders numeric triples and strips a leading `v`, since this repo carries both `v1.5.0` and `1.4.6`. `isShallowClone` gates every commit-distance calculation; `detectPluginInstall` reads the marketplace and plugin name out of the cache path. **Branch- and fork-aware** as before: resolves `<remote>/master`/`/main`/`/HEAD`, reads the current branch and its tracked upstream, and shapes `manual_command` per situation — the `claude plugin ...` commands for `plugin_managed`; `git fetch <remote> --tags && git merge --ff-only <tag>` for `release_available`; `git pull --ff-only` when the branch tracks the canonical ref; `git fetch <remote> && git merge --ff-only <ref>` for forks; `git fetch <remote>` only on a feature branch or detached HEAD. Non-git installs, missing remotes, fetch failures, and unresolvable upstream refs are returned as soft payloads — never throws. |
|
||||||
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 300 000, floor 60 000). First tick is scheduled 8s after server start with `.unref()` so it doesn't block shutdown. Broadcasts only when the fingerprint `{update_available, remote_sha, commits_behind, fetch_error, manual_command}` changes — `manual_command` is included so situation transitions (e.g. user switches branches, or adds an `upstream` remote) trigger a re-broadcast even when the SHA and commit count are unchanged. Emits a framed message to stdout on "up-to-date → behind" transitions, and only suggests "restart the dashboard" when the printed command actually rewrites the working tree. `DASHBOARD_UPDATE_CHECK=0\|false\|off` disables the scheduler entirely. |
|
| **`server/update-scheduler.js`** | Ticks the lib every `DASHBOARD_UPDATE_CHECK_INTERVAL_MS` (default 3 600 000 = 1 h, floor 60 000). A tick with no `/ws` browser connected (`getConnectionCount`) is skipped, and the first browser to connect afterwards (`onConnection` in `server/websocket.js`) gets one check 3 s later if the last one is older than the interval; the manual `POST /api/updates/check` is unaffected. 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 (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/routes/updates.js`** | Three endpoints: `GET /status` (read-only check), `POST /check` (check + broadcast), `POST /apply`. `/apply` has **two modes**, chosen by how this copy was installed, never by the request body: **checkout** — fast-forward onto the release tag, `npm run setup`, `npm run build` in production (both through the bootstrap's `npmCommand` with `NODE_ENV` dropped — `npmInvocation` in `server/lib/update-check.js` — since the server's `NODE_ENV=production` makes npm omit the client's devDependency build toolchain, and a bare `npm.cmd` is EINVAL under `execFile` on Windows), then `scheduleRestart()`; **plugin** — `applyUpdate`'s `plugin_managed` refusal is caught and `schedulePluginUpdate()` runs instead, answering `200 {applied:true, mode:"plugin"}`. The plugin branch fires only when there is genuinely something to pull (`update_available` plus a marketplace and plugin name parsed from the cache path) — a `plugin_managed` refusal with nothing newer published stays a 409 rather than spawning a chain that would stop the server for nothing. Otherwise 409 `UPDATE_NOT_APPLICABLE` with `reason` `"up_to_date"`, `"not_fast_forwardable"`, or `"plugin_managed"`. Both modes write the response BEFORE anything stops the process. Mounted under `/api/*`, so all three ride the default loopback bind as their trust boundary and are gated by `DASHBOARD_TOKEN` when one is set. |
|
||||||
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
| **`server/lib/plugin-update.js`** + **`scripts/plugin-update-helper.js`** | The plugin-cache update chain. The lib only spawns the helper detached (cwd = the runtime dir, **not** the plugin root, which is about to be superseded) and clears any previous verdict first so a stale success can't be read back as this run's result. All work is in the helper because its last step SIGTERMs the server that asked for the update — the same split as `self-restart.js`, one step further. The helper re-validates the marketplace and plugin names against `/^[A-Za-z0-9][\w.-]*$/` (they reach an argv across a process boundary), resolves `claude` on PATH or at `~/.local/bin/claude` (a hook-spawned server does not inherit the login shell's PATH), picks the newest cache directory that isn't the old root and does contain `scripts/plugin-bootstrap.js`, and records **every** outcome — including an unexpected throw — to `plugin-update.json` under `~/.claude/agenthub/runtime/`, outside the cache so it survives the swap. Steps: `validate`, `resolve_claude`, `marketplace_update`, `plugin_update`, `already_current` (`ok:true`; nothing was pulled and the old server was left running), `bootstrap`, `done`. `readLastPluginUpdate()` surfaces it as `last_plugin_update` on the status payload — without it a failed chain is indistinguishable from a slow one. |
|
||||||
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
| **`UpdateNotifier.tsx`** | Modal. Hydrates from `api.updates.status()` on mount and mirrors the payload back into the local `eventBus` so the Sidebar can listen without a second git fetch. Subscribes to `update_status` WS frames for ongoing sync. Keeps the dismissal key in `localStorage` (`agent-monitor-update-dismissed-sha`) and in React state — the **release tag** when one was read, else the upstream SHA, so a newer release re-opens the prompt while a dismissed one stays dismissed; a window event `dashboard:reset-update-dismissal` from the Sidebar clears both. ESC / backdrop click dismisses. |
|
||||||
@@ -2325,7 +2337,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.
|
- **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.
|
- **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`: 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).
|
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`, which appends the new server's stdout/stderr to `data/agenthub-server.log` like `agenthub start` (it used to discard them, so a crash after an update left no stack trace). 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -2356,7 +2368,7 @@ graph TD
|
|||||||
SHELL["Sagi.tsx<br/>shell: open/closed state,<br/>⌘B / Esc, reduced-motion,<br/>navigation"]
|
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"]
|
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||||
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
BUBBLE["SpeechBubble.tsx<br/>bubble"]
|
||||||
PANEL["SagiPanel.tsx<br/>status + Needs you + Mute / Clear alerts + Ask box"]
|
PANEL["PetChat.tsx + SagiInfo.tsx<br/>the chat window: status strip, Needs you,<br/>Mute / Clear alerts, local answers, AI chat"]
|
||||||
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
CSS["sagi.css<br/>keyframes + per-mood expressions"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2386,8 +2398,8 @@ flowchart LR
|
|||||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||||
DERIVED --> AVATAR["SagiAvatar"]
|
DERIVED --> AVATAR["SagiAvatar"]
|
||||||
DERIVED --> BUBBLE["SpeechBubble"]
|
DERIVED --> BUBBLE["SpeechBubble"]
|
||||||
DERIVED --> PANEL["SagiPanel"]
|
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
|
||||||
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
PANEL -->|"unmatched question"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
||||||
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
|
ENSURE --> START["POST /api/run<br/>(spawn tmux-backed claude<br/>in Sagi lane, initialPrompt=…)"]
|
||||||
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
|
START --> RUN["/run?laneId=…<br/>(existing Run page picks up<br/>the live run)"]
|
||||||
|
|
||||||
@@ -2407,10 +2419,10 @@ The mood state machine in `deriveMood` resolves to a single expression using a f
|
|||||||
| **`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 }`. |
|
| **`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. |
|
| **`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: 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). |
|
| **`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. |
|
| **`SagiInfo.tsx`** | Pure presentational pieces of the chat window: `PetNameButton` (click to rename — `agenthub.pet.names`, synced to the account) and `SagiInfo`, the status strip under the header — live / waiting / errored and the connection always shown; expanded (by default while something needs you) a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts. |
|
||||||
| **`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. |
|
| **`PetChat.tsx`** | The pet's whole UI — clicking the pet opens it (there is no separate panel): header (name, pat heart, Open in workspace, New chat, close), the `SagiInfo` strip, then the conversation. Before a chat runs, a question `intents.ts` can answer is answered in place (`onLocal`); anything else goes to the AI. 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` — numbered menus and Claude's unnumbered folder-trust prompt alike) 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, so closing and reopening the window continues it; **New chat** stops that session (`DELETE /api/run/:id?archive=1`, asking first only mid-reply); 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. |
|
| **`Sagi.tsx`** | Shell component. A pat (the chat's heart, a typed pat, 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 chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns the chat's 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. |
|
| **`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 go to `PetChat`'s AI session; 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. |
|
| **`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. |
|
| **`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. |
|
| **`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. |
|
||||||
|
|||||||
+402
-77
@@ -1,6 +1,6 @@
|
|||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
A step-by-step guide to get AgentHub — the local control surface for Claude Code agents — up and running on your machine, with optional sections for importing history and running in a container.
|
The one install-and-setup guide for AgentHub — the local control surface for Claude Code agents: the plugin install, a checkout install, configuration, the database, importing history, and troubleshooting. (`SETUP.md` is only a pointer here now.)
|
||||||
|
|
||||||
## Fastest path — install it as a Claude Code plugin
|
## Fastest path — install it as a Claude Code plugin
|
||||||
|
|
||||||
@@ -17,17 +17,20 @@ on PATH — no clone, no `npm run setup`, no
|
|||||||
result and `/agenthub-open` to build the UI and get the URL.
|
result and `/agenthub-open` to build the UI and get the URL.
|
||||||
|
|
||||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||||
the server stores data through `node:sqlite`). Full behavior, including the
|
the server stores data through `node:sqlite`), plus `git` and `tmux` from
|
||||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
[Step 0](#step-0--system-packages). Full behavior, including the uninstall
|
||||||
|
cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md). If
|
||||||
|
the dashboard never comes up, see
|
||||||
|
[Plugin bootstrap never comes up](#plugin-bootstrap-never-comes-up).
|
||||||
|
|
||||||
Follow the steps below instead when you want a checkout — to develop the
|
Follow the steps below instead when you want a checkout — to develop the
|
||||||
dashboard, run it from source, or stay on Node 20.
|
dashboard or run it from source.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| Requirement | Version | Notes |
|
| Requirement | Version | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Node.js | 20+ (22+ recommended) | Required for server and client |
|
| Node.js | **22.5+** | Required for server and client (`node:sqlite`). Node 20 is end-of-life and unsupported |
|
||||||
| npm | 9+ | Comes with Node.js |
|
| npm | 9+ | Comes with Node.js |
|
||||||
| Claude Code | 2.x+ | Required for hook integration |
|
| Claude Code | 2.x+ | Required for hook integration |
|
||||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||||
@@ -76,7 +79,7 @@ Check it:
|
|||||||
```bash
|
```bash
|
||||||
tmux -V # e.g. tmux 3.4
|
tmux -V # e.g. tmux 3.4
|
||||||
git --version
|
git --version
|
||||||
node -v # v20.x / v22.x / v24.x
|
node -v # v22.5+ / v24.x
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the server is up you can ask it directly — this is the same probe the
|
Once the server is up you can ask it directly — this is the same probe the
|
||||||
@@ -113,8 +116,8 @@ brew install node git tmux # Node >= 22.5 for the plugin install (nvm work
|
|||||||
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc # where the plugin puts `agenthub`
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
|
||||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
checkout needs the same). The `agenthub` launcher
|
||||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||||
`brew upgrade` or `nvm` switch deletes that binary.
|
`brew upgrade` or `nvm` switch deletes that binary.
|
||||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||||
@@ -172,24 +175,20 @@ cd AgentHub
|
|||||||
## Step 2 — Install dependencies
|
## Step 2 — Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run setup
|
npm run setup # or: make setup
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs all server and client dependencies, plus the VS Code extension, and links the `agenthub` CLI.
|
This runs `npm install` (whose `postinstall` hook installs the client's
|
||||||
|
dependencies in `client/`), installs `client/` once more explicitly, and links
|
||||||
|
the `agenthub` CLI (`npm link`; if that has no permission it prints how to link
|
||||||
|
it by hand). A plain `npm install` is enough to build and run the dashboard —
|
||||||
|
only the CLI link is missing.
|
||||||
|
|
||||||
A plain root install already covers server **and** client — a `postinstall` hook installs the client dependencies automatically, so this alone is enough to build and run the dashboard:
|
With `--ignore-scripts` the `postinstall` hook is skipped; install the client
|
||||||
|
yourself with `npm --prefix client install`.
|
||||||
|
|
||||||
```bash
|
`better-sqlite3` gyp/compile warnings here are harmless — see
|
||||||
npm install
|
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
|
||||||
```
|
|
||||||
|
|
||||||
`npm run setup` additionally installs the VS Code extension and links the `agenthub` CLI. (If you install with `--ignore-scripts`, the `postinstall` hook is skipped — run `cd client && npm install` manually in that case.)
|
|
||||||
|
|
||||||
Or via Makefile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make setup
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
|
|||||||
- **Dashboard** — stats updating in real-time
|
- **Dashboard** — stats updating in real-time
|
||||||
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
|
- **Settings page** — data export and cleanup tools on the Dashboard tab; Claude Code's model pricing rules, hook configuration status and CLAUDE_HOME on **Settings → Claude Code → Overview**
|
||||||
|
|
||||||
If nothing appears after 30 seconds, see [SETUP.md](./SETUP.md#troubleshooting).
|
If nothing appears after 30 seconds, see [Troubleshooting](#no-sessions-appearing-after-starting-claude-code).
|
||||||
|
|
||||||
### PWA install (optional)
|
### PWA install (optional)
|
||||||
|
|
||||||
@@ -246,57 +245,110 @@ The dashboard is a Progressive Web App. After opening it in a supported browser
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 5 — (Optional) Import existing Claude Code history
|
## Step 5 — (Optional) Importing existing Claude Code history
|
||||||
|
|
||||||
The server **automatically imports** every session under
|
The dashboard automatically imports sessions from `~/.claude/projects/` on
|
||||||
`~/.claude/projects/` on startup, so if you've used Claude Code on this
|
**every startup**, so if Claude Code has been used on this machine, you'll
|
||||||
machine before, your historical sessions, agents, events, token counts,
|
see history immediately after the first launch. If you need to bring in
|
||||||
and cost totals should already be visible in the Sessions list.
|
history from another machine, from a backup, or just force a rescan, use
|
||||||
|
**Settings → Import History** in the UI — it's a guided, drag-and-drop
|
||||||
|
experience with live progress.
|
||||||
|
|
||||||
To bring in history from another machine, a backup, or a `.tar.gz`
|
### Pick the right mode
|
||||||
archive a teammate sent you, use **Settings → Import History** in the
|
|
||||||
UI. It supports three modes:
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart TD
|
||||||
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
|
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
|
||||||
UI --> M2["Scan a folder<br/>any absolute path"]
|
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
|
||||||
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
|
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
|
||||||
M1 --> P["Same parser as live<br/>hook ingestion"]
|
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
|
||||||
M2 --> P
|
|
||||||
M3 --> P
|
|
||||||
P --> DB[("SQLite<br/>sessions + tokens + cost")]
|
|
||||||
|
|
||||||
style UI fill:#a855f7,stroke:#c084fc,color:#fff
|
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
|
||||||
style P fill:#f59e0b,stroke:#fbbf24,color:#000
|
|
||||||
style DB fill:#10b981,stroke:#34d399,color:#fff
|
style M1 fill:#10b981,stroke:#34d399,color:#fff
|
||||||
|
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||||
|
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
|
||||||
```
|
```
|
||||||
|
|
||||||
Re-imports are idempotent: sessions are deduplicated by UUID and
|
### Step-by-step: moving history from one machine to another
|
||||||
compaction baselines preserve pre-compaction token totals, so running
|
|
||||||
the importer twice never double-counts tokens or cost.
|
|
||||||
|
|
||||||
Verify it worked by opening the **Analytics** page and checking that
|
**On the source machine**, bundle the projects folder:
|
||||||
per-model token totals and estimated cost look correct. Full walkthrough
|
|
||||||
with per-OS archive commands in
|
|
||||||
[SETUP.md → Importing existing Claude Code history](./SETUP.md#importing-existing-claude-code-history).
|
|
||||||
### Optional: tune import limits
|
|
||||||
|
|
||||||
If you regularly import very large archives, these environment variables
|
```bash
|
||||||
can be raised (the defaults are generous for typical use):
|
# macOS / Linux
|
||||||
|
tar -czf claude-history.tar.gz -C ~/.claude projects
|
||||||
|
|
||||||
|
# Windows (PowerShell, via built-in tar)
|
||||||
|
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
|
||||||
|
```
|
||||||
|
|
||||||
|
Transfer the resulting `claude-history.tar.gz` to the destination machine
|
||||||
|
however you like — AirDrop, `scp`, USB, cloud storage.
|
||||||
|
|
||||||
|
**On the destination machine**, in the dashboard:
|
||||||
|
|
||||||
|
1. Open **Settings → Import History**.
|
||||||
|
2. Pick **Upload files** (the third tab).
|
||||||
|
3. Drag the archive onto the drop zone.
|
||||||
|
4. Click **Upload & Import** and watch the progress.
|
||||||
|
5. When the green result card appears, open **Analytics → Cost** to confirm
|
||||||
|
per-model token totals and estimated cost.
|
||||||
|
|
||||||
|
### Supported inputs
|
||||||
|
|
||||||
|
Any of the following can be dropped onto the upload zone or found inside a
|
||||||
|
folder given to **Scan a folder**:
|
||||||
|
|
||||||
|
- `.jsonl` — session transcripts
|
||||||
|
- `.meta.json` — subagent metadata sidecars
|
||||||
|
- `.zip` — extracted with path-traversal protection
|
||||||
|
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
|
||||||
|
- `.gz` — single gzipped JSONL (streaming-decompressed)
|
||||||
|
|
||||||
|
### Accuracy guarantees
|
||||||
|
|
||||||
|
- **Idempotent** — re-importing never double-counts. Sessions are
|
||||||
|
deduplicated by UUID.
|
||||||
|
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
|
||||||
|
to preserve pre-compaction token totals, so re-ingesting a compacted
|
||||||
|
transcript never erases historical cost.
|
||||||
|
- **Same parser as live** — `parseSessionFile` + `importSession` is the
|
||||||
|
single source of truth for both hook-driven ingestion and manual
|
||||||
|
import, so imported numbers match captured numbers exactly.
|
||||||
|
|
||||||
|
### Safety
|
||||||
|
|
||||||
|
Archive extraction is hardened against path traversal and archive bombs.
|
||||||
|
The defaults are generous for real-world transcripts but tight enough to
|
||||||
|
stop obvious attacks. If you regularly import very large archives, raise them
|
||||||
|
before `npm run dev` or `npm start` (also listed under
|
||||||
|
[Environment variables](#environment-variables)):
|
||||||
|
|
||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
| --------------------------------- | ------- | ----------------------------------------------------------- |
|
| ----------------------------------- | ------- | ---------------------------------------------------------- |
|
||||||
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
||||||
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
|
| `AGENTHUB_IMPORT_MAX_FILES` | 2000 | Maximum files per upload request |
|
||||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
|
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | 4 GB | Ceiling on uncompressed bytes per archive (zip-bomb guard) |
|
||||||
|
|
||||||
Set them before `npm run dev` or `npm start`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
|
AGENTHUB_IMPORT_MAX_EXTRACT_BYTES=17179869184 npm start # allow 16 GB extraction
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI alternative
|
||||||
|
|
||||||
|
For scripts and automation, the same logic runs from the terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Import (or re-import) everything under ~/.claude/projects
|
||||||
|
npm run import-history
|
||||||
|
|
||||||
|
# Dry run — show what would be imported without writing
|
||||||
|
node scripts/import-history.js --dry-run
|
||||||
|
|
||||||
|
# Scope to a single project dir
|
||||||
|
node scripts/import-history.js --project my-project
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Production mode
|
## Production mode
|
||||||
@@ -328,44 +380,260 @@ and, for plugin installs, [`docs/PLUGINS.md` → Auto-start on boot](docs/PLUGIN
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Optional: Agent extension packs
|
## How it works
|
||||||
|
|
||||||
This repository includes extension packs for both Claude Code and Codex.
|
AgentHub integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
|
||||||
|
|
||||||
- Claude Code loads project extensions from:
|
```
|
||||||
|
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
||||||
|
↓
|
||||||
|
Browser ← WebSocket broadcast ← Express server ← SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Hook auto-installation
|
||||||
|
|
||||||
|
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hooks": {
|
||||||
|
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
|
||||||
|
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
|
||||||
|
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
|
||||||
|
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
|
||||||
|
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
|
||||||
|
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
|
||||||
|
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
|
||||||
|
|
||||||
|
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
|
||||||
|
|
||||||
|
To re-run hook installation manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run install-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
|
||||||
|
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
||||||
|
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
||||||
|
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
||||||
|
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
||||||
|
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
||||||
|
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
||||||
|
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
||||||
|
| `DASHBOARD_DATA_DIR` | `~/.claude/agenthub` | AgentHub's data dir; a plugin install keeps its runtime (deps, UI build, logs) in `runtime/` under it |
|
||||||
|
|
||||||
|
Example with a custom port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DASHBOARD_PORT=9000 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
|
||||||
|
>
|
||||||
|
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
||||||
|
>
|
||||||
|
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
||||||
|
>
|
||||||
|
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
|
||||||
|
|
||||||
|
### Agent extension setup (Claude Code + Codex)
|
||||||
|
|
||||||
|
This repository ships extension files for both agent ecosystems:
|
||||||
|
|
||||||
|
- Claude Code:
|
||||||
- `CLAUDE.md`
|
- `CLAUDE.md`
|
||||||
- `.claude/rules/`
|
- `.claude/rules/*`
|
||||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
- `skills/*` (the `agenthub` plugin's own skills)
|
||||||
- Codex project packs live under `.codex/`:
|
- Codex:
|
||||||
- `AGENTS.md`
|
- `AGENTS.md`
|
||||||
- `.codex/rules/`
|
- `.codex/config.toml`
|
||||||
- `.codex/agents/`
|
- `.codex/rules/default.rules`
|
||||||
- `.codex/skills/`
|
- `.codex/agents/*`
|
||||||
|
- `.codex/skills/*`
|
||||||
|
|
||||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
||||||
|
|
||||||
|
### PWA configuration (optional)
|
||||||
|
|
||||||
|
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
||||||
|
|
||||||
|
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
||||||
|
|
||||||
|
- `name` / `short_name` — displayed on the home screen / dock
|
||||||
|
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
||||||
|
- `background_color` — splash screen background
|
||||||
|
- `start_url` — entry point when launched from home screen
|
||||||
|
|
||||||
|
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
||||||
|
|
||||||
|
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
||||||
|
|
||||||
|
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
The SQLite database is created automatically on first run (`data/dashboard.db` in a checkout, unless `DASHBOARD_DB_PATH` says otherwise). The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
|
||||||
|
|
||||||
|
### Clear all data
|
||||||
|
|
||||||
|
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run clear-data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data management via Settings page
|
||||||
|
|
||||||
|
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
||||||
|
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
||||||
|
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
||||||
|
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
||||||
|
hooks in their own tabs.
|
||||||
|
|
||||||
|
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
||||||
|
- **Hook Configuration** — check which hooks are installed and reinstall them
|
||||||
|
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
|
||||||
|
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
|
||||||
|
- **Clear All Data** — remove all sessions, agents, events, and token usage
|
||||||
|
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
|
||||||
|
|
||||||
|
### Seed demo data
|
||||||
|
|
||||||
|
To populate the dashboard with sample sessions, agents, and events for UI exploration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run seed
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scripts reference
|
||||||
|
|
||||||
|
| Script | Command | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `setup` | `npm run setup` | Install all dependencies (server + client) |
|
||||||
|
| `dev` | `npm run dev` | Start server + client in development mode |
|
||||||
|
| `start` | `npm start` | Start server in production mode |
|
||||||
|
| `build` | `npm run build` | Build the React client to `client/dist/` |
|
||||||
|
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
|
||||||
|
| `clear-data` | `npm run clear-data` | Delete all data from the database |
|
||||||
|
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
|
||||||
|
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
|
||||||
|
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
|
||||||
|
| `test` | `npm test` | Run all server and client tests |
|
||||||
|
| `test:server` | `npm run test:server` | Run server integration tests only |
|
||||||
|
| `test:client` | `npm run test:client` | Run client unit tests only |
|
||||||
|
| `format` | `npm run format` | Format all files with Prettier |
|
||||||
|
| `format:check` | `npm run format:check` | Check formatting without writing |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Makefile targets
|
||||||
|
|
||||||
|
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
Commonly used targets:
|
||||||
|
|
||||||
|
| Make target | Equivalent npm command | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
|
||||||
|
| `make dev` | `npm run dev` | Start server + client in watch mode |
|
||||||
|
| `make build` | `npm run build` | Build the React client for production |
|
||||||
|
| `make start` | `npm start` | Start the production server |
|
||||||
|
| `make prod` | `npm run build && npm start` | Build then start in one step |
|
||||||
|
| `make test` | `npm test` | Run all tests (server + client) |
|
||||||
|
| `make test-server` | `npm run test:server` | Run server tests only |
|
||||||
|
| `make test-client` | `npm run test:client` | Run client tests only |
|
||||||
|
| `make format` | `npm run format` | Format all files with Prettier |
|
||||||
|
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
||||||
|
| `make seed` | `npm run seed` | Load demo data |
|
||||||
|
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### `npm run setup` shows `better-sqlite3` errors
|
### Plugin bootstrap never comes up
|
||||||
|
|
||||||
This is expected and **non-fatal**. `better-sqlite3` is a native C++ module listed as an optional dependency. If prebuilt binaries are not available for your Node version or platform, npm will print gyp/compilation errors but still complete successfully.
|
The plugin's `SessionStart` hook only checks a fast path and hands the real
|
||||||
|
work (npm install, UI build, server start) to a detached background worker, so
|
||||||
|
a failure there does not show in the session. When the worker's last run
|
||||||
|
failed, the next session start prints a one-time **AgentHub:** message naming
|
||||||
|
the log to read. Everything is logged under `~/.claude/agenthub/runtime/`:
|
||||||
|
|
||||||
At runtime the server uses this fallback chain:
|
| File | What it holds |
|
||||||
|
|---|---|
|
||||||
|
| `bootstrap.log` | Each bootstrap step and why it stopped (Node too old, install failed, superseded build, …) |
|
||||||
|
| `npm-install.log` | npm's output for the runtime dependency install |
|
||||||
|
| `client-build.log` | The dashboard UI build (the API still works when only this fails) |
|
||||||
|
| `server.log` | The server's own output, when it was started detached |
|
||||||
|
| `last-failure.json` | The last failure the session-start message reports (removed once a bootstrap succeeds) |
|
||||||
|
|
||||||
1. **`better-sqlite3`** — used when prebuilt binaries are available (Node 20/22/24 on Windows x64, macOS arm64/x64, Linux x64/arm64)
|
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
|
||||||
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
|
dependencies, the server and the service unit, and names the fix for each
|
||||||
|
failing row. `/agenthub-update` re-runs the bootstrap in the foreground with its
|
||||||
|
output visible. The most common causes: Node older than 22.5 (`node -v` in the
|
||||||
|
shell Claude Code was started from), no network or a proxy blocking the npm
|
||||||
|
registry (`npm-install.log`), and a port held by another program (`server.log`,
|
||||||
|
see [Port 4820 already in use](#port-4820-already-in-use)).
|
||||||
|
|
||||||
If you see an error box at startup saying *"SQLite backend not available"*, either:
|
### `better-sqlite3` errors during `npm install` / `npm run setup`
|
||||||
|
|
||||||
- **Upgrade to Node.js 22+** (recommended — zero native dependencies needed), or
|
Only a **checkout** install sees these — the plugin installs with
|
||||||
- **Install build tools** so `better-sqlite3` can compile from source:
|
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
|
||||||
- **Windows:** `npm install -g windows-build-tools` or install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
`better-sqlite3` is an optional dependency, and when it cannot be built npm
|
||||||
|
skips it and the server uses Node's built-in `node:sqlite` (Node 22.5+). You do
|
||||||
|
**not** need Python, Visual Studio Build Tools or a C++ compiler.
|
||||||
|
|
||||||
|
To build it anyway (it is used when present), install a toolchain and run
|
||||||
|
`npm rebuild better-sqlite3`:
|
||||||
|
|
||||||
|
- **Windows:** [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
||||||
- **macOS:** `xcode-select --install`
|
- **macOS:** `xcode-select --install`
|
||||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
||||||
|
|
||||||
Then run: `npm rebuild better-sqlite3`
|
### "SQLite backend not available" error on startup
|
||||||
|
|
||||||
|
Neither `better-sqlite3` nor `node:sqlite` could be loaded — almost always a
|
||||||
|
Node older than 22.5. Upgrade Node.
|
||||||
|
|
||||||
|
### Database is locked / busy errors
|
||||||
|
|
||||||
|
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
|
||||||
|
|
||||||
|
- Ensure only one dashboard server instance is running
|
||||||
|
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
|
||||||
|
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
|
||||||
|
|
||||||
### `npm run dev` fails immediately
|
### `npm run dev` fails immediately
|
||||||
|
|
||||||
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
|
|||||||
npm run setup
|
npm run setup
|
||||||
```
|
```
|
||||||
|
|
||||||
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both root and `client/`, then re-run setup:
|
If the error mentions a missing module like `express` or `react`, dependencies may be incomplete. Delete `node_modules` in both the root and `client/`, then re-run setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf node_modules client/node_modules
|
rm -rf node_modules client/node_modules
|
||||||
@@ -409,9 +677,66 @@ that case.
|
|||||||
|
|
||||||
### No sessions appearing after starting Claude Code
|
### No sessions appearing after starting Claude Code
|
||||||
|
|
||||||
See [SETUP.md — Troubleshooting](./SETUP.md#troubleshooting) for detailed hook debugging steps.
|
**Check 1 — Is the server running?**
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
curl http://localhost:4820/api/health
|
||||||
|
# Expected: {"status":"ok","timestamp":"..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check 2 — Are hooks installed?**
|
||||||
|
|
||||||
|
With the plugin, its hooks come with it — run `/agenthub-doctor` instead. For a
|
||||||
|
checkout, open `~/.claude/settings.json` and confirm it contains a `hooks`
|
||||||
|
section with entries referencing `hook-handler.js`. If not, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run install-hooks
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check 3 — Did you start a new Claude Code session after the server started?**
|
||||||
|
|
||||||
|
Hooks only apply to sessions started after installation. Restart Claude Code.
|
||||||
|
|
||||||
|
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
|
||||||
|
|
||||||
|
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
|
||||||
|
|
||||||
|
### Dashboard shows "Disconnected" in the sidebar
|
||||||
|
|
||||||
|
The WebSocket connection to the server failed. Ensure the server is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The client will automatically reconnect every 2 seconds once the server is available.
|
||||||
|
|
||||||
|
### Port 4820 already in use
|
||||||
|
|
||||||
|
When another program holds the port, the server stops with
|
||||||
|
`Port 4820 is already in use by another program…` (a running AgentHub there is
|
||||||
|
detected and simply reused). Pick another port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DASHBOARD_PORT=4821 npm start # or: DASHBOARD_PORT=4821 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev proxy and the Claude Code hooks follow the port on their own
|
||||||
|
(`client/vite.config.ts` reads `DASHBOARD_PORT`; the server publishes the port it bound).
|
||||||
|
|
||||||
|
And make sure Claude Code posts hooks to the new port:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLAUDE_DASHBOARD_PORT=4821 claude
|
||||||
|
# or edit scripts/hook-handler.js and change the default port
|
||||||
|
```
|
||||||
|
|
||||||
## Ports
|
## Ports
|
||||||
|
|
||||||
|
|||||||
@@ -1,422 +1,14 @@
|
|||||||
# Setup Guide
|
# Setup Guide
|
||||||
|
|
||||||
A comprehensive guide to setting up and configuring the Agent Dashboard, including how it integrates with Claude Code, environment variables, container deployment, and troubleshooting common issues.
|
This guide has moved into **[INSTALL.md](./INSTALL.md)** — the one install and
|
||||||
|
setup guide (it and this file had grown into near-duplicates). Everything that
|
||||||
## How it works
|
was here is there:
|
||||||
|
|
||||||
Agent Dashboard integrates with Claude Code through its native hook system. When Claude Code performs any action (session start, tool use, turn completion, subagent finish, session exit), it fires a hook that calls a small Node.js script bundled with this project. That script forwards the event over HTTP to the dashboard server, which stores it in SQLite and broadcasts it to the browser over WebSocket.
|
| Was in SETUP.md | Now in INSTALL.md |
|
||||||
|
|---|---|
|
||||||
```
|
| How it works | [How it works](./INSTALL.md#how-it-works) |
|
||||||
Claude Code → hook fires → hook-handler.js → POST /api/hooks/event
|
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
|
||||||
↓
|
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
|
||||||
Browser ← WebSocket broadcast ← Express server ← SQLite
|
| Importing existing Claude Code history | [Step 5](./INSTALL.md#step-5--optional-importing-existing-claude-code-history) |
|
||||||
```
|
| Scripts reference, Makefile targets | [Scripts reference](./INSTALL.md#scripts-reference) |
|
||||||
|
| Troubleshooting (incl. "Port 4820 already in use") | [Troubleshooting](./INSTALL.md#troubleshooting) |
|
||||||
No extra Claude Code configuration is required in the normal host-run path — when you start the dashboard with `npm run dev` or `npm start`, the server configures the hooks automatically on startup (or, with the `agenthub` plugin enabled in `~/.claude/settings.json`, removes the checkout's copies — the plugin's own hooks already run, and both would post every event twice). Container deployments are the exception: after the container is up, run `npm run install-hooks` on the host so Claude Code points at `http://localhost:4820`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Hook auto-installation
|
|
||||||
|
|
||||||
When the dashboard is running directly on the host, the server writes the following to `~/.claude/settings.json` every time it starts:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hooks": {
|
|
||||||
"SessionStart": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionStart" }] }],
|
|
||||||
"PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PreToolUse" }] }],
|
|
||||||
"PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" PostToolUse" }] }],
|
|
||||||
"Stop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Stop" }] }],
|
|
||||||
"SubagentStop": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SubagentStop" }] }],
|
|
||||||
"Notification": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" Notification" }] }],
|
|
||||||
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "node \"/path/to/scripts/hook-handler.js\" SessionEnd" }] }]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Note: `SessionStart` and `SessionEnd` hooks do not support the `matcher` field — they fire unconditionally on every session start and exit.
|
|
||||||
|
|
||||||
Existing hooks in that file are preserved. The dashboard only adds or updates entries that contain `hook-handler.js`.
|
|
||||||
|
|
||||||
To re-run hook installation manually:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run install-hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> Container note: if you run the dashboard inside a container (a Dev Container, Codespaces, or your own image), do not install hooks from in there — the hook path a container writes points at the container filesystem, not the host. Run `npm run install-hooks` on the host instead. As a safeguard (issue #193) the installer **detects container execution and refuses to run** (exiting non-zero) so it can never poison a bind-mounted host `~/.claude`. If Claude Code itself genuinely runs inside that container, override with `AGENTHUB_ALLOW_CONTAINER_HOOKS=1 npm run install-hooks`.
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> Prefer a ready-made dev environment? This repo ships an **optional** Dev Container (`.devcontainer/`) for VS Code / GitHub Codespaces — Node 22, native build tools for `better-sqlite3`, Python, and ports `4820`/`5173` preconfigured. It's purely opt-in and changes nothing for host-based development. See [`.devcontainer/README.md`](.devcontainer/README.md). (Hooks remain host-side there too.)
|
|
||||||
|
|
||||||
### Environment variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `DASHBOARD_PORT` | `4820` | Port the Express server listens on |
|
|
||||||
| `CLAUDE_DASHBOARD_PORT` | `4820` | Port the hook handler uses when posting events to the dashboard |
|
|
||||||
| `DASHBOARD_DB_PATH` | `data/dashboard.db` | Path to the SQLite database file |
|
|
||||||
| `NODE_ENV` | `development` | Set to `production` to serve built client |
|
|
||||||
| `DASHBOARD_CLIENT_DIST` | `client/dist` | Directory the built UI is served from in production. Set by the `agenthub` plugin bootstrap to its own runtime dir, because the plugin cache is replaced on every update |
|
|
||||||
| `AGENTHUB_IMPORT_MAX_BYTES` | `1073741824` (1 GB) | Maximum size per uploaded file on `/api/import/upload` |
|
|
||||||
| `AGENTHUB_IMPORT_MAX_FILES` | `2000` | Maximum number of files per upload request |
|
|
||||||
| `AGENTHUB_IMPORT_MAX_EXTRACT_BYTES` | `4294967296` (4 GB) | Maximum uncompressed bytes any single archive is allowed to expand to (zip-bomb defense) |
|
|
||||||
|
|
||||||
Example with a custom port:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DASHBOARD_PORT=9000 npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> You usually do **not** need to set `DASHBOARD_PORT` manually. `npm run dev` is wrapped by `scripts/dev.js`, which probes both `127.0.0.1` and `::1` (so an SSH `LocalForward` bound to one loopback can't slip past) and picks the first free port in `4820–4859` automatically. The chosen port is propagated to the Vite dev proxy via `DASHBOARD_PORT`, and the Express server writes it to `~/.claude/.agenthub.json` so the Claude Code hook handler discovers it without any env var.
|
|
||||||
>
|
|
||||||
> Multiple dashboards can run side by side — for example two `npm run dev` checkouts, or `npm run dev` alongside `npm start`. Each one appends its `{port, pid, startedAt}` entry to the discovery file, and `scripts/hook-handler.js` fan-outs every hook event to every live entry, so both UIs keep their real-time stream.
|
|
||||||
>
|
|
||||||
> Setting `CLAUDE_DASHBOARD_PORT=N` overrides discovery entirely and forces the hook handler to a single port — useful for tests and container setups where the in-process discovery file isn't reachable from the host.
|
|
||||||
>
|
|
||||||
> If you bypass the picker (e.g. `npm run dev:raw`, container builds, or anything else that calls `node server/index.js` directly), make sure your client is built / proxied against the port the server actually bound.
|
|
||||||
|
|
||||||
### Agent extension setup (Claude Code + Codex)
|
|
||||||
|
|
||||||
This repository ships extension files for both agent ecosystems:
|
|
||||||
|
|
||||||
- Claude Code:
|
|
||||||
- `CLAUDE.md`
|
|
||||||
- `.claude/rules/*`
|
|
||||||
- `skills/*` (the `agenthub` plugin's own skills)
|
|
||||||
- `agents/*` (the `agenthub` plugin's own agents)
|
|
||||||
- Codex:
|
|
||||||
- `AGENTS.md`
|
|
||||||
- `.codex/config.toml`
|
|
||||||
- `.codex/rules/default.rules`
|
|
||||||
- `.codex/agents/*`
|
|
||||||
- `.codex/skills/*`
|
|
||||||
|
|
||||||
See [`.codex/README.md`](./.codex/README.md) for Codex extension details.
|
|
||||||
|
|
||||||
### PWA configuration (optional)
|
|
||||||
|
|
||||||
The dashboard ships as a Progressive Web App. No configuration is required — its manifest and service worker are included out of the box.
|
|
||||||
|
|
||||||
**Customising the manifest:** Edit `client/public/manifest.json`. Common fields to change:
|
|
||||||
|
|
||||||
- `name` / `short_name` — displayed on the home screen / dock
|
|
||||||
- `theme_color` — address bar / title bar tint (default: `#6366f1`)
|
|
||||||
- `background_color` — splash screen background
|
|
||||||
- `start_url` — entry point when launched from home screen
|
|
||||||
|
|
||||||
**Updating the service worker cache:** The SW has a `CACHE_NAME` constant (e.g. `dashboard-v2`). After deploying new assets, bump the version string to force browsers to re-fetch — though for the dashboard this is rarely needed: hashed `/assets/*` URLs are immutable per build, everything else is fetched network-first with cache fallback, and a `controllerchange` listener in the client reloads the page exactly once when a new SW takes over, so a rebuild propagates without a hard refresh.
|
|
||||||
|
|
||||||
**Browser support:** PWA install prompts appear in Chrome 107+, Edge 107+, and Firefox 110+ (desktop and Android). Safari supports `apple-mobile-web-app-capable` for iOS home-screen mode but does not show an install banner.
|
|
||||||
|
|
||||||
**Verifying PWA status:** Open DevTools → Application → Manifest to confirm the manifest loads. Check the Service Workers section to verify the SW is registered and active. The Lighthouse PWA audit should pass all core checks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database
|
|
||||||
|
|
||||||
The SQLite database is created automatically at `data/dashboard.db` on first run. The directory is created if it does not exist. The database uses WAL mode for concurrent reads and foreign keys for referential integrity.
|
|
||||||
|
|
||||||
### Clear all data
|
|
||||||
|
|
||||||
To remove all sessions, agents, events, and token usage (useful after running seed data or for a clean start):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run clear-data
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data management via Settings page
|
|
||||||
|
|
||||||
The Settings page (`/settings`) provides a UI for the following. **Model Pricing**
|
|
||||||
and **Hook Configuration** are not on its Dashboard tab: they sit under the
|
|
||||||
Claude Code tab's **Overview** (Settings → Claude Code → Overview), beside
|
|
||||||
CLAUDE_HOME; Codex (its Overview) and Antigravity have their own pricing and
|
|
||||||
hooks in their own tabs.
|
|
||||||
|
|
||||||
- **Model Pricing** — view and edit per-model cost rates, reset to defaults, add custom models
|
|
||||||
- **Hook Configuration** — check which hooks are installed and reinstall them
|
|
||||||
- **Data Export** — download all sessions, agents, events, and pricing as a JSON file
|
|
||||||
- **Session Cleanup** — abandon stale active sessions after N hours, purge old completed sessions after N days
|
|
||||||
- **Clear All Data** — remove all sessions, agents, events, and token usage
|
|
||||||
- **Data Management** and **About** sections render with loading placeholders while server info is being fetched, so the page is always fully navigable
|
|
||||||
|
|
||||||
### Seed demo data
|
|
||||||
|
|
||||||
To populate the dashboard with sample sessions, agents, and events for UI exploration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run seed
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Importing existing Claude Code history
|
|
||||||
|
|
||||||
The dashboard automatically imports sessions from `~/.claude/projects/` on
|
|
||||||
**every startup**, so if Claude Code has been used on this machine, you'll
|
|
||||||
see history immediately after the first launch. If you need to bring in
|
|
||||||
history from another machine, from a backup, or just force a rescan, use
|
|
||||||
**Settings → Import History** in the UI — it's a guided, drag-and-drop
|
|
||||||
experience with live progress.
|
|
||||||
### Pick the right mode
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
Q["Where is the history?"] --> Q1{Is it on this machine<br/>under ~/.claude/projects?}
|
|
||||||
Q1 -->|yes, and I just want<br/>to re-scan| M1["Mode: Rescan default folder<br/>one click"]
|
|
||||||
Q1 -->|yes, but in another folder<br/>on this machine| M2["Mode: Scan a folder<br/>paste the absolute path"]
|
|
||||||
Q1 -->|no — it's on another machine<br/>or in an archive file| M3["Mode: Upload files<br/>drag-drop JSONL or archive"]
|
|
||||||
|
|
||||||
M3 --> PREP["Archive source:<br/>tar -czf claude-history.tar.gz<br/>-C ~/.claude projects"]
|
|
||||||
|
|
||||||
style M1 fill:#10b981,stroke:#34d399,color:#fff
|
|
||||||
style M2 fill:#f59e0b,stroke:#fbbf24,color:#000
|
|
||||||
style M3 fill:#a855f7,stroke:#c084fc,color:#fff
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step-by-step: moving history from one machine to another
|
|
||||||
|
|
||||||
**On the source machine**, bundle the projects folder:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# macOS / Linux
|
|
||||||
tar -czf claude-history.tar.gz -C ~/.claude projects
|
|
||||||
|
|
||||||
# Windows (PowerShell, via built-in tar)
|
|
||||||
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
|
|
||||||
```
|
|
||||||
|
|
||||||
Transfer the resulting `claude-history.tar.gz` to the destination machine
|
|
||||||
however you like — AirDrop, `scp`, USB, cloud storage.
|
|
||||||
|
|
||||||
**On the destination machine**, in the dashboard:
|
|
||||||
|
|
||||||
1. Open **Settings → Import History**.
|
|
||||||
2. Pick **Upload files** (the third tab).
|
|
||||||
3. Drag the archive onto the drop zone.
|
|
||||||
4. Click **Upload & Import** and watch the progress.
|
|
||||||
5. When the green result card appears, open **Analytics → Cost** to confirm
|
|
||||||
per-model token totals and estimated cost.
|
|
||||||
|
|
||||||
### Supported inputs
|
|
||||||
|
|
||||||
Any of the following can be dropped onto the upload zone or found inside a
|
|
||||||
folder given to **Scan a folder**:
|
|
||||||
|
|
||||||
- `.jsonl` — session transcripts
|
|
||||||
- `.meta.json` — subagent metadata sidecars
|
|
||||||
- `.zip` — extracted with path-traversal protection
|
|
||||||
- `.tar`, `.tar.gz`, `.tgz` — extracted via the `tar` package
|
|
||||||
- `.gz` — single gzipped JSONL (streaming-decompressed)
|
|
||||||
|
|
||||||
### Accuracy guarantees
|
|
||||||
|
|
||||||
- **Idempotent** — re-importing never double-counts. Sessions are
|
|
||||||
deduplicated by UUID.
|
|
||||||
- **Cost-preserving** — the `token_usage` table uses `baseline_*` columns
|
|
||||||
to preserve pre-compaction token totals, so re-ingesting a compacted
|
|
||||||
transcript never erases historical cost.
|
|
||||||
- **Same parser as live** — `parseSessionFile` + `importSession` is the
|
|
||||||
single source of truth for both hook-driven ingestion and manual
|
|
||||||
import, so imported numbers match captured numbers exactly.
|
|
||||||
|
|
||||||
### Safety
|
|
||||||
|
|
||||||
Archive extraction is hardened against path traversal and archive bombs.
|
|
||||||
The defaults are generous for real-world transcripts but tight enough to
|
|
||||||
stop obvious attacks; see the env vars table above for
|
|
||||||
`AGENTHUB_IMPORT_MAX_BYTES`, `AGENTHUB_IMPORT_MAX_FILES`, and
|
|
||||||
`AGENTHUB_IMPORT_MAX_EXTRACT_BYTES`.
|
|
||||||
|
|
||||||
### CLI alternative
|
|
||||||
|
|
||||||
For scripts and automation, the same logic runs from the terminal:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Import (or re-import) everything under ~/.claude/projects
|
|
||||||
npm run import-history
|
|
||||||
|
|
||||||
# Dry run — show what would be imported without writing
|
|
||||||
node scripts/import-history.js --dry-run
|
|
||||||
|
|
||||||
# Scope to a single project dir
|
|
||||||
node scripts/import-history.js --project my-project
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Scripts reference
|
|
||||||
|
|
||||||
| Script | Command | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `setup` | `npm run setup` | Install all dependencies (server + client) |
|
|
||||||
| `dev` | `npm run dev` | Start server + client in development mode |
|
|
||||||
| `start` | `npm start` | Start server in production mode |
|
|
||||||
| `build` | `npm run build` | Build the React client to `client/dist/` |
|
|
||||||
| `install-hooks` | `npm run install-hooks` | Write Claude Code hooks to `~/.claude/settings.json` |
|
|
||||||
| `clear-data` | `npm run clear-data` | Delete all data from the database |
|
|
||||||
| `seed` | `npm run seed` | Insert demo sessions/agents/events |
|
|
||||||
| `import-history` | `npm run import-history` | Import legacy sessions from `~/.claude/` (also runs on startup) |
|
|
||||||
| `claude` | Claude CLI | Uses `CLAUDE.md`, `.claude/rules`, and `.claude/skills` automatically |
|
|
||||||
| `test` | `npm test` | Run all server and client tests |
|
|
||||||
| `test:server` | `npm run test:server` | Run server integration tests only |
|
|
||||||
| `test:client` | `npm run test:client` | Run client unit tests only |
|
|
||||||
| `format` | `npm run format` | Format all files with Prettier |
|
|
||||||
| `format:check` | `npm run format:check` | Check formatting without writing |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Makefile targets
|
|
||||||
|
|
||||||
All npm scripts are mirrored as `make` targets for convenience. Run `make help` to list them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make help
|
|
||||||
```
|
|
||||||
|
|
||||||
Commonly used targets:
|
|
||||||
|
|
||||||
| Make target | Equivalent npm command | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `make setup` | `npm run setup` | Install all dependencies (root + client) |
|
|
||||||
| `make dev` | `npm run dev` | Start server + client in watch mode |
|
|
||||||
| `make build` | `npm run build` | Build the React client for production |
|
|
||||||
| `make start` | `npm start` | Start the production server |
|
|
||||||
| `make prod` | `npm run build && npm start` | Build then start in one step |
|
|
||||||
| `make test` | `npm test` | Run all tests (server + client) |
|
|
||||||
| `make test-server` | `npm run test:server` | Run server tests only |
|
|
||||||
| `make test-client` | `npm run test:client` | Run client tests only |
|
|
||||||
| `make format` | `npm run format` | Format all files with Prettier |
|
|
||||||
| `make format-check` | `npm run format:check` | Check formatting without writing |
|
|
||||||
| `make seed` | `npm run seed` | Load demo data |
|
|
||||||
| `make clear-data` | `npm run clear-data` | Delete all data rows |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### `better-sqlite3` errors during `npm install` / `npm run setup`
|
|
||||||
|
|
||||||
These warnings are **harmless**. `better-sqlite3` is an optional dependency — if it cannot compile, npm skips it and the server falls back to Node.js built-in `node:sqlite` (available on Node 22+).
|
|
||||||
|
|
||||||
You do **not** need Python, Visual Studio Build Tools, or any C++ compiler to run this project on Node 22+.
|
|
||||||
|
|
||||||
If you are on Node 20 or 21 and `better-sqlite3` prebuilds are not available for your platform (there is no `node:sqlite` fallback below Node 22), you have two options:
|
|
||||||
|
|
||||||
1. **Upgrade to Node.js 22+** — the built-in `node:sqlite` fallback requires no native compilation at all
|
|
||||||
2. **Install build tools** and run `npm rebuild better-sqlite3`:
|
|
||||||
- **Windows:** install [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) with the C++ workload
|
|
||||||
- **macOS:** `xcode-select --install`
|
|
||||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu)
|
|
||||||
|
|
||||||
### "SQLite backend not available" error on startup
|
|
||||||
|
|
||||||
This means neither `better-sqlite3` nor `node:sqlite` could be loaded. The most common cause is running Node.js < 22 without `better-sqlite3` prebuilds. Upgrade to Node.js 22+ to resolve this.
|
|
||||||
|
|
||||||
### Database is locked / busy errors
|
|
||||||
|
|
||||||
The SQLite database uses WAL mode with a 5-second busy timeout. If you see lock errors:
|
|
||||||
|
|
||||||
- Ensure only one dashboard server instance is running
|
|
||||||
- Check for zombie `node server/index.js` processes: `ps aux | grep server/index`
|
|
||||||
- Delete `data/dashboard.db-wal` and `data/dashboard.db-shm` if the server was killed uncleanly, then restart
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### The run console won't start a run / the terminal never attaches
|
|
||||||
|
|
||||||
Runs are tmux-backed: the server executes the `tmux` binary and has no fallback.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -V # missing → sudo apt install tmux
|
|
||||||
curl -s http://localhost:4820/api/run/tmux # {"available":true}
|
|
||||||
curl -s "http://localhost:4820/api/run/binary?tool=claude" # {"found":true,"path":"…"}
|
|
||||||
```
|
|
||||||
|
|
||||||
`{"available":false}` while your own shell finds tmux means the **server's**
|
|
||||||
`PATH` differs from yours — typically a systemd `--user` service, which never
|
|
||||||
reads your shell profile. Install tmux system-wide (`/usr/bin/tmux`) and
|
|
||||||
re-run `agenthub service uninstall && agenthub service install`. The same
|
|
||||||
applies to `claude` / `codex`: a run launches them by name.
|
|
||||||
|
|
||||||
Install instructions per OS: [INSTALL.md → Step 0](./INSTALL.md#step-0--system-packages).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### No sessions appearing after starting Claude Code
|
|
||||||
|
|
||||||
**Check 1 — Is the server running?**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:4820/api/health
|
|
||||||
# Expected: {"status":"ok","timestamp":"..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check 2 — Are hooks installed?**
|
|
||||||
|
|
||||||
Open `~/.claude/settings.json` and confirm it contains a `hooks` section with entries referencing `hook-handler.js`. If not, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run install-hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
**Check 3 — Did you start a new Claude Code session after the server started?**
|
|
||||||
|
|
||||||
Hooks only apply to sessions started after installation. Restart Claude Code.
|
|
||||||
|
|
||||||
**Check 4 — Is Node.js in PATH when Claude Code runs hooks?**
|
|
||||||
|
|
||||||
On some systems, the shell environment when Claude Code fires hooks may not include the full PATH. Test with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
node --version
|
|
||||||
```
|
|
||||||
|
|
||||||
If Node.js is not found, use the full path to `node` in the hook command. Edit `scripts/install-hooks.js`, replace `node` with the absolute path (e.g. `/usr/local/bin/node`), and re-run `npm run install-hooks`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Dashboard shows "Disconnected" in the sidebar
|
|
||||||
|
|
||||||
The WebSocket connection to the server failed. Ensure the server is running:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The client will automatically reconnect every 2 seconds once the server is available.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Events Today shows 0 despite recent activity
|
|
||||||
|
|
||||||
This was a known timezone bug (fixed in current version). If you are still seeing this, ensure you are running the latest code and restart the server.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Port 4820 already in use
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DASHBOARD_PORT=4821 npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Then update the Vite proxy in `client/vite.config.ts`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:4821",
|
|
||||||
"/ws": { target: "ws://localhost:4821", ws: true }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
And make sure Claude Code posts hooks to the new port:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CLAUDE_DASHBOARD_PORT=4821 claude
|
|
||||||
# or edit scripts/hook-handler.js and change the default port
|
|
||||||
```
|
|
||||||
|
|||||||
+149
-14
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
|
|||||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||||
// else.
|
// else.
|
||||||
try {
|
try {
|
||||||
const { available } = await get("/api/run/tmux");
|
const { available, upgrade } = await get("/api/run/tmux");
|
||||||
console.log(
|
console.log(
|
||||||
available
|
!available
|
||||||
? `${c.green("✔")} tmux available to the server`
|
? `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
||||||
: `${c.yellow("!")} tmux not on the server's PATH — Start Run and \`agenthub workspace shell\` will fail. ` +
|
|
||||||
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
`Install it (sudo apt install tmux / brew install tmux), then restart the server`
|
||||||
|
: upgrade
|
||||||
|
? `${c.yellow("!")} tmux ${upgrade}`
|
||||||
|
: `${c.green("✔")} tmux available to the server`
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||||
@@ -2853,8 +2855,8 @@ const COMMAND_GROUPS = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
"accounts",
|
"accounts",
|
||||||
"list|add|remove [name] [--tool codex]",
|
"list|add|remove [name]|pin <account> <user>|unpin <user> [--tool codex]",
|
||||||
"Claude/Codex account pool: new sessions spread over these accounts",
|
"Claude/Codex account pool: new sessions spread over these accounts; pin = one user always on one",
|
||||||
],
|
],
|
||||||
["repl", "", "Open the interactive shell (also: shell, i)"],
|
["repl", "", "Open the interactive shell (also: shell, i)"],
|
||||||
],
|
],
|
||||||
@@ -3812,7 +3814,7 @@ const SUBCOMMANDS = {
|
|||||||
service: ["install", "uninstall", "status"],
|
service: ["install", "uninstall", "status"],
|
||||||
server: ["setup", "off", "status"],
|
server: ["setup", "off", "status"],
|
||||||
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
users: ["list", "add", "passwd", "role", "disable", "enable", "assign", "audit"],
|
||||||
accounts: ["list", "add", "remove"],
|
accounts: ["list", "add", "remove", "pin", "unpin"],
|
||||||
pricing: ["set", "delete", "reset"],
|
pricing: ["set", "delete", "reset"],
|
||||||
webhooks: ["test"],
|
webhooks: ["test"],
|
||||||
import: ["rescan", "path"],
|
import: ["rescan", "path"],
|
||||||
@@ -3932,21 +3934,73 @@ async function cmdServer(sub, flags) {
|
|||||||
} else {
|
} else {
|
||||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||||
}
|
}
|
||||||
|
const wasServer = auth.isServerMode();
|
||||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||||
envNote("AGENTHUB_MODE");
|
envNote("AGENTHUB_MODE");
|
||||||
console.log(`${c.green("✔")} server mode on (${file})`);
|
console.log(`${c.green("✔")} server mode on (${file})`);
|
||||||
|
// One Linux user per account (S3) is the default on Linux: install it now
|
||||||
|
// (one sudo), unless it is already in place or `--no-system` opts out.
|
||||||
|
const linux = require(path.join(REPO_ROOT, "server", "lib", "linux-accounts.js"));
|
||||||
|
const shared = flags["no-system"] === true || process.platform !== "linux";
|
||||||
|
if (!shared) {
|
||||||
|
if (linux.serverConf()) {
|
||||||
|
console.log(c.dim(" one Linux user per account is already installed"));
|
||||||
|
// The system unit serves; it reads the mode only when it starts.
|
||||||
|
if (!wasServer) {
|
||||||
|
const r = spawnSync("sudo", [
|
||||||
|
"-n",
|
||||||
|
"systemctl",
|
||||||
|
"restart",
|
||||||
|
"--no-block",
|
||||||
|
"agenthub.service",
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
r.status === 0
|
||||||
|
? `${c.green("✔")} agenthub.service restarted`
|
||||||
|
: c.yellow("! restart it: sudo systemctl restart agenthub")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(" Installing one Linux user per account (asks for your sudo password)…");
|
||||||
|
try {
|
||||||
|
await installSystem({}, settingsEnv);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`${e.message}\n Server mode is on, but every pane still runs as this account.` +
|
||||||
|
"\n Fix it and run `agenthub server install-system`, or keep one shared" +
|
||||||
|
" account with `agenthub server setup --no-system`."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The admin made above (and anyone made before) gets their Linux account.
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
await cmdUsers("provision", [], { all: true });
|
||||||
|
}
|
||||||
console.log(" Next:");
|
console.log(" Next:");
|
||||||
console.log(
|
console.log(
|
||||||
" 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
shared
|
||||||
|
? " 1. Restart the dashboard: systemctl --user restart agenthub (or agenthub start)"
|
||||||
|
: " 1. Log out and back in once, so your shell joins the agenthub groups"
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
" 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."
|
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||||
);
|
);
|
||||||
|
if (shared) {
|
||||||
console.log(
|
console.log(
|
||||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
c.yellow(
|
||||||
|
process.platform === "linux"
|
||||||
|
? "! --no-system: every pane runs as this one Linux account"
|
||||||
|
: "! one Linux user per account needs Linux: every pane runs as this one account"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
console.log(
|
||||||
|
c.dim(" Members see only their own workspaces, but an agent in a pane can read")
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
c.dim(" every user's files — use it with people you trust (docs/SERVER-MODE.md).")
|
||||||
|
);
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
c.dim(" Workspaces made before now have no owner: agenthub users assign <id> <user>")
|
||||||
);
|
);
|
||||||
@@ -3987,7 +4041,7 @@ async function cmdServer(sub, flags) {
|
|||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
c.red(
|
c.red(
|
||||||
"✖ Usage: agenthub server setup [--hostname name] [--admin name] | install-system [--dry-run|--uninstall] | tools | off | status"
|
"✖ Usage: agenthub server setup [--hostname name] [--admin name] [--no-system] | install-system [--dry-run|--uninstall] | tools | off | status"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@@ -4232,7 +4286,18 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
}
|
}
|
||||||
pool.resetQuota();
|
pool.resetQuota();
|
||||||
table(
|
table(
|
||||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
[
|
||||||
|
"codex account",
|
||||||
|
"kind",
|
||||||
|
"live",
|
||||||
|
"$ 5h",
|
||||||
|
"$ 7d",
|
||||||
|
"tok 5h",
|
||||||
|
"tok 7d",
|
||||||
|
"quota",
|
||||||
|
"state",
|
||||||
|
"pinned",
|
||||||
|
],
|
||||||
pool
|
pool
|
||||||
.poolStatus()
|
.poolStatus()
|
||||||
.map((a) => [
|
.map((a) => [
|
||||||
@@ -4247,10 +4312,64 @@ async function cmdCodexAccounts(sub, name, flags) {
|
|||||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||||
: "–",
|
: "–",
|
||||||
a.full ? "full" : "ok",
|
a.full ? "full" : "ok",
|
||||||
|
pinnedUsers("codex", a.name),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `agenthub accounts pin <account> <user>` / `unpin <user>` [--tool codex] —
|
||||||
|
* that user's new sessions run on that account (server/lib/account-pins.js).
|
||||||
|
*/
|
||||||
|
async function cmdAccountPin(sub, positional, tool) {
|
||||||
|
const pins = require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"));
|
||||||
|
const [first, second] = positional;
|
||||||
|
const username = sub === "pin" ? second : first;
|
||||||
|
if (!username || (sub === "pin" && !first)) {
|
||||||
|
throw new Error("usage: agenthub accounts pin <account> <user> | unpin <user> [--tool codex]");
|
||||||
|
}
|
||||||
|
const user = require(path.join(REPO_ROOT, "server", "db.js"))
|
||||||
|
.db.prepare("SELECT id FROM users WHERE username = ?")
|
||||||
|
.get(username);
|
||||||
|
if (!user) throw new Error(`no user ${username}`);
|
||||||
|
const pin = pins.setPin(user.id, tool, sub === "pin" ? first : null);
|
||||||
|
if (!require(path.join(REPO_ROOT, "server", "lib", "auth.js")).isServerMode()) {
|
||||||
|
console.log(c.dim(" Pins apply in server mode only."));
|
||||||
|
}
|
||||||
|
if (!pin.account) {
|
||||||
|
console.log(`${c.green("✔")} ${pin.username} unpinned — new ${tool} sessions use the balancer`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const switched = await pins.applyPin(user.id, tool);
|
||||||
|
console.log(`${c.green("✔")} ${pin.username}'s ${tool} sessions run on ${pin.account}`);
|
||||||
|
console.log(c.dim(` ${SWITCH_NOTE[switched] || SWITCH_NOTE["next-session"]}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What `applyPin` did, for the CLI. */
|
||||||
|
const SWITCH_NOTE = {
|
||||||
|
switched: "Switched now: running sessions take it at their next login check, new ones at once.",
|
||||||
|
kept: "Their home already holds it.",
|
||||||
|
"own-login":
|
||||||
|
"They signed in with their own login, which is left alone: no pool login is written over it.",
|
||||||
|
expired: "That login has expired: new sessions fall back to the balancer until it is re-added.",
|
||||||
|
"next-session": "New sessions use it; running ones keep their account until they end.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The `pinned` column: who an admin pinned to `account`. */
|
||||||
|
function pinnedUsers(tool, account) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
require(path.join(REPO_ROOT, "server", "lib", "account-pins.js"))
|
||||||
|
.listPins(tool)
|
||||||
|
.filter((p) => p.account === account)
|
||||||
|
.map((p) => p.username)
|
||||||
|
.join(",") || "–"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
/** `agenthub accounts …` — the Claude account pool (server/lib/claude-pool.js). */
|
||||||
async function cmdAccounts(sub, positional, flags = {}) {
|
async function cmdAccounts(sub, positional, flags = {}) {
|
||||||
serverAuth(); // the same settings + database the server reads
|
serverAuth(); // the same settings + database the server reads
|
||||||
@@ -4262,6 +4381,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
if (flags.tool != null && flags.tool !== "claude" && flags.tool !== "codex") {
|
||||||
throw new Error("--tool is claude (the default) or codex");
|
throw new Error("--tool is claude (the default) or codex");
|
||||||
}
|
}
|
||||||
|
if (sub === "pin" || sub === "unpin")
|
||||||
|
return cmdAccountPin(sub, positional, flags.tool || "claude");
|
||||||
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
if (flags.tool === "codex") return cmdCodexAccounts(sub, name, flags);
|
||||||
if (sub === "add") {
|
if (sub === "add") {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -4283,7 +4404,18 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
await pool.refreshQuotas({ force: true });
|
await pool.refreshQuotas({ force: true });
|
||||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||||
table(
|
table(
|
||||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
[
|
||||||
|
"account",
|
||||||
|
"live",
|
||||||
|
"$ 5h",
|
||||||
|
"$ 7d",
|
||||||
|
"tok 5h",
|
||||||
|
"tok 7d",
|
||||||
|
"quota 5h",
|
||||||
|
"quota wk",
|
||||||
|
"state",
|
||||||
|
"pinned",
|
||||||
|
],
|
||||||
pool
|
pool
|
||||||
.poolStatus()
|
.poolStatus()
|
||||||
.map((a) => [
|
.map((a) => [
|
||||||
@@ -4302,6 +4434,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
: a.quota?.error
|
: a.quota?.error
|
||||||
? `no quota (${a.quota.error})`
|
? `no quota (${a.quota.error})`
|
||||||
: "ok",
|
: "ok",
|
||||||
|
pinnedUsers("claude", a.name),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
const codex = require(path.join(REPO_ROOT, "server", "lib", "codex-pool.js"));
|
||||||
@@ -4309,7 +4442,9 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(
|
console.error(
|
||||||
c.red("✖ Usage: agenthub accounts list | add <name> | remove <name> [--tool codex]")
|
c.red(
|
||||||
|
"✖ Usage: agenthub accounts list | add <name> | remove <name> | pin <account> <user> | unpin <user> [--tool codex]"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -89,7 +89,9 @@ const SnapshotView = lazy(() =>
|
|||||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||||
);
|
);
|
||||||
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
|
||||||
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
|
const Administration = lazy(() =>
|
||||||
|
import("./pages/Administration").then((m) => ({ default: m.Administration }))
|
||||||
|
);
|
||||||
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
const Artifacts = lazy(() => import("./pages/Artifacts").then((m) => ({ default: m.Artifacts })));
|
||||||
|
|
||||||
function PageFallback() {
|
function PageFallback() {
|
||||||
@@ -237,13 +239,15 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="users"
|
path="admin"
|
||||||
element={
|
element={
|
||||||
<Page>
|
<Page>
|
||||||
<Users />
|
<Administration />
|
||||||
</Page>
|
</Page>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* The Users page became Administration's first tab. */}
|
||||||
|
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||||
<Route
|
<Route
|
||||||
path="settings/*"
|
path="settings/*"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -165,12 +165,15 @@ export function MenuItem({
|
|||||||
danger,
|
danger,
|
||||||
disabled,
|
disabled,
|
||||||
testId,
|
testId,
|
||||||
|
title,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
/** Hover text — e.g. why a disabled item cannot run. */
|
||||||
|
title?: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -179,6 +182,7 @@ export function MenuItem({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
data-testid={testId}
|
data-testid={testId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
className={`${MENU_ITEM}${danger ? " !text-status-danger" : ""}`}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
*
|
*
|
||||||
* ----------------------------------------------------------------------------- */
|
* ----------------------------------------------------------------------------- */
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
import { lazy, Suspense, useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||||
import { Outlet, useLocation } from "react-router-dom";
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
|
|||||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||||
import { SagiMount } from "./Sagi/SagiMount";
|
import { SagiMount } from "./Sagi/SagiMount";
|
||||||
import { StatusBar } from "./StatusBar";
|
import { StatusBar } from "./StatusBar";
|
||||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
import { useOnboarding } from "../lib/userPrefs";
|
||||||
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
import { OnboardingTour } from "./onboarding/OnboardingTour";
|
||||||
|
|
||||||
|
// Loaded only for an account that has not finished onboarding: the wizard
|
||||||
|
// carries the appearance, pet and notification settings screens.
|
||||||
|
const OnboardingWizard = lazy(() =>
|
||||||
|
import("./onboarding/OnboardingWizard").then((m) => ({ default: m.OnboardingWizard }))
|
||||||
|
);
|
||||||
|
|
||||||
const WIDTH_KEY = "agenthub.sidebar.width";
|
const WIDTH_KEY = "agenthub.sidebar.width";
|
||||||
/** The default width, and the narrowest the edge drags to. */
|
/** The default width, and the narrowest the edge drags to. */
|
||||||
export const SIDEBAR_MIN = 240;
|
export const SIDEBAR_MIN = 240;
|
||||||
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
const { t } = useTranslation("nav");
|
const { t } = useTranslation("nav");
|
||||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||||
|
const onboarding = useOnboarding();
|
||||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||||
const drawer = useMedia(DRAWER_QUERY);
|
const drawer = useMedia(DRAWER_QUERY);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
|
|||||||
<UpdateNotifier />
|
<UpdateNotifier />
|
||||||
<InterruptedSessionsPrompt />
|
<InterruptedSessionsPrompt />
|
||||||
<SagiMount />
|
<SagiMount />
|
||||||
|
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
{drawer && drawerOpen && (
|
{drawer && drawerOpen && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file PetChat.tsx
|
* @file PetChat.tsx
|
||||||
* @description The pet's chat window. A question its Ask box cannot answer
|
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||||
* locally opens here instead of leaving the page: the first message starts a
|
* it. Above the conversation sits what the pet knows (`info`: live / waiting /
|
||||||
|
* errored, the sessions that need you, Mute, Clear alerts — `SagiInfo.tsx`).
|
||||||
|
* Before a chat runs, a question the pet can answer itself (`onLocal`: status,
|
||||||
|
* who are you…) is answered right here; anything else — and every message once
|
||||||
|
* a chat runs — goes to the AI: the first message starts a
|
||||||
* session of the user's DEFAULT AI tool (`agenthub.defaultTool`, Claude or
|
* 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
|
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||||
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
* first free tab (`startInFreeTabDetailed`), and the conversation is shown as
|
||||||
@@ -11,16 +15,17 @@
|
|||||||
* attached pane, exactly like the workspace's own Chat view), so nothing new
|
* 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)
|
* 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
|
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
* reopening the window continues it; **New chat** STOPS that session (kills
|
||||||
|
* its pane and archives it, asking first only mid-reply), **Open in workspace**
|
||||||
* opens its pane there. Opening never resumes an ended session: once the pane
|
* opens its pane there. Opening never resumes an ended session: once the pane
|
||||||
* is gone, the next message starts a new one.
|
* is gone, the next message starts a new one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowLeft, ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
import { ArrowUp, ExternalLink, Loader2, SquarePen, Wrench, X } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import type { TranscriptMessage } from "../../lib/types";
|
import type { TranscriptMessage } from "../../lib/types";
|
||||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
@@ -29,10 +34,13 @@ import {
|
|||||||
chooseRunOption,
|
chooseRunOption,
|
||||||
interruptRun,
|
interruptRun,
|
||||||
readRunMenu,
|
readRunMenu,
|
||||||
|
stepRunMenu,
|
||||||
submitToRun,
|
submitToRun,
|
||||||
type PaneMenu,
|
type PaneMenu,
|
||||||
} from "../../lib/panePaste";
|
} from "../../lib/panePaste";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
import { TerminalView } from "../run/TerminalView";
|
import { TerminalView } from "../run/TerminalView";
|
||||||
import { PaneMenuCard } from "../run/PaneChat";
|
import { PaneMenuCard } from "../run/PaneChat";
|
||||||
@@ -141,14 +149,21 @@ function toolSummary(names: string[]): string {
|
|||||||
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
return [...counts].map(([n, c]) => (c > 1 ? `${n} ×${c}` : n)).join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the container made of a message: an answer the pet gives itself, or
|
||||||
|
* `handled` (it took the message elsewhere); null sends it to the AI. */
|
||||||
|
export type PetLocalReply = { answer: string } | { handled: true } | null;
|
||||||
|
|
||||||
interface PetChatProps {
|
interface PetChatProps {
|
||||||
/** The pet's shown name, for the header and greeting. */
|
/** The pet's shown name, for the header and greeting. */
|
||||||
name: string;
|
name: string;
|
||||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
/** Shown in place of the plain name (the rename button). */
|
||||||
initialPrompt?: string | null;
|
title?: ReactNode;
|
||||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||||
onPromptTaken?: () => void;
|
headerActions?: ReactNode;
|
||||||
onBack: () => void;
|
/** Under the header: what the pet knows (status, sessions that need you). */
|
||||||
|
info?: ReactNode;
|
||||||
|
/** Asked first while no chat runs. */
|
||||||
|
onLocal?: (text: string) => PetLocalReply;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Opens the chat's pane in its workspace. */
|
/** Opens the chat's pane in its workspace. */
|
||||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||||
@@ -158,9 +173,10 @@ interface PetChatProps {
|
|||||||
|
|
||||||
export function PetChat({
|
export function PetChat({
|
||||||
name,
|
name,
|
||||||
initialPrompt,
|
title,
|
||||||
onPromptTaken,
|
headerActions,
|
||||||
onBack,
|
info,
|
||||||
|
onLocal,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenWorkspace,
|
onOpenWorkspace,
|
||||||
onBusy,
|
onBusy,
|
||||||
@@ -175,6 +191,8 @@ export function PetChat({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
const [menu, setMenu] = useState<PaneMenu | null>(null);
|
||||||
|
// The pet's own answers, given before any chat ran.
|
||||||
|
const [local, setLocal] = useState<Line[]>([]);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const remember = (ref: PetChatRef | null) => {
|
const remember = (ref: PetChatRef | null) => {
|
||||||
@@ -248,7 +266,7 @@ export function PetChat({
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [chat]);
|
}, [chat]);
|
||||||
|
|
||||||
const lines = chatLines(messages);
|
const lines = [...local, ...chatLines(messages)];
|
||||||
// The pending line shows until the transcript has it.
|
// The pending line shows until the transcript has it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||||
@@ -269,6 +287,19 @@ export function PetChat({
|
|||||||
const prompt = text.trim();
|
const prompt = text.trim();
|
||||||
if (!prompt || starting) return;
|
if (!prompt || starting) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
if (!chat && onLocal) {
|
||||||
|
const reply = onLocal(prompt);
|
||||||
|
if (reply && "handled" in reply) return;
|
||||||
|
if (reply) {
|
||||||
|
const n = Date.now();
|
||||||
|
setLocal((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ key: `lu${n}`, role: "user", text: prompt },
|
||||||
|
{ key: `la${n}`, role: "assistant", text: reply.answer },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
setPending(prompt);
|
setPending(prompt);
|
||||||
if (chat) {
|
if (chat) {
|
||||||
// Typed into the attached pane; it may still be attaching.
|
// Typed into the attached pane; it may still be attaching.
|
||||||
@@ -288,7 +319,7 @@ export function PetChat({
|
|||||||
const r = await startInFreeTabDetailed(lane.id, {
|
const r = await startInFreeTabDetailed(lane.id, {
|
||||||
prompt,
|
prompt,
|
||||||
...(tool !== lane.tool ? { tool } : {}),
|
...(tool !== lane.tool ? { tool } : {}),
|
||||||
...(tool === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
...(tool === "claude" ? { permissionMode: readDefaultMode({ safe: true }) } : {}),
|
||||||
});
|
});
|
||||||
remember({ laneId: r.laneId, runId: r.runId, tool });
|
remember({ laneId: r.laneId, runId: r.runId, tool });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -302,17 +333,35 @@ export function PetChat({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[chat, starting, t]
|
[chat, starting, t, onLocal]
|
||||||
);
|
);
|
||||||
|
|
||||||
// The Ask box's question, sent once.
|
// New chat: the old one's session is stopped, not left running unseen —
|
||||||
const taken = useRef(false);
|
// asking first only while it is still writing a reply.
|
||||||
useEffect(() => {
|
const newChat = async () => {
|
||||||
if (!initialPrompt || taken.current) return;
|
if (chat) {
|
||||||
taken.current = true;
|
if (
|
||||||
onPromptTaken?.();
|
busy &&
|
||||||
void send(initialPrompt);
|
!(await confirmDialog({
|
||||||
}, [initialPrompt, onPromptTaken, send]);
|
title: t("sagi.chat.endTitle"),
|
||||||
|
message: t("sagi.chat.endBody"),
|
||||||
|
confirmLabel: t("sagi.chat.endConfirm"),
|
||||||
|
destructive: true,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
await api.run.kill(chat.runId, { archive: true });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remember(null);
|
||||||
|
setLocal([]);
|
||||||
|
setPending(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const submit = (e?: FormEvent) => {
|
const submit = (e?: FormEvent) => {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
@@ -333,11 +382,11 @@ export function PetChat({
|
|||||||
>
|
>
|
||||||
{/* header */}
|
{/* header */}
|
||||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||||
<ArrowLeft size={16} />
|
🐾
|
||||||
</IconButton>
|
</span>
|
||||||
<div className="min-w-0 flex-1 px-1">
|
<div className="min-w-0 flex-1 px-1">
|
||||||
<div className="truncate text-sm font-semibold text-fg-primary">{name}</div>
|
{title ?? <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">
|
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider text-fg-muted">
|
||||||
<span
|
<span
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
@@ -352,6 +401,7 @@ export function PetChat({
|
|||||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{headerActions}
|
||||||
{chat && (
|
{chat && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t("sagi.chat.openWorkspace")}
|
label={t("sagi.chat.openWorkspace")}
|
||||||
@@ -362,8 +412,8 @@ export function PetChat({
|
|||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
label={t("sagi.chat.new")}
|
label={t("sagi.chat.new")}
|
||||||
onClick={() => remember(null)}
|
onClick={() => void newChat()}
|
||||||
disabled={!chat || starting}
|
disabled={(!chat && !local.length) || starting}
|
||||||
>
|
>
|
||||||
<SquarePen size={15} />
|
<SquarePen size={15} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -372,6 +422,8 @@ export function PetChat({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{info}
|
||||||
|
|
||||||
{/* conversation */}
|
{/* conversation */}
|
||||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||||
{empty && (
|
{empty && (
|
||||||
@@ -454,6 +506,9 @@ export function PetChat({
|
|||||||
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
if (answerRunMenu(chat.runId, menu, text)) setMenu(null);
|
||||||
else setError(t("sagi.chat.notSent"));
|
else setError(t("sagi.chat.notSent"));
|
||||||
}}
|
}}
|
||||||
|
onStep={(dir) => {
|
||||||
|
if (!stepRunMenu(chat.runId, dir)) setError(t("sagi.chat.notSent"));
|
||||||
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (interruptRun(chat.runId)) setMenu(null);
|
if (interruptRun(chat.runId)) setMenu(null);
|
||||||
else setError(t("sagi.chat.notSent"));
|
else setError(t("sagi.chat.notSent"));
|
||||||
@@ -475,10 +530,9 @@ export function PetChat({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
// Esc leaves the chat for the panel, not the whole mascot.
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.nativeEvent.stopImmediatePropagation();
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
onBack();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,17 +5,20 @@
|
|||||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
* 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
|
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||||
* shares the single WebSocket.
|
* shares the single WebSocket.
|
||||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
* Clicking the pet opens its chat window (`PetChat`), which is its whole UI:
|
||||||
|
* the status strip (`SagiInfo`) sits on top, a question the pet can answer
|
||||||
|
* itself is answered there, anything else goes to the AI.
|
||||||
|
* Owns the open/closed chat state, the ⌘B / Esc shortcuts, reduced-motion
|
||||||
* detection, and route navigation. Reactive personality + status/Ask come
|
* detection, and route navigation. Reactive personality + status/Ask come
|
||||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||||
* bubble/panel render in a self-clamping flyout so
|
* bubble/panel render in a self-clamping flyout so
|
||||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||||
*
|
*
|
||||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
* chat cannot read back starts there and its session view opens
|
||||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
* (`/workspace?lane=<id>&run=<runId>`) — no new LLM backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||||
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
* in an editable field (a terminal's Ctrl+B is tmux's prefix).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
* ## Internal dependencies
|
* ## Internal dependencies
|
||||||
* - `./SagiAvatar`
|
* - `./SagiAvatar`
|
||||||
* - `./SpeechBubble`
|
* - `./SpeechBubble`
|
||||||
* - `./SagiPanel`
|
* - `./SagiInfo`
|
||||||
* - `./useSagiBrain`
|
* - `./useSagiBrain`
|
||||||
* - `./useSagiPosition`
|
* - `./useSagiPosition`
|
||||||
* - `./intents`
|
* - `./intents`
|
||||||
@@ -78,12 +81,13 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useSettingsHref } from "../../lib/settingsLink";
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
|
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SagiAvatar } from "./SagiAvatar";
|
import { SagiAvatar } from "./SagiAvatar";
|
||||||
import { PetSprite } from "./PetSprite";
|
import { PetSprite } from "./PetSprite";
|
||||||
import { PetMenu } from "./PetMenu";
|
import { PetMenu } from "./PetMenu";
|
||||||
import { SpeechBubble } from "./SpeechBubble";
|
import { SpeechBubble } from "./SpeechBubble";
|
||||||
import { SagiPanel } from "./SagiPanel";
|
import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||||
import { useSagiBrain } from "./useSagiBrain";
|
import { useSagiBrain } from "./useSagiBrain";
|
||||||
import { useSagiPosition } from "./useSagiPosition";
|
import { useSagiPosition } from "./useSagiPosition";
|
||||||
import { useSagiEnabled } from "./SagiMount";
|
import { useSagiEnabled } from "./SagiMount";
|
||||||
@@ -94,7 +98,8 @@ import { api } from "../../lib/api";
|
|||||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||||
import { workspaceHref } from "../SessionRowMenu";
|
import { workspaceHref } from "../SessionRowMenu";
|
||||||
import { PetChat, petChatTool } from "./PetChat";
|
import { Heart } from "lucide-react";
|
||||||
|
import { PetChat, petChatTool, type PetLocalReply } from "./PetChat";
|
||||||
|
|
||||||
// Stroking: this many left/right turns of the pointer over the pet, within
|
// 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.
|
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||||
@@ -126,23 +131,6 @@ interface Anchor {
|
|||||||
dragging: boolean;
|
dragging: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The OS "reduce motion" preference, followed live. */
|
|
||||||
export function usePrefersReducedMotion(): boolean {
|
|
||||||
const [reduced, setReduced] = useState(
|
|
||||||
() =>
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
||||||
if (!mq) return;
|
|
||||||
const onChange = () => setReduced(mq.matches);
|
|
||||||
mq.addEventListener?.("change", onChange);
|
|
||||||
return () => mq.removeEventListener?.("change", onChange);
|
|
||||||
}, []);
|
|
||||||
return reduced;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fixed-position wrapper that places its content next to the avatar and clamps
|
* Fixed-position wrapper that places its content next to the avatar and clamps
|
||||||
* it inside the viewport. It measures itself (and re-measures on content/size
|
* it inside the viewport. It measures itself (and re-measures on content/size
|
||||||
@@ -241,10 +229,8 @@ function SagiBody() {
|
|||||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||||
|
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||||
const [open, setOpen] = useState(false);
|
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`).
|
// The right-click pet menu (`PetMenu.tsx`).
|
||||||
const [menu, setMenu] = useState(false);
|
const [menu, setMenu] = useState(false);
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
@@ -296,7 +282,6 @@ function SagiBody() {
|
|||||||
setOpen((v) => !v);
|
setOpen((v) => !v);
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === "Escape") {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
@@ -346,7 +331,6 @@ function SagiBody() {
|
|||||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||||
brain.dismissBubble();
|
brain.dismissBubble();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
};
|
};
|
||||||
api.run
|
api.run
|
||||||
.list()
|
.list()
|
||||||
@@ -356,21 +340,17 @@ function SagiBody() {
|
|||||||
[brain, navigate]
|
[brain, navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAsk = useCallback(
|
// Asked by the chat before any session runs.
|
||||||
(query: string): string | null => {
|
const onLocal = useCallback(
|
||||||
|
(query: string): PetLocalReply => {
|
||||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||||
if (result.kind === "answer") {
|
if (result.kind === "answer") {
|
||||||
if (result.moment) brain.playMoment(result.moment);
|
if (result.moment) brain.playMoment(result.moment);
|
||||||
return result.text;
|
return { answer: 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 question the pet cannot answer: the chat starts a session of the
|
||||||
|
// default AI tool and shows it as chat.
|
||||||
|
if (petChatTool()) return null;
|
||||||
// A default tool the chat cannot read back (not Claude/Codex): start it
|
// A default tool the chat cannot read back (not Claude/Codex): start it
|
||||||
// in the "Sagi" workspace and open its pane there, as before.
|
// in the "Sagi" workspace and open its pane there, as before.
|
||||||
const prompt = result.prompt;
|
const prompt = result.prompt;
|
||||||
@@ -392,7 +372,7 @@ function SagiBody() {
|
|||||||
brain.setThinking(false);
|
brain.setThinking(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return null;
|
return { handled: true };
|
||||||
},
|
},
|
||||||
[brain, navigate, t, name]
|
[brain, navigate, t, name]
|
||||||
);
|
);
|
||||||
@@ -431,16 +411,34 @@ function SagiBody() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
|
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||||
does not - it is Sagi talking, and it has to stay by its mouth. */}
|
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||||
{!place.dragging && open && (
|
re-attaches the same session. */}
|
||||||
<SagiFlyout anchor={anchor}>
|
{open && (
|
||||||
<SagiPanel
|
<SagiFlyout anchor={anchor} beside>
|
||||||
|
<PetChat
|
||||||
|
name={name}
|
||||||
|
title={
|
||||||
|
<PetNameButton
|
||||||
name={name}
|
name={name}
|
||||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||||
nickname={sagiPrefs.getNickname(pet.id)}
|
nickname={sagiPrefs.getNickname(pet.id)}
|
||||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||||
onPat={brain.pat}
|
/>
|
||||||
|
}
|
||||||
|
headerActions={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full p-1.5 text-fg-muted transition-colors hover:bg-surface-4 hover:text-status-danger"
|
||||||
|
onClick={brain.pat}
|
||||||
|
aria-label={t("sagi.panel.pat", { name })}
|
||||||
|
title={t("sagi.panel.pat", { name })}
|
||||||
|
>
|
||||||
|
<Heart size={15} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
info={
|
||||||
|
<SagiInfo
|
||||||
attention={brain.attention}
|
attention={brain.attention}
|
||||||
onOpenSession={openSession}
|
onOpenSession={openSession}
|
||||||
status={brain.status}
|
status={brain.status}
|
||||||
@@ -448,29 +446,14 @@ function SagiBody() {
|
|||||||
onToggleMute={brain.toggleMute}
|
onToggleMute={brain.toggleMute}
|
||||||
onClearAlerts={brain.clearAlerts}
|
onClearAlerts={brain.clearAlerts}
|
||||||
onNavigate={onNavigate}
|
onNavigate={onNavigate}
|
||||||
onAsk={onAsk}
|
defaultOpen={brain.attention.length > 0}
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
/>
|
||||||
</SagiFlyout>
|
}
|
||||||
)}
|
onLocal={onLocal}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
{/* 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) => {
|
onOpenWorkspace={(laneId, runId) => {
|
||||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||||
setChatOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
onBusy={onBusy}
|
onBusy={onBusy}
|
||||||
/>
|
/>
|
||||||
@@ -491,7 +474,7 @@ function SagiBody() {
|
|||||||
</SagiFlyout>
|
</SagiFlyout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!open && !menu && !chatOpen && brain.bubble && (
|
{!open && !menu && brain.bubble && (
|
||||||
<SagiFlyout anchor={anchor}>
|
<SagiFlyout anchor={anchor}>
|
||||||
<SpeechBubble
|
<SpeechBubble
|
||||||
bubble={brain.bubble}
|
bubble={brain.bubble}
|
||||||
@@ -519,18 +502,16 @@ function SagiBody() {
|
|||||||
// A drag just ended - swallow the synthetic click so the panel
|
// A drag just ended - swallow the synthetic click so the panel
|
||||||
// doesn't toggle when the user only repositioned the avatar.
|
// doesn't toggle when the user only repositioned the avatar.
|
||||||
if (place.consumeDrag()) return;
|
if (place.consumeDrag()) return;
|
||||||
if (chatOpen) setChatOpen(false);
|
setOpen((v) => !v);
|
||||||
else setOpen((v) => !v);
|
|
||||||
}}
|
}}
|
||||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setChatOpen(false);
|
|
||||||
setMenu(true);
|
setMenu(true);
|
||||||
}}
|
}}
|
||||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||||
aria-expanded={open || chatOpen}
|
aria-expanded={open}
|
||||||
title={t("sagi.avatarTitle", { name })}
|
title={t("sagi.avatarTitle", { name })}
|
||||||
>
|
>
|
||||||
{pet.id === "sagi" || !("kind" in pet) ? (
|
{pet.id === "sagi" || !("kind" in pet) ? (
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/**
|
||||||
|
* @file SagiInfo.tsx
|
||||||
|
* @description What the pet knows, shown inside its chat window (`PetChat`):
|
||||||
|
* the name you rename by clicking it (`PetNameButton`), and a status strip
|
||||||
|
* (`SagiInfo`) — live / waiting / errored counts and the connection state
|
||||||
|
* always in sight, and on expanding, the sessions that need the user, by
|
||||||
|
* name, plus Mute and Clear alerts. Pure presentational: all data and
|
||||||
|
* behavior are injected by the container (`Sagi.tsx`).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Hourglass,
|
||||||
|
Radio,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** The pet's name; a click renames it in place (empty = its own name). */
|
||||||
|
export function PetNameButton({
|
||||||
|
name,
|
||||||
|
ownName,
|
||||||
|
nickname,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
ownName: string;
|
||||||
|
nickname: string;
|
||||||
|
onRename: (name: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
// The name being typed, or null while not renaming.
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const save = () => {
|
||||||
|
if (renaming === null) return;
|
||||||
|
onRename(renaming);
|
||||||
|
setRenaming(null);
|
||||||
|
};
|
||||||
|
return renaming === null ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-full 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={save}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
save();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
// Esc cancels the rename only, not the whole chat.
|
||||||
|
e.stopPropagation();
|
||||||
|
e.nativeEvent.stopImmediatePropagation();
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SagiInfoProps {
|
||||||
|
attention: ReadonlyArray<AttentionItem>;
|
||||||
|
onOpenSession: (s: SagiSubject) => void;
|
||||||
|
status: SagiStatus;
|
||||||
|
muted: boolean;
|
||||||
|
onToggleMute: () => void;
|
||||||
|
onClearAlerts: () => void;
|
||||||
|
onNavigate: (route: string) => void;
|
||||||
|
/** Expanded at first: the sessions that need the user and the actions. */
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The status strip under the chat's header. */
|
||||||
|
export function SagiInfo({
|
||||||
|
attention,
|
||||||
|
onOpenSession,
|
||||||
|
status,
|
||||||
|
muted,
|
||||||
|
onToggleMute,
|
||||||
|
onClearAlerts,
|
||||||
|
onNavigate,
|
||||||
|
defaultOpen = false,
|
||||||
|
}: SagiInfoProps) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const Chevron = open ? ChevronDown : ChevronRight;
|
||||||
|
return (
|
||||||
|
<div className="pet-chat-info border-b border-border/60 px-3 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-1.5 rounded-lg text-left"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={t("sagi.chat.status")}
|
||||||
|
title={t("sagi.chat.status")}
|
||||||
|
>
|
||||||
|
<StatChip
|
||||||
|
icon={Radio}
|
||||||
|
label={t("sagi.panel.statLive")}
|
||||||
|
value={status.liveCount}
|
||||||
|
tone={status.liveCount > 0 ? "accent" : "muted"}
|
||||||
|
/>
|
||||||
|
<StatChip
|
||||||
|
icon={Hourglass}
|
||||||
|
label={t("sagi.panel.statWaiting")}
|
||||||
|
value={status.waitingCount}
|
||||||
|
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
||||||
|
/>
|
||||||
|
<StatChip
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label={t("sagi.panel.statErrored")}
|
||||||
|
value={status.errorCount}
|
||||||
|
tone={status.errorCount > 0 ? "red" : "muted"}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`ml-auto inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
status.connected
|
||||||
|
? "bg-status-success/15 text-status-success"
|
||||||
|
: "bg-status-danger/15 text-status-danger"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||||
|
status.connected ? "bg-status-success" : "bg-status-danger"
|
||||||
|
}`}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
||||||
|
</span>
|
||||||
|
<Chevron size={13} className="shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||||
|
{/* the sessions that need the user, by name */}
|
||||||
|
{attention.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<ActionButton
|
||||||
|
icon={muted ? BellOff : Bell}
|
||||||
|
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
||||||
|
onClick={onToggleMute}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
icon={Trash2}
|
||||||
|
label={t("sagi.panel.clearAlerts")}
|
||||||
|
disabled={status.errorCount === 0}
|
||||||
|
onClick={onClearAlerts}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tone {
|
||||||
|
wrap: string;
|
||||||
|
value: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_MUTED: Tone = {
|
||||||
|
wrap: "border-border bg-surface-1",
|
||||||
|
value: "text-fg-secondary",
|
||||||
|
icon: "text-fg-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONES: Record<string, Tone> = {
|
||||||
|
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
||||||
|
amber: {
|
||||||
|
wrap: "border-status-warning/30 bg-status-warning/10",
|
||||||
|
value: "text-status-warning",
|
||||||
|
icon: "text-status-warning",
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
wrap: "border-status-danger/30 bg-status-danger/10",
|
||||||
|
value: "text-status-danger",
|
||||||
|
icon: "text-status-danger",
|
||||||
|
},
|
||||||
|
muted: TONE_MUTED,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One count, compact: icon, number, label on one line. */
|
||||||
|
function StatChip({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
tone: string;
|
||||||
|
}) {
|
||||||
|
const t = TONES[tone] ?? TONE_MUTED;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 ${t.wrap}`}>
|
||||||
|
<Icon size={11} className={t.icon} aria-hidden />
|
||||||
|
<span className={`text-xs font-semibold tabular-nums ${t.value}`}>{value}</span>
|
||||||
|
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<Icon size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,392 +0,0 @@
|
|||||||
/**
|
|
||||||
* @file SagiPanel.tsx
|
|
||||||
* @description Expanded Sagi panel: a live status strip (live / waiting /
|
|
||||||
* errored stat chips + connection state), quick navigation actions, and a
|
|
||||||
* local "Ask" box. Pure presentational - all data and the ask/navigation
|
|
||||||
* behavior are injected by the container.
|
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
||||||
*/
|
|
||||||
/* =============================================================================
|
|
||||||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
|
||||||
* =============================================================================
|
|
||||||
* **Purpose:** Sagi is the optional on-screen mascot assistant — quips, intents, and lightweight event reactions layered above the dashboard chrome.
|
|
||||||
*
|
|
||||||
* ## Design constraints
|
|
||||||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
|
||||||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
|
||||||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
|
||||||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
|
||||||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
|
||||||
*
|
|
||||||
* ## Remote data & SSH
|
|
||||||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
|
||||||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
|
||||||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
|
||||||
*
|
|
||||||
* ## Internal dependencies
|
|
||||||
* - `./brain`
|
|
||||||
*
|
|
||||||
* ## Public surface
|
|
||||||
* - `SagiPanel` — exported API; see TSDoc on the symbol for behavior.
|
|
||||||
*
|
|
||||||
* ## Testing pointers
|
|
||||||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
|
||||||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
|
||||||
*
|
|
||||||
* ## Related docs
|
|
||||||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
|
||||||
* - `docs/API.md` — REST reference.
|
|
||||||
* - `skills/file-headers/` — mandatory `@author` header policy.
|
|
||||||
* ============================================================================= */
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
|
||||||
* -----------------------------------------------------------------------------
|
|
||||||
* **SagiPanel**
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* ----------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
Bell,
|
|
||||||
BellOff,
|
|
||||||
Trash2,
|
|
||||||
X,
|
|
||||||
Send,
|
|
||||||
AlertTriangle,
|
|
||||||
Hourglass,
|
|
||||||
Radio,
|
|
||||||
Heart,
|
|
||||||
ChevronRight,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
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;
|
|
||||||
onClearAlerts: () => void;
|
|
||||||
onNavigate: (route: string) => void;
|
|
||||||
/** Returns an answer to display, or null when the query was handed off. */
|
|
||||||
onAsk: (query: string) => string | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SagiPanel({
|
|
||||||
name,
|
|
||||||
ownName,
|
|
||||||
nickname,
|
|
||||||
onRename,
|
|
||||||
onPat,
|
|
||||||
attention,
|
|
||||||
onOpenSession,
|
|
||||||
status,
|
|
||||||
muted,
|
|
||||||
onToggleMute,
|
|
||||||
onClearAlerts,
|
|
||||||
onNavigate,
|
|
||||||
onAsk,
|
|
||||||
onClose,
|
|
||||||
}: SagiPanelProps) {
|
|
||||||
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();
|
|
||||||
const result = onAsk(query);
|
|
||||||
setAnswer(result); // null means it handed off (container navigates/closes)
|
|
||||||
setQuery("");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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", { 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">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<span className="text-base leading-none" aria-hidden>
|
|
||||||
🐾
|
|
||||||
</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
|
|
||||||
? "bg-status-success/15 text-status-success"
|
|
||||||
: "bg-status-danger/15 text-status-danger"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
|
||||||
status.connected ? "bg-status-success" : "bg-status-danger"
|
|
||||||
}`}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
{status.connected ? t("sagi.panel.live") : t("sagi.panel.offline")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<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">
|
|
||||||
{/* status stat chips */}
|
|
||||||
<div className="mb-3 grid grid-cols-3 gap-1.5">
|
|
||||||
<StatChip
|
|
||||||
icon={Radio}
|
|
||||||
label={t("sagi.panel.statLive")}
|
|
||||||
value={status.liveCount}
|
|
||||||
tone={status.liveCount > 0 ? "accent" : "muted"}
|
|
||||||
/>
|
|
||||||
<StatChip
|
|
||||||
icon={Hourglass}
|
|
||||||
label={t("sagi.panel.statWaiting")}
|
|
||||||
value={status.waitingCount}
|
|
||||||
tone={status.waitingCount > 0 ? "amber" : "muted"}
|
|
||||||
/>
|
|
||||||
<StatChip
|
|
||||||
icon={AlertTriangle}
|
|
||||||
label={t("sagi.panel.statErrored")}
|
|
||||||
value={status.errorCount}
|
|
||||||
tone={status.errorCount > 0 ? "red" : "muted"}
|
|
||||||
/>
|
|
||||||
</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={muted ? BellOff : Bell}
|
|
||||||
label={muted ? t("sagi.panel.unmute") : t("sagi.panel.mute")}
|
|
||||||
onClick={onToggleMute}
|
|
||||||
/>
|
|
||||||
<ActionButton
|
|
||||||
icon={Trash2}
|
|
||||||
label={t("sagi.panel.clearAlerts")}
|
|
||||||
disabled={status.errorCount === 0}
|
|
||||||
onClick={onClearAlerts}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ask */}
|
|
||||||
<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", { name })}
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
aria-label={t("sagi.panel.ask", { name })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary justify-center px-2.5 py-2"
|
|
||||||
aria-label={t("sagi.panel.send")}
|
|
||||||
>
|
|
||||||
<Send size={14} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{answer && (
|
|
||||||
<p className="mt-2 rounded-lg bg-surface-1/70 px-2.5 py-2 text-xs leading-relaxed text-fg-secondary">
|
|
||||||
{answer}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Tone {
|
|
||||||
wrap: string;
|
|
||||||
value: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TONE_MUTED: Tone = {
|
|
||||||
wrap: "border-border bg-surface-1",
|
|
||||||
value: "text-fg-secondary",
|
|
||||||
icon: "text-fg-muted",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TONES: Record<string, Tone> = {
|
|
||||||
accent: { wrap: "border-accent/30 bg-accent/10", value: "text-fg-primary", icon: "text-accent" },
|
|
||||||
amber: {
|
|
||||||
wrap: "border-status-warning/30 bg-status-warning/10",
|
|
||||||
value: "text-status-warning",
|
|
||||||
icon: "text-status-warning",
|
|
||||||
},
|
|
||||||
red: {
|
|
||||||
wrap: "border-status-danger/30 bg-status-danger/10",
|
|
||||||
value: "text-status-danger",
|
|
||||||
icon: "text-status-danger",
|
|
||||||
},
|
|
||||||
muted: TONE_MUTED,
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatChip({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
tone: string;
|
|
||||||
}): ReactNode {
|
|
||||||
const t = TONES[tone] ?? TONE_MUTED;
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col items-center gap-0.5 rounded-xl border py-1.5 ${t.wrap}`}>
|
|
||||||
<Icon size={13} className={t.icon} aria-hidden />
|
|
||||||
<span className={`text-base font-semibold leading-none tabular-nums ${t.value}`}>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
<span className="text-[9px] uppercase tracking-wider text-fg-muted">{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({
|
|
||||||
icon: Icon,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
icon: LucideIcon;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
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}
|
|
||||||
>
|
|
||||||
<Icon size={14} className="shrink-0" />
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @file Sagi.test.tsx
|
* @file Sagi.test.tsx
|
||||||
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B panel toggle, and accessibility attributes.
|
* @description Render tests for the Sagi companion component — mounting, mood rendering, the ⌘B chat toggle (the chat window is the pet's whole UI: status strip, local answers, New chat stopping the old session), and accessibility attributes.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
|
|||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { Sagi } from "../Sagi";
|
import { Sagi } from "../Sagi";
|
||||||
import { eventBus } from "../../../lib/eventBus";
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
import type { WSMessage, Session } from "../../../lib/types";
|
import type { WSMessage, Session } from "../../../lib/types";
|
||||||
|
|
||||||
function renderSagi() {
|
function renderSagi() {
|
||||||
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
expect(screen.getByRole("img", { name: /sagi/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the panel on click and answers a local status question", () => {
|
it("opens the chat on click and answers a local status question there", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
expect(panel).toBeInTheDocument();
|
expect(panel).toBeInTheDocument();
|
||||||
|
|
||||||
const input = within(panel).getByLabelText(/ask sagi/i);
|
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||||
fireEvent.change(input, { target: { value: "status" } });
|
fireEvent.change(input, { target: { value: "status" } });
|
||||||
fireEvent.submit(input.closest("form")!);
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||||
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(btn).getByText("1")).toBeInTheDocument();
|
expect(within(btn).getByText("1")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the errored session in its bubble and in the panel", () => {
|
it("names the errored session in its bubble and in the chat", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish({
|
eventBus.publish({
|
||||||
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -121,43 +122,54 @@ describe("Sagi widget", () => {
|
|||||||
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
expect(JSON.parse(localStorage.getItem("agenthub.pet.dismissedErrors")!)).toEqual(["a"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames the pet from the panel header", () => {
|
it("renames the pet from the chat header", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||||
const input = screen.getByLabelText("Pet name");
|
const input = screen.getByLabelText("Pet name");
|
||||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||||
fireEvent.keyDown(input, { key: "Enter" });
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /chat with mochi/i })).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/ask mochi anything/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands a question it cannot answer to the chat window", () => {
|
it("hands a question it cannot answer to the AI", () => {
|
||||||
localStorage.setItem("agenthub.defaultTool", "claude");
|
localStorage.setItem("agenthub.defaultTool", "claude");
|
||||||
|
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
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 });
|
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||||
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.change(input, { target: { value: "write me a haiku" } });
|
||||||
fireEvent.submit(input.closest("form")!);
|
fireEvent.submit(input.closest("form")!);
|
||||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
ensure.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a pat shows its reaction on the pet even while the panel is open", () => {
|
it("reopens the remembered chat, and New chat stops its session", async () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"agenthub.pet.chat",
|
||||||
|
JSON.stringify({ laneId: 7, runId: "agenthub-lane-7-1", tool: "claude" })
|
||||||
|
);
|
||||||
|
const get = vi
|
||||||
|
.spyOn(api.run, "get")
|
||||||
|
.mockReturnValue(new Promise(() => {}) as ReturnType<typeof api.run.get>);
|
||||||
|
const kill = vi.spyOn(api.run, "kill").mockResolvedValue({ ok: true });
|
||||||
|
renderSagi();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
|
expect(screen.getByRole("button", { name: /open in workspace/i })).toBeInTheDocument();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /new chat/i }));
|
||||||
|
});
|
||||||
|
expect(kill).toHaveBeenCalledWith("agenthub-lane-7-1", { archive: true });
|
||||||
|
expect(localStorage.getItem("agenthub.pet.chat")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /open in workspace/i })).not.toBeInTheDocument();
|
||||||
|
get.mockRestore();
|
||||||
|
kill.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||||
@@ -175,16 +187,16 @@ describe("Sagi widget", () => {
|
|||||||
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
expect(pet.querySelector(".sagi-reaction-text")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reflects the live count in the panel status", () => {
|
it("reflects the live count in the chat status", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
act(() => {
|
act(() => {
|
||||||
eventBus.publish(sessionMsg("a", "active"));
|
eventBus.publish(sessionMsg("a", "active"));
|
||||||
eventBus.publish(sessionMsg("b", "active"));
|
eventBus.publish(sessionMsg("b", "active"));
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||||
// The "live" stat chip shows value 2 next to its label.
|
// The "live" stat chip shows value 2 next to its label.
|
||||||
const liveChip = within(panel).getByText("live").closest("div")!;
|
const liveChip = within(panel).getByText("live").closest("span.rounded-full")! as HTMLElement;
|
||||||
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
expect(within(liveChip).getByText("2")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -194,7 +206,7 @@ describe("Sagi widget", () => {
|
|||||||
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /open sagi companion/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a tap (no movement) still opens the panel", () => {
|
it("a tap (no movement) still opens the chat", () => {
|
||||||
renderSagi();
|
renderSagi();
|
||||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -202,7 +214,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
it("dragging snaps to an edge, persists position, and does not open the panel", () => {
|
||||||
@@ -231,7 +243,7 @@ describe("Sagi widget", () => {
|
|||||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||||
});
|
});
|
||||||
fireEvent.click(btn);
|
fireEvent.click(btn);
|
||||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||||
// No position was persisted because no real drag happened.
|
// No position was persisted because no real drag happened.
|
||||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
LogOut,
|
LogOut,
|
||||||
UsersRound,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
@@ -479,11 +479,11 @@ export function Sidebar({
|
|||||||
{[
|
{[
|
||||||
// The file manager serves a shared server; locally it is the desktop's.
|
// The file manager serves a shared server; locally it is the desktop's.
|
||||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||||
// Server mode: the Users page, for an admin or analyst.
|
// Server mode: Administration (users, server), for an admin or analyst.
|
||||||
...(auth.state.mode === "server" &&
|
...(auth.state.mode === "server" &&
|
||||||
auth.state.user &&
|
auth.state.user &&
|
||||||
auth.state.user.role !== "developer"
|
auth.state.user.role !== "developer"
|
||||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||||
: []),
|
: []),
|
||||||
].map(({ to, icon: Icon, key }) => {
|
].map(({ to, icon: Icon, key }) => {
|
||||||
const label = t(key);
|
const label = t(key);
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
* Workspace page, the open workspace's total (`?lane=` on the same call: the
|
||||||
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
* sessions it owns) and the shown session's (`GET /api/pricing/cost/:id` for
|
||||||
* the URL's `session`, or the focused pane's session from the page's run poll,
|
* the URL's `session`, or the focused pane's session from the page's run poll,
|
||||||
* `LIVE_RUNS_EVENT`), all re-read every 30 s while the tab is visible and
|
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
|
||||||
* whenever the URL names another workspace or session. Each figure carries its
|
* workspace or session, then at most every 30 s while events or session
|
||||||
|
* updates arrive (spend only moves with work) and every 2 min otherwise, while
|
||||||
|
* the tab is visible. The session counts re-read only when a session is
|
||||||
|
* created or its status / waiting / archived state changes (every 60 s
|
||||||
|
* otherwise). Each figure carries its
|
||||||
* token total, and hovering it lists tokens and cost per model. At the right
|
* token total, and hovering it lists tokens and cost per model. At the right
|
||||||
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
* end, the plan quota spent per native tool (`GET /api/pricing/quota`, every
|
||||||
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
* 5 min while visible; a click forces a re-read): one "N% used" chip per tool —
|
||||||
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
|||||||
import { AiToolIcon } from "./AiToolIcon";
|
import { AiToolIcon } from "./AiToolIcon";
|
||||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||||
import { useSpin } from "../hooks/useSpin";
|
import { useSpin } from "../hooks/useSpin";
|
||||||
|
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||||
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
import { LIVE_RUNS_EVENT, selectedFrom } from "./WorkspaceTree";
|
||||||
|
|
||||||
|
/** Spend moves only with work: at most this often while events arrive… */
|
||||||
const COST_POLL_MS = 30_000;
|
const COST_POLL_MS = 30_000;
|
||||||
|
/** …and this often regardless (date rollover, budget period, a missed frame). */
|
||||||
|
const COST_FALLBACK_MS = 120_000;
|
||||||
|
/** The counts' safety poll; session frames that change a count drive the rest. */
|
||||||
|
const COUNTS_FALLBACK_MS = 60_000;
|
||||||
const QUOTA_POLL_MS = 5 * 60_000;
|
const QUOTA_POLL_MS = 5 * 60_000;
|
||||||
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
const QUOTA_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||||
claude: "Claude",
|
claude: "Claude",
|
||||||
@@ -384,51 +394,57 @@ export function StatusBar({
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(loadCounts, [loadCounts]);
|
useEffect(loadCounts, [loadCounts]);
|
||||||
useLiveRefresh(loadCounts, {
|
// Only a frame that moves a session between the counted states (a new
|
||||||
intervalMs: 15_000,
|
// session, a status / waiting / archived change) re-reads: a session row is
|
||||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
// re-broadcast on almost every hook.
|
||||||
});
|
const [countsChanged] = useState(() => sessionChangeMatcher());
|
||||||
|
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
|
||||||
|
|
||||||
useEffect(() => {
|
// Bumped on every lane/session switch: an answer for the previous one is dropped.
|
||||||
let live = true;
|
const costGen = useRef(0);
|
||||||
// Another workspace or session: never show the previous one's figure.
|
const loadCosts = useCallback(() => {
|
||||||
setLaneCost(null);
|
const gen = costGen.current;
|
||||||
setSessionCost(null);
|
const live = () => gen === costGen.current;
|
||||||
const tick = () => {
|
|
||||||
if (document.visibilityState !== "visible") return;
|
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ date: localDate() })
|
.totalCost({ date: localDate() })
|
||||||
.then((r) => live && setToday(r))
|
.then((r) => live() && setToday(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
api.budgets
|
api.budgets
|
||||||
.status()
|
.status()
|
||||||
.then(({ budgets }) => {
|
.then(({ budgets }) => {
|
||||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||||
if (live) setBudget(hit[0] ?? null);
|
if (live()) setBudget(hit[0] ?? null);
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
if (lane !== null) {
|
if (lane !== null) {
|
||||||
api.pricing
|
api.pricing
|
||||||
.totalCost({ lane })
|
.totalCost({ lane })
|
||||||
.then((r) => live && setLaneCost(r))
|
.then((r) => live() && setLaneCost(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
api.pricing
|
api.pricing
|
||||||
.sessionCost(sessionId)
|
.sessionCost(sessionId)
|
||||||
.then((r) => live && setSessionCost(r))
|
.then((r) => live() && setSessionCost(r))
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const id = setInterval(tick, COST_POLL_MS);
|
|
||||||
document.addEventListener("visibilitychange", tick);
|
|
||||||
return () => {
|
|
||||||
live = false;
|
|
||||||
clearInterval(id);
|
|
||||||
document.removeEventListener("visibilitychange", tick);
|
|
||||||
};
|
|
||||||
}, [lane, sessionId]);
|
}, [lane, sessionId]);
|
||||||
|
useEffect(() => {
|
||||||
|
costGen.current++;
|
||||||
|
// Another workspace or session: never show the previous one's figure.
|
||||||
|
setLaneCost(null);
|
||||||
|
setSessionCost(null);
|
||||||
|
if (document.visibilityState === "visible") loadCosts();
|
||||||
|
return () => {
|
||||||
|
costGen.current++;
|
||||||
|
};
|
||||||
|
}, [loadCosts]);
|
||||||
|
useLiveRefresh(loadCosts, {
|
||||||
|
intervalMs: COST_FALLBACK_MS,
|
||||||
|
throttleMs: COST_POLL_MS,
|
||||||
|
match: (msg) =>
|
||||||
|
msg.type === "new_event" || msg.type === "session_updated" || msg.type === "session_created",
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
|
|||||||
@@ -33,13 +33,16 @@ export function Switch({
|
|||||||
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
// On: the solid accent (white knob clears 3:1 in every theme). Off: a
|
||||||
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
// border-light track with an inset fg-muted/80 ring (≥3:1 in every theme), so the control's edge
|
||||||
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
// is visible on any surface (surface-4 alone was ~1:1 on a light card).
|
||||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 ${
|
// Hover darkens the track (enabled only), so it reads as clickable.
|
||||||
checked ? "bg-accent-solid" : "bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||||
|
checked
|
||||||
|
? "bg-accent-solid enabled:hover:bg-accent-solid-hover"
|
||||||
|
: "bg-border-light ring-1 ring-inset ring-fg-muted/80 enabled:hover:ring-fg-secondary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
|
className={`inline-block h-4 w-4 rounded-full bg-white shadow-sm ring-1 ring-black/10 transition-transform duration-200 ease-out ${
|
||||||
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
checked ? "translate-x-[18px]" : "translate-x-0.5"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,9 +16,13 @@
|
|||||||
* — and the poll waits for the old server to actually go DOWN first, because it
|
* — and the poll waits for the old server to actually go DOWN first, because it
|
||||||
* keeps answering while the chain installs. New skills/slash commands still
|
* keeps answering while the chain installs. New skills/slash commands still
|
||||||
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
* need `/reload-plugins` in an already-open Claude Code session; nothing a
|
||||||
* background process can do for it. Escape, the backdrop and "Not now" close it
|
* background process can do for it. Escape, the backdrop and × close it for
|
||||||
* for this page load only (never while applying); "Skip this version" is the
|
* this page load only (never while applying); "Skip this version" is the one
|
||||||
* one persistent, per-version dismiss.
|
* persistent, per-version dismiss. After a failed update, "Auto fix" hands the
|
||||||
|
* error to an AI agent: it opens (or adopts) a workspace on the install folder
|
||||||
|
* and starts a Claude Code session there with the error as its prompt — the
|
||||||
|
* agent fixes whatever blocked the update (a dirty lockfile, a failed build…)
|
||||||
|
* and runs the update command; the dashboard itself still decides nothing.
|
||||||
*
|
*
|
||||||
* ## State sources
|
* ## State sources
|
||||||
* - Initial fetch via `api.updates.status()` on mount.
|
* - Initial fetch via `api.updates.status()` on mount.
|
||||||
@@ -81,7 +85,18 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Download, X, Copy, Check, RefreshCw, Zap, ExternalLink } from "lucide-react";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
RefreshCw,
|
||||||
|
Zap,
|
||||||
|
ExternalLink,
|
||||||
|
SkipForward,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { eventBus } from "../lib/eventBus";
|
import { eventBus } from "../lib/eventBus";
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
|
|||||||
import { useDialog } from "../hooks/useDialog";
|
import { useDialog } from "../hooks/useDialog";
|
||||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||||
import { usePermissions } from "./AuthGate";
|
import { usePermissions } from "./AuthGate";
|
||||||
|
import { startInFreeTab } from "./lanes/startSession";
|
||||||
|
|
||||||
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
/** `localStorage` key storing the dismissed release tag (or upstream SHA). */
|
||||||
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
|
||||||
@@ -107,6 +123,32 @@ function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The prompt the Auto fix session starts with: what failed, where, and the
|
||||||
|
* limits that keep the agent from taking down the dashboard it runs under. */
|
||||||
|
function autoFixPrompt(status: UpdateStatusPayload, error: string): string {
|
||||||
|
const target = status.latest_release || status.remote_ref || "the newest version";
|
||||||
|
const plugin = status.situation === "plugin_managed";
|
||||||
|
return [
|
||||||
|
`The AgentHub dashboard's self-update to ${target} failed${status.local_version ? ` (running ${status.local_version})` : ""}.`,
|
||||||
|
plugin
|
||||||
|
? "AgentHub is installed as a Claude Code plugin (the `agenthub` plugin from the `agenthub` marketplace)."
|
||||||
|
: `The install is the git checkout at ${status.repo_root}.`,
|
||||||
|
"",
|
||||||
|
"Error:",
|
||||||
|
"```",
|
||||||
|
error,
|
||||||
|
"```",
|
||||||
|
...(status.manual_command ? ["", "Update command:", "```", status.manual_command, "```"] : []),
|
||||||
|
"",
|
||||||
|
"Find the cause, fix it, then finish the update" +
|
||||||
|
(plugin
|
||||||
|
? " (`claude plugin marketplace update agenthub` then `claude plugin update agenthub@agenthub`)."
|
||||||
|
: " by running the update command."),
|
||||||
|
"Rules: never lose the user's work — before discarding any local change, save it (e.g. `git diff > ~/agenthub-update-backup.patch`) and say where; do not stop or restart the AgentHub server, and never kill tmux sessions or the tmux server (this session runs inside one).",
|
||||||
|
"When done, tell me what you changed; I will restart the dashboard from its update prompt.",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
|
||||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||||
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
|
||||||
@@ -130,7 +172,7 @@ export function UpdateNotifier() {
|
|||||||
const { t } = useTranslation("updates");
|
const { t } = useTranslation("updates");
|
||||||
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
const [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||||
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
const [dismissedSha, setDismissedSha] = useState<string | null>(loadDismissedSha);
|
||||||
// Closed for THIS page load only (Escape, backdrop, ×, "Not now") — the
|
// Closed for THIS page load only (Escape, backdrop, ×) — the
|
||||||
// persistent per-version dismiss is the explicit "Skip this version".
|
// persistent per-version dismiss is the explicit "Skip this version".
|
||||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
|
|||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
const [applying, setApplying] = useState(false);
|
const [applying, setApplying] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
const [fixing, setFixing] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
|
|||||||
dismissedSha !== dismissKey &&
|
dismissedSha !== dismissKey &&
|
||||||
closedKey !== dismissKey
|
closedKey !== dismissKey
|
||||||
);
|
);
|
||||||
const busy = applying || restarting;
|
const busy = applying || restarting || fixing;
|
||||||
|
|
||||||
const closeForNow = useCallback(() => {
|
const closeForNow = useCallback(() => {
|
||||||
if (busy || !dismissKey) return;
|
if (busy || !dismissKey) return;
|
||||||
@@ -317,6 +361,29 @@ export function UpdateNotifier() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Hand the failure to an AI agent: a workspace on the install folder (the
|
||||||
|
// home for a plugin install — never a lane inside the plugin cache) and a
|
||||||
|
// Claude Code session there with the error as its first prompt.
|
||||||
|
const autoFix = async () => {
|
||||||
|
if (busy || !status || !error) return;
|
||||||
|
setFixing(true);
|
||||||
|
try {
|
||||||
|
const plugin = status.situation === "plugin_managed";
|
||||||
|
const { lane } = await api.lanes.ensure({
|
||||||
|
...(plugin || !status.repo_root ? {} : { cwd: status.repo_root, exact: true }),
|
||||||
|
title: t("autoFixWorkspace"),
|
||||||
|
tool: "claude",
|
||||||
|
});
|
||||||
|
const runId = await startInFreeTab(lane.id, { prompt: autoFixPrompt(status, error) });
|
||||||
|
if (dismissKey) setClosedKey(dismissKey);
|
||||||
|
navigate(`/workspace?lane=${lane.id}&run=${encodeURIComponent(runId)}`);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : t("autoFixError"));
|
||||||
|
} finally {
|
||||||
|
setFixing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!show || !status) return null;
|
if (!show || !status) return null;
|
||||||
|
|
||||||
const refLabel = status.remote_ref || "origin";
|
const refLabel = status.remote_ref || "origin";
|
||||||
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
|
|||||||
title={t("skipVersionHint")}
|
title={t("skipVersionHint")}
|
||||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
|
<SkipForward className="w-3.5 h-3.5" aria-hidden />
|
||||||
{t("skipVersion")}
|
{t("skipVersion")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={closeForNow}
|
|
||||||
disabled={busy}
|
|
||||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{t("later")}
|
|
||||||
</button>
|
|
||||||
{status.manual_command ? (
|
{status.manual_command ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
|
|||||||
{copied ? t("copied") : t("copy")}
|
{copied ? t("copied") : t("copy")}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* Only after a failed update: an AI agent takes the error from here. */}
|
||||||
|
{error ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={autoFix}
|
||||||
|
disabled={busy}
|
||||||
|
title={t("autoFixHint")}
|
||||||
|
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{fixing ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Wrench className="w-3.5 h-3.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
{t("autoFix")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{isAutoApplicable(status.situation) ? (
|
{isAutoApplicable(status.situation) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={applyNow}
|
onClick={applyNow}
|
||||||
disabled={applying || restarting}
|
disabled={busy}
|
||||||
className="btn-primary disabled:opacity-70"
|
className="btn-primary disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{applying || restarting ? (
|
{applying || restarting ? (
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
|
|||||||
const statusMock = vi.fn();
|
const statusMock = vi.fn();
|
||||||
const checkMock = vi.fn();
|
const checkMock = vi.fn();
|
||||||
const applyMock = vi.fn();
|
const applyMock = vi.fn();
|
||||||
|
const ensureMock = vi.fn();
|
||||||
|
const startMock = vi.fn();
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lanes/startSession", () => ({
|
||||||
|
startInFreeTab: (...args: unknown[]) => startMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
|||||||
check: (...args: unknown[]) => checkMock(...args),
|
check: (...args: unknown[]) => checkMock(...args),
|
||||||
apply: (...args: unknown[]) => applyMock(...args),
|
apply: (...args: unknown[]) => applyMock(...args),
|
||||||
},
|
},
|
||||||
|
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -56,6 +69,9 @@ beforeEach(() => {
|
|||||||
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||||
applyMock.mockReset();
|
applyMock.mockReset();
|
||||||
|
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
|
||||||
|
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
|
||||||
|
navigateMock.mockReset();
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
|
|
||||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||||
render(<UpdateNotifier />);
|
render(<UpdateNotifier />);
|
||||||
await userEvent.click(await screen.findByText(i18n.t("updates:later")));
|
await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
|
||||||
);
|
);
|
||||||
@@ -290,3 +306,26 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
|||||||
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("UpdateNotifier — Auto fix", () => {
|
||||||
|
it("offers no Not now button, and Auto fix only once an update failed", async () => {
|
||||||
|
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
|
||||||
|
render(<UpdateNotifier />);
|
||||||
|
await screen.findByText(i18n.t("updates:updateNow"));
|
||||||
|
expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText(i18n.t("updates:updateNow")));
|
||||||
|
await userEvent.click(await screen.findByText(i18n.t("updates:autoFix")));
|
||||||
|
|
||||||
|
await waitFor(() => expect(navigateMock).toHaveBeenCalled());
|
||||||
|
expect(ensureMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" })
|
||||||
|
);
|
||||||
|
const [laneId, body] = startMock.mock.calls[0] ?? [];
|
||||||
|
expect(laneId).toBe(7);
|
||||||
|
expect(body.prompt).toContain("git merge --ff-only v2.8.0");
|
||||||
|
expect(body.prompt).toContain(BASE_STATUS.manual_command);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ describe("WorkspaceTree", () => {
|
|||||||
let menu = screen.getByTestId("session-menu-s-old");
|
let menu = screen.getByTestId("session-menu-s-old");
|
||||||
// Ended: no pane to split or close.
|
// Ended: no pane to split or close.
|
||||||
expect(within(menu).queryByText("Open in split")).toBeNull();
|
expect(within(menu).queryByText("Open in split")).toBeNull();
|
||||||
expect(within(menu).queryByText("Close tab (stops its session)")).toBeNull();
|
expect(within(menu).queryByText("Close tab (stops and archives its session)")).toBeNull();
|
||||||
await user.click(within(menu).getByText("Rename"));
|
await user.click(within(menu).getByText("Rename"));
|
||||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||||
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
expect(screen.getByTestId("workspace-session-rename-s-old")).toBeInTheDocument();
|
||||||
@@ -271,11 +271,17 @@ describe("WorkspaceTree", () => {
|
|||||||
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
await user.click(within(screen.getByTestId("session-menu-s-live")).getByText("Archive"));
|
||||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||||
|
|
||||||
|
await right(screen.getByTestId("workspace-session-s-live"));
|
||||||
|
await user.click(screen.getByTestId("session-menu-sleep"));
|
||||||
|
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: false });
|
||||||
|
|
||||||
await right(screen.getByTestId("workspace-session-s-live"));
|
await right(screen.getByTestId("workspace-session-s-live"));
|
||||||
await user.click(
|
await user.click(
|
||||||
within(screen.getByTestId("session-menu-s-live")).getByText("Close tab (stops its session)")
|
within(screen.getByTestId("session-menu-s-live")).getByText(
|
||||||
|
"Close tab (stops and archives its session)"
|
||||||
|
)
|
||||||
);
|
);
|
||||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2");
|
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-2", { archive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
it("archiving a worktree session removes its worktree first, after asking", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* @file ServerPanel.tsx
|
||||||
|
* @description The Administration page's Server tab: the machine AgentHub
|
||||||
|
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||||
|
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||||
|
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||||
|
* machine, resident memory, process count, the busiest processes by name, and
|
||||||
|
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||||
|
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||||
|
* at once. Read-only — the only action here is the members' tmux card
|
||||||
|
* (`MemberTmuxCard`, an admin's restart).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Cpu,
|
||||||
|
HardDrive,
|
||||||
|
MemoryStick,
|
||||||
|
MonitorCog,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type HostStats,
|
||||||
|
type HostUsageGroup,
|
||||||
|
type ServerUserUsage,
|
||||||
|
type UserStorage,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import { formatBytes } from "../../lib/format";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { StatCard } from "../StatCard";
|
||||||
|
import { UserAvatar } from "../users/UserAvatar";
|
||||||
|
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||||
|
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
|
||||||
|
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||||
|
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||||
|
|
||||||
|
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||||
|
function Meter({ value, label }: { value: number; label: string }) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="meter"
|
||||||
|
aria-label={label}
|
||||||
|
aria-valuenow={Math.round(v)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||||
|
const d = Math.floor(sec / 86400);
|
||||||
|
const h = Math.floor((sec % 86400) / 3600);
|
||||||
|
const m = Math.floor((sec % 3600) / 60);
|
||||||
|
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const [stats, setStats] = useState<HostStats | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
|
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||||
|
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
api.host
|
||||||
|
.stats()
|
||||||
|
.then((s) => {
|
||||||
|
setStats(s);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void load();
|
||||||
|
}, POLL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// Names, pictures and home sizes: read once, they change slowly.
|
||||||
|
useEffect(() => {
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
api.users
|
||||||
|
.storage()
|
||||||
|
.then((r) => setStorage(r.storage))
|
||||||
|
.catch(() => setStorage({}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||||
|
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||||
|
const loading = !stats && !error;
|
||||||
|
const mem = stats?.memory;
|
||||||
|
const toggle = (key: string) =>
|
||||||
|
setOpen((s) => {
|
||||||
|
const n = new Set(s);
|
||||||
|
if (n.has(key)) n.delete(key);
|
||||||
|
else n.add(key);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
const groupName = (g: HostUsageGroup) => {
|
||||||
|
if (g.kind === "host") return t("server.hostGroup");
|
||||||
|
if (g.kind === "system") return t("server.systemGroup");
|
||||||
|
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||||
|
return u?.displayName || g.username || "";
|
||||||
|
};
|
||||||
|
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6" data-testid="server-panel">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||||
|
<Server className="w-4 h-4 text-accent" />
|
||||||
|
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||||
|
</h2>
|
||||||
|
{stats && (
|
||||||
|
<p className="mt-0.5 text-xs text-fg-muted">
|
||||||
|
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||||
|
{" · "}
|
||||||
|
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
|
className="btn-ghost"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||||
|
{t("common:refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0">{error}</span>
|
||||||
|
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||||
|
{t("common:retry")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label={t("server.cpu")}
|
||||||
|
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||||
|
icon={Cpu}
|
||||||
|
trend={
|
||||||
|
stats
|
||||||
|
? t("server.cores", {
|
||||||
|
n: stats.cpu.cores,
|
||||||
|
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
raw={stats?.cpu.model ?? undefined}
|
||||||
|
help={t("server.cpuHelp")}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("server.memory")}
|
||||||
|
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||||
|
icon={MemoryStick}
|
||||||
|
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("server.swap")}
|
||||||
|
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||||
|
icon={MonitorCog}
|
||||||
|
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t("server.homesDisk")}
|
||||||
|
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||||
|
icon={HardDrive}
|
||||||
|
trend={
|
||||||
|
homesDisk
|
||||||
|
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
help={t("server.homesDiskHelp")}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<div className="px-5 pt-4 pb-2">
|
||||||
|
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{stats && !stats.usage ? (
|
||||||
|
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className={th}>{t("server.who")}</th>
|
||||||
|
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||||
|
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||||
|
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||||
|
{t("server.sampling")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{stats?.usage?.groups.map((g) => {
|
||||||
|
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||||
|
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||||
|
const isOpen = open.has(g.key);
|
||||||
|
const name = groupName(g);
|
||||||
|
return (
|
||||||
|
<Fragment key={g.key}>
|
||||||
|
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(g.key)}
|
||||||
|
disabled={!g.top.length}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||||
|
>
|
||||||
|
{g.top.length ? (
|
||||||
|
isOpen ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="w-3.5" />
|
||||||
|
)}
|
||||||
|
{g.kind === "user" ? (
|
||||||
|
<UserAvatar
|
||||||
|
name={name}
|
||||||
|
avatarAt={u?.avatarAt}
|
||||||
|
userId={g.userId}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||||
|
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||||
|
{g.kind === "user" && u?.displayName && (
|
||||||
|
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||||
|
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="text-sm text-fg-primary tabular-nums">
|
||||||
|
{formatBytes(g.memory)}{" "}
|
||||||
|
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||||
|
</div>
|
||||||
|
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||||
|
{g.processes}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
{home ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm text-fg-primary tabular-nums">
|
||||||
|
{formatBytes(home.bytes)}
|
||||||
|
{homesDisk && (
|
||||||
|
<span className="text-fg-muted">
|
||||||
|
{" "}
|
||||||
|
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{homesDisk && (
|
||||||
|
<Meter
|
||||||
|
value={pct(home.bytes, homesDisk.total)}
|
||||||
|
label={`${name} ${t("server.home")}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-fg-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isOpen &&
|
||||||
|
g.top.map((p) => (
|
||||||
|
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||||
|
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||||
|
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||||
|
{fmtPct(p.cpu)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||||
|
{formatBytes(p.memory)}
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats && stats.disks.length > 0 && (
|
||||||
|
<div className="card overflow-x-auto">
|
||||||
|
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||||
|
{t("server.disks")}
|
||||||
|
</h3>
|
||||||
|
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className={th}>{t("server.mount")}</th>
|
||||||
|
<th className={th}>{t("server.device")}</th>
|
||||||
|
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{stats.disks.map((d) => (
|
||||||
|
<tr key={d.device}>
|
||||||
|
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||||
|
<span className="font-mono">{d.mount}</span>
|
||||||
|
{d.mount === stats.homesMount && (
|
||||||
|
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||||
|
<span className="font-mono">{d.device}</span> · {d.type}
|
||||||
|
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<div className="text-sm text-fg-primary tabular-nums">
|
||||||
|
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||||
|
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||||
|
</div>
|
||||||
|
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||||
|
{formatBytes(d.free)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats && stats.gpus.length > 0 && (
|
||||||
|
<div className="card p-5 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||||
|
{stats.gpus.map((g, i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-fg-primary">{g.name}</span>
|
||||||
|
<span className="text-fg-muted tabular-nums">
|
||||||
|
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||||
|
{formatBytes(g.memoryTotal)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Meter value={g.utilization} label={g.name} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MemberTmuxCard isAdmin={isAdmin} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
* and tick the ones to add; or type `name@marketplace` by hand, which is
|
||||||
* also where a link the server cannot read (ssh, a private repo) ends up.
|
* also where a link the server cannot read (ssh, a private repo) ends up.
|
||||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||||
|
* - `SkillNameField`/`splitSkillMd` — also used by the page's skill editor.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -24,8 +25,8 @@ import { api, type MarketplaceInfo } from "../../lib/api";
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
const field = "w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
const field = "input w-full";
|
||||||
const label = "block text-[11px] font-medium text-fg-secondary";
|
const label = "block text-xs text-fg-secondary";
|
||||||
const hint = "text-[11px] text-fg-muted";
|
const hint = "text-[11px] text-fg-muted";
|
||||||
|
|
||||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||||
@@ -155,8 +156,19 @@ function Tabs<T extends string>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SKILL.md split for editing: its frontmatter `name`/`description` and the body. */
|
||||||
|
export function splitSkillMd(text: string): { name: string; description: string; body: string } {
|
||||||
|
const clean = text.replace(/^\uFEFF/, "");
|
||||||
|
const fm = /^---\r?\n[\s\S]*?\r?\n---[^\n]*(\r?\n|$)/.exec(clean);
|
||||||
|
return {
|
||||||
|
name: frontmatterField(clean, "name"),
|
||||||
|
description: frontmatterField(clean, "description"),
|
||||||
|
body: (fm ? clean.slice(fm[0].length) : clean).replace(/^\s+/, ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** The name field with the folder/command it becomes. */
|
/** The name field with the folder/command it becomes. */
|
||||||
function SkillNameField({
|
export function SkillNameField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -175,7 +187,7 @@ function SkillNameField({
|
|||||||
{t("skill.name")}
|
{t("skill.name")}
|
||||||
{required ? " *" : ""}
|
{required ? " *" : ""}
|
||||||
<input
|
<input
|
||||||
className={`${field} mt-0.5`}
|
className={`${field} mt-1`}
|
||||||
value={value}
|
value={value}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="release-notes"
|
placeholder="release-notes"
|
||||||
@@ -296,7 +308,7 @@ export function SkillAddPanel({
|
|||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("skill.description")}
|
{t("skill.description")}
|
||||||
<textarea
|
<textarea
|
||||||
className={`${field} mt-0.5`}
|
className={`${field} mt-1`}
|
||||||
rows={2}
|
rows={2}
|
||||||
value={description}
|
value={description}
|
||||||
disabled={many}
|
disabled={many}
|
||||||
@@ -310,7 +322,7 @@ export function SkillAddPanel({
|
|||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("form.title")}
|
{t("form.title")}
|
||||||
<input
|
<input
|
||||||
className={`${field} mt-0.5`}
|
className={`${field} mt-1`}
|
||||||
value={title}
|
value={title}
|
||||||
disabled={many}
|
disabled={many}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
@@ -452,7 +464,10 @@ export function SkillAddPanel({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{meta}
|
{meta}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary text-sm"
|
className="btn-primary text-sm"
|
||||||
@@ -489,7 +504,7 @@ export function SkillAddPanel({
|
|||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("skill.description")} *
|
{t("skill.description")} *
|
||||||
<textarea
|
<textarea
|
||||||
className={`${field} mt-0.5`}
|
className={`${field} mt-1`}
|
||||||
rows={2}
|
rows={2}
|
||||||
value={description}
|
value={description}
|
||||||
placeholder={t("skill.descriptionHint")}
|
placeholder={t("skill.descriptionHint")}
|
||||||
@@ -499,7 +514,7 @@ export function SkillAddPanel({
|
|||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("skill.content")} *
|
{t("skill.content")} *
|
||||||
<textarea
|
<textarea
|
||||||
className={`${field} mt-0.5 font-mono text-xs`}
|
className={`${field} mt-1 font-mono text-xs`}
|
||||||
rows={10}
|
rows={10}
|
||||||
value={content}
|
value={content}
|
||||||
placeholder={t("skill.contentHint")}
|
placeholder={t("skill.contentHint")}
|
||||||
@@ -509,12 +524,15 @@ export function SkillAddPanel({
|
|||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("form.title")}
|
{t("form.title")}
|
||||||
<input
|
<input
|
||||||
className={`${field} mt-0.5`}
|
className={`${field} mt-1`}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary text-sm"
|
className="btn-primary text-sm"
|
||||||
@@ -534,7 +552,7 @@ export function SkillAddPanel({
|
|||||||
{mode === "existing" && (
|
{mode === "existing" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<select
|
<select
|
||||||
className={field}
|
className="select w-full"
|
||||||
aria-label={t("form.workspace")}
|
aria-label={t("form.workspace")}
|
||||||
value={laneId ?? ""}
|
value={laneId ?? ""}
|
||||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||||
@@ -696,7 +714,7 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
|||||||
>
|
>
|
||||||
<label className={label}>
|
<label className={label}>
|
||||||
{t("plugin.link")}
|
{t("plugin.link")}
|
||||||
<div className="mt-0.5 flex gap-2">
|
<div className="mt-1 flex gap-2">
|
||||||
<input
|
<input
|
||||||
className={field}
|
className={field}
|
||||||
value={link}
|
value={link}
|
||||||
@@ -777,7 +795,15 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost text-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary text-sm"
|
className="btn-primary text-sm"
|
||||||
@@ -853,7 +879,10 @@ export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onCl
|
|||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<button type="button" className="btn-ghost text-sm" onClick={onClose} disabled={busy}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary text-sm"
|
className="btn-primary text-sm"
|
||||||
@@ -902,7 +931,7 @@ export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
data-testid="file-upload"
|
data-testid="file-upload"
|
||||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
className="mt-1 block w-full text-xs text-fg-secondary file:mr-3 file:cursor-pointer file:rounded-lg file:border file:border-border-light file:bg-surface-2 file:px-3 file:py-1.5 file:text-xs file:text-fg-primary hover:file:bg-surface-4"
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentDetails.tsx
|
||||||
|
* @description What the chat shows of a subagent, like Claude Desktop: an
|
||||||
|
* Agent/Task call's facts (type, model, status, time, tokens, tool calls),
|
||||||
|
* the task it was given and the report it returned — read off the call, its
|
||||||
|
* result (`tool_result.agent`) and, for a background agent, the
|
||||||
|
* `<task-notification>` that closed it — and, one click further, the
|
||||||
|
* subagent's own conversation as a small chat right there in the step, every
|
||||||
|
* step of it. The chat's owner (ConversationView) supplies the links through
|
||||||
|
* `AgentContext`: which agent transcript a call launched, the notifications
|
||||||
|
* seen, and how to render one agent's conversation (a nested
|
||||||
|
* ConversationView — passed in, so this module never imports it back).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createContext, useContext, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckCircle2, ChevronRight, CircleSlash, Loader2, XCircle } from "lucide-react";
|
||||||
|
import type { TranscriptContent, TranscriptMessage } from "../../lib/types";
|
||||||
|
import { fmt, formatModelName, formatMs } from "../../lib/format";
|
||||||
|
import { MarkdownContent } from "./MarkdownContent";
|
||||||
|
|
||||||
|
export type AgentStatus = "running" | "done" | "error" | "stopped";
|
||||||
|
|
||||||
|
/** One subagent as the chat knows it from the call that launched it. */
|
||||||
|
export interface AgentRef {
|
||||||
|
/** The Agent/Task `tool_use` id. */
|
||||||
|
toolUseId: string;
|
||||||
|
/** Its own transcript's `agent_id`; null while nothing names it yet. */
|
||||||
|
agentId: string | null;
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
model: string | null;
|
||||||
|
status: AgentStatus;
|
||||||
|
background: boolean;
|
||||||
|
prompt: string | null;
|
||||||
|
report: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
tokens: number | null;
|
||||||
|
toolUses: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a `<task-notification>` said about a background agent. */
|
||||||
|
export interface AgentNotice {
|
||||||
|
status: string;
|
||||||
|
result: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
tokens: number | null;
|
||||||
|
toolUses: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentLinks {
|
||||||
|
/** tool_use id → agent transcript id, from the session's transcript list. */
|
||||||
|
ids: Map<string, string>;
|
||||||
|
/** tool_use id → the notification that closed that background agent. */
|
||||||
|
notices: Map<string, AgentNotice>;
|
||||||
|
/** The session runs in a pane: a call with no result yet still works. */
|
||||||
|
live: boolean;
|
||||||
|
/** One agent's own conversation, as a chat that fills its box. */
|
||||||
|
render?: (agent: AgentRef) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AgentContext = createContext<AgentLinks>({
|
||||||
|
ids: new Map(),
|
||||||
|
notices: new Map(),
|
||||||
|
live: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const AGENT_TOOLS = new Set(["Agent", "Task"]);
|
||||||
|
export const isAgentCall = (b: TranscriptContent) =>
|
||||||
|
b.type === "tool_use" && AGENT_TOOLS.has(b.name ?? "");
|
||||||
|
|
||||||
|
// The result names the agent's own transcript (`agent-<id>.jsonl`).
|
||||||
|
const AGENT_ID = /agentId:\s*([\w-]+)/;
|
||||||
|
const USAGE = /<usage>([\s\S]*?)<\/usage>/;
|
||||||
|
|
||||||
|
const tag = (text: string, name: string) =>
|
||||||
|
text.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))?.[1]?.trim() ?? null;
|
||||||
|
|
||||||
|
/** The time, tokens and tool calls of a `<usage>` block, in either form
|
||||||
|
* Claude Code writes it: `<total_tokens>N</…>` tags or `total_tokens: N`. */
|
||||||
|
export function parseUsage(text: string): Pick<AgentNotice, "durationMs" | "tokens" | "toolUses"> {
|
||||||
|
const body = text.match(USAGE)?.[1] ?? "";
|
||||||
|
const num = (...names: string[]) => {
|
||||||
|
for (const n of names) {
|
||||||
|
const v = body.match(new RegExp(`${n}(?:>|:\\s*)(\\d+)`))?.[1];
|
||||||
|
if (v) return Number(v);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
durationMs: num("duration_ms"),
|
||||||
|
tokens: num("subagent_tokens", "total_tokens"),
|
||||||
|
toolUses: num("tool_uses"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Each `<task-notification>` in the transcript, by the call it closes. */
|
||||||
|
export function agentNotices(messages: TranscriptMessage[]): Map<string, AgentNotice> {
|
||||||
|
const out = new Map<string, AgentNotice>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (msg.type !== "user") continue;
|
||||||
|
for (const c of msg.content) {
|
||||||
|
if (c.type !== "text" || !c.text?.includes("<task-notification>")) continue;
|
||||||
|
for (const part of c.text.split("<task-notification>").slice(1)) {
|
||||||
|
const id = tag(part, "tool-use-id");
|
||||||
|
if (!id) continue;
|
||||||
|
out.set(id, {
|
||||||
|
status: tag(part, "status") ?? "completed",
|
||||||
|
result: tag(part, "result"),
|
||||||
|
...parseUsage(part),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A report without the footer Claude Code appends for the orchestrator. */
|
||||||
|
function stripFooter(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(USAGE, "")
|
||||||
|
.replace(/^agentId:.*$/m, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the chat can say about the subagent `block` launched. */
|
||||||
|
export function agentRef(
|
||||||
|
block: TranscriptContent,
|
||||||
|
result: TranscriptContent | null | undefined,
|
||||||
|
links: AgentLinks
|
||||||
|
): AgentRef {
|
||||||
|
const raw = block.input as Record<string, unknown> | undefined;
|
||||||
|
const input = raw && !("_truncated" in raw) ? raw : {};
|
||||||
|
const str = (v: unknown) => (typeof v === "string" && v ? v : null);
|
||||||
|
const id = block.id ?? "";
|
||||||
|
const facts = result?.agent;
|
||||||
|
const output = result?.output ?? "";
|
||||||
|
const background = facts?.status === "async_launched" || output.includes("Async agent launched");
|
||||||
|
const notice = background ? links.notices.get(id) : undefined;
|
||||||
|
const usage = !background && output ? parseUsage(output) : null;
|
||||||
|
|
||||||
|
let status: AgentStatus;
|
||||||
|
if (result?.is_error) status = "error";
|
||||||
|
else if (!result || (background && !notice)) status = links.live ? "running" : "stopped";
|
||||||
|
else if (notice && notice.status !== "completed")
|
||||||
|
status = notice.status === "failed" ? "error" : "stopped";
|
||||||
|
else status = "done";
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolUseId: id,
|
||||||
|
agentId: facts?.id ?? output.match(AGENT_ID)?.[1] ?? links.ids.get(id) ?? null,
|
||||||
|
type: str(input.subagent_type) ?? "Agent",
|
||||||
|
description: str(input.description) ?? "",
|
||||||
|
model: facts?.model ?? str(input.model),
|
||||||
|
status,
|
||||||
|
background,
|
||||||
|
prompt: str(input.prompt),
|
||||||
|
report: background ? (notice?.result ?? null) : output ? stripFooter(output) || null : null,
|
||||||
|
durationMs: facts?.duration_ms ?? notice?.durationMs ?? usage?.durationMs ?? null,
|
||||||
|
tokens: facts?.tokens ?? notice?.tokens ?? usage?.tokens ?? null,
|
||||||
|
toolUses: facts?.tool_uses ?? notice?.toolUses ?? usage?.toolUses ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_ICON: Record<AgentStatus, React.ReactNode> = {
|
||||||
|
running: <Loader2 className="h-3.5 w-3.5 flex-shrink-0 animate-spin text-accent" />,
|
||||||
|
done: <CheckCircle2 className="h-3.5 w-3.5 flex-shrink-0 text-status-success" />,
|
||||||
|
error: <XCircle className="h-3.5 w-3.5 flex-shrink-0 text-status-danger" />,
|
||||||
|
stopped: <CircleSlash className="h-3.5 w-3.5 flex-shrink-0 text-fg-muted" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One muted line: its status as an icon, then type · model · time ·
|
||||||
|
* tokens · tool calls, as far as they are known. */
|
||||||
|
export function AgentFacts({ agent }: { agent: AgentRef }) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const parts = [
|
||||||
|
agent.type,
|
||||||
|
agent.model && formatModelName(agent.model),
|
||||||
|
agent.background && t("conversation.agent.background"),
|
||||||
|
agent.durationMs != null && formatMs(agent.durationMs),
|
||||||
|
agent.tokens != null && t("conversation.agentContext", { tokens: fmt(agent.tokens) }),
|
||||||
|
agent.toolUses != null && t("conversation.agent.toolUses", { count: agent.toolUses }),
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="agent-facts"
|
||||||
|
title={t(`conversation.agent.status.${agent.status}`)}
|
||||||
|
className="flex min-w-0 items-center gap-1.5 text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
{STATUS_ICON[agent.status]}
|
||||||
|
<span className="sr-only">{t(`conversation.agent.status.${agent.status}`)}</span>
|
||||||
|
<span className="truncate">{parts.join(" · ")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"inline-flex items-center gap-0.5 text-xs text-fg-muted transition-colors hover:text-fg-secondary";
|
||||||
|
|
||||||
|
/** The subagent's own conversation, a thread hung under its step by a line
|
||||||
|
* on the left — as tall as it is, up to 24rem, then it scrolls. */
|
||||||
|
export function AgentChatBox({ agent }: { agent: AgentRef }) {
|
||||||
|
const links = useContext(AgentContext);
|
||||||
|
return (
|
||||||
|
<div data-testid="agent-chat" className="mt-2 border-l-2 border-border pl-3">
|
||||||
|
{links.render?.(agent)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An Agent/Task step opened in the chat: one line of facts, its report as
|
||||||
|
* prose, and "View conversation ›", which puts the agent's own thread (its
|
||||||
|
* task, every step, its reply) in the report's place. With no transcript
|
||||||
|
* to show, the task it was given opens on its own instead. */
|
||||||
|
export function AgentStepBody({
|
||||||
|
block,
|
||||||
|
result,
|
||||||
|
}: {
|
||||||
|
block: TranscriptContent;
|
||||||
|
result: TranscriptContent | null;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("sessions");
|
||||||
|
const links = useContext(AgentContext);
|
||||||
|
const agent = agentRef(block, result, links);
|
||||||
|
const canOpen = !!agent.agentId && !!links.render;
|
||||||
|
const [thread, setThread] = useState(false);
|
||||||
|
const [task, setTask] = useState(false);
|
||||||
|
return (
|
||||||
|
<div data-testid="agent-step" className="space-y-1.5">
|
||||||
|
<AgentFacts agent={agent} />
|
||||||
|
{thread ? (
|
||||||
|
<AgentChatBox agent={agent} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!canOpen && agent.prompt && task && (
|
||||||
|
<div className="max-h-60 overflow-auto border-l-2 border-border pl-3 text-fg-secondary">
|
||||||
|
<MarkdownContent text={agent.prompt} dense />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agent.report && (
|
||||||
|
<div
|
||||||
|
className={`max-h-60 overflow-auto ${agent.status === "error" ? "text-status-danger" : "text-fg-secondary"}`}
|
||||||
|
>
|
||||||
|
<MarkdownContent text={agent.report} dense />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canOpen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="agent-open"
|
||||||
|
aria-expanded={thread}
|
||||||
|
onClick={() => setThread(!thread)}
|
||||||
|
className={linkClass}
|
||||||
|
>
|
||||||
|
{t(thread ? "conversation.agent.hide" : "conversation.agent.open")}
|
||||||
|
<ChevronRight className={`h-3 w-3 transition-transform ${thread ? "-rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
agent.prompt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={task}
|
||||||
|
onClick={() => setTask(!task)}
|
||||||
|
className={linkClass}
|
||||||
|
>
|
||||||
|
{t("conversation.agent.prompt")}
|
||||||
|
<ChevronRight className={`h-3 w-3 transition-transform ${task ? "rotate-90" : ""}`} />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
* 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).
|
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||||
|
* A caller that knows the session has ended passes `ended`: nothing more is
|
||||||
|
* being written, so the catch-up poll drops from 3 s to 60 s and the
|
||||||
|
* sub-agent list stops being rescanned — a `new_event` frame, a reconnect or
|
||||||
|
* the view being shown again still fetch the tail at once. The main
|
||||||
|
* transcript hands MessageList its `sessionId`, so the list can read its
|
||||||
|
* background agents' own transcripts for their progress.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
@@ -67,6 +73,7 @@ import { api } from "../../lib/api";
|
|||||||
import { eventBus } from "../../lib/eventBus";
|
import { eventBus } from "../../lib/eventBus";
|
||||||
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
import { isRemoteDataRefreshMessage } from "../../lib/remoteDataEvents";
|
||||||
import { MessageList, promptTokens } from "./MessageList";
|
import { MessageList, promptTokens } from "./MessageList";
|
||||||
|
import { agentNotices, type AgentLinks, type AgentRef } from "./AgentDetails";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
import { useOnScreen, isShown } from "../../hooks/useOnScreen";
|
||||||
import { throttle } from "../../lib/throttle";
|
import { throttle } from "../../lib/throttle";
|
||||||
@@ -79,6 +86,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
|
|||||||
// hook event. A short visibility-gated poll closes that gap and also rescues
|
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||||
// the conversation from missed/late WebSocket frames.
|
// the conversation from missed/late WebSocket frames.
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
// An ended session writes nothing more: a slow safety poll only, in case it was
|
||||||
|
// resumed somewhere whose frames this view missed.
|
||||||
|
const ENDED_POLL_INTERVAL_MS = 60000;
|
||||||
// Rescan the transcripts list periodically so new subagents that spawn
|
// Rescan the transcripts list periodically so new subagents that spawn
|
||||||
// mid-session appear in the dropdown without a page reload.
|
// mid-session appear in the dropdown without a page reload.
|
||||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||||
@@ -86,6 +96,9 @@ const TRANSCRIPTS_REFRESH_MS = 15000;
|
|||||||
// at most this often: the first event still fetches at once, the last is never
|
// 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.
|
// lost, and a long transcript is not re-read for every single frame.
|
||||||
const EVENT_FETCH_MS = 1500;
|
const EVENT_FETCH_MS = 1500;
|
||||||
|
// The server saw this session's transcript grow (`transcript_changed`, itself
|
||||||
|
// at most every 250 ms): the new lines are on disk, so read them nearly at once.
|
||||||
|
const PUSH_FETCH_MS = 300;
|
||||||
|
|
||||||
interface ConversationViewProps {
|
interface ConversationViewProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -93,8 +106,15 @@ interface ConversationViewProps {
|
|||||||
/** Fill the parent as a chat (a workspace's session view): no toolbar, no
|
/** 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. */
|
* page-height cap — the parent sizes it and puts a composer under it. */
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
/** A subagent's conversation inside its step: no toolbar, as tall as its
|
||||||
|
* content up to 24rem, then it scrolls. */
|
||||||
|
compact?: boolean;
|
||||||
/** Opens a file named in a turn's "Edited N files" card. */
|
/** Opens a file named in a turn's "Edited N files" card. */
|
||||||
onOpenFile?: (path: string) => void;
|
onOpenFile?: (path: string) => void;
|
||||||
|
/** Opens an artifact card's file in the chat's artifact panel. */
|
||||||
|
onOpenArtifact?: (path: string) => void;
|
||||||
|
/** The messages on screen (for an artifact's versions), as they change. */
|
||||||
|
onMessages?: (messages: TranscriptMessage[]) => void;
|
||||||
/** The session runs in a pane: show the step in progress. */
|
/** The session runs in a pane: show the step in progress. */
|
||||||
live?: boolean;
|
live?: boolean;
|
||||||
/** Changed (a chat's send): back to the end, and stick to it. */
|
/** Changed (a chat's send): back to the end, and stick to it. */
|
||||||
@@ -110,13 +130,18 @@ interface ConversationViewProps {
|
|||||||
onContext?: (tokens: number, model: string | null) => void;
|
onContext?: (tokens: number, model: string | null) => void;
|
||||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||||
onNotStarted?: (notStarted: boolean) => void;
|
onNotStarted?: (notStarted: boolean) => void;
|
||||||
|
/** The session has ended (and no pane holds it): poll slowly, if at all. */
|
||||||
|
ended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConversationViewPane({
|
function ConversationViewPane({
|
||||||
sessionId,
|
sessionId,
|
||||||
initialTranscriptId,
|
initialTranscriptId,
|
||||||
fill = false,
|
fill = false,
|
||||||
|
compact = false,
|
||||||
onOpenFile,
|
onOpenFile,
|
||||||
|
onOpenArtifact,
|
||||||
|
onMessages,
|
||||||
live = false,
|
live = false,
|
||||||
follow = 0,
|
follow = 0,
|
||||||
waiting = false,
|
waiting = false,
|
||||||
@@ -124,22 +149,25 @@ function ConversationViewPane({
|
|||||||
stopped = 0,
|
stopped = 0,
|
||||||
onContext,
|
onContext,
|
||||||
onNotStarted,
|
onNotStarted,
|
||||||
|
ended = false,
|
||||||
}: ConversationViewProps) {
|
}: ConversationViewProps) {
|
||||||
const { t } = useTranslation("sessions");
|
const { t } = useTranslation("sessions");
|
||||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||||
|
const shown = messages;
|
||||||
// A live pane's session has no row until its first hook — and Claude Code
|
// 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
|
// 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.
|
// other question before the session starts) waits unseen in the terminal.
|
||||||
const [notStarted, setNotStarted] = useState(false);
|
const [notStarted, setNotStarted] = useState(false);
|
||||||
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
useEffect(() => onNotStarted?.(notStarted), [notStarted, onNotStarted]);
|
||||||
|
useEffect(() => onMessages?.(shown), [shown, onMessages]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onContext) return;
|
if (!onContext) return;
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = shown.length - 1; i >= 0; i--) {
|
||||||
const m = messages[i]!;
|
const m = shown[i]!;
|
||||||
if (m.type === "assistant" && m.usage)
|
if (m.type === "assistant" && m.usage)
|
||||||
return onContext(promptTokens(m.usage), m.model ?? null);
|
return onContext(promptTokens(m.usage), m.model ?? null);
|
||||||
}
|
}
|
||||||
}, [messages, onContext]);
|
}, [shown, onContext]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
@@ -174,9 +202,31 @@ function ConversationViewPane({
|
|||||||
// Refresh-button spinner state - separate from initial `loading` so the
|
// Refresh-button spinner state - separate from initial `loading` so the
|
||||||
// existing skeleton doesn't blink during a manual refresh.
|
// existing skeleton doesn't blink during a manual refresh.
|
||||||
|
|
||||||
|
// A subagent's own conversation opens inside its step as a small chat: this
|
||||||
|
// same view, on that agent's transcript.
|
||||||
|
const renderAgent = useCallback(
|
||||||
|
(a: AgentRef) => (
|
||||||
|
<ConversationView
|
||||||
|
key={a.agentId}
|
||||||
|
sessionId={sessionId}
|
||||||
|
initialTranscriptId={a.agentId}
|
||||||
|
compact
|
||||||
|
live={live && a.status === "running"}
|
||||||
|
ended={a.status !== "running"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[sessionId, live]
|
||||||
|
);
|
||||||
|
const agentLinks = useMemo<AgentLinks>(() => {
|
||||||
|
const ids = new Map<string, string>();
|
||||||
|
for (const tr of transcripts) if (tr.tool_use_id) ids.set(tr.tool_use_id, tr.id);
|
||||||
|
return { ids, notices: agentNotices(shown), live, render: renderAgent };
|
||||||
|
}, [transcripts, shown, live, renderAgent]);
|
||||||
|
|
||||||
// Load available transcript list (also rescanned on a short interval so
|
// Load available transcript list (also rescanned on a short interval so
|
||||||
// newly-spawned subagents appear in the dropdown without a page reload).
|
// newly-spawned subagents appear in the dropdown without a page reload).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadTranscripts() {
|
async function loadTranscripts() {
|
||||||
try {
|
try {
|
||||||
@@ -188,14 +238,18 @@ function ConversationViewPane({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadTranscripts();
|
loadTranscripts();
|
||||||
const interval = window.setInterval(() => {
|
// An ended session spawns no new sub-agents: the load above (re-run when
|
||||||
|
// it ends, for the last ones) is enough.
|
||||||
|
const interval = ended
|
||||||
|
? undefined
|
||||||
|
: window.setInterval(() => {
|
||||||
if (isShown(onScreen)) loadTranscripts();
|
if (isShown(onScreen)) loadTranscripts();
|
||||||
}, TRANSCRIPTS_REFRESH_MS);
|
}, TRANSCRIPTS_REFRESH_MS);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [sessionId, ended]);
|
||||||
|
|
||||||
// Sync external initialTranscriptId to internal state
|
// Sync external initialTranscriptId to internal state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,6 +266,12 @@ function ConversationViewPane({
|
|||||||
firstLineRef.current = 0;
|
firstLineRef.current = 0;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
// No session yet: no history.
|
||||||
|
if (!sessionId) {
|
||||||
|
setMessages([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -255,6 +315,7 @@ function ConversationViewPane({
|
|||||||
// parsed message we've seen. The server already de-overlaps via
|
// parsed message we've seen. The server already de-overlaps via
|
||||||
// afterLine, so we can safely append.
|
// afterLine, so we can safely append.
|
||||||
const fetchNewMessages = useCallback(async () => {
|
const fetchNewMessages = useCallback(async () => {
|
||||||
|
if (!sessionId) return;
|
||||||
if (fetchingRef.current) {
|
if (fetchingRef.current) {
|
||||||
// Coalesce: remember a trigger arrived during this fetch and re-run
|
// Coalesce: remember a trigger arrived during this fetch and re-run
|
||||||
// exactly once when the in-flight request settles.
|
// exactly once when the in-flight request settles.
|
||||||
@@ -316,7 +377,12 @@ function ConversationViewPane({
|
|||||||
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
() => throttle(() => void fetchNewMessages(), EVENT_FETCH_MS),
|
||||||
[fetchNewMessages]
|
[fetchNewMessages]
|
||||||
);
|
);
|
||||||
|
const fetchPushed = useMemo(
|
||||||
|
() => throttle(() => void fetchNewMessages(), PUSH_FETCH_MS),
|
||||||
|
[fetchNewMessages]
|
||||||
|
);
|
||||||
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
useEffect(() => () => fetchSoon.cancel(), [fetchSoon]);
|
||||||
|
useEffect(() => () => fetchPushed.cancel(), [fetchPushed]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
const unsubscribe = eventBus.subscribe((msg: WSMessage) => {
|
||||||
// Hidden tab or hidden pane: catching up once on show is enough.
|
// Hidden tab or hidden pane: catching up once on show is enough.
|
||||||
@@ -325,13 +391,14 @@ function ConversationViewPane({
|
|||||||
fetchSoon();
|
fetchSoon();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (msg.type !== "new_event") return;
|
if (msg.type !== "new_event" && msg.type !== "transcript_changed") return;
|
||||||
const data = msg.data as { session_id?: string };
|
const data = msg.data as { session_id?: string };
|
||||||
if (data.session_id !== sessionId) return;
|
if (data.session_id !== sessionId) return;
|
||||||
fetchSoon();
|
if (msg.type === "transcript_changed") fetchPushed();
|
||||||
|
else fetchSoon();
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [sessionId, fetchSoon, onScreen]);
|
}, [sessionId, fetchSoon, fetchPushed, onScreen]);
|
||||||
|
|
||||||
onShowRef.current = () => {
|
onShowRef.current = () => {
|
||||||
if (document.visibilityState === "visible") void fetchNewMessages();
|
if (document.visibilityState === "visible") void fetchNewMessages();
|
||||||
@@ -355,9 +422,12 @@ function ConversationViewPane({
|
|||||||
let interval: number | null = null;
|
let interval: number | null = null;
|
||||||
function start() {
|
function start() {
|
||||||
if (interval !== null) return;
|
if (interval !== null) return;
|
||||||
interval = window.setInterval(() => {
|
interval = window.setInterval(
|
||||||
|
() => {
|
||||||
if (isShown(onScreen)) fetchNewMessages();
|
if (isShown(onScreen)) fetchNewMessages();
|
||||||
}, POLL_INTERVAL_MS);
|
},
|
||||||
|
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
|
||||||
|
);
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
if (interval !== null) {
|
if (interval !== null) {
|
||||||
@@ -382,7 +452,7 @@ function ConversationViewPane({
|
|||||||
stop();
|
stop();
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
document.removeEventListener("visibilitychange", onVisibility);
|
||||||
};
|
};
|
||||||
}, [fetchNewMessages, onScreen]);
|
}, [fetchNewMessages, onScreen, ended]);
|
||||||
|
|
||||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||||
// a sync without reloading the page.
|
// a sync without reloading the page.
|
||||||
@@ -490,7 +560,7 @@ function ConversationViewPane({
|
|||||||
|
|
||||||
// Auto-scroll to bottom after initial load
|
// Auto-scroll to bottom after initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && messages.length > 0) {
|
if (!loading && shown.length > 0) {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [loading, scrollToBottom]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
@@ -499,7 +569,7 @@ function ConversationViewPane({
|
|||||||
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
<div className={`relative flex flex-col ${fill ? "h-full" : ""}`} style={{ minHeight: 0 }}>
|
||||||
{/* Toolbar - always rendered after the initial load so users can
|
{/* Toolbar - always rendered after the initial load so users can
|
||||||
refresh even when no messages have streamed yet. */}
|
refresh even when no messages have streamed yet. */}
|
||||||
{!loading && !fill && (
|
{!loading && !fill && !compact && (
|
||||||
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
<div className="flex items-center gap-3 mb-3 flex-shrink-0">
|
||||||
{transcripts.length > 1 && (
|
{transcripts.length > 1 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -547,7 +617,13 @@ function ConversationViewPane({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
className="flex-1 overflow-y-auto [scrollbar-gutter:stable]"
|
||||||
style={fill ? { minHeight: 0 } : { maxHeight: "calc(100vh - 320px)", minHeight: 200 }}
|
style={
|
||||||
|
compact
|
||||||
|
? { maxHeight: "24rem" }
|
||||||
|
: fill
|
||||||
|
? { minHeight: 0 }
|
||||||
|
: { maxHeight: "calc(100vh - 320px)", minHeight: 200 }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* A chat keeps its first turn clear of the pane's top-right controls
|
{/* A chat keeps its first turn clear of the pane's top-right controls
|
||||||
(the outline's file button floats there). */}
|
(the outline's file button floats there). */}
|
||||||
@@ -570,10 +646,12 @@ function ConversationViewPane({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
|
<div
|
||||||
|
className={`flex items-center justify-center text-fg-muted text-sm ${compact ? "py-3" : "py-12"}`}
|
||||||
|
>
|
||||||
{t("conversation.loading")}
|
{t("conversation.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 && notStarted ? (
|
) : shown.length === 0 && notStarted ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
<p className="text-sm text-fg-secondary">{t("conversation.notStarted")}</p>
|
||||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||||
@@ -589,7 +667,7 @@ function ConversationViewPane({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : messages.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
<div className="mx-auto max-w-md py-12 text-center">
|
<div className="mx-auto max-w-md py-12 text-center">
|
||||||
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
<p className="text-sm text-fg-secondary">{t("conversation.empty")}</p>
|
||||||
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
<p className="mt-2 text-xs leading-relaxed text-fg-muted">
|
||||||
@@ -598,13 +676,19 @@ function ConversationViewPane({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MessageList
|
<MessageList
|
||||||
messages={messages}
|
messages={shown}
|
||||||
loading={false}
|
loading={false}
|
||||||
onOpenFile={onOpenFile}
|
onOpenFile={onOpenFile}
|
||||||
|
onOpenArtifact={onOpenArtifact}
|
||||||
live={live}
|
live={live}
|
||||||
waiting={waiting}
|
waiting={waiting}
|
||||||
onOpenTerminal={onOpenTerminal}
|
onOpenTerminal={onOpenTerminal}
|
||||||
stopped={stopped}
|
stopped={stopped}
|
||||||
|
agentLinks={agentLinks}
|
||||||
|
compact={compact}
|
||||||
|
sessionId={
|
||||||
|
selectedTranscript && selectedTranscript !== "main" ? undefined : sessionId
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -631,7 +715,7 @@ function ConversationViewPane({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* New messages indicator */}
|
{/* New messages indicator */}
|
||||||
{showNewMsg && !fill && (
|
{showNewMsg && !fill && !compact && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
* and auto-linked URLs. A file path — absolute (POSIX or a Windows `E:\…`), or relative with a folder and
|
||||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
* 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`
|
* 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
|
* when the page provides one (the page resolves a relative one against its own
|
||||||
@@ -84,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
|||||||
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
||||||
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
const OL_RE = /^(\s*)(\d+)\.\s+(.*)$/;
|
||||||
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
const QUOTE_RE = /^\s*>\s?(.*)$/;
|
||||||
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/;
|
const TABLE_DIVIDER_RE = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/;
|
||||||
|
|
||||||
|
/** A GFM table starts at a line holding a pipe that is followed by a divider. */
|
||||||
|
function isTableStart(lines: string[], i: number): boolean {
|
||||||
|
return lines[i]!.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
function splitTableRow(line: string): string[] {
|
function splitTableRow(line: string): string[] {
|
||||||
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
||||||
@@ -171,7 +176,7 @@ function parseBlocks(src: string): Block[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Table: header line followed by an alignment divider
|
// Table: header line followed by an alignment divider
|
||||||
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER_RE.test(lines[i + 1]!)) {
|
if (isTableStart(lines, i)) {
|
||||||
const header = splitTableRow(line);
|
const header = splitTableRow(line);
|
||||||
const aligns = parseAlignments(lines[i + 1]!);
|
const aligns = parseAlignments(lines[i + 1]!);
|
||||||
i += 2;
|
i += 2;
|
||||||
@@ -227,7 +232,8 @@ function parseBlocks(src: string): Block[] {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paragraph: collect until a blank line or the start of another block
|
// Paragraph: collect until a blank line or the start of another block —
|
||||||
|
// a table included, which (as in GFM) may follow prose with no blank line.
|
||||||
const paraLines: string[] = [line];
|
const paraLines: string[] = [line];
|
||||||
i++;
|
i++;
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
@@ -239,7 +245,8 @@ function parseBlocks(src: string): Block[] {
|
|||||||
HR_RE.test(nl) ||
|
HR_RE.test(nl) ||
|
||||||
UL_RE.test(nl) ||
|
UL_RE.test(nl) ||
|
||||||
OL_RE.test(nl) ||
|
OL_RE.test(nl) ||
|
||||||
QUOTE_RE.test(nl)
|
QUOTE_RE.test(nl) ||
|
||||||
|
isTableStart(lines, i)
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -265,8 +272,14 @@ const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?
|
|||||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||||
const REL_PATH_RE =
|
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;
|
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||||
|
// A Windows absolute path (`E:\notes\a.xlsx`, `C:/x/y.ts:9`): a drive letter and one segment at least.
|
||||||
|
const WIN_PATH_RE =
|
||||||
|
/^[A-Za-z]:[\\/](?:[^\s\\/<>()'"`,;:|*?]+[\\/])*[^\s\\/<>()'"`,;:|*?]*[^\s\\/<>()'"`,;:|*?.!](?::\d+(?::\d+)?)?/;
|
||||||
|
// In inline code the whole text is the path, so a Windows one may hold spaces.
|
||||||
|
const WIN_CODE_PATH_RE = /^[A-Za-z]:[\\/][^<>"|?*\n]*[^\s<>"|?*\\/.]$/;
|
||||||
/** A path at the start of `text`, absolute or relative, or null. */
|
/** 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;
|
const matchPath = (text: string) =>
|
||||||
|
PATH_RE.exec(text)?.[0] ?? WIN_PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||||
// A markdown link target that is a page, not a file.
|
// A markdown link target that is a page, not a file.
|
||||||
@@ -300,7 +313,10 @@ function UrlLink({ url }: { url: string }) {
|
|||||||
|
|
||||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||||
function decodeHref(href: string): string {
|
function decodeHref(href: string): string {
|
||||||
let p = href.replace(/^file:\/\//i, "").replace(/#L(\d+)(?:-L?\d+)?$/, ":$1").replace(/#.*$/, "");
|
let p = href
|
||||||
|
.replace(/^file:\/\//i, "")
|
||||||
|
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
|
||||||
|
.replace(/#.*$/, "");
|
||||||
try {
|
try {
|
||||||
p = decodeURIComponent(p);
|
p = decodeURIComponent(p);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -384,7 +400,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
push(
|
push(
|
||||||
matchPath(codeM[1]!) === codeM[1] ? (
|
matchPath(codeM[1]!) === codeM[1] || WIN_CODE_PATH_RE.test(codeM[1]!) ? (
|
||||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||||
) : (
|
) : (
|
||||||
code
|
code
|
||||||
@@ -440,9 +456,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
|||||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||||
) {
|
) {
|
||||||
push(
|
push(
|
||||||
<PathLink path={decodeHref(href)}>
|
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
|
||||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
|
||||||
</PathLink>
|
|
||||||
);
|
);
|
||||||
i += linkM[0].length;
|
i += linkM[0].length;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentDetails.test.tsx
|
||||||
|
* @description Verifies what the chat shows of a subagent: an Agent step's
|
||||||
|
* facts, task and report (from its result, or the task-notification that
|
||||||
|
* closed a background one), and that "View conversation" opens that
|
||||||
|
* agent's own conversation in place, rendered by the chat's owner.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { MessageList } from "../MessageList";
|
||||||
|
import { agentNotices, agentRef, parseUsage, type AgentLinks } from "../AgentDetails";
|
||||||
|
import type { TranscriptContent, TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||||
|
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||||
|
|
||||||
|
const call: TranscriptContent = {
|
||||||
|
type: "tool_use",
|
||||||
|
id: "t1",
|
||||||
|
name: "Agent",
|
||||||
|
input: { description: "Map the hooks", subagent_type: "Explore", prompt: "Read **server/**" },
|
||||||
|
};
|
||||||
|
const links = (p: Partial<AgentLinks> = {}): AgentLinks => ({
|
||||||
|
ids: new Map(),
|
||||||
|
notices: new Map(),
|
||||||
|
live: false,
|
||||||
|
...p,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("agentRef", () => {
|
||||||
|
it("reads a foreground agent's facts and report, without the orchestrator's footer", () => {
|
||||||
|
const a = agentRef(
|
||||||
|
call,
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "t1",
|
||||||
|
output: "The hooks live in server/routes/hooks.js.\n\nagentId: a1b2 (for resuming)",
|
||||||
|
agent: {
|
||||||
|
id: "a1b2",
|
||||||
|
model: "claude-opus-5-5",
|
||||||
|
status: "completed",
|
||||||
|
duration_ms: 65_000,
|
||||||
|
tokens: 12_000,
|
||||||
|
tool_uses: 7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
links()
|
||||||
|
);
|
||||||
|
expect(a).toMatchObject({
|
||||||
|
agentId: "a1b2",
|
||||||
|
type: "Explore",
|
||||||
|
status: "done",
|
||||||
|
background: false,
|
||||||
|
prompt: "Read **server/**",
|
||||||
|
report: "The hooks live in server/routes/hooks.js.",
|
||||||
|
durationMs: 65_000,
|
||||||
|
tokens: 12_000,
|
||||||
|
toolUses: 7,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes a background agent with its notification's report and usage", () => {
|
||||||
|
const notice = m({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "<task-notification>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n<result>All done</result>\n<usage><subagent_tokens>88007</subagent_tokens><tool_uses>31</tool_uses><duration_ms>132350</duration_ms></usage>\n</task-notification>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const launched: TranscriptContent = {
|
||||||
|
type: "tool_result",
|
||||||
|
id: "t1",
|
||||||
|
output: "Async agent launched successfully.\nagentId: a9b4",
|
||||||
|
};
|
||||||
|
const running = agentRef(call, launched, links({ live: true }));
|
||||||
|
expect(running).toMatchObject({ status: "running", background: true, report: null });
|
||||||
|
const done = agentRef(call, launched, links({ notices: agentNotices([notice]) }));
|
||||||
|
expect(done).toMatchObject({
|
||||||
|
agentId: "a9b4",
|
||||||
|
status: "done",
|
||||||
|
report: "All done",
|
||||||
|
tokens: 88007,
|
||||||
|
toolUses: 31,
|
||||||
|
durationMs: 132350,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds the transcript from the session's list when the result names none", () => {
|
||||||
|
const a = agentRef(call, null, links({ ids: new Map([["t1", "c3d4"]]) }));
|
||||||
|
expect(a).toMatchObject({ agentId: "c3d4", status: "stopped" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses the line form of a usage block", () => {
|
||||||
|
expect(parseUsage("<usage>total_tokens: 500\ntool_uses: 3\nduration_ms: 900</usage>")).toEqual({
|
||||||
|
tokens: 500,
|
||||||
|
toolUses: 3,
|
||||||
|
durationMs: 900,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("an Agent step in the chat", () => {
|
||||||
|
it("shows the agent's facts and report, and opens its conversation in place", () => {
|
||||||
|
const render_ = vi.fn((a: { agentId: string | null }) => <p>chat of {a.agentId}</p>);
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
agentLinks={links({ render: render_ })}
|
||||||
|
messages={[
|
||||||
|
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||||
|
m({ type: "assistant", sender: "assistant", stop_reason: "tool_use", content: [call] }),
|
||||||
|
m({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "t1",
|
||||||
|
output: "Found **3** hooks",
|
||||||
|
agent: {
|
||||||
|
id: "a1b2",
|
||||||
|
model: null,
|
||||||
|
status: "completed",
|
||||||
|
duration_ms: null,
|
||||||
|
tokens: null,
|
||||||
|
tool_uses: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /ran an agent/i }));
|
||||||
|
expect(screen.getByTestId("agent-facts").textContent).toContain("Explore");
|
||||||
|
expect(screen.getByTestId("agent-facts").textContent).toContain("done");
|
||||||
|
expect(screen.getByTestId("agent-facts").textContent).toContain("4 tool calls");
|
||||||
|
expect(screen.getByTestId("agent-step").textContent).toContain("Found 3 hooks");
|
||||||
|
expect(screen.queryByTestId("agent-chat")).toBeNull();
|
||||||
|
fireEvent.click(screen.getByTestId("agent-open"));
|
||||||
|
expect(screen.getByTestId("agent-chat").textContent).toBe("chat of a1b2");
|
||||||
|
// The thread takes the report's place.
|
||||||
|
expect(screen.getByTestId("agent-step").textContent).not.toContain("Found 3 hooks");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* @file ConversationView.pushed.test.tsx
|
||||||
|
* @description The server says a session's transcript grew
|
||||||
|
* (`transcript_changed`): the chat showing that session reads the new lines
|
||||||
|
* at once, and again within a short window after a burst — not at the hook
|
||||||
|
* throttle's 1.5 s pace — while another session's notice fetches nothing.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, 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-21T00:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
has_more: false,
|
||||||
|
first_line: 1,
|
||||||
|
last_line: 1,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushed = (session_id: string) =>
|
||||||
|
act(() => eventBus.publish({ type: "transcript_changed", data: { session_id } } as never));
|
||||||
|
const calls = () => vi.mocked(api.sessions.transcript).mock.calls.length;
|
||||||
|
|
||||||
|
describe("ConversationView on transcript_changed", () => {
|
||||||
|
it("reads its own session's new lines at once, and a burst within 300 ms", async () => {
|
||||||
|
render(<ConversationView sessionId="s1" fill live />);
|
||||||
|
await screen.findByText("hello");
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const before = calls();
|
||||||
|
pushed("s1");
|
||||||
|
expect(calls()).toBe(before + 1);
|
||||||
|
pushed("s1");
|
||||||
|
pushed("s1");
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(350);
|
||||||
|
});
|
||||||
|
expect(calls()).toBe(before + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores another session's transcript", async () => {
|
||||||
|
render(<ConversationView sessionId="s1" fill live />);
|
||||||
|
await screen.findByText("hello");
|
||||||
|
const before = calls();
|
||||||
|
pushed("s2");
|
||||||
|
await act(async () => {});
|
||||||
|
expect(calls()).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,20 @@ describe("file links", () => {
|
|||||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a Windows path, bare or in inline code", () => {
|
||||||
|
const open = withOpen(
|
||||||
|
"File: `E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx`. Also C:/src/a.ts:9, and `D:\\My Docs\\b.md`"
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "C:/src/a.ts:9" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "D:\\My Docs\\b.md" }));
|
||||||
|
expect(open.mock.calls).toEqual([
|
||||||
|
["E:\\Jx2-2017\\notes\\HE-SO-REWARD.xlsx"],
|
||||||
|
["C:/src/a.ts"],
|
||||||
|
["D:\\My Docs\\b.md"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("opens a markdown link to a file instead of navigating", () => {
|
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)");
|
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||||
@@ -153,4 +167,18 @@ describe("<MarkdownContent />", () => {
|
|||||||
expect(container.firstChild).not.toBeNull();
|
expect(container.firstChild).not.toBeNull();
|
||||||
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
|
expect(container.querySelectorAll("p, ul, ol, blockquote, pre, hr").length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a table that follows prose with no blank line (issue #83)", () => {
|
||||||
|
const text = 'Cách kiểm tra:\n| Token xoá | Số chỗ |\n|---|---|\n| `width="N"` | 29 |';
|
||||||
|
const { container } = render(<MarkdownContent text={text} />);
|
||||||
|
expect(container.querySelector("p")?.textContent).toBe("Cách kiểm tra:");
|
||||||
|
expect(container.querySelectorAll("th").length).toBe(2);
|
||||||
|
expect(container.querySelectorAll("tbody tr").length).toBe(1);
|
||||||
|
expect(container.textContent).not.toContain("|---|");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a GFM divider with single dashes", () => {
|
||||||
|
const { container } = render(<MarkdownContent text={"| a | b |\n|-|:-:|\n| 1 | 2 |"} />);
|
||||||
|
expect(container.querySelectorAll("th").length).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* @file MessageList.agents.test.tsx
|
||||||
|
* @description Verifies a live chat's background-agents card reads each
|
||||||
|
* running agent's own transcript (by the agentId its launch result names)
|
||||||
|
* and shows the step it is on and its context size (issue #81).
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MessageList, agentProgress } from "../MessageList";
|
||||||
|
import { api } from "../../../lib/api";
|
||||||
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return { ...actual, api: { sessions: { transcript: vi.fn() } } };
|
||||||
|
});
|
||||||
|
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
const m = (p: Partial<TranscriptMessage>): TranscriptMessage =>
|
||||||
|
({ type: "user", timestamp: at, content: [], ...p }) as TranscriptMessage;
|
||||||
|
|
||||||
|
// The subagent's own transcript: its task, then a search still running.
|
||||||
|
const tail: TranscriptMessage[] = [
|
||||||
|
m({ sender: "orchestrator", content: [{ type: "text", text: "Find copyId" }] }),
|
||||||
|
m({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
usage: { input_tokens: 300, output_tokens: 20, cache_read_input_tokens: 54_000 },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "g1",
|
||||||
|
name: "Grep",
|
||||||
|
input: { pattern: "copyId", description: "Searching for copyId" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const main: TranscriptMessage[] = [
|
||||||
|
m({ sender: "user", content: [{ type: "text", text: "go" }] }),
|
||||||
|
m({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "t1",
|
||||||
|
name: "Agent",
|
||||||
|
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
m({
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "t1",
|
||||||
|
output: JSON.stringify([
|
||||||
|
{ type: "text", text: "Async agent launched successfully.\nagentId: a4118367b503adecc" },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
m({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
content: [{ type: "text", text: "waiting" }],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("background agents' progress", () => {
|
||||||
|
const rects = Element.prototype.getClientRects;
|
||||||
|
beforeEach(() => {
|
||||||
|
// jsdom lays nothing out: make the card count as on screen.
|
||||||
|
Element.prototype.getClientRects = () => [{}] as unknown as DOMRectList;
|
||||||
|
vi.mocked(api.sessions.transcript).mockResolvedValue({
|
||||||
|
messages: tail,
|
||||||
|
total: 2,
|
||||||
|
has_more: false,
|
||||||
|
first_line: 1,
|
||||||
|
last_line: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
Element.prototype.getClientRects = rects;
|
||||||
|
vi.mocked(api.sessions.transcript).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the step an agent is on and its context", () => {
|
||||||
|
expect(agentProgress(tail)).toEqual({ step: "Searching for copyId", context: 54_300 });
|
||||||
|
expect(agentProgress(tail.slice(0, 1))).toEqual({ step: null, context: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows them on the agent's line, from its own transcript", async () => {
|
||||||
|
render(<MessageList live loading={false} messages={main} sessionId="s1" />);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("chat-background-agent-step")).toHaveTextContent(
|
||||||
|
"Searching for copyId"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(api.sessions.transcript).toHaveBeenCalledWith("s1", {
|
||||||
|
agent_id: "a4118367b503adecc",
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent("↓ 54.3K tokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads nothing without the session", () => {
|
||||||
|
render(<MessageList live loading={false} messages={main} />);
|
||||||
|
expect(screen.getByTestId("chat-background-agent")).toBeInTheDocument();
|
||||||
|
expect(api.sessions.transcript).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ describe("the Edited N files card", () => {
|
|||||||
user("fix it"),
|
user("fix it"),
|
||||||
call("1", "Edit", "/w/a.ts"),
|
call("1", "Edit", "/w/a.ts"),
|
||||||
result("1", { added: 3, removed: 1 }),
|
result("1", { added: 3, removed: 1 }),
|
||||||
call("2", "Write", "/w/b.md"),
|
call("2", "Write", "/w/b.json"),
|
||||||
result("2", { added: 10, removed: 0 }),
|
result("2", { added: 10, removed: 0 }),
|
||||||
call("3", "Edit", "/w/a.ts"),
|
call("3", "Edit", "/w/a.ts"),
|
||||||
result("3", { added: 2, removed: 2 }),
|
result("3", { added: 2, removed: 2 }),
|
||||||
@@ -65,8 +65,35 @@ describe("the Edited N files card", () => {
|
|||||||
expect(within(card).getByText("+15")).toBeTruthy();
|
expect(within(card).getByText("+15")).toBeTruthy();
|
||||||
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
expect(within(card).getByText("+5")).toBeTruthy(); // a.ts: 3+2 added
|
||||||
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
expect(within(card).queryByText("c.ts")).toBeNull(); // failed
|
||||||
fireEvent.click(within(card).getByTitle("/w/b.md"));
|
fireEvent.click(within(card).getByTitle("/w/b.json"));
|
||||||
expect(onOpenFile).toHaveBeenCalledWith("/w/b.md");
|
expect(onOpenFile).toHaveBeenCalledWith("/w/b.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a page or document the turn made as an artifact card, opened in the chat's panel", () => {
|
||||||
|
const onOpenFile = vi.fn();
|
||||||
|
const onOpenArtifact = vi.fn();
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
onOpenFile={onOpenFile}
|
||||||
|
onOpenArtifact={onOpenArtifact}
|
||||||
|
messages={[
|
||||||
|
user("make a page"),
|
||||||
|
call("1", "Write", "/w/page.html"),
|
||||||
|
result("1", { added: 20, removed: 0 }),
|
||||||
|
call("2", "Edit", "/w/App.tsx"),
|
||||||
|
result("2", { added: 1, removed: 1 }),
|
||||||
|
user("thanks"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const cards = screen.getAllByTestId("artifact-card");
|
||||||
|
// A .tsx it only edited is project code, not an artifact.
|
||||||
|
expect(cards).toHaveLength(1);
|
||||||
|
expect(cards[0]!.textContent).toContain("page");
|
||||||
|
fireEvent.click(cards[0]!);
|
||||||
|
expect(onOpenArtifact).toHaveBeenCalledWith("/w/page.html");
|
||||||
|
expect(within(screen.getByTestId("edited-files")).getByTitle("/w/App.tsx")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("drags a row as its @path attachment, and the header as every file", () => {
|
it("drags a row as its @path attachment, and the header as every file", () => {
|
||||||
@@ -77,7 +104,7 @@ describe("the Edited N files card", () => {
|
|||||||
user("fix it"),
|
user("fix it"),
|
||||||
call("1", "Edit", "/w/a.ts"),
|
call("1", "Edit", "/w/a.ts"),
|
||||||
result("1", { added: 1, removed: 0 }),
|
result("1", { added: 1, removed: 0 }),
|
||||||
call("2", "Write", "/w/b.md"),
|
call("2", "Write", "/w/b.json"),
|
||||||
result("2", { added: 2, removed: 0 }),
|
result("2", { added: 2, removed: 0 }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -90,14 +117,14 @@ describe("the Edited N files card", () => {
|
|||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
const one = drag(within(card).getByTitle("/w/b.md"));
|
const one = drag(within(card).getByTitle("/w/b.json"));
|
||||||
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
expect(JSON.parse(one["application/x-agenthub-attach"]!)).toEqual([
|
||||||
{ path: "@/w/b.md", name: "b.md" },
|
{ path: "@/w/b.json", name: "b.json" },
|
||||||
]);
|
]);
|
||||||
expect(one["text/plain"]).toBe("/w/b.md");
|
expect(one["text/plain"]).toBe("/w/b.json");
|
||||||
const all = drag(within(card).getByText("Edited 2 files"));
|
const all = drag(within(card).getByText("Edited 2 files"));
|
||||||
expect(
|
expect(
|
||||||
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
JSON.parse(all["application/x-agenthub-attach"]!).map((a: { path: string }) => a.path)
|
||||||
).toEqual(["@/w/a.ts", "@/w/b.md"]);
|
).toEqual(["@/w/a.ts", "@/w/b.json"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
* layout), Main agent / System under their own label — instead of treating
|
* layout), Main agent / System under their own label — instead of treating
|
||||||
* every `type:"user"` line as the user (reported transcript mis-attribution) —
|
* 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`
|
* and folds a run of tool calls into one line; a typed `/command` or `@path`
|
||||||
* shows as a chip.
|
* shows as a chip. A live chat lists the background subagents still running
|
||||||
|
* and does not call their turn done (issue #81).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { MessageList, turnStats, workLabel, workingOn } from "../MessageList";
|
import { MessageList, backgroundAgents, turnStats, workLabel, workingOn } from "../MessageList";
|
||||||
import type { TranscriptMessage } from "../../../lib/types";
|
import type { TranscriptMessage } from "../../../lib/types";
|
||||||
|
|
||||||
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
function msg(partial: Partial<TranscriptMessage>): TranscriptMessage {
|
||||||
@@ -137,7 +138,28 @@ describe("MessageList — sender attribution", () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
expect(screen.getByText(/↓ 123/)).toBeTruthy();
|
// In the hover bar's tooltip, beside the model and time.
|
||||||
|
expect(screen.getByTestId("chat-actions").getAttribute("title")).toMatch(/↓ 123/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a local command as one line with its output, never its caveat", () => {
|
||||||
|
const line = (text: string, sender: "user" | "system" = "user") =>
|
||||||
|
msg({ type: "user", sender, content: [{ type: "text", text }] });
|
||||||
|
render(
|
||||||
|
<MessageList
|
||||||
|
loading={false}
|
||||||
|
messages={[
|
||||||
|
line("<local-command-caveat>Caveat: the command below…</local-command-caveat>", "system"),
|
||||||
|
line("<command-name>/model</command-name><command-args>opus</command-args>"),
|
||||||
|
line("<local-command-stdout>Set model to Opus</local-command-stdout>"),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const row = screen.getByTestId("chat-command");
|
||||||
|
expect(row).toHaveTextContent("/model opus");
|
||||||
|
expect(row).toHaveTextContent("Set model to Opus");
|
||||||
|
expect(screen.queryByText(/Caveat/)).toBeNull();
|
||||||
|
expect(screen.queryByTestId("chat-user")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ends a live chat with the step still running", () => {
|
it("ends a live chat with the step still running", () => {
|
||||||
@@ -167,7 +189,9 @@ describe("MessageList — sender attribution", () => {
|
|||||||
const row = screen.getByTestId("chat-running-step");
|
const row = screen.getByTestId("chat-running-step");
|
||||||
expect(row).toHaveTextContent("Run the full client suite");
|
expect(row).toHaveTextContent("Run the full client suite");
|
||||||
// Like the terminal: the turn's time since the prompt, and its output.
|
// Like the terminal: the turn's time since the prompt, and its output.
|
||||||
expect(row).toHaveTextContent(/\(1m \ds · ↑ 3\.5K tokens\)/);
|
// (the output shows on hover).
|
||||||
|
expect(row).toHaveTextContent(/1m \ds/);
|
||||||
|
expect(row).toHaveTextContent(/↑ 3\.5K tokens/);
|
||||||
// Its result arrived: nothing is running any more.
|
// Its result arrived: nothing is running any more.
|
||||||
rerender(
|
rerender(
|
||||||
<MessageList
|
<MessageList
|
||||||
@@ -340,7 +364,88 @@ describe("MessageList — sender attribution", () => {
|
|||||||
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
content: [{ type: "text", text: "[Request interrupted by user]" }],
|
||||||
});
|
});
|
||||||
expect(state([prompt, call, result, stopped])).toBeNull();
|
expect(state([prompt, call, result, stopped])).toBeNull();
|
||||||
|
// A local command (`/clear`) is answered by the CLI itself: no turn.
|
||||||
|
const local = (text: string) =>
|
||||||
|
msg({ type: "user", sender: "user", timestamp: at, content: [{ type: "text", text }] });
|
||||||
|
const clear = local("<command-name>/clear</command-name>");
|
||||||
|
const out = local("<local-command-stdout></local-command-stdout>");
|
||||||
|
expect(state([clear, out])).toBeNull();
|
||||||
|
// A prompt command (a skill) is still answered by the model.
|
||||||
|
expect(state([clear])).toEqual(thinking);
|
||||||
// Silent for 15 minutes with nothing running: the turn died.
|
// Silent for 15 minutes with nothing running: the turn died.
|
||||||
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
expect(workingOn([prompt], new Map(), now + 20 * 60_000)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("background subagents", () => {
|
||||||
|
const at = new Date().toISOString();
|
||||||
|
const launch = msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
stop_reason: "tool_use",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_use",
|
||||||
|
id: "t1",
|
||||||
|
name: "Agent",
|
||||||
|
input: { description: "Checking copyId coverage", subagent_type: "Explore" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const launched = msg({
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id: "t1",
|
||||||
|
output: JSON.stringify([{ type: "text", text: "Async agent launched successfully." }]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const reply = msg({
|
||||||
|
type: "assistant",
|
||||||
|
sender: "assistant",
|
||||||
|
timestamp: at,
|
||||||
|
stop_reason: "end_turn",
|
||||||
|
content: [{ type: "text", text: "waiting for the agent" }],
|
||||||
|
});
|
||||||
|
const notified = msg({
|
||||||
|
sender: "system",
|
||||||
|
timestamp: at,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "<task-notification>\n<task-id>a1</task-id>\n<tool-use-id>t1</tool-use-id>\n<status>completed</status>\n</task-notification>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const base = [msg({ sender: "user", timestamp: at }), launch, launched, reply];
|
||||||
|
|
||||||
|
it("lists an agent until its notification names the call", () => {
|
||||||
|
const results = (m: TranscriptMessage[]) =>
|
||||||
|
new Map(
|
||||||
|
m.flatMap((x) => x.content.filter((c) => c.type === "tool_result").map((c) => [c.id!, c]))
|
||||||
|
);
|
||||||
|
expect(backgroundAgents(base, results(base)).map((a) => a.block.id)).toEqual(["t1"]);
|
||||||
|
const all = [...base, notified];
|
||||||
|
expect(backgroundAgents(all, results(all))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call the turn done while the agent works", () => {
|
||||||
|
const { rerender } = render(<MessageList live loading={false} messages={base} />);
|
||||||
|
expect(screen.getByTestId("chat-background-agent")).toHaveTextContent(
|
||||||
|
"ExploreChecking copyId coverage"
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Replied in");
|
||||||
|
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("1 agent still running");
|
||||||
|
rerender(<MessageList live loading={false} messages={[...base, notified]} />);
|
||||||
|
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||||
|
expect(screen.getByTestId("chat-turn-done")).toHaveTextContent("Done in");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing once the session is no longer live", () => {
|
||||||
|
render(<MessageList loading={false} messages={base} />);
|
||||||
|
expect(screen.queryByTestId("chat-background-agents")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
* 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/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
|
* 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
|
* Settings → Git; **Partial clone** makes it blobless, and `CloneProgress`
|
||||||
|
* shows its queue place, then git's progress). 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
|
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||||
* AI on the workspace home. Either
|
* AI on the workspace home. Either
|
||||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||||
@@ -30,6 +31,7 @@ import { useDialog } from "../../hooks/useDialog";
|
|||||||
import { CwdAutocomplete } from "../run/RunSetup";
|
import { CwdAutocomplete } from "../run/RunSetup";
|
||||||
import { PathPickButton } from "../PathPickButton";
|
import { PathPickButton } from "../PathPickButton";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
import type { CwdSuggestion } from "../../lib/api";
|
import type { CwdSuggestion } from "../../lib/api";
|
||||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
@@ -116,6 +118,9 @@ export function AddLaneModal({
|
|||||||
// server asked for one — a login for the URL's https host.
|
// server asked for one — a login for the URL's https host.
|
||||||
const [cloneUrl, setCloneUrl] = useState("");
|
const [cloneUrl, setCloneUrl] = useState("");
|
||||||
const [cloneName, setCloneName] = useState("");
|
const [cloneName, setCloneName] = useState("");
|
||||||
|
const [clonePartial, setClonePartial] = useState(false);
|
||||||
|
/** The running clone's progress token (CloneProgress polls it). */
|
||||||
|
const [cloneToken, setCloneToken] = useState<string | null>(null);
|
||||||
const [login, setLogin] = useState<{
|
const [login, setLogin] = useState<{
|
||||||
host: string;
|
host: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -159,6 +164,7 @@ export function AddLaneModal({
|
|||||||
setMode("repo");
|
setMode("repo");
|
||||||
setCloneUrl("");
|
setCloneUrl("");
|
||||||
setCloneName("");
|
setCloneName("");
|
||||||
|
setClonePartial(false);
|
||||||
setLogin(null);
|
setLogin(null);
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
setSourceRepo("");
|
setSourceRepo("");
|
||||||
@@ -236,13 +242,17 @@ export function AddLaneModal({
|
|||||||
const url = cloneUrl.trim();
|
const url = cloneUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
setSshRefused(false);
|
setSshRefused(false);
|
||||||
|
const progressToken = newCloneToken();
|
||||||
|
setCloneToken(progressToken);
|
||||||
try {
|
try {
|
||||||
const result = await api.lanes.clone({
|
const result = await api.lanes.clone({
|
||||||
|
progressToken,
|
||||||
url,
|
url,
|
||||||
parent: repo,
|
parent: repo,
|
||||||
name: cloneName.trim() || undefined,
|
name: cloneName.trim() || undefined,
|
||||||
title: name || undefined,
|
title: name || undefined,
|
||||||
tool,
|
tool,
|
||||||
|
...(clonePartial ? { partial: true } : {}),
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -274,6 +284,8 @@ export function AddLaneModal({
|
|||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
} finally {
|
||||||
|
setCloneToken(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -547,6 +559,23 @@ export function AddLaneModal({
|
|||||||
spellCheck={false}
|
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"
|
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"
|
||||||
/>
|
/>
|
||||||
|
<label
|
||||||
|
className="mt-2 flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||||
|
title={t("clone.partialHint")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="add-lane-clone-partial"
|
||||||
|
checked={clonePartial}
|
||||||
|
onChange={(e) => setClonePartial(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("clone.partial")}
|
||||||
|
</label>
|
||||||
|
{cloneToken && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<CloneProgress token={cloneToken} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* @file CloneHereDialog.tsx
|
||||||
|
* @description The Files page's **New → Clone repository**: clones a URL into
|
||||||
|
* the folder the File Explorer has open, through the same
|
||||||
|
* `POST /api/lanes/clone` as Add workspace → Clone — the server holds the URL
|
||||||
|
* to its allowlist and the folder to the user's own, and clones as the
|
||||||
|
* folder's account with that account's own SSH key / HTTPS login — and opens
|
||||||
|
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
|
||||||
|
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FolderGit2 } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { cloneDirName } from "./AddLaneModal";
|
||||||
|
|
||||||
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
|
export function CloneHereDialog({
|
||||||
|
open,
|
||||||
|
parent,
|
||||||
|
onClose,
|
||||||
|
onCloned,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
/** The absolute folder the clone goes into. */
|
||||||
|
parent: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCloned: (lane: Lane, path: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [login, setLogin] = useState<Login | null>(null);
|
||||||
|
const [sshRefused, setSshRefused] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useDialog(open, panelRef, busy ? () => {} : onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setUrl("");
|
||||||
|
setName("");
|
||||||
|
setLogin(null);
|
||||||
|
setSshRefused(false);
|
||||||
|
setBusy(false);
|
||||||
|
setError(null);
|
||||||
|
}, [open, parent]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
const folder = name.trim() || cloneDirName(url);
|
||||||
|
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const u = url.trim();
|
||||||
|
if (!u || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setSshRefused(false);
|
||||||
|
try {
|
||||||
|
const result = await api.lanes.clone({
|
||||||
|
url: u,
|
||||||
|
parent,
|
||||||
|
name: name.trim() || undefined,
|
||||||
|
...(login?.username.trim() && login.token
|
||||||
|
? {
|
||||||
|
credentials: {
|
||||||
|
username: login.username.trim(),
|
||||||
|
password: login.token,
|
||||||
|
remember: login.remember,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
onCloned(result.lane, result.path);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||||
|
const host = e.auth?.host || u;
|
||||||
|
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 createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
||||||
|
onClick={busy ? undefined : onClose}
|
||||||
|
// A portal still bubbles through React: keep keys away from the File
|
||||||
|
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
data-testid="fx-clone-dialog"
|
||||||
|
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||||
|
<FolderGit2 className="h-4 w-4" />
|
||||||
|
{t("fx.cloneRepo")}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-url`}>
|
||||||
|
{t("clone.urlLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${titleId}-url`}
|
||||||
|
autoFocus
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="https://host/owner/repo.git"
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[11px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-fg-secondary" htmlFor={`${titleId}-name`}>
|
||||||
|
{t("clone.nameLabel")}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${titleId}-name`}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder={cloneDirName(url)}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 break-all text-[11px] text-fg-muted">
|
||||||
|
{t("clone.parentHint", { path: into })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{login && (
|
||||||
|
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
|
||||||
|
<input
|
||||||
|
aria-label={t("clone.username")}
|
||||||
|
placeholder={t("clone.username")}
|
||||||
|
autoComplete="username"
|
||||||
|
value={login.username}
|
||||||
|
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full text-xs"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("clone.token")}
|
||||||
|
placeholder={t("clone.token")}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={login.token}
|
||||||
|
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||||
|
disabled={busy}
|
||||||
|
className="input w-full text-xs"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={login.remember}
|
||||||
|
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{t("clone.remember")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{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 className="flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
|
||||||
|
{t("fx.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !url.trim()}
|
||||||
|
className="btn-primary text-xs"
|
||||||
|
data-testid="fx-clone-submit"
|
||||||
|
>
|
||||||
|
{busy ? t("clone.cloning") : t("fx.cloneSubmit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @file CloneProgress — the live line under a running clone: its place in the
|
||||||
|
* server's clone queue while it waits (clones run a few at a time machine-wide,
|
||||||
|
* `server/lib/clone-queue.js`), then git's own phase, percent, size and speed
|
||||||
|
* with a bar. It polls `GET /api/lanes/clone/progress/:token` every second for
|
||||||
|
* the token the clone's POST carried (`newCloneToken`); a 404 — the POST not
|
||||||
|
* yet arrived, or an older server — just shows nothing yet.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { api, type CloneProgressView } from "../../lib/api";
|
||||||
|
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
|
||||||
|
/** A fresh, unguessable progress token (works on plain http, unlike randomUUID). */
|
||||||
|
export function newCloneToken(): string {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloneProgress({ token }: { token: string }) {
|
||||||
|
const { t } = useTranslation("lanes");
|
||||||
|
const [view, setView] = useState<CloneProgressView | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setView(null);
|
||||||
|
// Never throws: progress is a courtesy, the clone's own answer decides.
|
||||||
|
const poll = () =>
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => api.lanes.cloneProgress(token))
|
||||||
|
.then((v) => {
|
||||||
|
if (alive) setView(v);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
void poll();
|
||||||
|
const timer = setInterval(() => void poll(), POLL_MS);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!view || view.state === "done" || view.state === "failed") return null;
|
||||||
|
|
||||||
|
if (view.state === "queued") {
|
||||||
|
return (
|
||||||
|
<p data-testid="clone-progress" className="text-[11px] text-status-warning">
|
||||||
|
{t("clone.queued", {
|
||||||
|
position: view.position ?? 1,
|
||||||
|
waiting: view.waiting,
|
||||||
|
running: view.running,
|
||||||
|
limit: view.limit,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phase = view.phase
|
||||||
|
? view.phase === "other"
|
||||||
|
? view.label || ""
|
||||||
|
: t(`clone.phase.${view.phase}`)
|
||||||
|
: t("clone.connecting");
|
||||||
|
const percent = view.percent ?? 0;
|
||||||
|
return (
|
||||||
|
<div data-testid="clone-progress" className="space-y-1">
|
||||||
|
<p className="flex justify-between gap-2 text-[11px] text-fg-secondary">
|
||||||
|
<span className="truncate">
|
||||||
|
{phase}
|
||||||
|
{view.phase ? ` ${percent}%` : ""}
|
||||||
|
</span>
|
||||||
|
{view.detail && (
|
||||||
|
<span className="shrink-0 font-mono text-[10px] text-fg-muted">{view.detail}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{view.phase && (
|
||||||
|
<div
|
||||||
|
className="h-1 overflow-hidden rounded bg-surface-2"
|
||||||
|
role="progressbar"
|
||||||
|
aria-valuenow={percent}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={phase}
|
||||||
|
>
|
||||||
|
<div className="h-full bg-accent-solid transition-all" style={{ width: `${percent}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,18 +4,62 @@
|
|||||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
* (`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
|
* 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
|
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||||
* URLs with it, like Add workspace → Clone.
|
* URLs with it, like Add workspace → Clone. Before anything runs, each URL
|
||||||
|
* is checked against the user's own git identity (`GET /api/me/git`): an SSH
|
||||||
|
* link while the account has no SSH key is not cloned at all — the user is
|
||||||
|
* sent to Settings → Git first — and an HTTPS host with no saved login offers
|
||||||
|
* the login row up front (optional: a public repo needs none). The identity is
|
||||||
|
* read again when the window regains focus, so a key made in another tab
|
||||||
|
* counts at once. The running clone shows its queue place, then git's
|
||||||
|
* progress (`CloneProgress`); **Partial clone** sends `partial: true`.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Loader2, X } from "lucide-react";
|
import { Check, Loader2, X } from "lucide-react";
|
||||||
import { api, type LaneSourcesView } from "../../lib/api";
|
import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api";
|
||||||
|
import { CloneProgress, newCloneToken } from "./CloneProgress";
|
||||||
|
|
||||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
type Row = {
|
||||||
|
url: string;
|
||||||
|
state: "wait" | "busy" | "done" | "failed";
|
||||||
|
note?: string;
|
||||||
|
/** The note points at Settings → Git (no key, or a key the host refused). */
|
||||||
|
ssh?: boolean;
|
||||||
|
/** While busy: the progress token CloneProgress polls. */
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||||
|
|
||||||
|
/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */
|
||||||
|
export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null {
|
||||||
|
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url);
|
||||||
|
if (scp?.[1]) return { kind: "ssh", host: scp[1] };
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname };
|
||||||
|
if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host };
|
||||||
|
} catch {
|
||||||
|
/* not a URL yet — the server's allowlist answers that one */
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */
|
||||||
|
function missingFor(
|
||||||
|
url: string,
|
||||||
|
id: GitIdentity | null
|
||||||
|
): { kind: "ssh" | "https"; host: string } | null {
|
||||||
|
const target = cloneTarget(url);
|
||||||
|
if (!id || !target) return null;
|
||||||
|
if (target.kind === "ssh") {
|
||||||
|
const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0);
|
||||||
|
return keys > 0 ? null : target;
|
||||||
|
}
|
||||||
|
return id.https.some((h) => h.host === target.host) ? null : target;
|
||||||
|
}
|
||||||
|
|
||||||
export function CloneSourcesForm({
|
export function CloneSourcesForm({
|
||||||
laneId,
|
laneId,
|
||||||
parent,
|
parent,
|
||||||
@@ -31,6 +75,25 @@ export function CloneSourcesForm({
|
|||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [login, setLogin] = useState<Login | null>(null);
|
const [login, setLogin] = useState<Login | null>(null);
|
||||||
|
const [partial, setPartial] = useState(false);
|
||||||
|
const [identity, setIdentity] = useState<GitIdentity | null>(null);
|
||||||
|
|
||||||
|
// An unknown identity (an older server, a failed read) checks nothing: the
|
||||||
|
// server's own ECREDENTIALS / ESSHAUTH answers still cover it.
|
||||||
|
const loadIdentity = useCallback(
|
||||||
|
() =>
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => api.gitIdentity.get())
|
||||||
|
.then(setIdentity)
|
||||||
|
.catch(() => {}),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadIdentity();
|
||||||
|
const onFocus = () => void loadIdentity();
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [loadIdentity]);
|
||||||
|
|
||||||
const urls = (s: string) => [
|
const urls = (s: string) => [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -44,16 +107,27 @@ export function CloneSourcesForm({
|
|||||||
const cloneAll = async (list: string[]) => {
|
const cloneAll = async (list: string[]) => {
|
||||||
if (busy || !list.length) return;
|
if (busy || !list.length) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
// An SSH link with no key on the account would only fail at the host:
|
||||||
|
// it is not tried, and its row says what to set up first.
|
||||||
|
const next: Row[] = list.map((url) => {
|
||||||
|
const miss = missingFor(url, identity);
|
||||||
|
return miss?.kind === "ssh"
|
||||||
|
? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true }
|
||||||
|
: { url, state: "wait" };
|
||||||
|
});
|
||||||
const show = () => setRows([...next]);
|
const show = () => setRows([...next]);
|
||||||
show();
|
show();
|
||||||
let asked: Login | null = null;
|
let asked: Login | null = null;
|
||||||
for (const row of next) {
|
for (const row of next) {
|
||||||
|
if (row.state === "failed") continue;
|
||||||
row.state = "busy";
|
row.state = "busy";
|
||||||
|
row.token = newCloneToken();
|
||||||
show();
|
show();
|
||||||
try {
|
try {
|
||||||
const view = await api.lanes.cloneFolder(laneId, {
|
const view = await api.lanes.cloneFolder(laneId, {
|
||||||
url: row.url,
|
url: row.url,
|
||||||
|
progressToken: row.token,
|
||||||
|
...(partial ? { partial: true } : {}),
|
||||||
...(login?.username.trim() && login.token
|
...(login?.username.trim() && login.token
|
||||||
? {
|
? {
|
||||||
credentials: {
|
credentials: {
|
||||||
@@ -80,10 +154,12 @@ export function CloneSourcesForm({
|
|||||||
};
|
};
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
row.note = t("clone.sshRefused", { host });
|
row.note = t("clone.sshRefused", { host });
|
||||||
|
row.ssh = true;
|
||||||
} else {
|
} else {
|
||||||
row.note = e.message || String(err);
|
row.note = e.message || String(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
row.token = undefined;
|
||||||
show();
|
show();
|
||||||
}
|
}
|
||||||
if (asked) setLogin(asked);
|
if (asked) setLogin(asked);
|
||||||
@@ -99,6 +175,18 @@ export function CloneSourcesForm({
|
|||||||
|
|
||||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||||
|
|
||||||
|
// Checked as the user types, so a missing key is said before Clone is pressed.
|
||||||
|
const missing = urls(text)
|
||||||
|
.map((u) => missingFor(u, identity))
|
||||||
|
.filter((m): m is NonNullable<typeof m> => m !== null);
|
||||||
|
const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))];
|
||||||
|
const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))];
|
||||||
|
const settingsLink = (
|
||||||
|
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||||
|
{t("clone.sshSettings")}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
<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>
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||||
@@ -112,6 +200,41 @@ export function CloneSourcesForm({
|
|||||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
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>
|
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-1.5 text-[11px] text-fg-secondary"
|
||||||
|
title={t("clone.partialHint")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="workspace-clone-partial"
|
||||||
|
checked={partial}
|
||||||
|
onChange={(e) => setPartial(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("clone.partial")}
|
||||||
|
</label>
|
||||||
|
{sshHosts.length > 0 && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
data-testid="workspace-clone-need-ssh"
|
||||||
|
className="text-[11px] text-status-danger"
|
||||||
|
>
|
||||||
|
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{httpsHosts.length > 0 && !login && (
|
||||||
|
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||||
|
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline"
|
||||||
|
onClick={() =>
|
||||||
|
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.cloneEnterLogin")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{login && (
|
{login && (
|
||||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -178,7 +301,17 @@ export function CloneSourcesForm({
|
|||||||
{r.url}
|
{r.url}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
{r.state === "busy" && r.token && (
|
||||||
|
<div className="ml-[18px] mt-0.5">
|
||||||
|
<CloneProgress token={r.token} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{r.note && (
|
||||||
|
<span className="ml-[18px] block text-status-danger">
|
||||||
|
{r.note}
|
||||||
|
{r.ssh && <> {settingsLink}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -18,7 +18,12 @@
|
|||||||
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
* or dropped from the desktop), streamed with progress, ≤ 500 MB a batch;
|
||||||
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
* drag items onto a folder to move them (Ctrl: copy); download a file as
|
||||||
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
* itself, anything else as one zip; a taken name asks Replace / Keep both /
|
||||||
* Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js).
|
* Skip. Deleting forever asks first when it empties the Trash or touches
|
||||||
|
* another user's. An admin's Trash opens on one folder per user (with its
|
||||||
|
* item count); each folder is THAT user's Trash, to restore from or purge
|
||||||
|
* without waiting the 7 days.
|
||||||
|
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
||||||
|
* (server/lib/file-explorer.js).
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -82,12 +87,52 @@ import {
|
|||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { formatBytes } from "../../lib/format";
|
import { formatBytes } from "../../lib/format";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
|
import { CloneHereDialog } from "./CloneHereDialog";
|
||||||
|
|
||||||
/** One upload batch at most (the server caps one file the same). */
|
/** One upload batch at most (the server caps one file the same). */
|
||||||
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
||||||
|
/** Files up to this size travel together, many per request (`uploadBatch`):
|
||||||
|
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
||||||
|
const SMALL_FILE = 4 * 1024 * 1024;
|
||||||
|
const PACK_BYTES = 32 * 1024 * 1024;
|
||||||
|
const PACK_FILES = 1000;
|
||||||
|
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
||||||
|
const PACK_NAME_BYTES = 400 * 1024;
|
||||||
|
|
||||||
|
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
||||||
|
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
||||||
|
const packs: T[][] = [];
|
||||||
|
const singles: T[] = [];
|
||||||
|
let cur: T[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
let names = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.file.size > SMALL_FILE) {
|
||||||
|
singles.push(f);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const n = f.rel.length * 3 + 64;
|
||||||
|
if (
|
||||||
|
cur.length &&
|
||||||
|
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
||||||
|
) {
|
||||||
|
packs.push(cur);
|
||||||
|
cur = [];
|
||||||
|
bytes = 0;
|
||||||
|
names = 0;
|
||||||
|
}
|
||||||
|
cur.push(f);
|
||||||
|
bytes += f.file.size;
|
||||||
|
names += n;
|
||||||
|
}
|
||||||
|
if (cur.length) packs.push(cur);
|
||||||
|
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
||||||
|
return { packs: packs.filter((p) => p.length >= 2), singles };
|
||||||
|
}
|
||||||
const MIME = "application/x-agenthub-fx";
|
const MIME = "application/x-agenthub-fx";
|
||||||
const VIEW_KEY = "agenthub.files.view";
|
const VIEW_KEY = "agenthub.files.view";
|
||||||
const HIDDEN_KEY = "agenthub.files.hidden";
|
const HIDDEN_KEY = "agenthub.files.hidden";
|
||||||
@@ -96,6 +141,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
|||||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||||
let clipboard: Clip | null = null;
|
let clipboard: Clip | null = null;
|
||||||
|
|
||||||
|
/** A menu item's handler that closes its menu first. */
|
||||||
|
const pick = (m: { close: () => void }, fn: () => void) => () => {
|
||||||
|
m.close();
|
||||||
|
fn();
|
||||||
|
};
|
||||||
/** The workspace id of a place, or null for a home. */
|
/** The workspace id of a place, or null for a home. */
|
||||||
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
||||||
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
||||||
@@ -250,9 +300,14 @@ export function FileBrowser({
|
|||||||
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
||||||
const [query, setQuery] = useState<string | null>(null);
|
const [query, setQuery] = useState<string | null>(null);
|
||||||
const [trash, setTrash] = useState<FsTrashItem[]>([]);
|
const [trash, setTrash] = useState<FsTrashItem[]>([]);
|
||||||
|
// An admin's Trash opens on its root: one folder per user's Trash, counted
|
||||||
|
// from `GET /api/homes/trash` (a Trash that could not be read: its error).
|
||||||
|
const [trashRoot, setTrashRoot] = useState(false);
|
||||||
|
const [trashErrors, setTrashErrors] = useState<Record<string, string>>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||||
|
const [cloning, setCloning] = useState(false);
|
||||||
const [ask, setAsk] = useState<Ask | null>(null);
|
const [ask, setAsk] = useState<Ask | null>(null);
|
||||||
const [askAll, setAskAll] = useState(false);
|
const [askAll, setAskAll] = useState(false);
|
||||||
const [progress, setProgress] = useState<{
|
const [progress, setProgress] = useState<{
|
||||||
@@ -269,6 +324,9 @@ export function FileBrowser({
|
|||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
const folderInput = useRef<HTMLInputElement>(null);
|
const folderInput = useRef<HTMLInputElement>(null);
|
||||||
const ctx = useContextMenu<FsEntry | null>();
|
const ctx = useContextMenu<FsEntry | null>();
|
||||||
|
// The sidebar's places and the path bar's crumbs have their own menus.
|
||||||
|
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
|
||||||
|
const crumbCtx = useContextMenu<string>();
|
||||||
|
|
||||||
// A new caller target (another workspace's Files tab, the page's URL) —
|
// A new caller target (another workspace's Files tab, the page's URL) —
|
||||||
// not the echo of our own `go()` through the page's URL, which would wipe
|
// not the echo of our own `go()` through the page's URL, which would wipe
|
||||||
@@ -296,6 +354,22 @@ export function FileBrowser({
|
|||||||
}, []);
|
}, []);
|
||||||
// Another user's home (an admin's view) is read-only.
|
// Another user's home (an admin's view) is read-only.
|
||||||
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly;
|
||||||
|
// Clone into the open folder: the absolute path of a workspace's own folder
|
||||||
|
// or of the user's own home (never a session worktree, an extra folder or
|
||||||
|
// another user's home); the server checks the folder again.
|
||||||
|
const cloneRoot = isHomePlace(target)
|
||||||
|
? target.home === "me"
|
||||||
|
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
||||||
|
: null
|
||||||
|
: typeof target === "number"
|
||||||
|
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
||||||
|
: null;
|
||||||
|
const cloneParent =
|
||||||
|
cloneRoot && view === "files"
|
||||||
|
? path
|
||||||
|
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
||||||
|
: cloneRoot
|
||||||
|
: null;
|
||||||
|
|
||||||
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
||||||
const go = (p: string, tgt: FsPlace = target) => {
|
const go = (p: string, tgt: FsPlace = target) => {
|
||||||
@@ -330,7 +404,12 @@ export function FileBrowser({
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
if (view === "trash") setTrash((await api.files.trash(target)).items);
|
if (view === "trash" && trashRoot) {
|
||||||
|
const out = await api.files.allTrash();
|
||||||
|
setTrash(out.items);
|
||||||
|
// A user whose trash could not be read is said so on their folder.
|
||||||
|
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
|
||||||
|
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
||||||
else setListing(await api.files.list(target, path));
|
else setListing(await api.files.list(target, path));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||||
@@ -338,10 +417,11 @@ export function FileBrowser({
|
|||||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||||
);
|
);
|
||||||
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
||||||
|
if (view === "trash") setTrash([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [target, path, view, t]);
|
}, [target, path, view, trashRoot, t]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
@@ -518,17 +598,18 @@ export function FileBrowser({
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
|
let done = 0;
|
||||||
let saved = 0;
|
let saved = 0;
|
||||||
let forAll: FsConflict | null = null;
|
let forAll: FsConflict | null = null;
|
||||||
try {
|
// false once the server says a batch cannot be written here (an old helper).
|
||||||
for (let i = 0; i < files.length; i++) {
|
let batchable = true;
|
||||||
const { file, rel } = files[i]!;
|
const show = (name: string, extra = 0) =>
|
||||||
|
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
||||||
|
/** One file; false = the user cancelled. */
|
||||||
|
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||||
const dest = join(dir, rel);
|
const dest = join(dir, rel);
|
||||||
setProgress({ name: rel, done: i, total: files.length, sent, bytes });
|
show(rel);
|
||||||
const send = (c: FsConflict) =>
|
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
||||||
api.files.upload(target, dest, file, c, (n) =>
|
|
||||||
setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes })
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||||
if (errCode(e) !== "EEXISTS") throw e;
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
@@ -539,11 +620,73 @@ export function FileBrowser({
|
|||||||
});
|
});
|
||||||
if (!r.skipped) saved++;
|
if (!r.skipped) saved++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (errCode(e) === "ECANCEL") break;
|
if (errCode(e) === "ECANCEL") return false;
|
||||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||||
}
|
}
|
||||||
sent += file.size;
|
sent += file.size;
|
||||||
|
done++;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
||||||
|
const pack = async (b: { file: File; rel: string }[]) => {
|
||||||
|
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
||||||
|
const size = b.reduce((n, f) => n + f.file.size, 0);
|
||||||
|
show(label);
|
||||||
|
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
||||||
|
const choose = async (taken: string[]) => {
|
||||||
|
if (forAll) return forAll;
|
||||||
|
if (!taken.length) return "fail" as const;
|
||||||
|
const a = await askConflict(taken, files.length > 1);
|
||||||
|
if (a.all && a.choice) forAll = a.choice;
|
||||||
|
return a.choice;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const c = await api.files.uploadCheck(
|
||||||
|
target,
|
||||||
|
dir,
|
||||||
|
b.map((f) => f.rel)
|
||||||
|
);
|
||||||
|
if (!c.batch) {
|
||||||
|
batchable = false;
|
||||||
|
return "fallback";
|
||||||
}
|
}
|
||||||
|
let policy = await choose(c.taken);
|
||||||
|
if (!policy) return "cancel";
|
||||||
|
const send = (p: FsConflict) =>
|
||||||
|
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
||||||
|
const r = await send(policy).catch(async (e) => {
|
||||||
|
// Taken meanwhile: ask about those, then once more.
|
||||||
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
|
policy = await choose((e as { names?: string[] }).names ?? []);
|
||||||
|
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
||||||
|
return send(policy);
|
||||||
|
});
|
||||||
|
saved += r.saved;
|
||||||
|
} catch (e) {
|
||||||
|
const code = errCode(e);
|
||||||
|
if (code === "ECANCEL") return "cancel";
|
||||||
|
// Refused before a byte was written (a bad name, an old helper…):
|
||||||
|
// each file then gets its own answer.
|
||||||
|
if (code === "EUPGRADE") batchable = false;
|
||||||
|
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
||||||
|
return "fallback";
|
||||||
|
}
|
||||||
|
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||||
|
}
|
||||||
|
sent += size;
|
||||||
|
done += b.length;
|
||||||
|
return "ok";
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { packs, singles } = packUploads(files);
|
||||||
|
let go = true;
|
||||||
|
for (const b of packs) {
|
||||||
|
const r = batchable ? await pack(b) : "fallback";
|
||||||
|
if (r === "cancel") go = false;
|
||||||
|
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
||||||
|
if (!go) break;
|
||||||
|
}
|
||||||
|
for (const f of singles) if (!go || !(await one(f))) break;
|
||||||
} finally {
|
} finally {
|
||||||
setProgress(null);
|
setProgress(null);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -609,7 +752,7 @@ export function FileBrowser({
|
|||||||
writePref(VIEW_KEY, l);
|
writePref(VIEW_KEY, l);
|
||||||
};
|
};
|
||||||
const onKey = (e: ReactKeyboardEvent) => {
|
const onKey = (e: ReactKeyboardEvent) => {
|
||||||
if (renaming || creating || ask) return;
|
if (renaming || creating || ask || cloning) return;
|
||||||
const mod = e.ctrlKey || e.metaKey;
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
const k = e.key.toLowerCase();
|
const k = e.key.toLowerCase();
|
||||||
// Another user's home is read-only: no key that writes.
|
// Another user's home is read-only: no key that writes.
|
||||||
@@ -635,11 +778,82 @@ export function FileBrowser({
|
|||||||
|
|
||||||
const homeName = (who: string) =>
|
const homeName = (who: string) =>
|
||||||
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
||||||
|
/** Delete forever — asked first when it is all of it or someone else's. */
|
||||||
|
const purge = async (id: string) => {
|
||||||
|
if (id === "all" || ro) {
|
||||||
|
const item = trash.find((i) => i.id === id);
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
|
||||||
|
message:
|
||||||
|
id === "all"
|
||||||
|
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
|
||||||
|
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
|
||||||
|
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
const item = trash.find((i) => i.id === id);
|
||||||
|
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
|
||||||
|
};
|
||||||
|
/** Empty one user's whole Trash from the Trash root — asked first. */
|
||||||
|
const emptyTrashOf = async (home: string, count: number) => {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("fx.emptyTrashTitle"),
|
||||||
|
message: t("fx.emptyTrashBody", {
|
||||||
|
count,
|
||||||
|
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
|
||||||
|
}),
|
||||||
|
confirmLabel: t("fx.emptyTrash"),
|
||||||
|
});
|
||||||
|
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
|
||||||
|
};
|
||||||
|
const restoreItem = (id: string) => {
|
||||||
|
const item = trash.find((i) => i.id === id);
|
||||||
|
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Open a Trash. Every user has ONE recycle bin — their home's, over every
|
||||||
|
* place they deleted in — so `home` opens that user's ("me": the caller's);
|
||||||
|
* null (no home to open) falls back to the trash of the place shown, and
|
||||||
|
* "*" opens the root an admin starts on: one folder per user's Trash.
|
||||||
|
*/
|
||||||
|
const openTrash = (home: string | null) => {
|
||||||
|
if (home === "*") {
|
||||||
|
setTrashRoot(true);
|
||||||
|
setView("trash");
|
||||||
|
setSelected(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrashRoot(false);
|
||||||
|
if (home && !(isHomePlace(target) && target.home === home)) {
|
||||||
|
setTarget({ home });
|
||||||
|
setPathState("");
|
||||||
|
setHistory({ back: [], fwd: [] });
|
||||||
|
onNavigate?.({ home }, "");
|
||||||
|
}
|
||||||
|
setView("trash");
|
||||||
|
setSelected(new Set());
|
||||||
|
};
|
||||||
|
// Someone else's Trash can be opened: the Trash starts on its folders.
|
||||||
|
const manyTrashes = homes.some((h) => h.id !== "me");
|
||||||
|
const openTrashes = () =>
|
||||||
|
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
|
||||||
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
||||||
const laneName = (id: number) => {
|
const laneName = (id: number) => {
|
||||||
const l = lanes.find((x) => x.id === id);
|
const l = lanes.find((x) => x.id === id);
|
||||||
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
||||||
};
|
};
|
||||||
|
// A home's Trash is its USER's recycle bin, named after them: another
|
||||||
|
// user's shows THAT user's (an admin may restore from or purge it; the rest
|
||||||
|
// of the home stays read-only). A workspace's (no home to open) is the
|
||||||
|
// trash of whoever owns its folder.
|
||||||
|
const trashLabel = trashRoot
|
||||||
|
? t("fx.trash")
|
||||||
|
: !isHomePlace(target)
|
||||||
|
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
|
||||||
|
: target.home === "me"
|
||||||
|
? t("fx.myTrash")
|
||||||
|
: t("fx.trashOf", { name: homeName(target.home) });
|
||||||
const crumbs = path ? path.split("/") : [];
|
const crumbs = path ? path.split("/") : [];
|
||||||
const menuItem = ctx.menu?.item ?? null;
|
const menuItem = ctx.menu?.item ?? null;
|
||||||
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
||||||
@@ -689,6 +903,19 @@ export function FileBrowser({
|
|||||||
onClick={() => setMenu(null)}
|
onClick={() => setMenu(null)}
|
||||||
data-testid="file-explorer"
|
data-testid="file-explorer"
|
||||||
>
|
>
|
||||||
|
{cloneParent && (
|
||||||
|
<CloneHereDialog
|
||||||
|
open={cloning}
|
||||||
|
parent={cloneParent}
|
||||||
|
onClose={() => setCloning(false)}
|
||||||
|
onCloned={(lane, at) => {
|
||||||
|
setCloning(false);
|
||||||
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
||||||
|
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
||||||
|
changed();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* ── Places ── */}
|
{/* ── Places ── */}
|
||||||
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
|
||||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
|
||||||
@@ -697,6 +924,7 @@ export function FileBrowser({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go("", { home: "me" })}
|
onClick={() => go("", { home: "me" })}
|
||||||
|
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||||
className={placeClass(
|
className={placeClass(
|
||||||
view === "files" && isHomePlace(target) && target.home === "me"
|
view === "files" && isHomePlace(target) && target.home === "me"
|
||||||
)}
|
)}
|
||||||
@@ -719,6 +947,7 @@ export function FileBrowser({
|
|||||||
type="button"
|
type="button"
|
||||||
title={t("fx.readOnlyHome")}
|
title={t("fx.readOnlyHome")}
|
||||||
onClick={() => go("", { home: h.id })}
|
onClick={() => go("", { home: h.id })}
|
||||||
|
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||||
className={placeClass(
|
className={placeClass(
|
||||||
view === "files" && isHomePlace(target) && target.home === h.id
|
view === "files" && isHomePlace(target) && target.home === h.id
|
||||||
)}
|
)}
|
||||||
@@ -739,6 +968,7 @@ export function FileBrowser({
|
|||||||
type="button"
|
type="button"
|
||||||
title={l.cwd}
|
title={l.cwd}
|
||||||
onClick={() => go("", l.id)}
|
onClick={() => go("", l.id)}
|
||||||
|
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||||
className={placeClass(
|
className={placeClass(
|
||||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||||
)}
|
)}
|
||||||
@@ -748,17 +978,10 @@ export function FileBrowser({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="border-t border-border p-2">
|
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||||
<button
|
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||||
type="button"
|
<Trash2 className="h-4 w-4 shrink-0" />
|
||||||
onClick={() => {
|
<span className="truncate">{t("fx.trash")}</span>
|
||||||
setView("trash");
|
|
||||||
setSelected(new Set());
|
|
||||||
}}
|
|
||||||
className={placeClass(view === "trash")}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
{t("fx.trash")}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -774,8 +997,8 @@ export function FileBrowser({
|
|||||||
</HeadButton>
|
</HeadButton>
|
||||||
<HeadButton
|
<HeadButton
|
||||||
label={t("fx.up")}
|
label={t("fx.up")}
|
||||||
disabled={view !== "files" || listing?.parent == null}
|
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
|
||||||
onClick={goUp}
|
onClick={view === "trash" ? () => openTrash("*") : goUp}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
</HeadButton>
|
</HeadButton>
|
||||||
@@ -805,15 +1028,36 @@ export function FileBrowser({
|
|||||||
aria-label={t("fx.location")}
|
aria-label={t("fx.location")}
|
||||||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
|
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
|
||||||
>
|
>
|
||||||
{view === "trash" ? (
|
{view === "trash" && manyTrashes ? (
|
||||||
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openTrash("*")}
|
||||||
|
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||||
|
trashRoot ? "font-semibold" : "text-fg-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
|
||||||
|
</button>
|
||||||
|
{!trashRoot && (
|
||||||
|
<span className="flex shrink-0 items-center">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
|
||||||
|
<span className="px-2 py-1 text-sm font-semibold">
|
||||||
|
{isHomePlace(target) ? homeName(target.home) : trashLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : view === "trash" ? (
|
||||||
|
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
|
||||||
|
<Trash2 className="h-4 w-4" /> {trashLabel}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go("")}
|
onClick={() => go("")}
|
||||||
|
onContextMenu={(e) => crumbCtx.open(e, "")}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => void onDrop(e, "")}
|
onDrop={(e) => void onDrop(e, "")}
|
||||||
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||||
@@ -833,6 +1077,7 @@ export function FileBrowser({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
||||||
|
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
|
||||||
onDragOver={(e) => e.preventDefault()}
|
onDragOver={(e) => e.preventDefault()}
|
||||||
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
||||||
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||||
@@ -905,6 +1150,17 @@ export function FileBrowser({
|
|||||||
>
|
>
|
||||||
{t("fx.newFile")}
|
{t("fx.newFile")}
|
||||||
</DropItem>
|
</DropItem>
|
||||||
|
{cloneParent && (
|
||||||
|
<DropItem
|
||||||
|
icon={<FolderGit2 className="h-4 w-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
setMenu(null);
|
||||||
|
setCloning(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("fx.cloneRepo")}
|
||||||
|
</DropItem>
|
||||||
|
)}
|
||||||
</DropButton>
|
</DropButton>
|
||||||
<DropButton
|
<DropButton
|
||||||
label={t("fx.upload")}
|
label={t("fx.upload")}
|
||||||
@@ -1169,12 +1425,26 @@ export function FileBrowser({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : trashRoot ? (
|
||||||
|
<TrashFolders
|
||||||
|
homes={homes}
|
||||||
|
items={trash}
|
||||||
|
errors={trashErrors}
|
||||||
|
error={error}
|
||||||
|
loading={loading}
|
||||||
|
homeName={homeName}
|
||||||
|
onOpen={openTrash}
|
||||||
|
onEmpty={(home, count) => void emptyTrashOf(home, count)}
|
||||||
|
onRefresh={() => void load()}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TrashView
|
<TrashView
|
||||||
items={trash}
|
items={trash}
|
||||||
|
error={error}
|
||||||
laneName={laneName}
|
laneName={laneName}
|
||||||
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
|
onRestore={restoreItem}
|
||||||
onPurge={(id) => void run(() => api.files.purge(target, id))}
|
onPurge={(id) => void purge(id)}
|
||||||
|
onRefresh={() => void load()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1322,6 +1592,11 @@ export function FileBrowser({
|
|||||||
{t("fx.newFolder")}
|
{t("fx.newFolder")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
|
||||||
|
{cloneParent && (
|
||||||
|
<MenuItem onSelect={close(() => setCloning(true))}>
|
||||||
|
{t("fx.cloneRepo")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
<MenuItem onSelect={close(() => fileInput.current?.click())}>
|
||||||
{t("fx.uploadFiles")}
|
{t("fx.uploadFiles")}
|
||||||
@@ -1350,6 +1625,78 @@ export function FileBrowser({
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{placeCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={placeCtx.menu.x}
|
||||||
|
y={placeCtx.menu.y}
|
||||||
|
onClose={placeCtx.close}
|
||||||
|
testId="fx-place-menu"
|
||||||
|
>
|
||||||
|
{"lane" in placeCtx.menu.item
|
||||||
|
? (() => {
|
||||||
|
const l = placeCtx.menu.item.lane;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
|
||||||
|
{t("fx.open")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
|
||||||
|
{t("fx.copyPath")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
: (() => {
|
||||||
|
const home = (placeCtx.menu.item as { home: string }).home;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
|
||||||
|
{t("fx.open")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
|
||||||
|
{t("fx.openTrash")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{crumbCtx.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={crumbCtx.menu.x}
|
||||||
|
y={crumbCtx.menu.y}
|
||||||
|
onClose={crumbCtx.close}
|
||||||
|
testId="fx-crumb-menu"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const dir = crumbCtx.menu.item;
|
||||||
|
const name = dir.split("/").pop() || placeName(target);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onSelect={pick(crumbCtx, () =>
|
||||||
|
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t("fx.downloadZip")}
|
||||||
|
</MenuItem>
|
||||||
|
{!ro && clip && (
|
||||||
|
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
|
||||||
|
{t("fx.pasteInto")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
|
||||||
|
{t("fx.copyPath")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
|
||||||
{ask && (
|
{ask && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
||||||
@@ -1651,20 +1998,157 @@ function InlineName({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An admin's Trash root: one folder per user's Trash (theirs first), each
|
||||||
|
* with its item count — or why it could not be read. Opening one shows that
|
||||||
|
* user's Trash, where its items are restored or deleted forever.
|
||||||
|
*/
|
||||||
|
function TrashFolders({
|
||||||
|
homes,
|
||||||
|
items,
|
||||||
|
errors,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
homeName,
|
||||||
|
onOpen,
|
||||||
|
onEmpty,
|
||||||
|
onRefresh,
|
||||||
|
}: {
|
||||||
|
homes: FsHome[];
|
||||||
|
/** Every Trash's items, merged, each tagged with its `home`. */
|
||||||
|
items: FsTrashItem[];
|
||||||
|
/** Per home id: why its Trash could not be read. */
|
||||||
|
errors: Record<string, string>;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
homeName: (id: string) => string;
|
||||||
|
onOpen: (home: string) => void;
|
||||||
|
onEmpty: (home: string, count: number) => void;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const menu = useContextMenu<string | null>();
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1);
|
||||||
|
const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
data-testid="fx-trash-root"
|
||||||
|
onContextMenu={(e) => menu.open(e, null)}
|
||||||
|
>
|
||||||
|
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||||
|
{t("fx.trashFoldersHint")}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
|
||||||
|
{ordered.map((h) => {
|
||||||
|
const n = counts.get(h.id) ?? 0;
|
||||||
|
const failed = errors[h.id];
|
||||||
|
const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
type="button"
|
||||||
|
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
|
||||||
|
onClick={() => onOpen(h.id)}
|
||||||
|
onContextMenu={(e) => menu.open(e, h.id)}
|
||||||
|
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
|
||||||
|
data-testid="fx-trash-folder"
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<Folder
|
||||||
|
className={`h-14 w-14 ${
|
||||||
|
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{h.id === "me" ? (
|
||||||
|
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||||
|
) : (
|
||||||
|
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="w-full truncate text-[13px] font-medium">{name}</span>
|
||||||
|
<span
|
||||||
|
className={`w-full truncate text-[11px] ${
|
||||||
|
failed ? "text-status-danger" : "text-fg-muted"
|
||||||
|
}`}
|
||||||
|
title={failed}
|
||||||
|
>
|
||||||
|
{failed
|
||||||
|
? failed
|
||||||
|
: loading
|
||||||
|
? "…"
|
||||||
|
: n
|
||||||
|
? t("fx.itemsCount", { count: n })
|
||||||
|
: t("fx.trashFolderEmpty")}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{menu.menu && (
|
||||||
|
<ContextMenu
|
||||||
|
x={menu.menu.x}
|
||||||
|
y={menu.menu.y}
|
||||||
|
onClose={menu.close}
|
||||||
|
testId="fx-trash-root-menu"
|
||||||
|
>
|
||||||
|
{menu.menu.item !== null ? (
|
||||||
|
(() => {
|
||||||
|
const home = menu.menu.item;
|
||||||
|
const n = counts.get(home) ?? 0;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
|
||||||
|
{t("fx.emptyTrash")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TrashView({
|
function TrashView({
|
||||||
items,
|
items,
|
||||||
|
error,
|
||||||
laneName,
|
laneName,
|
||||||
onRestore,
|
onRestore,
|
||||||
onPurge,
|
onPurge,
|
||||||
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
items: FsTrashItem[];
|
items: FsTrashItem[];
|
||||||
|
error: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
laneName: (id: number) => string;
|
laneName: (id: number) => string;
|
||||||
onRestore: (id: string) => void;
|
onRestore: (id: string) => void;
|
||||||
onPurge: (id: string) => void;
|
onPurge: (id: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
|
const menu = useContextMenu<FsTrashItem | null>();
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="fx-trash">
|
<div
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
data-testid="fx-trash"
|
||||||
|
onContextMenu={(e) => menu.open(e, null)}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
|
||||||
<span className="flex-1">{t("fx.trashHint")}</span>
|
<span className="flex-1">{t("fx.trashHint")}</span>
|
||||||
<button
|
<button
|
||||||
@@ -1692,6 +2176,7 @@ function TrashView({
|
|||||||
key={i.id}
|
key={i.id}
|
||||||
className="border-b border-border/40 hover:bg-surface-2"
|
className="border-b border-border/40 hover:bg-surface-2"
|
||||||
data-testid="fx-trash-row"
|
data-testid="fx-trash-row"
|
||||||
|
onContextMenu={(e) => menu.open(e, i)}
|
||||||
>
|
>
|
||||||
<td className="truncate px-4 py-1.5">
|
<td className="truncate px-4 py-1.5">
|
||||||
<span className="flex items-center gap-2" title={i.path}>
|
<span className="flex items-center gap-2" title={i.path}>
|
||||||
@@ -1732,13 +2217,44 @@ function TrashView({
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{!items.length && (
|
{!items.length && !error && (
|
||||||
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
||||||
<Trash2 className="h-14 w-14 opacity-40" />
|
<Trash2 className="h-14 w-14 opacity-40" />
|
||||||
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{menu.menu && (
|
||||||
|
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
|
||||||
|
{menu.menu.item ? (
|
||||||
|
(() => {
|
||||||
|
const i = menu.menu.item;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
|
||||||
|
{t("fx.restore")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
|
||||||
|
{t("fx.copyPath")}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
|
||||||
|
{t("fx.purge")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
|
||||||
|
<MenuSeparator />
|
||||||
|
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
|
||||||
|
{t("fx.emptyTrash")}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Discard all: every non-conflicted file in one `discard`, asked once in a
|
||||||
|
// dialog that names how many untracked files it will delete.
|
||||||
|
const discardAll = async () => {
|
||||||
|
const rows = files.filter((f) => !f.conflicted);
|
||||||
|
if (!rows.length) return;
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("changes.discardAllTitle"),
|
||||||
|
message: t("changes.discardAllConfirm", {
|
||||||
|
count: rows.length,
|
||||||
|
untracked: rows.filter((f) => f.untracked).length,
|
||||||
|
}),
|
||||||
|
confirmLabel: t("changes.discardAll"),
|
||||||
|
});
|
||||||
|
if (ok)
|
||||||
|
await act(
|
||||||
|
"discard",
|
||||||
|
rows.map((f) => f.path),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
// resolve / merge-commit / merge-abort: same shape as `act`, other body.
|
||||||
const mergeAct = async (
|
const mergeAct = async (
|
||||||
action: "resolve" | "merge-commit" | "merge-abort",
|
action: "resolve" | "merge-commit" | "merge-abort",
|
||||||
@@ -1728,7 +1749,12 @@ export function LaneChangesPanel({
|
|||||||
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
{commits?.[0]?.sha === commitCtx.menu.item.sha && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
disabled={busy || opOpen || commitCtx.menu.item.parents.length !== 1}
|
// Pushed: the server refuses it (EPUSHED), so say why here
|
||||||
|
// instead of failing after the confirm.
|
||||||
|
disabled={
|
||||||
|
busy || opOpen || headPushed || commitCtx.menu.item.parents.length !== 1
|
||||||
|
}
|
||||||
|
title={headPushed ? t("changes.undoCommitPushed") : undefined}
|
||||||
testId="lane-changes-menu-undo-commit"
|
testId="lane-changes-menu-undo-commit"
|
||||||
onSelect={pick(
|
onSelect={pick(
|
||||||
commitCtx.close,
|
commitCtx.close,
|
||||||
@@ -2751,6 +2777,17 @@ export function LaneChangesPanel({
|
|||||||
>
|
>
|
||||||
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="lane-changes-discard-all"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void discardAll()}
|
||||||
|
title={t("changes.discardAllTitle")}
|
||||||
|
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" aria-hidden />
|
||||||
|
{t("changes.discardAll")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,19 +5,20 @@
|
|||||||
* does: open it the way its click does (a live pane attaches, an ended session
|
* 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,
|
* opens its conversation — never a resume), open a live one in a split,
|
||||||
* rename / pin / unpin / 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
|
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
|
||||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
* after the same confirm for an AI tab, which may be mid-turn): **Sleep** stops
|
||||||
|
* it and keeps the session listed to resume later, **Close tab** stops it and
|
||||||
|
* archives the session.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { api, type RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import type { Session } from "../../lib/types";
|
import type { Session } from "../../lib/types";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { toast } from "../../lib/toast";
|
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||||
|
|
||||||
export function SessionMenu({
|
export function SessionMenu({
|
||||||
laneId,
|
laneId,
|
||||||
@@ -50,24 +51,12 @@ export function SessionMenu({
|
|||||||
onClose();
|
onClose();
|
||||||
fn();
|
fn();
|
||||||
};
|
};
|
||||||
const closeTab = async () => {
|
// Stop archives the session; Sleep keeps it listed to resume later
|
||||||
|
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||||
|
const endTab = (sleep: boolean) => {
|
||||||
if (!run) return;
|
if (!run) return;
|
||||||
onClose();
|
onClose();
|
||||||
if (
|
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
|
||||||
run.tool !== "shell" &&
|
|
||||||
!(await confirmDialog({
|
|
||||||
title: t("tabs.stopTitle", { name: session?.name || t("tree.newSession") }),
|
|
||||||
message: t("tabs.stopBody"),
|
|
||||||
confirmLabel: t("tabs.stopConfirm"),
|
|
||||||
destructive: true,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
try {
|
|
||||||
await api.run.kill(run.id);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -105,8 +94,13 @@ export function SessionMenu({
|
|||||||
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||||
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</MenuItem>}
|
||||||
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
{onArchive && <MenuItem onSelect={pick(onArchive)}>{t("sessionMenu.archive")}</MenuItem>}
|
||||||
|
{canSleep(run) && (
|
||||||
|
<MenuItem testId="session-menu-sleep" onSelect={() => endTab(true)}>
|
||||||
|
{t("tabs.sleep")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{run && (
|
{run && (
|
||||||
<MenuItem danger onSelect={() => void closeTab()}>
|
<MenuItem danger onSelect={() => endTab(false)}>
|
||||||
{t("tabs.close")}
|
{t("tabs.close")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
* first, and a session some tab already holds opens THAT tab instead of a
|
* first, and a session some tab already holds opens THAT tab instead of a
|
||||||
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
* second `--resume` (the server refuses one too, `ESESSIONLIVE`) — the typed
|
||||||
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
* message is pasted there (never sent: Enter is the user's), with a toast; while
|
||||||
* it is live the composer is off. Only Claude and Codex can resume a session by id —
|
* it is live the composer is off. Only Claude, Codex and a catalog tool with
|
||||||
* any other tool's session offers a new session from the workspace home
|
* `resumeById` (omp) can resume a session by id — any other tool's session
|
||||||
|
* offers a new session from the workspace home
|
||||||
* instead. The header carries the transcript page's Resume/Fork
|
* instead. The header carries the transcript page's Resume/Fork
|
||||||
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
* (`SessionResumeButton`, behind its warning). The session's tool and folder
|
||||||
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
* come from its row (`GET /api/sessions/:id`), so a resume runs on the tool the
|
||||||
@@ -32,6 +33,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import i18n from "../../i18n";
|
import i18n from "../../i18n";
|
||||||
import { liveRunFor } from "./WorkspaceSessionList";
|
import { liveRunFor } from "./WorkspaceSessionList";
|
||||||
import { SessionResumeButton } from "./SessionResumeButton";
|
import { SessionResumeButton } from "./SessionResumeButton";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
|
||||||
/** A message for a session that a tab already holds goes into THAT tab as a
|
/** A message for a session that a tab already holds goes into THAT tab as a
|
||||||
* paste, once its terminal is on screen (it mounts after the navigation);
|
* paste, once its terminal is on screen (it mounts after the navigation);
|
||||||
@@ -47,6 +49,7 @@ function pasteWhenShown(runId: string, text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
/** The tools whose launcher resumes a session BY ID (server/lib/pty-run.js). */
|
||||||
|
// Before the catalog loads: the two hand-written launchers resume by id.
|
||||||
const RESUMABLE = new Set(["claude", "codex"]);
|
const RESUMABLE = new Set(["claude", "codex"]);
|
||||||
|
|
||||||
export function SessionReplay({
|
export function SessionReplay({
|
||||||
@@ -65,6 +68,7 @@ export function SessionReplay({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { byId } = useAiTools();
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
@@ -88,9 +92,9 @@ export function SessionReplay({
|
|||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
const tool = session?.tool || "claude";
|
const tool = session?.tool || "claude";
|
||||||
const resumable = RESUMABLE.has(tool);
|
const resumable = byId(tool)?.resumeById ?? RESUMABLE.has(tool);
|
||||||
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
const livePane = liveRunFor(lane, liveRuns, sessionId);
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images, tool === "claude");
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!text || busy || uploading || livePane || !resumable) return;
|
if (!text || busy || uploading || livePane || !resumable) return;
|
||||||
@@ -132,7 +136,11 @@ export function SessionReplay({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<ConversationView sessionId={sessionId} fill />
|
<ConversationView
|
||||||
|
sessionId={sessionId}
|
||||||
|
fill
|
||||||
|
ended={!livePane && !!session && session.status !== "active"}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{livePane ? (
|
{livePane ? (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* @file SessionResumeButton.tsx
|
* @file SessionResumeButton.tsx
|
||||||
* @description The transcript page's explicit Resume and Fork for a Claude
|
* @description The transcript page's explicit Resume and Fork for a Claude
|
||||||
* session inside a workspace. Opening a session never resumes it; this is the
|
* session inside a workspace (Resume only for a catalog tool that resumes by
|
||||||
|
* id — omp's `--resume <id>`). Opening a session never resumes it; this is the
|
||||||
* one place that does, behind a warning, because a resume re-reads the whole
|
* one place that does, behind a warning, because a resume re-reads the whole
|
||||||
* transcript into context. Fork resumes it into a NEW session id
|
* transcript into context. Fork resumes it into a NEW session id
|
||||||
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
* (`forkSession: true` → `claude --resume <id> --fork-session`), leaving the
|
||||||
@@ -26,6 +27,7 @@ import { ConfirmModal } from "../ConfirmModal";
|
|||||||
import { startInFreeTabDetailed } from "./startSession";
|
import { startInFreeTabDetailed } from "./startSession";
|
||||||
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
import { liveRunFor, sessionWorktreeName } from "./WorkspaceSessionList";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
|
||||||
/** A path without its trailing separators — `/` or a Windows `\`. */
|
/** A path without its trailing separators — `/` or a Windows `\`. */
|
||||||
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
const strip = (p: string) => p.replace(/[\\/]+$/, "");
|
||||||
@@ -54,6 +56,7 @@ export function SessionResumeButton({
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lanes } = useLanes();
|
const { lanes } = useLanes();
|
||||||
const { canEditLane } = usePermissions();
|
const { canEditLane } = usePermissions();
|
||||||
|
const { byId } = useAiTools();
|
||||||
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
const [runs, setRuns] = useState<RunHandle[] | null>(null);
|
||||||
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
const [confirming, setConfirming] = useState<"resume" | "fork" | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -70,12 +73,13 @@ export function SessionResumeButton({
|
|||||||
};
|
};
|
||||||
}, [session.id]);
|
}, [session.id]);
|
||||||
|
|
||||||
// Claude only: a Codex session id is not what
|
// Claude, or a catalog tool that resumes by id (no launcher of its own): a
|
||||||
// `codex resume` takes for every kind of thread.
|
// Codex session id is not what `codex resume` takes for every kind of thread.
|
||||||
const tool = session.tool || "claude";
|
const tool = session.tool || "claude";
|
||||||
|
const catalogResume = !!byId(tool)?.resumeById && !byId(tool)?.launcher;
|
||||||
// The workspace that owns it, else the one whose folder holds it.
|
// The workspace that owns it, else the one whose folder holds it.
|
||||||
const lane =
|
const lane =
|
||||||
tool !== "claude" || !session.cwd
|
(tool !== "claude" && !catalogResume) || !session.cwd
|
||||||
? null
|
? null
|
||||||
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
: (lanes.find((l) => l.id === session.lane_id && l.tool === tool) ??
|
||||||
owningLane(session.cwd, tool, lanes));
|
owningLane(session.cwd, tool, lanes));
|
||||||
@@ -140,7 +144,7 @@ export function SessionResumeButton({
|
|||||||
{t("sessions.resume")}
|
{t("sessions.resume")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{resumable && (
|
{resumable && tool === "claude" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirming("fork")}
|
onClick={() => setConfirming("fork")}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
* shell tab at once, like the tab bar's New terminal) and one Model chip (`ModelMenu`: the tool's own list or the
|
||||||
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
* proxy's models, plus Effort); under it the quieter choices — AI Proxy
|
||||||
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
* (Native or a profile), Permission (Claude's mode, Codex's sandbox/approval,
|
||||||
* a catalog tool's bypass) and Local / New worktree — the workspace's Recents — the sessions it
|
* a catalog tool's bypass — a YOLO chip for Codex) and Local / New worktree — the workspace's Recents — the sessions it
|
||||||
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
* owns and has not archived (`?lane=&archived=0`, with each one's state dot,
|
||||||
* **Show more** past the first page, and the sidebar row's Rename/Archive in
|
* **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,
|
* its right-click menu), then the archived ones folded with Unarchive; a live session opens its pane,
|
||||||
@@ -20,7 +20,6 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Checkbox } from "../Checkbox";
|
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -76,7 +76,7 @@ export {
|
|||||||
} from "./startSession";
|
} from "./startSession";
|
||||||
import { startInFreeTab } from "./startSession";
|
import { startInFreeTab } from "./startSession";
|
||||||
import { WorkspaceContext } from "./WorkspaceContext";
|
import { WorkspaceContext } from "./WorkspaceContext";
|
||||||
import { readDefaultMode } from "../../lib/sessionDefaults";
|
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||||
|
|
||||||
/** Recents' page size; Show more adds another. */
|
/** Recents' page size; Show more adds another. */
|
||||||
const RECENT_PAGE = 30;
|
const RECENT_PAGE = 30;
|
||||||
@@ -165,12 +165,14 @@ function HomeBody({
|
|||||||
// Claude and Codex always take a model and an effort; a catalog tool a
|
// 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.
|
// model only when its file names the flag, an effort never.
|
||||||
const hasModel = claude || codex || !!entry?.modelFlag;
|
const hasModel = claude || codex || !!entry?.modelFlag;
|
||||||
// The account's own default (first-run setup) — never bypass.
|
// The account's own defaults (first-run setup, Settings → AI tools →
|
||||||
|
// Session defaults); a pick here is for this session only.
|
||||||
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
const [mode, setMode] = useState<PermissionMode>(readDefaultMode);
|
||||||
const [sandbox, setSandbox] = useState<CodexSandbox>("workspace-write");
|
const [sandbox, setSandbox] = useState<CodexSandbox>(() => readCodexDefaults().sandbox);
|
||||||
const [approval, setApproval] = useState<CodexApproval>("on-request");
|
const [approval, setApproval] = useState<CodexApproval>(() => readCodexDefaults().approval);
|
||||||
// Per session, never remembered.
|
// Off for every tool but Codex, which starts from the account's default.
|
||||||
const [bypass, setBypass] = useState(false);
|
const [bypass, setBypass] = useState(false);
|
||||||
|
useEffect(() => setBypass(codex && readCodexDefaults().bypass), [codex]);
|
||||||
const canBypass = !claude && !!entry?.bypass;
|
const canBypass = !claude && !!entry?.bypass;
|
||||||
const [effort, setEffort] = useState<EffortLevel>("");
|
const [effort, setEffort] = useState<EffortLevel>("");
|
||||||
const hasEffort = claude || codex;
|
const hasEffort = claude || codex;
|
||||||
@@ -254,7 +256,7 @@ function HomeBody({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const text = promptWithImages(prompt, images);
|
const text = promptWithImages(prompt, images, claude);
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
// Enter reaches here past the disabled button: the same guards again.
|
// Enter reaches here past the disabled button: the same guards again.
|
||||||
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
// A ref, not only the state: two Enters in one tick both read `starting` false.
|
||||||
@@ -607,15 +609,25 @@ function HomeBody({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canBypass && (
|
{canBypass && (
|
||||||
<span className="px-2">
|
// A chip like the selects beside it: quiet when off, red when on.
|
||||||
<Checkbox
|
<button
|
||||||
tone="danger"
|
type="button"
|
||||||
checked={bypass}
|
aria-pressed={bypass}
|
||||||
onChange={setBypass}
|
data-testid="workspace-home-bypass"
|
||||||
label={t("run:fields.bypass")}
|
title={t(codex ? "run:fields.yoloHint" : "run:fields.bypass")}
|
||||||
labelClassName="text-xs text-status-danger"
|
onClick={() => setBypass(!bypass)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-status-danger/60 ${
|
||||||
|
bypass
|
||||||
|
? "bg-status-danger/10 font-medium text-status-danger ring-1 ring-inset ring-status-danger/30 hover:bg-status-danger/20"
|
||||||
|
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Zap
|
||||||
|
className={`h-3.5 w-3.5 ${bypass ? "fill-current" : "text-fg-muted"}`}
|
||||||
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
</span>
|
{t(codex ? "run:fields.yolo" : "run:fields.bypassShort")}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{danger && (
|
{danger && (
|
||||||
|
|||||||
@@ -786,9 +786,25 @@ describe("AddLaneModal — Clone", () => {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||||
|
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].partial).toBeUndefined();
|
||||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends partial only when the box is ticked", async () => {
|
||||||
|
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||||
|
lane: laneFixture({ id: 7 }),
|
||||||
|
created: true,
|
||||||
|
path: "/home/an/code/big",
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await fillClone(user, "https://git.example.com/o/big.git");
|
||||||
|
await user.click(screen.getByTestId("add-lane-clone-partial"));
|
||||||
|
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ partial: true }))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||||
vi.mocked(api.lanes.clone)
|
vi.mocked(api.lanes.clone)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for CloneProgress: a queued clone shows its place in the
|
||||||
|
* server's clone queue, a running one git's phase, percent and speed with a
|
||||||
|
* bar, and nothing shows before the server knows the token. Also that
|
||||||
|
* `newCloneToken` makes a token the server accepts.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import "../../../i18n";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/api", () => ({
|
||||||
|
api: { lanes: { cloneProgress: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api, type CloneProgressView } from "../../../lib/api";
|
||||||
|
import { CloneProgress, newCloneToken } from "../CloneProgress";
|
||||||
|
|
||||||
|
const base: CloneProgressView = {
|
||||||
|
state: "running",
|
||||||
|
position: null,
|
||||||
|
running: 2,
|
||||||
|
waiting: 1,
|
||||||
|
limit: 2,
|
||||||
|
phase: null,
|
||||||
|
label: null,
|
||||||
|
percent: null,
|
||||||
|
detail: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("CloneProgress", () => {
|
||||||
|
// Braces: a function returned from beforeEach is run as its teardown.
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the clone's place in the queue while it waits", async () => {
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
state: "queued",
|
||||||
|
position: 1,
|
||||||
|
});
|
||||||
|
render(<CloneProgress token="t" />);
|
||||||
|
expect(await screen.findByTestId("clone-progress")).toHaveTextContent(
|
||||||
|
"Waiting in the clone queue — #1 of 1 (2/2 clones running)"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows git's phase, percent and speed with a bar while it runs", async () => {
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
phase: "receiving",
|
||||||
|
label: "Receiving objects",
|
||||||
|
percent: 45,
|
||||||
|
detail: "450/1000, 120.50 MiB | 5.20 MiB/s",
|
||||||
|
});
|
||||||
|
render(<CloneProgress token="t" />);
|
||||||
|
const line = await screen.findByTestId("clone-progress");
|
||||||
|
expect(line).toHaveTextContent("Downloading 45%");
|
||||||
|
expect(line).toHaveTextContent("120.50 MiB | 5.20 MiB/s");
|
||||||
|
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing while the server does not know the token yet", async () => {
|
||||||
|
vi.mocked(api.lanes.cloneProgress).mockImplementation(() =>
|
||||||
|
Promise.reject(Object.assign(new Error("no such clone"), { code: "ENOTFOUND" }))
|
||||||
|
);
|
||||||
|
render(<CloneProgress token="t" />);
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(screen.queryByTestId("clone-progress")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes tokens the server accepts", () => {
|
||||||
|
const a = newCloneToken();
|
||||||
|
expect(a).toMatch(/^[A-Za-z0-9_-]{16,64}$/);
|
||||||
|
expect(newCloneToken()).not.toBe(a);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
* @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
|
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||||
* re-runs only the failed URLs with that login.
|
* re-runs only the failed URLs with that login. An SSH link while the
|
||||||
|
* account has no key is never sent — the user is pointed at Settings → Git —
|
||||||
|
* and an HTTPS host with no saved login is flagged before Clone. The
|
||||||
|
* partial-clone box adds `partial: true` to every clone.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,16 +14,20 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import "../../../i18n";
|
import "../../../i18n";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { lanes: { cloneFolder: vi.fn() } },
|
api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||||
|
|
||||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||||
|
|
||||||
describe("CloneSourcesForm", () => {
|
describe("CloneSourcesForm", () => {
|
||||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
beforeEach(() => {
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockReset();
|
||||||
|
// Unknown identity: nothing is checked up front.
|
||||||
|
vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server"));
|
||||||
|
});
|
||||||
|
|
||||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -53,9 +60,61 @@ describe("CloneSourcesForm", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||||
|
progressToken: expect.any(String),
|
||||||
url: "https://private.test/b.git",
|
url: "https://private.test/b.git",
|
||||||
credentials: { username: "dev", password: "tok", remember: true },
|
credentials: { username: "dev", password: "tok", remember: true },
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("asks for partial clones when the box is ticked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||||
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||||
|
await user.type(screen.getByTestId("workspace-clone-input"), "https://h/big.git");
|
||||||
|
await user.click(screen.getByTestId("workspace-clone-partial"));
|
||||||
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||||
|
progressToken: expect.any(String),
|
||||||
|
url: "https://h/big.git",
|
||||||
|
partial: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tells an SSH link from an HTTPS one", () => {
|
||||||
|
expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" });
|
||||||
|
expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" });
|
||||||
|
expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" });
|
||||||
|
expect(cloneTarget("not a url")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue({
|
||||||
|
ssh: null,
|
||||||
|
sshKeys: [],
|
||||||
|
https: [],
|
||||||
|
} as unknown as GitIdentity);
|
||||||
|
vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view);
|
||||||
|
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await user.type(
|
||||||
|
screen.getByTestId("workspace-clone-input"),
|
||||||
|
"git@github.com:o/r.git\nhttps://h.test/a.git"
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com");
|
||||||
|
expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test");
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||||
|
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1));
|
||||||
|
expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, {
|
||||||
|
url: "https://h.test/a.git",
|
||||||
|
progressToken: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||||
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
|
||||||
* batch over 500 MB before sending anything, and restores from the Trash.
|
* batch over 500 MB before sending anything, restores from the user's own
|
||||||
|
* Trash, opens an admin's Trash on one folder per user (counted, an unreadable
|
||||||
|
* one saying why), opens each user's Trash from its folder (asking before a
|
||||||
|
* purge there, sending the item's store) and
|
||||||
|
* shows a Trash that could not be read as an error, never as empty.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -12,13 +16,18 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { FileExplorer } from "../FileExplorer";
|
import { FileExplorer } from "../FileExplorer";
|
||||||
import { api, type FsListing } from "../../../lib/api";
|
import { api, type FsListing } from "../../../lib/api";
|
||||||
import { toast } from "../../../lib/toast";
|
import { toast } from "../../../lib/toast";
|
||||||
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
|
|
||||||
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||||
|
source ? { lane, source } : session ? { lane, session } : lane,
|
||||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||||
api: {
|
api: {
|
||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
|
clone: vi.fn(),
|
||||||
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
|
||||||
},
|
},
|
||||||
files: {
|
files: {
|
||||||
@@ -32,9 +41,12 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
copy: vi.fn(),
|
copy: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
trash: vi.fn(),
|
trash: vi.fn(),
|
||||||
|
allTrash: vi.fn(),
|
||||||
restore: vi.fn(),
|
restore: vi.fn(),
|
||||||
purge: vi.fn(),
|
purge: vi.fn(),
|
||||||
upload: vi.fn(),
|
upload: vi.fn(),
|
||||||
|
uploadBatch: vi.fn(),
|
||||||
|
uploadCheck: vi.fn(),
|
||||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -70,6 +82,7 @@ beforeEach(() => {
|
|||||||
} as never);
|
} as never);
|
||||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
||||||
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
||||||
|
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||||
vi.mocked(api.files.homes).mockResolvedValue({
|
vi.mocked(api.files.homes).mockResolvedValue({
|
||||||
homes: [
|
homes: [
|
||||||
{ id: "me", label: "An", readOnly: false },
|
{ id: "me", label: "An", readOnly: false },
|
||||||
@@ -185,6 +198,41 @@ describe("FileExplorer", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||||
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||||
|
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const files = ["a.txt", "b.txt", "c.txt"].map((n) => new File(["hi"], n));
|
||||||
|
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||||
|
const ask = await screen.findByTestId("fx-conflict");
|
||||||
|
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
files.map((file) => ({ file, rel: file.name })),
|
||||||
|
"replace",
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||||
|
expect(api.files.upload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||||
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||||
|
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||||
|
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||||
|
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses a batch over 500 MB before sending anything", async () => {
|
it("refuses a batch over 500 MB before sending anything", async () => {
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
@@ -212,10 +260,178 @@ describe("FileExplorer", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
await user.click(screen.getByRole("button", { name: "Trash" }));
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||||
|
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||||
const trashRow = await screen.findByTestId("fx-trash-row");
|
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||||
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
|
||||||
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
|
await waitFor(() =>
|
||||||
|
expect(api.files.restore).toHaveBeenCalledWith({ home: "me" }, "abc12345", undefined)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens an admin's Trash on one folder per user, each opening that user's Trash", async () => {
|
||||||
|
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "bob12345",
|
||||||
|
name: "old",
|
||||||
|
laneId: null,
|
||||||
|
path: "old",
|
||||||
|
isDir: true,
|
||||||
|
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||||
|
store: "host",
|
||||||
|
home: "bob",
|
||||||
|
owner: "Bob",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errors: [{ home: "me", owner: "An", message: "EMEMBERGONE" }],
|
||||||
|
});
|
||||||
|
vi.mocked(api.files.trash).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "bob12345",
|
||||||
|
name: "old",
|
||||||
|
laneId: null,
|
||||||
|
path: "old",
|
||||||
|
isDir: true,
|
||||||
|
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||||
|
store: "host",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const trashes = screen.getByTestId("fx-trashes");
|
||||||
|
expect(within(trashes).getAllByRole("button")).toHaveLength(1);
|
||||||
|
await user.click(within(trashes).getByRole("button", { name: "Trash" }));
|
||||||
|
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||||
|
expect(folders.map((f) => f.textContent)).toEqual(["My TrashEMEMBERGONE", "Bob1 item(s)"]);
|
||||||
|
expect(screen.queryByTestId("fx-trash-row")).toBeNull();
|
||||||
|
await user.click(folders[1]!);
|
||||||
|
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "bob" }));
|
||||||
|
expect(screen.getByRole("navigation", { name: "Location" }).textContent).toContain("Bob");
|
||||||
|
const trashRow = await screen.findByTestId("fx-trash-row");
|
||||||
|
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ message: expect.stringContaining("Bob's Trash") })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
expect(api.files.purge).not.toHaveBeenCalled();
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
|
await user.click(within(trashRow).getByRole("button", { name: /Delete forever/ }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||||
|
);
|
||||||
|
// Back to the folders through the path bar.
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByRole("navigation", { name: "Location" })).getByRole("button", {
|
||||||
|
name: "Trash",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findAllByTestId("fx-trash-folder")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-clicks a Trash folder to empty it, and a Trash row to restore it", async () => {
|
||||||
|
vi.mocked(api.files.allTrash).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "bob12345",
|
||||||
|
name: "old",
|
||||||
|
laneId: null,
|
||||||
|
path: "old",
|
||||||
|
isDir: true,
|
||||||
|
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||||
|
store: "host",
|
||||||
|
home: "bob",
|
||||||
|
owner: "Bob",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errors: [],
|
||||||
|
});
|
||||||
|
vi.mocked(api.files.trash).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "bob12345",
|
||||||
|
name: "old",
|
||||||
|
laneId: null,
|
||||||
|
path: "old",
|
||||||
|
isDir: true,
|
||||||
|
deletedAt: "2026-10-15T08:00:00.000Z",
|
||||||
|
store: "host",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(api.files.restore).mockResolvedValue({ path: "old", laneId: null });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||||
|
);
|
||||||
|
const folders = await screen.findAllByTestId("fx-trash-folder");
|
||||||
|
// My Trash is empty: its Empty Trash is disabled.
|
||||||
|
fireEvent.contextMenu(folders[0]!);
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||||
|
name: "Empty Trash",
|
||||||
|
})
|
||||||
|
).toHaveProperty("disabled", true);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
fireEvent.contextMenu(folders[1]!);
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", {
|
||||||
|
name: "Empty Trash",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.files.purge).toHaveBeenCalledWith({ home: "bob" }, "all"));
|
||||||
|
expect(screen.queryByTestId("fx-trash-root-menu")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.contextMenu((await screen.findAllByTestId("fx-trash-folder"))[1]!);
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trash-root-menu")).getByRole("menuitem", { name: "Open" })
|
||||||
|
);
|
||||||
|
fireEvent.contextMenu(await screen.findByTestId("fx-trash-row"));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trash-menu")).getByRole("menuitem", { name: "Restore" })
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.files.restore).toHaveBeenCalledWith({ home: "bob" }, "bob12345", "host")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("right-clicks a sidebar workspace and a path crumb", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
fireEvent.contextMenu(screen.getByRole("button", { name: "two" }));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open" })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
|
||||||
|
fireEvent.contextMenu(screen.getByRole("button", { name: "Home" }));
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-place-menu")).getByRole("menuitem", { name: "Open Trash" })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(api.files.trash).toHaveBeenLastCalledWith({ home: "me" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows why a Trash could not be read instead of an empty one", async () => {
|
||||||
|
vi.mocked(api.files.trash).mockRejectedValue(new Error("ENOENT: no such folder"));
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
await user.click(
|
||||||
|
within(screen.getByTestId("fx-trashes")).getByRole("button", { name: "Trash" })
|
||||||
|
);
|
||||||
|
await user.click(await screen.findByRole("button", { name: /My Trash/ }));
|
||||||
|
expect(await screen.findByText(/ENOENT: no such folder/)).toBeTruthy();
|
||||||
|
expect(screen.queryByText("The Trash is empty.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
|
||||||
@@ -242,7 +458,7 @@ describe("FileExplorer", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
await user.click(await screen.findByRole("button", { name: /Bob/ }));
|
await user.click(await screen.findByRole("button", { name: "Bob" }));
|
||||||
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
|
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||||
@@ -255,3 +471,87 @@ describe("FileExplorer", () => {
|
|||||||
expect(api.files.remove).not.toHaveBeenCalled();
|
expect(api.files.remove).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("FileExplorer — New → Clone repository", () => {
|
||||||
|
const openClone = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
await user.click(screen.getByRole("menuitem", { name: "Clone repository" }));
|
||||||
|
return screen.getByTestId("fx-clone-dialog");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("clones into the open folder of the workspace and says where", async () => {
|
||||||
|
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||||
|
lane: { id: 9, title: "repo", cwd: "/w/one/src/repo" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/src/repo",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
fireEvent.doubleClick(await screen.findByText("src"));
|
||||||
|
await screen.findByText("x.js");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
|
||||||
|
expect(within(dialog).getByText(/\/w\/one\/src\/repo/)).toBeInTheDocument();
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.clone).toHaveBeenCalledWith({
|
||||||
|
url: "git@host:o/repo.git",
|
||||||
|
parent: "/w/one/src",
|
||||||
|
name: undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(toast.success).toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for a login on ECREDENTIALS and sends it once", async () => {
|
||||||
|
vi.mocked(api.lanes.clone)
|
||||||
|
.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
lane: { id: 9, title: "r", cwd: "/w/one/r" },
|
||||||
|
created: true,
|
||||||
|
path: "/w/one/r",
|
||||||
|
} as never);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
const login = await within(dialog).findByTestId("fx-clone-login");
|
||||||
|
await user.type(within(login).getByLabelText("Username"), "ann");
|
||||||
|
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||||
|
await user.click(within(dialog).getByTestId("fx-clone-submit"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.clone).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
parent: "/w/one",
|
||||||
|
credentials: { username: "ann", password: "tok", remember: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps typing in the dialog away from the explorer's shortcuts", async () => {
|
||||||
|
vi.mocked(api.files.remove).mockResolvedValue({ trashed: [] });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
open();
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
fireEvent.click(row("a.txt"));
|
||||||
|
const dialog = await openClone(user);
|
||||||
|
const url = within(dialog).getByLabelText("Repository URL");
|
||||||
|
await user.type(url, "abc{Backspace}{Delete}");
|
||||||
|
expect(api.files.remove).not.toHaveBeenCalled();
|
||||||
|
expect(url).toHaveValue("ab");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered in an extra folder", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<FileExplorer open laneId={1} source={3} onClose={vi.fn()} onChanged={vi.fn()} />);
|
||||||
|
await screen.findByText("a.txt");
|
||||||
|
await user.click(screen.getByRole("button", { name: "New" }));
|
||||||
|
expect(screen.queryByRole("menuitem", { name: "Clone repository" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("discards every file at once only after the dialog is confirmed", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
|
||||||
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
||||||
|
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||||
|
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(screen.getByTestId("lane-changes-discard-all"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
|
||||||
|
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
|
||||||
|
confirm: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<LaneChangesPanel laneId={7} />);
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
@@ -1062,6 +1080,20 @@ describe("LaneChangesPanel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not offer Undo for a top commit its upstream already holds", async () => {
|
||||||
|
withParent();
|
||||||
|
vi.mocked(api.lanes.git).mockResolvedValue(
|
||||||
|
facts({ hasRemote: true, upstream: "origin/feat/thing", ahead: 0, behind: 0 })
|
||||||
|
);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<LaneChangesPanel laneId={7} />);
|
||||||
|
await screen.findByTestId("lane-changes-overview");
|
||||||
|
await user.click(screen.getByRole("button", { name: "History" }));
|
||||||
|
const undo = within(await commitMenu(user)).getByTestId("lane-changes-menu-undo-commit");
|
||||||
|
await waitFor(() => expect(undo).toBeDisabled());
|
||||||
|
expect(undo).toHaveAttribute("title", expect.stringMatching(/already on the remote/));
|
||||||
|
});
|
||||||
|
|
||||||
it("cherry-picks only from another branch's history", async () => {
|
it("cherry-picks only from another branch's history", async () => {
|
||||||
withParent();
|
withParent();
|
||||||
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
vi.mocked(api.lanes.git).mockResolvedValue(facts());
|
||||||
|
|||||||
@@ -245,7 +245,9 @@ describe("WorkspaceHome", () => {
|
|||||||
// As the Workspace page: `&run=` replaces the home with the session view.
|
// As the Workspace page: `&run=` replaces the home with the session view.
|
||||||
function HomeUntilRun() {
|
function HomeUntilRun() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
return params.get("run") ? null : <WorkspaceHome lane={lane()} onMenuAction={() => undefined} />;
|
return params.get("run") ? null : (
|
||||||
|
<WorkspaceHome lane={lane()} onMenuAction={() => undefined} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ import { PetSettings } from "../settings/PetSettings";
|
|||||||
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
import { Swatch, THEME_LABELS } from "../settings/AppearanceSettings";
|
||||||
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
import { markOnboarded, useOnboarding } from "../../lib/userPrefs";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MODES,
|
|
||||||
readDefaultMode,
|
readDefaultMode,
|
||||||
readDefaultTool,
|
readDefaultTool,
|
||||||
|
SAFE_MODES,
|
||||||
writeDefaultMode,
|
writeDefaultMode,
|
||||||
writeDefaultTool,
|
writeDefaultTool,
|
||||||
type DefaultMode,
|
type DefaultMode,
|
||||||
@@ -310,7 +310,7 @@ function AgentStep() {
|
|||||||
writeDefaultMode(m);
|
writeDefaultMode(m);
|
||||||
setMode(m);
|
setMode(m);
|
||||||
}}
|
}}
|
||||||
options={DEFAULT_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
options={SAFE_MODES.map((m) => ({ value: m, label: t(`agent.modes.${m}`) }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
<p className="text-xs text-fg-muted">{t("agent.permissionHint")}</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file Onboarding.test.tsx
|
* @file Onboarding.test.tsx
|
||||||
* @description The first-run setup (docs/plans/onboarding): the account's
|
* @description The first-run setup (docs/plans/onboarding): the account's
|
||||||
* preferences are loaded into localStorage on sign-in and every later change
|
* preferences are loaded into localStorage on sign-in and every later change
|
||||||
* is sent back; the starting choices never hold bypass; the wizard shows only
|
* is sent back; the wizard never offers bypass (a Settings default may hold it); the wizard shows only
|
||||||
* for an account that is new to it, writes each pick like Settings does and
|
* for an account that is new to it, writes each pick like Settings does and
|
||||||
* ends in the tour; the tour follows the real UI step by step.
|
* ends in the tour; the tour follows the real UI step by step.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
@@ -55,7 +55,7 @@ vi.mock("../../settings/PetSettings", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
import { __resetUserPrefs, hydrate, useOnboarding } from "../../../lib/userPrefs";
|
||||||
import { readDefaultMode, writeDefaultMode } from "../../../lib/sessionDefaults";
|
import { readDefaultMode, SAFE_MODES, writeDefaultMode } from "../../../lib/sessionDefaults";
|
||||||
import { OnboardingWizard } from "../OnboardingWizard";
|
import { OnboardingWizard } from "../OnboardingWizard";
|
||||||
import { OnboardingTour, startTour } from "../OnboardingTour";
|
import { OnboardingTour, startTour } from "../OnboardingTour";
|
||||||
import { __resetAiTools } from "../../../hooks/useAiTools";
|
import { __resetAiTools } from "../../../hooks/useAiTools";
|
||||||
@@ -98,10 +98,12 @@ describe("account preferences", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never keeps bypass as the starting permission mode", () => {
|
it("keeps a bypass default from Settings, but the pet chat's safe read and the wizard never offer it", () => {
|
||||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||||
expect(readDefaultMode()).toBe("auto");
|
expect(readDefaultMode()).toBe("bypassPermissions");
|
||||||
writeDefaultMode("bypassPermissions" as never);
|
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||||
|
expect(SAFE_MODES).not.toContain("bypassPermissions");
|
||||||
|
writeDefaultMode("nonsense" as never);
|
||||||
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
expect(localStorage.getItem("agenthub.permissionMode")).toBe("bypassPermissions");
|
||||||
writeDefaultMode("plan");
|
writeDefaultMode("plan");
|
||||||
expect(readDefaultMode()).toBe("plan");
|
expect(readDefaultMode()).toBe("plan");
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @file ChatAddMenu.tsx
|
* @file ChatAddMenu.tsx
|
||||||
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
* @description The Chat view's `+` menu, Claude Desktop's: Upload or add
|
||||||
* files (the prompt box's own picker), what is already there to attach —
|
* files (the prompt box's own picker), Choose a file on this computer (a
|
||||||
|
* browser on the dashboard's own machine only: the OS's own picker,
|
||||||
|
* `POST /api/lanes/pick-path`, attaches the file by its path — any size,
|
||||||
|
* nothing uploaded), what is already there to attach —
|
||||||
* Project files (a search of the box's folder, folders too:
|
* Project files (a search of the box's folder, folders too:
|
||||||
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
* `GET /api/run/files?dirs=1`, sent as `@path`), the workspace's Context
|
||||||
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
* files (`GET /api/lanes/:id/sources`) and Recent uploads (this browser's,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
History,
|
History,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Plug,
|
Plug,
|
||||||
@@ -29,7 +33,14 @@ import {
|
|||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { projectAttachment, recentUploads, type Attachment } from "../../lib/promptAttach";
|
import {
|
||||||
|
isLocalBrowser,
|
||||||
|
localAttachment,
|
||||||
|
projectAttachment,
|
||||||
|
recentUploads,
|
||||||
|
type Attachment,
|
||||||
|
} from "../../lib/promptAttach";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -244,6 +255,17 @@ export function ChatAddMenu({
|
|||||||
close();
|
close();
|
||||||
onAttach([item]);
|
onAttach([item]);
|
||||||
};
|
};
|
||||||
|
const pickLocal = async () => {
|
||||||
|
close();
|
||||||
|
// The dialog opens on the desktop, sometimes behind this window.
|
||||||
|
toast.info(t("chat.pickLocalOpen"));
|
||||||
|
try {
|
||||||
|
const r = await api.lanes.pickPath("file", cwd || undefined);
|
||||||
|
if (r.path) onAttach([localAttachment(r.path)]);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
const manage = (tab: "skills" | "plugins") => {
|
const manage = (tab: "skills" | "plugins") => {
|
||||||
close();
|
close();
|
||||||
navigate(`/cc-config?tab=${tab}`);
|
navigate(`/cc-config?tab=${tab}`);
|
||||||
@@ -291,6 +313,16 @@ export function ChatAddMenu({
|
|||||||
onUpload();
|
onUpload();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{isLocalBrowser() && (
|
||||||
|
<Row
|
||||||
|
icon={<HardDrive className="h-4 w-4" />}
|
||||||
|
label={t("chat.pickLocal")}
|
||||||
|
title={t("chat.pickLocalHint")}
|
||||||
|
testId="chat-add-pick-local"
|
||||||
|
onHover={() => setSub(null)}
|
||||||
|
onClick={() => void pickLocal()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Row
|
<Row
|
||||||
icon={<FolderOpen className="h-4 w-4" />}
|
icon={<FolderOpen className="h-4 w-4" />}
|
||||||
label={t("chat.projectFiles")}
|
label={t("chat.projectFiles")}
|
||||||
|
|||||||
@@ -18,15 +18,40 @@
|
|||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type DragEvent } from "react";
|
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, FileText, Loader2, X } from "lucide-react";
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Copy,
|
||||||
|
Download,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
|
Share2,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import { api, gitTarget } from "../../lib/api";
|
import { api, gitTarget } from "../../lib/api";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
import { dragSkill } from "../../lib/promptAttach";
|
import { dragSkill } from "../../lib/promptAttach";
|
||||||
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
|
import { MarkdownPreview } from "../MarkdownPreview";
|
||||||
|
import { CodeBlock } from "../conversation/CodeBlock";
|
||||||
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import type { TranscriptMessage } from "../../lib/types";
|
||||||
|
import {
|
||||||
|
RENDERED,
|
||||||
|
artifactDocument,
|
||||||
|
artifactKind,
|
||||||
|
artifactLang,
|
||||||
|
artifactVersions,
|
||||||
|
frameSandbox,
|
||||||
|
parseCsv,
|
||||||
|
type ArtifactKind,
|
||||||
|
} from "../../lib/artifactView";
|
||||||
|
|
||||||
/** Where a Chat pane's files can be read from. */
|
/** Where a Chat pane's files can be read from. */
|
||||||
export interface PreviewScope {
|
export interface PreviewScope {
|
||||||
@@ -146,33 +171,89 @@ function useContent(path: string, scope: PreviewScope): Content {
|
|||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
/** A CSV/TSV as a table (the first rows; a file is not a spreadsheet). */
|
||||||
|
function CsvTable({ text, tab }: { text: string; tab: boolean }) {
|
||||||
|
const rows = useMemo(() => parseCsv(text, tab ? "\t" : ",").slice(0, 1000), [text, tab]);
|
||||||
|
const [head, ...body] = rows;
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
<table className="w-full border-collapse text-xs">
|
||||||
|
{head && (
|
||||||
|
<thead className="sticky top-0 bg-surface-2">
|
||||||
|
<tr>
|
||||||
|
{head.map((h, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className="border border-border px-2 py-1 text-left font-medium text-fg-primary"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)}
|
||||||
|
<tbody>
|
||||||
|
{body.map((r, i) => (
|
||||||
|
<tr key={i} className="odd:bg-surface-1 even:bg-surface-2/40">
|
||||||
|
{r.map((c, j) => (
|
||||||
|
<td key={j} className="border border-border px-2 py-1 text-fg-secondary">
|
||||||
|
{c}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An HTML, SVG, Mermaid or React artifact, running in its own sandbox:
|
||||||
|
* scripts (none for an SVG) but never the dashboard's origin. */
|
||||||
|
function ArtifactFrame({ kind, text, title }: { kind: ArtifactKind; text: string; title: string }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const doc = useMemo(() => artifactDocument(kind, text, theme === "dark"), [kind, text, theme]);
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
data-testid="artifact-frame"
|
||||||
|
title={title}
|
||||||
|
srcDoc={doc ?? ""}
|
||||||
|
sandbox={frameSandbox(kind)}
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
className={`min-h-0 w-full flex-1 border-0 ${kind === "html" || kind === "react" ? "bg-white" : ""}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type View = "preview" | "code";
|
||||||
|
|
||||||
|
function Body({ path, content, view }: { path: string; content: Content; view: View }) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const loaded = useContent(path, scope);
|
const kind = artifactKind(path);
|
||||||
// An image the route refused (gone, server mode) shows like no preview.
|
// An image the route refused (gone, server mode) shows like no preview.
|
||||||
const [broken, setBroken] = useState<string | null>(null);
|
const [broken, setBroken] = useState<string | null>(null);
|
||||||
const content: Content =
|
const shown: Content =
|
||||||
loaded.kind === "image" && loaded.src === broken ? { kind: "none" } : loaded;
|
content.kind === "image" && content.src === broken ? { kind: "none" } : content;
|
||||||
if (content.kind === "loading") {
|
if (shown.kind === "loading") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
<Loader2 className="h-5 w-5 animate-spin text-fg-muted" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (content.kind === "image") {
|
if (shown.kind === "image") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
<div className="flex flex-1 items-center justify-center overflow-auto p-4">
|
||||||
<img
|
<img
|
||||||
src={content.src}
|
src={shown.src}
|
||||||
alt={baseName(path)}
|
alt={baseName(path)}
|
||||||
onError={() => setBroken(content.src)}
|
onError={() => setBroken(shown.src)}
|
||||||
className="max-h-full max-w-full object-contain"
|
className="max-h-full max-w-full object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (content.kind === "none") {
|
if (shown.kind === "none") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-sm text-fg-muted">
|
||||||
<FileText className="h-8 w-8" />
|
<FileText className="h-8 w-8" />
|
||||||
@@ -181,69 +262,244 @@ function Body({ path, scope }: { path: string; scope: PreviewScope }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
const note = shown.truncated && (
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
|
||||||
{isMarkdown(path) ? (
|
|
||||||
<MarkdownPreview text={content.text} className="p-4" />
|
|
||||||
) : (
|
|
||||||
<pre className="p-4 font-mono text-xs leading-relaxed text-fg-secondary">
|
|
||||||
{content.text}
|
|
||||||
</pre>
|
|
||||||
)}
|
|
||||||
{content.truncated && (
|
|
||||||
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
<p className="px-4 pb-4 text-xs text-fg-muted">{t("chat.outline.truncated")}</p>
|
||||||
)}
|
);
|
||||||
|
if (view === "code" || !RENDERED.has(kind)) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-3">
|
||||||
|
<CodeBlock code={shown.text} lang={artifactLang(path)} maxHeight="none" showLineNumbers />
|
||||||
|
{note}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (kind === "markdown") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
|
<MarkdownPreview text={shown.text} className="p-4" />
|
||||||
|
{note}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kind === "csv") return <CsvTable text={shown.text} tab={/\.tsv$/i.test(path)} />;
|
||||||
|
return <ArtifactFrame kind={kind} text={shown.text} title={baseName(path)} />;
|
||||||
|
}
|
||||||
|
|
||||||
/** One file: header (name · EXT, Copy path, Close) over its content. */
|
const iconBtn =
|
||||||
|
"flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40";
|
||||||
|
|
||||||
|
/** Saves text (or an image's URL) as a file through the browser. */
|
||||||
|
function download(name: string, content: Content) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
if (content.kind === "image") a.href = content.src;
|
||||||
|
else if (content.kind === "text")
|
||||||
|
a.href = URL.createObjectURL(new Blob([content.text], { type: "text/plain;charset=utf-8" }));
|
||||||
|
else return;
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
if (content.kind === "text") setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One file, Claude Desktop's artifact view: name · EXT, Preview | Code, the
|
||||||
|
* versions the conversation made of it, Copy, Download, Share to the
|
||||||
|
* Artifacts Hub, full screen, Close. */
|
||||||
export function FilePreview({
|
export function FilePreview({
|
||||||
path,
|
path,
|
||||||
scope,
|
scope,
|
||||||
onClose,
|
onClose,
|
||||||
className = "",
|
className = "",
|
||||||
|
messages,
|
||||||
}: {
|
}: {
|
||||||
path: string;
|
path: string;
|
||||||
scope: PreviewScope;
|
scope: PreviewScope;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** The conversation, for the versions its Write/Edit calls left. */
|
||||||
|
messages?: TranscriptMessage[];
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
return (
|
const kind = artifactKind(path);
|
||||||
|
const loaded = useContent(path, scope);
|
||||||
|
const [view, setView] = useState<View>(RENDERED.has(kind) ? "preview" : "code");
|
||||||
|
const [full, setFull] = useState(false);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const versions = useMemo(
|
||||||
|
() => (messages ? artifactVersions(messages, path) : []),
|
||||||
|
[messages, path]
|
||||||
|
);
|
||||||
|
// null: the newest — the file as it is now.
|
||||||
|
const [picked, setPicked] = useState<number | null>(null);
|
||||||
|
// Another file opens on its own default tab, at its newest version.
|
||||||
|
useEffect(() => {
|
||||||
|
setPicked(null);
|
||||||
|
setView(RENDERED.has(artifactKind(path)) ? "preview" : "code");
|
||||||
|
}, [path]);
|
||||||
|
const last = Math.max(versions.length - 1, 0);
|
||||||
|
const at = picked ?? last;
|
||||||
|
const older = picked !== null && picked < last ? versions[picked] : null;
|
||||||
|
const content: Content =
|
||||||
|
older && older.content !== null
|
||||||
|
? { kind: "text", text: older.content, truncated: false }
|
||||||
|
: loaded;
|
||||||
|
const known = (i: number) => i === last || versions[i]?.content != null;
|
||||||
|
const step = (dir: -1 | 1) => {
|
||||||
|
for (let i = at + dir; i >= 0 && i <= last; i += dir) {
|
||||||
|
if (known(i)) return setPicked(i === last ? null : i);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const share = async () => {
|
||||||
|
if (sharing) return;
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
const file = slashed(path);
|
||||||
|
const base = slashed(scope.laneCwd).replace(/\/+$/, "");
|
||||||
|
if (scope.laneId !== null && base && file.startsWith(`${base}/`) && picked === null) {
|
||||||
|
await api.artifacts.pinFile({
|
||||||
|
laneId: scope.laneId,
|
||||||
|
path: file.slice(base.length + 1),
|
||||||
|
title: displayName(path),
|
||||||
|
...(scope.sessionId ? { sessionId: scope.sessionId } : {}),
|
||||||
|
});
|
||||||
|
} else if (content.kind === "text") {
|
||||||
|
await api.artifacts.uploadFile(new File([content.text], displayName(path)), {
|
||||||
|
title: displayName(path),
|
||||||
|
});
|
||||||
|
} else throw new Error(t("chat.artifact.cannotShare"));
|
||||||
|
toast.success(t("chat.artifact.shared"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const panel = (
|
||||||
<section
|
<section
|
||||||
data-testid="chat-file-preview"
|
data-testid="chat-file-preview"
|
||||||
className={`flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`}
|
className={
|
||||||
|
full
|
||||||
|
? "fixed inset-4 z-[200] flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl"
|
||||||
|
: `flex min-h-0 min-w-0 flex-col overflow-hidden bg-surface-1 ${className}`
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<header className="flex items-center gap-2 border-b border-border px-4 py-2.5">
|
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
<span className="min-w-0 truncate text-sm text-fg-primary" title={path}>
|
|
||||||
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-fg-muted">{extOf(path)}</span>
|
|
||||||
<div className="ml-auto flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void copyText(path)}
|
onClick={() => void copyText(path)}
|
||||||
title={t("chat.outline.copyPath", { path })}
|
title={t("chat.outline.copyPath", { path })}
|
||||||
aria-label={t("chat.outline.copyPath", { path })}
|
className="min-w-0 truncate text-left text-sm text-fg-primary hover:underline"
|
||||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
>
|
||||||
|
{displayName(path).replace(/\.[A-Za-z0-9]+$/, "")}
|
||||||
|
</button>
|
||||||
|
<span className="flex-shrink-0 text-xs text-fg-muted">{extOf(path)}</span>
|
||||||
|
{versions.length > 1 && (
|
||||||
|
<span
|
||||||
|
data-testid="artifact-versions"
|
||||||
|
className="flex flex-shrink-0 items-center text-xs text-fg-muted"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
disabled={![...Array(at).keys()].some(known)}
|
||||||
|
aria-label={t("chat.artifact.older")}
|
||||||
|
title={t("chat.artifact.older")}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{t("chat.artifact.version", { n: at + 1, total: versions.length })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => step(1)}
|
||||||
|
disabled={at >= last}
|
||||||
|
aria-label={t("chat.artifact.newer")}
|
||||||
|
title={t("chat.artifact.newer")}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex flex-shrink-0 items-center gap-1">
|
||||||
|
{RENDERED.has(kind) && kind !== "image" && (
|
||||||
|
<div role="tablist" className="mr-1 flex rounded-md border border-border p-0.5 text-xs">
|
||||||
|
{(["preview", "code"] as const).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={view === v}
|
||||||
|
data-testid={`artifact-tab-${v}`}
|
||||||
|
onClick={() => setView(v)}
|
||||||
|
className={`rounded px-2 py-0.5 ${view === v ? "bg-surface-3 text-fg-primary" : "text-fg-muted hover:text-fg-primary"}`}
|
||||||
|
>
|
||||||
|
{t(`chat.artifact.${v}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="artifact-copy"
|
||||||
|
onClick={() => void copyText(content.kind === "text" ? content.text : path)}
|
||||||
|
title={t("chat.artifact.copy")}
|
||||||
|
aria-label={t("chat.artifact.copy")}
|
||||||
|
className={iconBtn}
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download(displayName(path), content)}
|
||||||
|
disabled={content.kind !== "text" && content.kind !== "image"}
|
||||||
|
title={t("chat.artifact.download")}
|
||||||
|
aria-label={t("chat.artifact.download")}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="artifact-share"
|
||||||
|
onClick={() => void share()}
|
||||||
|
disabled={sharing || content.kind === "loading" || content.kind === "none"}
|
||||||
|
title={t("chat.artifact.share")}
|
||||||
|
aria-label={t("chat.artifact.share")}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
{sharing ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Share2 className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="artifact-expand"
|
||||||
|
onClick={() => setFull((f) => !f)}
|
||||||
|
title={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||||
|
aria-label={t(full ? "chat.artifact.collapse" : "chat.artifact.expand")}
|
||||||
|
className={iconBtn}
|
||||||
|
>
|
||||||
|
{full ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
title={t("chat.outline.close")}
|
title={t("chat.outline.close")}
|
||||||
aria-label={t("chat.outline.close")}
|
aria-label={t("chat.outline.close")}
|
||||||
className="flex h-7 w-7 items-center justify-center rounded text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
|
className={iconBtn}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<Body path={path} scope={scope} />
|
<Body path={path} content={content} view={view} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
// Full screen: over the whole page, where a pane's edges cannot clip it.
|
||||||
|
return full ? createPortal(panel, document.body) : panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
/** Claude's Uploads dialog: the files on the left, the picked one on the right. */
|
||||||
|
|||||||
@@ -8,8 +8,11 @@
|
|||||||
* prompt it started on, else its tool and number; a combo tab shows every
|
* prompt it started on, else its tool and number; a combo tab shows every
|
||||||
* pane's icon and name. A tab carries a state dot (waiting for you / error /
|
* pane's icon and name. A tab carries a state dot (waiting for you / error /
|
||||||
* running — a combo shows the most urgent, with screen-reader text). A tab's
|
* running — a combo shows the most urgent, with screen-reader text). A tab's
|
||||||
* X stops every session in it (`DELETE /api/run/:id`) — after a confirm when
|
* X stops every session in it AND archives them (`endRuns`,
|
||||||
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
|
* `DELETE /api/run/:id?archive=1`); the moon beside it (and the tab menu's
|
||||||
|
* Sleep) stops its AI sessions but keeps them listed, to resume later. Either
|
||||||
|
* asks first when one is an AI tool, since it may be mid-turn; a shell closes
|
||||||
|
* at once. Arrow
|
||||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||||
@@ -48,13 +51,13 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { House, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
import { House, Moon, Plus, Settings, Settings2, SquareTerminal, X } from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useSettingsHref } from "../../lib/settingsLink";
|
import { useSettingsHref } from "../../lib/settingsLink";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
@@ -432,30 +435,10 @@ export function ConsoleTabs({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// An AI session may be mid-turn: stopping a tab holding one asks first. A
|
// Stop archives the tab's sessions; Sleep keeps them listed to resume
|
||||||
// shell holds no agent.
|
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||||
const close = async (tab: Tab) => {
|
const close = async (tab: Tab, sleep = false) => {
|
||||||
if (
|
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
|
||||||
tab.runs.some((r) => r.tool !== "shell") &&
|
|
||||||
!(await confirmDialog({
|
|
||||||
title:
|
|
||||||
tab.runs.length > 1
|
|
||||||
? t("tabs.stopGroupTitle", { count: tab.runs.length })
|
|
||||||
: t("tabs.stopTitle", { name: label(tab.focus) }),
|
|
||||||
message: t("tabs.stopBody"),
|
|
||||||
confirmLabel: t("tabs.stopConfirm"),
|
|
||||||
destructive: true,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
for (const r of tab.runs) {
|
|
||||||
try {
|
|
||||||
await api.run.kill(r.id);
|
|
||||||
onClosed(r.id);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const startRename = (r: RunHandle) => {
|
const startRename = (r: RunHandle) => {
|
||||||
@@ -638,6 +621,20 @@ export function ConsoleTabs({
|
|||||||
<span className="truncate">{tabLabel(tab)}</span>
|
<span className="truncate">{tabLabel(tab)}</span>
|
||||||
{state && <RunDot state={state} />}
|
{state && <RunDot state={state} />}
|
||||||
</button>
|
</button>
|
||||||
|
{tab.runs.some(canSleep) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`console-tab-sleep-${tab.key}`}
|
||||||
|
onClick={() => void close(tab, true)}
|
||||||
|
aria-label={t("tabs.sleep")}
|
||||||
|
title={t("tabs.sleep")}
|
||||||
|
className={`shrink-0 rounded p-0.5 hover:bg-surface-4 hover:text-fg-primary ${
|
||||||
|
active ? "" : "opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Moon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid={`console-tab-close-${tab.key}`}
|
data-testid={`console-tab-close-${tab.key}`}
|
||||||
@@ -785,6 +782,14 @@ export function ConsoleTabs({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
|
{ctxTab.runs.some(canSleep) && (
|
||||||
|
<MenuItem
|
||||||
|
testId="console-tab-menu-sleep"
|
||||||
|
onSelect={pickTab((tab) => void close(tab, true))}
|
||||||
|
>
|
||||||
|
{t("tabs.sleep")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
|
<MenuItem danger onSelect={pickTab((tab) => void close(tab))}>
|
||||||
{t("tabs.close")}
|
{t("tabs.close")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
* new lane's own live session for its slot, or an empty pane — never the
|
* new lane's own live session for its slot, or an empty pane — never the
|
||||||
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
* previous lane's terminal. `attachRequest` (a run picked in the sidebar or a
|
||||||
* tab) attaches to that tmux session only; nothing is started or resumed.
|
* tab) attaches to that tmux session only; nothing is started or resumed.
|
||||||
* A Claude or Codex pane also has a Chat view (`PaneChat`): the session's
|
* A pane of a `CHAT_TOOLS` tool (Claude, Codex, omp, Cursor, Antigravity) also
|
||||||
|
* has a Chat view (`PaneChat`): the session's
|
||||||
* conversation laid OVER the terminal, which stays mounted and attached under
|
* conversation laid OVER the terminal, which stays mounted and attached under
|
||||||
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
* it. Chat or terminal is each SESSION's own (`useSessionChat`, the toggle
|
||||||
* left of the pane's split buttons); the Chat UI setting (Settings →
|
* left of the pane's split buttons); the Chat UI setting (Settings →
|
||||||
@@ -29,9 +30,7 @@ import type { Lane } from "../../lib/types";
|
|||||||
import { useAiTools } from "../../hooks/useAiTools";
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
import { PaneChat } from "./PaneChat";
|
import { PaneChat } from "./PaneChat";
|
||||||
import { useSessionChat } from "../../lib/chatUi";
|
import { CHAT_TOOLS, useSessionChat } from "../../lib/chatUi";
|
||||||
|
|
||||||
const CHAT_TOOLS = new Set(["claude", "codex"]);
|
|
||||||
|
|
||||||
export interface LaneConsolePaneProps {
|
export interface LaneConsolePaneProps {
|
||||||
lanes: Lane[];
|
lanes: Lane[];
|
||||||
@@ -218,6 +217,7 @@ export function LaneConsolePane({
|
|||||||
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
wsBaseUrl={window.location.origin.replace(/^http/, "ws")}
|
||||||
autoFocus={autoFocus && !(chatable && chat)}
|
autoFocus={autoFocus && !(chatable && chat)}
|
||||||
covered={!!(chatable && chat)}
|
covered={!!(chatable && chat)}
|
||||||
|
tool={handle.tool}
|
||||||
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
// The pane died (double Ctrl+C out of `claude`, `exit`, a crash) —
|
||||||
// the tab bar moves on (another tab, or the home's prompt box)
|
// the tab bar moves on (another tab, or the home's prompt box)
|
||||||
// rather than leaving a dead terminal the user has to click out of.
|
// rather than leaving a dead terminal the user has to click out of.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
* in the box beside `+`. A menu the CLI asks with — AskUserQuestion, a
|
||||||
* permission or trust prompt, Codex's approval — is read off the terminal
|
* permission or trust prompt, Codex's approval — is read off the terminal
|
||||||
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
* underneath (`readRunMenu`) and shown in the prompt box's place as buttons
|
||||||
* that press its keys there (`PaneMenuCard`); its "Type something" row opens
|
* that press its keys there (`PaneMenuCard`) — a permission prompt with its tool, command and a deny-with-note
|
||||||
|
* (`PermissionMenuCard`); its "Type something" row opens
|
||||||
* a field in the card, whose text is typed there as the answer
|
* a field in the card, whose text is typed there as the answer
|
||||||
* (`answerRunMenu`).
|
* (`answerRunMenu`).
|
||||||
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
* It also has Claude Desktop's activity panel (`ChatOutline`: progress,
|
||||||
@@ -56,10 +57,26 @@
|
|||||||
|
|
||||||
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
Check,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
|
MessageCircleQuestion,
|
||||||
|
ShieldQuestion,
|
||||||
|
Square,
|
||||||
|
SquareCheck,
|
||||||
|
} from "lucide-react";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { ConversationView } from "../conversation/ConversationView";
|
import { ConversationView } from "../conversation/ConversationView";
|
||||||
import { ModelMenu, PromptEditor, promptWithImages, type PromptImage } from "./RunSetup";
|
import {
|
||||||
|
ModelMenu,
|
||||||
|
PromptEditor,
|
||||||
|
promptForPane,
|
||||||
|
promptWithImages,
|
||||||
|
type PromptImage,
|
||||||
|
} from "./RunSetup";
|
||||||
import { ChatAddMenu } from "./ChatAddMenu";
|
import { ChatAddMenu } from "./ChatAddMenu";
|
||||||
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
import { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||||
@@ -71,7 +88,11 @@ import { Divider, showTerminal } from "./PaneGrid";
|
|||||||
import {
|
import {
|
||||||
answerRunMenu,
|
answerRunMenu,
|
||||||
chooseRunOption,
|
chooseRunOption,
|
||||||
|
notePaneFocus,
|
||||||
|
onPaneOutput,
|
||||||
|
stepRunMenu,
|
||||||
interruptRun,
|
interruptRun,
|
||||||
|
readRunBusy,
|
||||||
readRunMenu,
|
readRunMenu,
|
||||||
submitToRun,
|
submitToRun,
|
||||||
type PaneMenu,
|
type PaneMenu,
|
||||||
@@ -83,7 +104,9 @@ import { toast } from "../../lib/toast";
|
|||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { setTabBadge } from "../../lib/tabBadge";
|
import { setTabBadge } from "../../lib/tabBadge";
|
||||||
import { fmt } from "../../lib/format";
|
import { fmt } from "../../lib/format";
|
||||||
import { agentAwaitingReason } from "../../lib/types";
|
import { agentAwaitingReason, type TranscriptMessage, type WSMessage } from "../../lib/types";
|
||||||
|
import { eventBus } from "../../lib/eventBus";
|
||||||
|
import { throttle } from "../../lib/throttle";
|
||||||
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
import { api, type EffortLevel, type PermissionMode, type RunHandle } from "../../lib/api";
|
||||||
|
|
||||||
const MODES: [PermissionMode, string][] = [
|
const MODES: [PermissionMode, string][] = [
|
||||||
@@ -94,6 +117,11 @@ const MODES: [PermissionMode, string][] = [
|
|||||||
["bypassPermissions", "Bypass"],
|
["bypassPermissions", "Bypass"],
|
||||||
];
|
];
|
||||||
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
const isSettable = (m: unknown): m is PermissionMode => MODES.some(([v]) => v === m);
|
||||||
|
// Pane output re-reads the transcript at most this often: a TUI spinner
|
||||||
|
// redraws several times a second, and each read walks the transcript file.
|
||||||
|
const OUTPUT_FETCH_MS = 1000;
|
||||||
|
/** A tool with no known busy line counts as at work this long after its pane last drew. */
|
||||||
|
const OUTPUT_BUSY_MS = 2000;
|
||||||
const SIDE_KEY = "agenthub.chat.panelWidth";
|
const SIDE_KEY = "agenthub.chat.panelWidth";
|
||||||
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
const clampSide = (f: number) => Math.round(Math.min(0.7, Math.max(0.2, f)) * 100) / 100;
|
||||||
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
const outlineKey = (sessionId: string) => `agenthub.chat.panel.${sessionId}`;
|
||||||
@@ -111,6 +139,7 @@ const storedSide = () => {
|
|||||||
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
* (LiteLLM's list, 2026-10). Null for another vendor's model. */
|
||||||
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
// ponytail: a fixed rule, not a table; read LiteLLM's max_input_tokens if a new 200K model ships.
|
||||||
export function contextWindow(model: string | null): number | null {
|
export function contextWindow(model: string | null): number | null {
|
||||||
|
// A Codex/omp chat reports its own window (ContextMeter's `window`).
|
||||||
if (!model?.startsWith("claude-")) return null;
|
if (!model?.startsWith("claude-")) return null;
|
||||||
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
return /^claude-(?:[23]|instant|(?:opus|sonnet|haiku)-4(?:-[0-5])?(?:-\d{8})?$)/.test(model)
|
||||||
? 200_000
|
? 200_000
|
||||||
@@ -119,7 +148,13 @@ export function contextWindow(model: string | null): number | null {
|
|||||||
|
|
||||||
/** How full the context is: a ring and a percentage, amber then red as
|
/** How full the context is: a ring and a percentage, amber then red as
|
||||||
* Claude Code's auto-compact comes near. */
|
* Claude Code's auto-compact comes near. */
|
||||||
function ContextMeter({ tokens, model }: { tokens: number; model: string | null }) {
|
function ContextMeter({
|
||||||
|
tokens,
|
||||||
|
model,
|
||||||
|
}: {
|
||||||
|
tokens: number;
|
||||||
|
model: string | null;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const size = contextWindow(model);
|
const size = contextWindow(model);
|
||||||
if (!size || !tokens) return null;
|
if (!size || !tokens) return null;
|
||||||
@@ -206,33 +241,222 @@ function PermissionChip({ runId, initial }: { runId: string; initial: Permission
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A permission prompt (Claude's tool approval, Codex's command approval,
|
||||||
|
* the folder-trust question): it offers a Yes and a No. */
|
||||||
|
export function isPermissionMenu(menu: PaneMenu): boolean {
|
||||||
|
if (menu.multi || menu.tabs) return false;
|
||||||
|
const labels = menu.options.map((o) => o.label);
|
||||||
|
return labels.some((l) => /^Yes\b/i.test(l)) && labels.some((l) => /^No\b/i.test(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A menu row's label without its key hint (`Yes, proceed (y)`). */
|
||||||
|
const bareLabel = (label: string) => label.replace(/\s+\((?:[a-z]|esc|shift\+tab)\)$/i, "");
|
||||||
|
|
||||||
|
/** A permission prompt read off the terminal: the tool, what it will run, and the CLI's own
|
||||||
|
* answers as buttons — each still presses its key in the pane. "No, and
|
||||||
|
* tell … differently" also takes a note, sent as the next message once the
|
||||||
|
* CLI is back at its prompt. */
|
||||||
|
function PermissionMenuCard({
|
||||||
|
menu,
|
||||||
|
onChoose,
|
||||||
|
onDenyNote,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
menu: PaneMenu;
|
||||||
|
onChoose: (index: number) => void;
|
||||||
|
onDenyNote: (index: number, note: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("run");
|
||||||
|
const [noting, setNoting] = useState(false);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const q = menu.question.split("\n");
|
||||||
|
const title = menu.detail?.[0] ?? q[0] ?? "";
|
||||||
|
const body = (menu.detail ? menu.detail.slice(1) : q.slice(1)).join("\n");
|
||||||
|
const ask = menu.detail ? menu.question : "";
|
||||||
|
const yes = menu.options.findIndex((o) => /^Yes\b/i.test(o.label));
|
||||||
|
const tell = menu.options.findIndex((o) => /^No, and tell\b/i.test(o.label));
|
||||||
|
const sendNote = () => {
|
||||||
|
if (note.trim() && tell >= 0) onDenyNote(tell, note.trim());
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div data-testid="pane-chat-permission" className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ShieldQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-status-warning" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{ask && <p className="text-xs text-fg-muted">{ask}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{body && (
|
||||||
|
<pre className="max-h-40 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-fg-secondary">
|
||||||
|
{body}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{noting ? (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-accent/50 bg-surface-2 px-3 py-1">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
data-testid="pane-chat-permission-note"
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendNote();
|
||||||
|
} else if (e.key === "Escape") setNoting(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("chat.request.notePlaceholder")}
|
||||||
|
aria-label={t("chat.request.notePlaceholder")}
|
||||||
|
className="min-w-0 flex-1 bg-transparent py-0.5 text-sm text-fg-primary outline-none placeholder:text-fg-muted"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sendNote}
|
||||||
|
disabled={!note.trim()}
|
||||||
|
className="btn-secondary text-xs disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("chat.request.send")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{menu.options.map((o, i) => (
|
||||||
|
<button
|
||||||
|
key={`${i}-${o.label}`}
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-permission-option"
|
||||||
|
autoFocus={i === yes}
|
||||||
|
onClick={() => onChoose(i)}
|
||||||
|
title={o.hint || undefined}
|
||||||
|
className={`${i === yes ? "btn-primary" : "btn-secondary"} max-w-full break-words text-left text-xs`}
|
||||||
|
>
|
||||||
|
{bareLabel(o.label)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{tell >= 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="pane-chat-permission-tell"
|
||||||
|
onClick={() => setNoting(true)}
|
||||||
|
className="text-xs text-fg-muted underline-offset-2 hover:text-fg-primary hover:underline"
|
||||||
|
>
|
||||||
|
{t("chat.request.denyWithNote")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="ml-auto rounded-md border border-border px-2 py-0.5 text-xs text-fg-muted hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
{t("chat.menu.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
/** The menu the CLI asks with, as buttons: a pick presses its key in the
|
||||||
* pane; its "Type something" row opens a field right there instead —
|
* pane; its "Type something" row opens a field right there instead —
|
||||||
* pressing that row's digit again would type the digit into the CLI's own. */
|
* pressing that row's digit again would type the digit into the CLI's own.
|
||||||
|
* A permission prompt takes its own layout (`PermissionMenuCard`), any
|
||||||
|
* other menu the question layout. */
|
||||||
export function PaneMenuCard({
|
export function PaneMenuCard({
|
||||||
menu,
|
menu,
|
||||||
onChoose,
|
onChoose,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
onStep,
|
||||||
|
onDenyNote,
|
||||||
}: {
|
}: {
|
||||||
menu: PaneMenu;
|
menu: PaneMenu;
|
||||||
onChoose: (index: number) => void;
|
onChoose: (index: number) => void;
|
||||||
onAnswer: (text: string) => void;
|
onAnswer: (text: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
|
/** Previous (-1) / next (1) question of a multi-question AskUserQuestion. */
|
||||||
|
onStep?: (dir: -1 | 1) => void;
|
||||||
|
/** A permission prompt's "No, and tell …", with the note to send after it. */
|
||||||
|
onDenyNote?: (index: number, note: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("run");
|
const { t } = useTranslation("run");
|
||||||
const [typing, setTyping] = useState(false);
|
const [typing, setTyping] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const submit = () => draft.trim() && onAnswer(draft);
|
const submit = () => draft.trim() && onAnswer(draft);
|
||||||
|
if (isPermissionMenu(menu))
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid="pane-chat-menu"
|
data-testid="pane-chat-menu"
|
||||||
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||||
>
|
>
|
||||||
|
<PermissionMenuCard
|
||||||
|
menu={menu}
|
||||||
|
onChoose={onChoose}
|
||||||
|
onDenyNote={onDenyNote ?? ((i) => onChoose(i))}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="pane-chat-menu"
|
||||||
|
className="rounded-2xl border border-border bg-surface-1 p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
{menu.tabs && (
|
||||||
|
<div
|
||||||
|
data-testid="pane-chat-menu-tabs"
|
||||||
|
className="mb-2 flex flex-wrap items-center gap-1 text-xs"
|
||||||
|
>
|
||||||
|
{onStep && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStep(-1)}
|
||||||
|
aria-label={t("chat.menu.prev")}
|
||||||
|
title={t("chat.menu.prev")}
|
||||||
|
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{menu.tabs.map((tab, i) => (
|
||||||
|
<span
|
||||||
|
key={`${i}-${tab.label}`}
|
||||||
|
className={`flex items-center gap-1 rounded-md border px-1.5 py-0.5 ${
|
||||||
|
i === menu.tab
|
||||||
|
? "border-accent/50 bg-surface-2 text-fg-primary"
|
||||||
|
: "border-border text-fg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.done ? (
|
||||||
|
<Check className="h-3 w-3 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Square className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{onStep && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStep(1)}
|
||||||
|
aria-label={t("chat.menu.next")}
|
||||||
|
title={t("chat.menu.next")}
|
||||||
|
className="rounded-md border border-border p-0.5 text-fg-muted hover:bg-surface-3"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{menu.question && (
|
{menu.question && (
|
||||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
<div className="mb-2 flex items-start gap-2">
|
||||||
|
<MessageCircleQuestion className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm font-medium text-fg-primary">
|
||||||
{menu.question}
|
{menu.question}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{menu.options.map((o, i) =>
|
{menu.options.map((o, i) =>
|
||||||
@@ -332,9 +556,10 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
const runId = run.id;
|
const runId = run.id;
|
||||||
const claude = run.tool === "claude";
|
const claude = run.tool === "claude";
|
||||||
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
// The unsent prompt outlives the terminal view and a reload (`lib/chatDraft`).
|
||||||
const [prompt, setPrompt] = useState(() => readChatDraft(sessionId).prompt);
|
const draftKey = sessionId;
|
||||||
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(sessionId).images);
|
const [prompt, setPrompt] = useState(() => readChatDraft(draftKey).prompt);
|
||||||
useEffect(() => writeChatDraft(sessionId, prompt, images), [sessionId, prompt, images]);
|
const [images, setImages] = useState<PromptImage[]>(() => readChatDraft(draftKey).images);
|
||||||
|
useEffect(() => writeChatDraft(draftKey, prompt, images), [draftKey, prompt, images]);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [follow, setFollow] = useState(0);
|
const [follow, setFollow] = useState(0);
|
||||||
const [model, setModel] = useState(run.model ?? "");
|
const [model, setModel] = useState(run.model ?? "");
|
||||||
@@ -397,7 +622,11 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
// status filter ignored: check the status here.
|
// status filter ignored: check the status here.
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
const main = r.agents.filter((a) => a.type === "main");
|
const main = r.agents.filter((a) => a.type === "main");
|
||||||
setWorking(main.some((a) => a.status === "working"));
|
// A fresh start, resume or `/clear` leaves main "working" at an
|
||||||
|
// empty prompt (SessionStart's flag) until the first prompt.
|
||||||
|
setWorking(
|
||||||
|
main.some((a) => a.status === "working" && agentAwaitingReason(a) !== "session_start")
|
||||||
|
);
|
||||||
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
setWaiting(main.some((a) => agentAwaitingReason(a) === "notification"));
|
||||||
})
|
})
|
||||||
.catch(() => {}),
|
.catch(() => {}),
|
||||||
@@ -406,6 +635,46 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void readWorking();
|
void readWorking();
|
||||||
}, [readWorking]);
|
}, [readWorking]);
|
||||||
|
// The terminal underneath drew something: the CLI is at work, so its
|
||||||
|
// transcript may have grown — read it now, for any tool, hooks or not. A
|
||||||
|
// spinner redraws all the time, hence the throttle.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
const poke = throttle(
|
||||||
|
() =>
|
||||||
|
eventBus.publish({
|
||||||
|
type: "transcript_changed",
|
||||||
|
data: { session_id: sessionId },
|
||||||
|
} as unknown as WSMessage),
|
||||||
|
OUTPUT_FETCH_MS
|
||||||
|
);
|
||||||
|
const off = onPaneOutput(runId, poke);
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
poke.cancel();
|
||||||
|
};
|
||||||
|
}, [runId, sessionId]);
|
||||||
|
// The terminal's own word on a turn: its busy line (BUSY_LINE) while it
|
||||||
|
// shows, else — a tool whose line we do not know — output in the last
|
||||||
|
// OUTPUT_BUSY_MS. Hooks miss a tool that sends none (omp, Cursor, Codex
|
||||||
|
// without its opt-in hooks), and arrive late on a dashboard they skip.
|
||||||
|
const [screenBusy, setScreenBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let last = 0;
|
||||||
|
const off = onPaneOutput(runId, () => (last = Date.now()));
|
||||||
|
const read = () => {
|
||||||
|
const known = readRunBusy(runId, run.tool);
|
||||||
|
setScreenBusy(known ?? (last > 0 && Date.now() - last < OUTPUT_BUSY_MS));
|
||||||
|
};
|
||||||
|
read();
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (isShown(onScreen)) read();
|
||||||
|
}, 700);
|
||||||
|
return () => {
|
||||||
|
off();
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [runId, run.tool, onScreen]);
|
||||||
useLiveRefresh(readWorking, {
|
useLiveRefresh(readWorking, {
|
||||||
throttleMs: 500,
|
throttleMs: 500,
|
||||||
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
// While Stop shows, also poll: a missed agent_updated (the hook reached
|
||||||
@@ -434,6 +703,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}, 700);
|
}, 700);
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [runId, onScreen]);
|
}, [runId, onScreen]);
|
||||||
|
const busy = working || screenBusy;
|
||||||
const asking = waiting || menu !== null;
|
const asking = waiting || menu !== null;
|
||||||
// Gone until the pane redraws: no second press on a stale menu.
|
// Gone until the pane redraws: no second press on a stale menu.
|
||||||
const choose = (index: number) => {
|
const choose = (index: number) => {
|
||||||
@@ -446,15 +716,22 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
setFollow((n) => n + 1);
|
setFollow((n) => n + 1);
|
||||||
} else toast.error(t("chat.notSent"));
|
} else toast.error(t("chat.notSent"));
|
||||||
};
|
};
|
||||||
|
// "No, and tell … differently": the CLI drops back to its prompt, where
|
||||||
|
// the note is what the user would type next.
|
||||||
|
const denyWithNote = (index: number, note: string) => {
|
||||||
|
if (!menu || !chooseRunOption(runId, menu, index)) return void toast.error(t("chat.notSent"));
|
||||||
|
setMenu(null);
|
||||||
|
window.setTimeout(() => void command(note), 800);
|
||||||
|
};
|
||||||
|
|
||||||
// A hidden tab says when this session waits on the user or its turn ended.
|
// A hidden tab says when this session waits on the user or its turn ended.
|
||||||
const was = useRef({ working: false, asking: false });
|
const was = useRef({ working: false, asking: false });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (asking) setTabBadge(runId, "waiting");
|
if (asking) setTabBadge(runId, "waiting");
|
||||||
else if (working) setTabBadge(runId, null);
|
else if (busy) setTabBadge(runId, null);
|
||||||
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
else if (was.current.working || was.current.asking) setTabBadge(runId, "done");
|
||||||
was.current = { working, asking };
|
was.current = { working: busy, asking };
|
||||||
}, [runId, working, asking]);
|
}, [runId, busy, asking]);
|
||||||
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
useEffect(() => () => setTabBadge(runId, null), [runId]);
|
||||||
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
const [context, setContext] = useState<{ tokens: number; model: string | null }>({
|
||||||
tokens: 0,
|
tokens: 0,
|
||||||
@@ -470,7 +747,20 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
|
|
||||||
// Stable, so typing in the prompt box does not re-render the conversation
|
// Stable, so typing in the prompt box does not re-render the conversation
|
||||||
// (ConversationView is memoized).
|
// (ConversationView is memoized).
|
||||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
const openTerminal = useCallback(
|
||||||
|
() => showTerminal(runId, sessionId, run.tool),
|
||||||
|
[runId, sessionId, run.tool]
|
||||||
|
);
|
||||||
|
// The conversation on screen: an artifact's versions are rebuilt from it.
|
||||||
|
const [convMessages, setConvMessages] = useState<TranscriptMessage[]>([]);
|
||||||
|
// An artifact card opens in this chat's own panel, like Claude Desktop's —
|
||||||
|
// not the workspace's Changes panel a plain file link goes to.
|
||||||
|
const openArtifact = useCallback(
|
||||||
|
(named: string) => {
|
||||||
|
setPreview(resolveIn(cwd, named));
|
||||||
|
},
|
||||||
|
[cwd]
|
||||||
|
);
|
||||||
const openFile = useCallback(
|
const openFile = useCallback(
|
||||||
(named: string) => {
|
(named: string) => {
|
||||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||||
@@ -491,7 +781,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
const [notStarted, setNotStarted] = useState(false);
|
const [notStarted, setNotStarted] = useState(false);
|
||||||
|
|
||||||
// A control is a command typed into the pane, like the user would.
|
// A control is a command typed into the pane, like the user would.
|
||||||
const command = async (line: string) => {
|
const command = async (line: string, files: string[] = []) => {
|
||||||
if (
|
if (
|
||||||
notStarted &&
|
notStarted &&
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
@@ -501,14 +791,15 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
return false;
|
return false;
|
||||||
const ok = submitToRun(runId, line);
|
const ok = submitToRun(runId, line, files);
|
||||||
if (!ok) toast.error(t("chat.notSent"));
|
if (!ok) toast.error(t("chat.notSent"));
|
||||||
return ok;
|
return ok;
|
||||||
};
|
};
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!text || uploading) return;
|
if (!text || uploading) return;
|
||||||
if (!(await command(text))) return;
|
const out = promptForPane(prompt, images, claude);
|
||||||
|
if (!(await command(out.text, out.files))) return;
|
||||||
setFollow((n) => n + 1);
|
setFollow((n) => n + 1);
|
||||||
setPrompt("");
|
setPrompt("");
|
||||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||||
@@ -519,6 +810,8 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
data-prompt-drop
|
data-prompt-drop
|
||||||
|
// The last chat focused is where the Changes panel's comments go.
|
||||||
|
onFocusCapture={() => notePaneFocus(runId)}
|
||||||
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
className="relative flex min-h-0 flex-1 [container-type:inline-size]"
|
||||||
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
style={{ "--chat-side": `${side * 100}%` } as CSSProperties}
|
||||||
>
|
>
|
||||||
@@ -535,6 +828,8 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
onContext={onContext}
|
onContext={onContext}
|
||||||
onNotStarted={setNotStarted}
|
onNotStarted={setNotStarted}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
|
onOpenArtifact={openArtifact}
|
||||||
|
onMessages={setConvMessages}
|
||||||
/>
|
/>
|
||||||
{!outlineOpen && !preview && (
|
{!outlineOpen && !preview && (
|
||||||
<button
|
<button
|
||||||
@@ -551,7 +846,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-4">
|
<div className="mx-4">
|
||||||
<div className="mx-auto w-full max-w-[max(48rem,70%)]">
|
<div className="mx-auto w-full max-w-3xl">
|
||||||
{menu && (
|
{menu && (
|
||||||
<PaneMenuCard
|
<PaneMenuCard
|
||||||
// A new question starts with its field closed.
|
// A new question starts with its field closed.
|
||||||
@@ -559,6 +854,10 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
menu={menu}
|
menu={menu}
|
||||||
onChoose={choose}
|
onChoose={choose}
|
||||||
onAnswer={answer}
|
onAnswer={answer}
|
||||||
|
onDenyNote={denyWithNote}
|
||||||
|
onStep={(dir) => {
|
||||||
|
if (!stepRunMenu(runId, dir)) toast.error(t("chat.notSent"));
|
||||||
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (interruptRun(runId)) setMenu(null);
|
if (interruptRun(runId)) setMenu(null);
|
||||||
else toast.error(t("chat.notSent"));
|
else toast.error(t("chat.notSent"));
|
||||||
@@ -568,7 +867,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
{/* In the card's place while the CLI asks: hidden, never unmounted,
|
||||||
so a draft or an upload in flight survives the question. */}
|
so a draft or an upload in flight survives the question. */}
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl border border-border bg-surface-1 p-2 shadow-sm transition-colors focus-within:border-accent/50 ${menu ? "hidden" : ""}`}
|
className={`rounded-[1.25rem] border border-border bg-surface-1 p-2.5 shadow-[0_2px_12px_-4px_rgb(0_0_0/0.12)] transition-[border-color,box-shadow] focus-within:border-border-light focus-within:shadow-[0_4px_20px_-6px_rgb(0_0_0/0.2)] ${menu ? "hidden" : ""}`}
|
||||||
>
|
>
|
||||||
<PromptEditor
|
<PromptEditor
|
||||||
bare
|
bare
|
||||||
@@ -595,6 +894,26 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
onAttach={(items) => setImages((prev) => addAttachments(prev, items))}
|
||||||
/>
|
/>
|
||||||
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
{claude && <PermissionChip runId={runId} initial={run.permissionMode} />}
|
||||||
|
{run.tool === "codex" &&
|
||||||
|
// Codex's `/model` (then its effort) and `/permissions` are
|
||||||
|
// numbered pickers: typed here, answered on the menu card.
|
||||||
|
(
|
||||||
|
[
|
||||||
|
["/permissions", t("fields.permissionMode")],
|
||||||
|
["/model", t("fields.model")],
|
||||||
|
] as const
|
||||||
|
).map(([line, label]) => (
|
||||||
|
<button
|
||||||
|
key={line}
|
||||||
|
type="button"
|
||||||
|
data-testid={`pane-chat-codex-${line.slice(1)}`}
|
||||||
|
onClick={() => void command(line)}
|
||||||
|
title={t("chat.codexPicker")}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<ContextMeter tokens={context.tokens} model={context.model} />
|
<ContextMeter tokens={context.tokens} model={context.model} />
|
||||||
{claude && (
|
{claude && (
|
||||||
@@ -610,7 +929,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{working && !text ? (
|
{busy && !text ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="pane-chat-stop"
|
data-testid="pane-chat-stop"
|
||||||
@@ -623,8 +942,13 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
}}
|
}}
|
||||||
aria-label={t("chat.stop")}
|
aria-label={t("chat.stop")}
|
||||||
title={t("chat.stop")}
|
title={t("chat.stop")}
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
className="relative flex h-8 w-8 items-center justify-center rounded-full bg-surface-3 text-fg-primary transition-colors hover:bg-surface-4"
|
||||||
>
|
>
|
||||||
|
{/* A turning ring: the turn is still going. */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent"
|
||||||
|
/>
|
||||||
<Square className="h-3 w-3 fill-current" />
|
<Square className="h-3 w-3 fill-current" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
@@ -650,6 +974,7 @@ export const PaneChat = memo(function PaneChat({
|
|||||||
<FilePreview
|
<FilePreview
|
||||||
path={preview}
|
path={preview}
|
||||||
scope={scope}
|
scope={scope}
|
||||||
|
messages={convMessages}
|
||||||
onClose={() => setPreview(null)}
|
onClose={() => setPreview(null)}
|
||||||
// Beside the chat when the pane has room, over it when not.
|
// Beside the chat when the pane has room, over it when not.
|
||||||
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
className="absolute inset-x-0 top-0 bottom-[calc(100%-var(--chat-area,100%))] z-30 rounded-xl border border-border [@container(min-width:40rem)]:static [@container(min-width:40rem)]:m-3 [@container(min-width:40rem)]:ml-0 [@container(min-width:40rem)]:w-[calc(var(--chat-side)-0.75rem)] [@container(min-width:40rem)]:flex-shrink-0"
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
* there, on its middle to swap), pick another live session for it, split it
|
* there, on its middle to swap), pick another live session for it, split it
|
||||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||||
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
* in a tab of its own — so its icon is not the tab's stop X), or stop its
|
||||||
* session there (X, `onStop`) without taking it out first. The grid shows
|
* session there (X, `onStop`, which archives it) or put an AI session to
|
||||||
|
* sleep (the moon left of it, `onSleep`: stopped but kept to resume) without
|
||||||
|
* taking it out first. The grid shows
|
||||||
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
* one tab's panes; `onChange` applies through `updateActive`, so a session
|
||||||
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
* dropped or swapped in from another tab leaves that tab. A header names the pane's session and
|
||||||
* shows its state dot, as its tab does.
|
* shows its state dot, as its tab does.
|
||||||
@@ -40,11 +42,13 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
|
Moon,
|
||||||
Rows2,
|
Rows2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { RunHandle } from "../../lib/api";
|
import type { RunHandle } from "../../lib/api";
|
||||||
|
import { canSleep } from "../../lib/stopSession";
|
||||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||||
import {
|
import {
|
||||||
EMPTY_GROUPS,
|
EMPTY_GROUPS,
|
||||||
@@ -66,10 +70,10 @@ import {
|
|||||||
} from "../../lib/paneLayout";
|
} from "../../lib/paneLayout";
|
||||||
import { AiToolIcon } from "../AiToolIcon";
|
import { AiToolIcon } from "../AiToolIcon";
|
||||||
import { RunDot, type RunState } from "./ConsoleTabs";
|
import { RunDot, type RunState } from "./ConsoleTabs";
|
||||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
import { CHAT_TOOLS, setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||||
import { promptWithImages } from "./RunSetup";
|
import { promptForPane } from "./RunSetup";
|
||||||
|
|
||||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||||
@@ -234,23 +238,23 @@ function PaneMenu({
|
|||||||
const ITEM =
|
const ITEM =
|
||||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-4 hover:text-fg-primary";
|
||||||
|
|
||||||
/** Chat UI ⇄ terminal for a Claude/Codex pane — this session's own view
|
/** Chat UI ⇄ terminal for a `CHAT_TOOLS` pane — this session's own view
|
||||||
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
* (`lib/chatUi.ts`); a session with no id yet has no chat to show. The
|
||||||
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
* unsent prompt goes along: the terminal's input box into the chat's draft,
|
||||||
* the draft (its files as paths) back into the input box, never sent. */
|
* the draft (its files as paths) back into the input box, never sent. */
|
||||||
/** A session's chat switched to its terminal: the unsent prompt goes into
|
/** A session's chat switched to its terminal: the unsent prompt goes into
|
||||||
* the CLI's input box, without Enter. */
|
* the CLI's input box, without Enter. */
|
||||||
export function showTerminal(runId: string, sessionId: string) {
|
export function showTerminal(runId: string, sessionId: string, tool?: string) {
|
||||||
const draft = readChatDraft(sessionId);
|
const draft = readChatDraft(sessionId);
|
||||||
const text = promptWithImages(draft.prompt, draft.images);
|
const { text, files } = promptForPane(draft.prompt, draft.images, tool === "claude");
|
||||||
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
|
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
|
||||||
setSessionChat(sessionId, false);
|
setSessionChat(sessionId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatToggle({ run }: { run?: RunHandle }) {
|
function ChatToggle({ run }: { run?: RunHandle }) {
|
||||||
const { t } = useTranslation("lanes");
|
const { t } = useTranslation("lanes");
|
||||||
const chat = useSessionChat(run?.sessionId);
|
const chat = useSessionChat(run?.sessionId);
|
||||||
if (!run?.sessionId || (run.tool !== "claude" && run.tool !== "codex")) return null;
|
if (!run?.sessionId || !CHAT_TOOLS.has(run.tool)) return null;
|
||||||
const sessionId = run.sessionId;
|
const sessionId = run.sessionId;
|
||||||
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
const label = chat ? t("panes.showTerminal") : t("panes.showChat");
|
||||||
return (
|
return (
|
||||||
@@ -261,7 +265,7 @@ function ChatToggle({ run }: { run?: RunHandle }) {
|
|||||||
title={label}
|
title={label}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (chat) return showTerminal(run.id, sessionId);
|
if (chat) return showTerminal(run.id, sessionId, run.tool);
|
||||||
const draft = readChatDraft(sessionId);
|
const draft = readChatDraft(sessionId);
|
||||||
const text = takeRunInput(run.id);
|
const text = takeRunInput(run.id);
|
||||||
if (text)
|
if (text)
|
||||||
@@ -402,6 +406,7 @@ export function PaneGrid({
|
|||||||
onNewSplit,
|
onNewSplit,
|
||||||
stateOf,
|
stateOf,
|
||||||
onStop,
|
onStop,
|
||||||
|
onSleep,
|
||||||
keyOf = (slot) => slot,
|
keyOf = (slot) => slot,
|
||||||
kept = [],
|
kept = [],
|
||||||
}: {
|
}: {
|
||||||
@@ -410,8 +415,10 @@ export function PaneGrid({
|
|||||||
keyOf?: (slot: number) => number;
|
keyOf?: (slot: number) => number;
|
||||||
/** Panes kept mounted, always hidden — never in the layout. */
|
/** Panes kept mounted, always hidden — never in the layout. */
|
||||||
kept?: { key: number; node: ReactNode }[];
|
kept?: { key: number; node: ReactNode }[];
|
||||||
/** Stops the session in `slot` (the header's X). */
|
/** Stops (and archives) the session in `slot` (the header's X). */
|
||||||
onStop?: (slot: number) => void;
|
onStop?: (slot: number) => void;
|
||||||
|
/** Sleeps the AI session in `slot`: stopped, kept to resume (the moon). */
|
||||||
|
onSleep?: (slot: number) => void;
|
||||||
/** A live AI run's state for its header dot (none for a shell). */
|
/** A live AI run's state for its header dot (none for a shell). */
|
||||||
stateOf?: (r: RunHandle) => RunState | null;
|
stateOf?: (r: RunHandle) => RunState | null;
|
||||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||||
@@ -641,6 +648,18 @@ export function PaneGrid({
|
|||||||
>
|
>
|
||||||
<Minimize2 className="h-3.5 w-3.5" />
|
<Minimize2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
{run && onSleep && canSleep(run) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`pane-sleep-${slot}`}
|
||||||
|
title={t("panes.sleep")}
|
||||||
|
aria-label={t("panes.sleep")}
|
||||||
|
onClick={() => onSleep(slot)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded text-fg-muted hover:bg-surface-4 hover:text-fg-primary"
|
||||||
|
>
|
||||||
|
<Moon className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{run && onStop && (
|
{run && onStop && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -696,6 +715,11 @@ export function PaneGrid({
|
|||||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||||
{t("panes.closeShort")}
|
{t("panes.closeShort")}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
|
||||||
|
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
|
||||||
|
{t("panes.sleep")}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
{onStop && runOf(paneCtx.menu.item) && (
|
{onStop && runOf(paneCtx.menu.item) && (
|
||||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||||
{t("panes.stop")}
|
{t("panes.stop")}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
* other as a name chip) attached by their saved paths (`POST /api/run/drop-file`,
|
||||||
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
* remembered for the + menu's Recent uploads), and a panel row (Files,
|
||||||
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
* Changes, a context file or folder) dropped on it (`lib/promptAttach.ts`)
|
||||||
* as an `@path` chip — a `bare` one takes a drop anywhere on its box;
|
* as an `@path` chip — so is a local file a browser on the dashboard's
|
||||||
|
* own machine drops or pastes with its `file://` path, never uploaded —
|
||||||
|
* a `bare` one takes a drop anywhere on its box;
|
||||||
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
* its `/command` and `@path` tokens are coloured by a mirror laid over
|
||||||
* the textarea (`lib/promptTokens.ts`);
|
* the textarea (`lib/promptTokens.ts`);
|
||||||
* a borderless (`bare`) one grows with its text up to 40vh;
|
* a borderless (`bare`) one grows with its text up to 40vh;
|
||||||
@@ -67,9 +69,14 @@ import { TOKEN_TONE, promptTokens } from "../../lib/promptTokens";
|
|||||||
import {
|
import {
|
||||||
ATTACH_MIME,
|
ATTACH_MIME,
|
||||||
addAttachments,
|
addAttachments,
|
||||||
|
claudeMention,
|
||||||
droppedAttachments,
|
droppedAttachments,
|
||||||
droppedSkill,
|
droppedSkill,
|
||||||
droppedText,
|
droppedText,
|
||||||
|
isLocalBrowser,
|
||||||
|
localAttachment,
|
||||||
|
localCandidates,
|
||||||
|
sameFilesHere,
|
||||||
SKILL_MIME,
|
SKILL_MIME,
|
||||||
rememberUploads,
|
rememberUploads,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
@@ -203,9 +210,37 @@ interface PromptEditorProps {
|
|||||||
export type PromptImage = { path: string; url?: string; name: string };
|
export type PromptImage = { path: string; url?: string; name: string };
|
||||||
|
|
||||||
/** The prompt as sent: the text, then each file's saved path — which is how
|
/** The prompt as sent: the text, then each file's saved path — which is how
|
||||||
* the CLI is handed an image or a file. */
|
* the CLI is handed an image or a file; for `claude`, as `@` mentions. */
|
||||||
export function promptWithImages(text: string, images: PromptImage[]): string {
|
export function promptWithImages(text: string, images: PromptImage[], claude = false): string {
|
||||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
return [text.trim(), ...images.map((i) => (claude ? claudeMention(i.path) : i.path))]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
||||||
|
|
||||||
|
/** The prompt as pasted into a live pane. `claude`: one paste, every file an
|
||||||
|
* `@` mention, and a trailing space so a mention at the end does not leave
|
||||||
|
* the `@` file suggester open to swallow the Enter after it. Any other tool:
|
||||||
|
* the text with every non-image file in it, and the images' paths apart,
|
||||||
|
* each in a paste of its own (how `codex` attaches a pasted image path). */
|
||||||
|
export function promptForPane(
|
||||||
|
text: string,
|
||||||
|
images: PromptImage[],
|
||||||
|
claude = false
|
||||||
|
): { text: string; files: string[] } {
|
||||||
|
if (claude) {
|
||||||
|
const all = promptWithImages(text, images, true);
|
||||||
|
return { text: images.length ? `${all} ` : all, files: [] };
|
||||||
|
}
|
||||||
|
const isImage = (i: PromptImage) => IMAGE_PATH.test(i.path);
|
||||||
|
return {
|
||||||
|
text: promptWithImages(
|
||||||
|
text,
|
||||||
|
images.filter((i) => !isImage(i))
|
||||||
|
),
|
||||||
|
files: images.filter(isImage).map((i) => i.path),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
/** A plain-text paste longer than this is attached as `pasted-text.txt`. */
|
||||||
@@ -273,7 +308,7 @@ export function PromptEditor({
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
|
||||||
]).then(([c, sk, pl]) => {
|
]).then(([c, sk, pl]) => {
|
||||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||||
// installed at both user and project scope is listed once.
|
// installed at both user and project scope is listed once.
|
||||||
@@ -373,7 +408,11 @@ export function PromptEditor({
|
|||||||
// A pasted or dropped file is saved on the server's machine (the same
|
// A pasted or dropped file is saved on the server's machine (the same
|
||||||
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
// route the terminal's drop uses) and shown as a thumbnail or a name chip;
|
||||||
// its path is added to the prompt only when it is sent (promptWithImages).
|
// its path is added to the prompt only when it is sent (promptWithImages).
|
||||||
const [upload, setUpload] = useState<"uploading" | "failed" | null>(null);
|
const [upload, setUpload] = useState<"uploading" | "failed" | "tooBig" | "tooBigLocal" | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||||
|
const [limitMb, setLimitMb] = useState(0);
|
||||||
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
const [viewing, setViewing] = useState<PromptImage | null>(null);
|
||||||
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
const [imageMenu, setImageMenu] = useState<(MenuPoint & { img: PromptImage }) | null>(null);
|
||||||
const uploading = upload === "uploading";
|
const uploading = upload === "uploading";
|
||||||
@@ -384,20 +423,64 @@ export function PromptEditor({
|
|||||||
const picked = Array.from(list ?? []);
|
const picked = Array.from(list ?? []);
|
||||||
if (picked.length === 0) return false;
|
if (picked.length === 0) return false;
|
||||||
setUpload("uploading");
|
setUpload("uploading");
|
||||||
Promise.all(picked.map((f) => api.run.dropFile(f, lane)))
|
// Each file on its own: one refused file does not cost the others.
|
||||||
.then((saved) => {
|
void Promise.allSettled(picked.map((f) => api.run.dropFile(f, lane))).then((results) => {
|
||||||
setUpload(null);
|
const added: PromptImage[] = [];
|
||||||
const added = picked.map((f, i) => ({
|
const errors: unknown[] = [];
|
||||||
path: saved[i]!.path,
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === "rejected") return void errors.push(r.reason);
|
||||||
|
const f = picked[i]!;
|
||||||
|
added.push({
|
||||||
|
path: r.value.path,
|
||||||
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
url: f.type.startsWith("image/") ? URL.createObjectURL(f) : undefined,
|
||||||
name: f.name,
|
name: f.name,
|
||||||
}));
|
});
|
||||||
|
});
|
||||||
|
if (added.length) {
|
||||||
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
rememberUploads(added.map((a) => ({ path: a.path, name: a.name, image: !!a.url })));
|
||||||
|
keepError.current = errors.length > 0; // these came with the error
|
||||||
onImagesChange((prev) => [...prev, ...added]);
|
onImagesChange((prev) => [...prev, ...added]);
|
||||||
})
|
}
|
||||||
.catch(() => setUpload("failed"));
|
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||||
|
const tooBig = errors
|
||||||
|
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||||
|
.find((e) => e?.code === "ETOOBIG");
|
||||||
|
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||||
|
setUpload(
|
||||||
|
!errors.length ? null : !tooBig ? "failed" : isLocalBrowser() ? "tooBigLocal" : "tooBig"
|
||||||
|
);
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
/** Dropped or pasted files: by their own paths when the browser is on the
|
||||||
|
* dashboard's machine and the server finds the same files there (a file
|
||||||
|
* manager hands over `file://` paths beside the bytes), else uploaded. */
|
||||||
|
const attachDropped = (
|
||||||
|
dt: DataTransfer | null | undefined,
|
||||||
|
list: FileList | null | undefined
|
||||||
|
) => {
|
||||||
|
const files = Array.from(list ?? []);
|
||||||
|
if (files.length === 0) return false;
|
||||||
|
const paths = localCandidates(dt, files);
|
||||||
|
if (!paths) return attachImages(files);
|
||||||
|
setUpload("uploading");
|
||||||
|
void sameFilesHere(paths, files).then((same) => {
|
||||||
|
if (!same) return void attachImages(files);
|
||||||
|
setUpload(null);
|
||||||
|
onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment)));
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
// Anything attached since — a path, a panel row, a + menu pick — ends a past
|
||||||
|
// upload's error; not the files that uploaded beside the one refused.
|
||||||
|
const keepError = useRef(false);
|
||||||
|
const imageCount = useRef(images.length);
|
||||||
|
useEffect(() => {
|
||||||
|
const grew = images.length > imageCount.current;
|
||||||
|
imageCount.current = images.length;
|
||||||
|
if (grew && !keepError.current) setUpload((u) => (u === "uploading" ? u : null));
|
||||||
|
keepError.current = false;
|
||||||
|
}, [images.length]);
|
||||||
const removeImage = (img: PromptImage) => {
|
const removeImage = (img: PromptImage) => {
|
||||||
if (img.url) URL.revokeObjectURL(img.url);
|
if (img.url) URL.revokeObjectURL(img.url);
|
||||||
onImagesChange((prev) => prev.filter((x) => x !== img));
|
onImagesChange((prev) => prev.filter((x) => x !== img));
|
||||||
@@ -426,7 +509,7 @@ export function PromptEditor({
|
|||||||
} else if (skill) {
|
} else if (skill) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
} else if (attachDropped(e.dataTransfer, e.dataTransfer?.files)) e.preventDefault();
|
||||||
else {
|
else {
|
||||||
// Only text (a file manager's or an editor's `file://` list, a
|
// Only text (a file manager's or an editor's `file://` list, a
|
||||||
// selection, a link): what the terminal beneath would paste.
|
// selection, a link): what the terminal beneath would paste.
|
||||||
@@ -591,7 +674,7 @@ export function PromptEditor({
|
|||||||
onBlur={() => setTrigger(null)}
|
onBlur={() => setTrigger(null)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={(e) => {
|
onPaste={(e) => {
|
||||||
if (attachImages(e.clipboardData?.files)) e.preventDefault();
|
if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault();
|
||||||
else {
|
else {
|
||||||
// A very long paste goes in as a text file, like Claude Desktop:
|
// A very long paste goes in as a text file, like Claude Desktop:
|
||||||
// the prompt then carries its path, never the text (#68: a CLI's
|
// the prompt then carries its path, never the text (#68: a CLI's
|
||||||
@@ -635,10 +718,10 @@ export function PromptEditor({
|
|||||||
</div>
|
</div>
|
||||||
{upload && (
|
{upload && (
|
||||||
<p
|
<p
|
||||||
role={upload === "failed" ? "alert" : "status"}
|
role={upload === "uploading" ? "status" : "alert"}
|
||||||
className={`mt-1 text-[11px] ${upload === "failed" ? "text-status-danger" : "text-fg-muted"}`}
|
className={`mt-1 text-[11px] ${upload === "uploading" ? "text-fg-muted" : "text-status-danger"}`}
|
||||||
>
|
>
|
||||||
{t(`drop.${upload}`)}
|
{t(`drop.${upload}`, { mb: limitMb })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{kind && (
|
{kind && (
|
||||||
|
|||||||
@@ -16,8 +16,11 @@
|
|||||||
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
|
||||||
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
|
||||||
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
|
||||||
* local terminal does on a drop, and how `claude` attaches an image or reads
|
* local terminal does on a drop — as `@` mentions in a `claude` pane, which
|
||||||
* a file. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
* reads them when the prompt is sent — an image as its bare path, which claude
|
||||||
|
* shows as `[Image #n]` like a native drop; a browser on the dashboard's own
|
||||||
|
* machine pastes a file manager's files (dropped, or copied: `file://` text)
|
||||||
|
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
|
||||||
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
|
||||||
* pasted into whichever of the workspace's panes was focused last.
|
* pasted into whichever of the workspace's panes was focused last.
|
||||||
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
|
||||||
@@ -62,12 +65,24 @@ import { useTheme } from "../../hooks/useTheme";
|
|||||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { writeClipboard } from "../../lib/clipboard";
|
import { writeClipboard } from "../../lib/clipboard";
|
||||||
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
|
import {
|
||||||
|
MENU_CURSOR_ROW,
|
||||||
|
notePaneFocus,
|
||||||
|
notePaneOutput,
|
||||||
|
registerPane,
|
||||||
|
} from "../../lib/panePaste";
|
||||||
|
import {
|
||||||
|
claudePastes,
|
||||||
|
droppedFilePaths,
|
||||||
|
isLocalBrowser,
|
||||||
|
localCandidates,
|
||||||
|
sameFilesHere,
|
||||||
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||||
|
|
||||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocal";
|
||||||
|
|
||||||
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
/** localStorage flag for xterm's screen-reader mode (a Settings toggle can set it later). */
|
||||||
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
export const SCREEN_READER_KEY = "agenthub.terminal.screenReader";
|
||||||
@@ -145,6 +160,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
|
|||||||
const hasText = (e: DragEvent) =>
|
const hasText = (e: DragEvent) =>
|
||||||
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
|
||||||
|
|
||||||
|
/** The `file://` paths a paste carries as text — a file manager's copy — or
|
||||||
|
* null: its uri-list, else its plain text when every line is one. */
|
||||||
|
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
|
||||||
|
const text = dt?.getData("text/plain") ?? "";
|
||||||
|
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
|
||||||
|
if (!/^file:\/\//.test(text.trim())) return null;
|
||||||
|
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
|
||||||
|
}
|
||||||
|
|
||||||
interface TerminalViewProps {
|
interface TerminalViewProps {
|
||||||
runId: string;
|
runId: string;
|
||||||
wsBaseUrl: string;
|
wsBaseUrl: string;
|
||||||
@@ -160,6 +184,10 @@ interface TerminalViewProps {
|
|||||||
* so it is moved out of the viewport with a transform, which keeps its
|
* so it is moved out of the viewport with a transform, which keeps its
|
||||||
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
* layout size (no PTY resize; the chat still reads and answers its menus). */
|
||||||
covered?: boolean;
|
covered?: boolean;
|
||||||
|
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
|
||||||
|
* as its bare path and any other file as an `@` mention (`claudePastes`),
|
||||||
|
* any other tool its plain path. */
|
||||||
|
tool?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
/** Memoized: the Workspace re-renders its pane tree on every lane_update, and
|
||||||
@@ -170,6 +198,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
onExit,
|
onExit,
|
||||||
autoFocus = true,
|
autoFocus = true,
|
||||||
covered = false,
|
covered = false,
|
||||||
|
tool,
|
||||||
}: TerminalViewProps) {
|
}: TerminalViewProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
// Read through a ref: an inline `onExit` from the parent changes identity on
|
// Read through a ref: an inline `onExit` from the parent changes identity on
|
||||||
@@ -193,6 +222,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const [drop, setDrop] = useState<DropState | null>(null);
|
const [drop, setDrop] = useState<DropState | null>(null);
|
||||||
|
// The server's own limit, from its ETOOBIG refusal (never a copy of it here).
|
||||||
|
const [limitMb, setLimitMb] = useState(0);
|
||||||
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
// The socket dropped after it had been up: keystrokes are not reaching tmux.
|
||||||
const [reconnecting, setReconnecting] = useState(false);
|
const [reconnecting, setReconnecting] = useState(false);
|
||||||
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
// Ctrl+M: Tab and Shift+Tab move focus out instead of reaching the pane.
|
||||||
@@ -207,10 +238,10 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// only goes away when the pointer leaves the terminal itself.
|
// only goes away when the pointer leaves the terminal itself.
|
||||||
const dragDepth = useRef(0);
|
const dragDepth = useRef(0);
|
||||||
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const flashTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const flash = (state: DropState) => {
|
const flash = (state: DropState, ms = 2500) => {
|
||||||
setDrop(state);
|
setDrop(state);
|
||||||
if (flashTimer.current) clearTimeout(flashTimer.current);
|
if (flashTimer.current) clearTimeout(flashTimer.current);
|
||||||
flashTimer.current = setTimeout(() => setDrop(null), 2500);
|
flashTimer.current = setTimeout(() => setDrop(null), ms);
|
||||||
};
|
};
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -227,15 +258,18 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
setDrop(null);
|
setDrop(null);
|
||||||
|
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
|
||||||
const text = e.dataTransfer.getData("text/plain");
|
const text = e.dataTransfer.getData("text/plain");
|
||||||
if (text) termRef.current?.paste(text + " ");
|
if (local) pastePaths(local);
|
||||||
|
else if (text) termRef.current?.paste(text + " ");
|
||||||
termRef.current?.focus();
|
termRef.current?.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!hasFiles(e)) return;
|
if (!hasFiles(e)) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dragDepth.current = 0;
|
dragDepth.current = 0;
|
||||||
void attachFiles(Array.from(e.dataTransfer.files));
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
void attachFiles(files, localCandidates(e.dataTransfer, files));
|
||||||
};
|
};
|
||||||
|
|
||||||
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
// A pasted image (a screenshot) goes the way of a drop: xterm only reads
|
||||||
@@ -247,26 +281,60 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
const ctrlV = ctrlVRef.current;
|
const ctrlV = ctrlVRef.current;
|
||||||
ctrlVRef.current = false;
|
ctrlVRef.current = false;
|
||||||
const files = Array.from(e.clipboardData?.files ?? []);
|
const files = Array.from(e.clipboardData?.files ?? []);
|
||||||
|
// A file manager's copied files reach a browser as `file://` text only.
|
||||||
|
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
|
||||||
|
if (local) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
pastePaths(local);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (files.length > 0) void attachFiles(files);
|
if (files.length > 0) void attachFiles(files, localCandidates(e.clipboardData, files));
|
||||||
else termRef.current?.input("\x16", true);
|
else termRef.current?.input("\x16", true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachFiles = async (files: File[]) => {
|
/** Pastes files' paths as a local terminal hands them over — paste(), not
|
||||||
|
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
|
||||||
|
* rather than typed keystrokes. A `claude` pane gets its images as bare
|
||||||
|
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
|
||||||
|
* (`claudePastes`). */
|
||||||
|
const pastePaths = (paths: string[]) => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return;
|
||||||
|
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
|
||||||
|
term.paste(text + " ");
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pastes the files' paths: their own (`local`, the `file://` paths a
|
||||||
|
* browser on the dashboard's machine is handed beside the bytes) once the
|
||||||
|
* server finds the same files there — nothing uploaded —, else the paths
|
||||||
|
* the uploads were saved at. A refused file does not cost the others. */
|
||||||
|
const attachFiles = async (files: File[], local: string[] | null = null) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
setDrop("uploading");
|
setDrop("uploading");
|
||||||
try {
|
let paths = local && (await sameFilesHere(local, files)) ? local : null;
|
||||||
const saved = await Promise.all(files.map((f) => api.run.dropFile(f, runId)));
|
const errors: unknown[] = [];
|
||||||
// paste(), not a raw send: it honours bracketed-paste mode, so claude
|
if (!paths) {
|
||||||
// sees pasted paths rather than typed keystrokes.
|
const results = await Promise.allSettled(files.map((f) => api.run.dropFile(f, runId)));
|
||||||
termRef.current?.paste(saved.map((r) => r.path).join(" ") + " ");
|
paths = results.flatMap((r) => {
|
||||||
termRef.current?.focus();
|
if (r.status === "fulfilled") return [r.value.path];
|
||||||
setDrop(null);
|
errors.push(r.reason);
|
||||||
} catch {
|
return [];
|
||||||
flash("failed");
|
});
|
||||||
}
|
}
|
||||||
|
if (paths.length) pastePaths(paths);
|
||||||
|
// Over the server's limit (ETOOBIG): refused before the body is sent.
|
||||||
|
const tooBig = errors
|
||||||
|
.map((e) => e as { code?: string; maxBytes?: number } | null)
|
||||||
|
.find((e) => e?.code === "ETOOBIG");
|
||||||
|
if (tooBig) setLimitMb(Math.round((tooBig.maxBytes ?? 0) / 1024 / 1024));
|
||||||
|
if (!errors.length) setDrop(null);
|
||||||
|
else if (!tooBig) flash("failed");
|
||||||
|
else flash(isLocalBrowser() ? "tooBigLocal" : "tooBig", 6000); // a sentence to read
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -355,9 +423,20 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||||
// because that protocol reports button presses only, never the wheel — so
|
// because that protocol reports button presses only, never the wheel — so
|
||||||
// xterm takes the emulation path for it as well.
|
// xterm takes the emulation path for it as well.
|
||||||
term.attachCustomWheelEventHandler(() => {
|
// psmux (Windows) never turns tracking on, so xterm never reports the
|
||||||
|
// wheel there; on the server's `{type:"wheel"}` this pane sends the SGR
|
||||||
|
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||||
|
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||||
|
let ownWheel = false;
|
||||||
|
term.attachCustomWheelEventHandler((ev) => {
|
||||||
const mode = term.modes.mouseTrackingMode;
|
const mode = term.modes.mouseTrackingMode;
|
||||||
const tracked = mode !== "none" && mode !== "x10";
|
const tracked = mode !== "none" && mode !== "x10";
|
||||||
|
if (!tracked && ownWheel) {
|
||||||
|
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return tracked || term.buffer.active.type !== "alternate";
|
return tracked || term.buffer.active.type !== "alternate";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -530,6 +609,8 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
requestAnimationFrame(scaleToFit);
|
requestAnimationFrame(scaleToFit);
|
||||||
} else if (msg.type === "lead") {
|
} else if (msg.type === "lead") {
|
||||||
takeLead();
|
takeLead();
|
||||||
|
} else if (msg.type === "wheel") {
|
||||||
|
ownWheel = true;
|
||||||
} else if (msg.type === "exit") {
|
} else if (msg.type === "exit") {
|
||||||
ended = true;
|
ended = true;
|
||||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||||
@@ -541,6 +622,7 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
notePaneOutput(runId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sock.onclose = (e?: CloseEvent) => {
|
sock.onclose = (e?: CloseEvent) => {
|
||||||
@@ -653,8 +735,12 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
() =>
|
() =>
|
||||||
registerPane(
|
registerPane(
|
||||||
runId,
|
runId,
|
||||||
(text, submit) => {
|
(text, submit, files = []) => {
|
||||||
termRef.current?.paste(text);
|
const term = termRef.current;
|
||||||
|
// The text, then each image's path in a paste of its own — the one
|
||||||
|
// shape `claude` turns into `[Image #n]` on every platform.
|
||||||
|
if (text) term?.paste(files.length ? `${text} ` : text);
|
||||||
|
files.forEach((f, i) => term?.paste(i < files.length - 1 ? `${f} ` : f));
|
||||||
// Enter a beat after the paste, so a TUI that times its paste
|
// Enter a beat after the paste, so a TUI that times its paste
|
||||||
// detection never reads it as a newline inside the paste. A submit
|
// detection never reads it as a newline inside the paste. A submit
|
||||||
// comes from the pane's Chat view, which keeps the keyboard.
|
// comes from the pane's Chat view, which keeps the keyboard.
|
||||||
@@ -699,6 +785,17 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
return { rows, cols: term.cols };
|
return { rows, cols: term.cols };
|
||||||
|
},
|
||||||
|
// Plain text of the rows on screen, dim cells kept (a status line's
|
||||||
|
// `esc to interrupt` is dim): the chat's busy read.
|
||||||
|
() => {
|
||||||
|
const term = termRef.current;
|
||||||
|
if (!term) return [];
|
||||||
|
const buf = term.buffer.active;
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (let y = 0; y < term.rows; y++)
|
||||||
|
rows.push(buf.getLine(buf.baseY + y)?.translateToString(true) ?? "");
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
[runId]
|
[runId]
|
||||||
@@ -855,10 +952,12 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
<div
|
<div
|
||||||
data-testid="terminal-drop"
|
data-testid="terminal-drop"
|
||||||
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
className={`pointer-events-none absolute inset-2 flex items-center justify-center rounded-lg border-2 border-dashed bg-black/50 text-sm font-medium ${
|
||||||
drop === "failed" ? "border-red-400 text-red-300" : "border-sky-400 text-sky-200"
|
drop === "failed" || drop === "tooBig" || drop === "tooBigLocal"
|
||||||
|
? "border-red-400 text-red-300"
|
||||||
|
: "border-sky-400 text-sky-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t(`drop.${drop}`)}
|
{t(`drop.${drop}`, { mb: limitMb })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
* @file ChatAddMenu.test.tsx
|
* @file ChatAddMenu.test.tsx
|
||||||
* @description The `+` menu's existing-resource pickers: Project files (a
|
* @description The `+` menu's existing-resource pickers: Project files (a
|
||||||
* search with folders, attached as `@path`), the workspace's Context files
|
* search with folders, attached as `@path`), the workspace's Context files
|
||||||
* (files only, none missing) and this browser's Recent uploads.
|
* (files only, none missing), this browser's Recent uploads and, for a
|
||||||
|
* browser on the dashboard's machine, a file chosen in the OS's own picker.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { ChatAddMenu } from "../ChatAddMenu";
|
import { ChatAddMenu } from "../ChatAddMenu";
|
||||||
import { api } from "../../../lib/api";
|
import { api } from "../../../lib/api";
|
||||||
@@ -21,6 +22,7 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
api: {
|
api: {
|
||||||
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
run: { files: vi.fn().mockResolvedValue({ items: ["src/", "src/a.ts"] }) },
|
||||||
lanes: {
|
lanes: {
|
||||||
|
pickPath: vi.fn().mockResolvedValue({ path: "/home/u/Videos/demo clip.mp4" }),
|
||||||
sources: vi.fn().mockResolvedValue({
|
sources: vi.fn().mockResolvedValue({
|
||||||
sources: [
|
sources: [
|
||||||
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
{ id: 1, kind: "file", path: "/data/ctx/1/brief.md", name: "brief.md", missing: false },
|
||||||
@@ -61,6 +63,28 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
describe("ChatAddMenu — a file on this computer", () => {
|
||||||
|
it("attaches the file the OS picker chose by its path, uploading nothing", async () => {
|
||||||
|
const onAttach = renderMenu();
|
||||||
|
fireEvent.click(screen.getByTestId("chat-add-pick-local"));
|
||||||
|
expect(api.lanes.pickPath).toHaveBeenCalledWith("file", "/repo");
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(onAttach).toHaveBeenCalledWith([
|
||||||
|
{ path: '@"/home/u/Videos/demo clip.mp4"', name: "demo clip.mp4" },
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not offered to a browser on another machine", () => {
|
||||||
|
// Only a loopback address: a name that merely starts with "localhost" is not one.
|
||||||
|
vi.stubGlobal("location", { ...window.location, hostname: "localhost.example.com" });
|
||||||
|
renderMenu();
|
||||||
|
expect(screen.queryByTestId("chat-add-pick-local")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ChatAddMenu — existing resources", () => {
|
describe("ChatAddMenu — existing resources", () => {
|
||||||
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
it("searches the project, folders included, and attaches a pick as @path", async () => {
|
||||||
const onAttach = renderMenu();
|
const onAttach = renderMenu();
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
gitRawUrl: () => "",
|
gitRawUrl: () => "",
|
||||||
},
|
},
|
||||||
|
artifacts: {
|
||||||
|
pinFile: vi.fn().mockResolvedValue({ item: { id: 1 } }),
|
||||||
|
uploadFile: vi.fn().mockResolvedValue({ item: { id: 2 } }),
|
||||||
|
},
|
||||||
sessions: {
|
sessions: {
|
||||||
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
readImageUrl: (id: string, p: string) => `/img/${id}?path=${p}`,
|
||||||
readFile: vi.fn((_id: string, p: string) =>
|
readFile: vi.fn((_id: string, p: string) =>
|
||||||
@@ -75,7 +79,11 @@ const scope = { laneId: 4, laneCwd: "/w", cwd: "/w", sessionId: "s1" };
|
|||||||
describe("FilePreview", () => {
|
describe("FilePreview", () => {
|
||||||
it("reads a workspace file through the lane", async () => {
|
it("reads a workspace file through the lane", async () => {
|
||||||
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
render(<FilePreview path="/w/src/a.ts" scope={scope} onClose={() => {}} />);
|
||||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,7 +97,11 @@ describe("FilePreview", () => {
|
|||||||
it("reads Windows paths the same way", async () => {
|
it("reads Windows paths the same way", async () => {
|
||||||
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
const win = { laneId: 4, laneCwd: "D:\\w", cwd: "D:\\w", sessionId: "s1" };
|
||||||
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
render(<FilePreview path={"D:\\w\\src\\a.ts"} scope={win} onClose={() => {}} />);
|
||||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
expect(vi.mocked(api.lanes.gitFile)).toHaveBeenCalledWith(4, "src/a.ts");
|
||||||
cleanup();
|
cleanup();
|
||||||
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
const tool = "C:\\Users\\k\\.claude\\projects\\D--w\\s1\\tool-results\\b.txt";
|
||||||
@@ -144,7 +156,11 @@ describe("FileListModal", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
fireEvent.click(screen.getByRole("button", { name: "a.ts" }));
|
||||||
expect(await screen.findByText("export const a = 1;")).toBeTruthy();
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("export const a = 1;")
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,3 +188,77 @@ describe("SkillListModal", () => {
|
|||||||
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
expect(screen.getByText("No preview for this file here")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the artifact view", () => {
|
||||||
|
const html = (lines: string[]) =>
|
||||||
|
vi.mocked(api.lanes.gitFile).mockResolvedValueOnce({
|
||||||
|
available: true,
|
||||||
|
path: "page.html",
|
||||||
|
rev: "work",
|
||||||
|
binary: false,
|
||||||
|
truncated: false,
|
||||||
|
lines,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
it("runs a page in a sandbox with scripts but no same origin, and shows its code", async () => {
|
||||||
|
html(["<button>go</button>"]);
|
||||||
|
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||||
|
const frame = (await screen.findByTestId("artifact-frame")) as HTMLIFrameElement;
|
||||||
|
expect(frame.getAttribute("sandbox")).toContain("allow-scripts");
|
||||||
|
expect(frame.getAttribute("sandbox")).not.toContain("allow-same-origin");
|
||||||
|
expect(frame.getAttribute("srcdoc")).toContain("<button>go</button>");
|
||||||
|
fireEvent.click(screen.getByTestId("artifact-tab-code"));
|
||||||
|
expect(screen.queryByTestId("artifact-frame")).toBeNull();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
(_t, el) => el?.tagName === "CODE" && !!el.textContent?.includes("<button>go</button>")
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steps back to an older version the conversation wrote", async () => {
|
||||||
|
html(["<p>two</p>"]);
|
||||||
|
const tool = (id: string, input: Record<string, unknown>, name = "Write") => ({
|
||||||
|
type: "assistant" as const,
|
||||||
|
timestamp: null,
|
||||||
|
content: [{ type: "tool_use" as const, id, name, input }],
|
||||||
|
});
|
||||||
|
const messages = [
|
||||||
|
tool("1", { file_path: "/w/page.html", content: "<p>one</p>" }),
|
||||||
|
tool("2", { file_path: "/w/page.html", old_string: "one", new_string: "two" }, "Edit"),
|
||||||
|
];
|
||||||
|
render(
|
||||||
|
<FilePreview
|
||||||
|
path="/w/page.html"
|
||||||
|
scope={scope}
|
||||||
|
onClose={() => {}}
|
||||||
|
messages={messages as never}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const versions = await screen.findByTestId("artifact-versions");
|
||||||
|
expect(versions.textContent).toContain("2");
|
||||||
|
expect(
|
||||||
|
((await screen.findByTestId("artifact-frame")) as HTMLIFrameElement).getAttribute("srcdoc")
|
||||||
|
).toContain("two");
|
||||||
|
fireEvent.click(versions.querySelector("button")!);
|
||||||
|
expect(screen.getByTestId("artifact-frame").getAttribute("srcdoc")).toContain("<p>one</p>");
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a workspace file to the Artifacts library by its path", async () => {
|
||||||
|
html(["<p>x</p>"]);
|
||||||
|
render(<FilePreview path="/w/page.html" scope={scope} onClose={() => {}} />);
|
||||||
|
await screen.findByTestId("artifact-frame");
|
||||||
|
fireEvent.click(screen.getByTestId("artifact-share"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(api.artifacts.pinFile).toHaveBeenCalledWith({
|
||||||
|
laneId: 4,
|
||||||
|
path: "page.html",
|
||||||
|
title: "page.html",
|
||||||
|
sessionId: "s1",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
* activity panel, collapsed into a file button with the output count, whose
|
* activity panel, collapsed into a file button with the output count, whose
|
||||||
* files open a preview in its place; a file in a turn's "Edited N files" card
|
* files open a preview in its place; a file in a turn's "Edited N files" card
|
||||||
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
* opens in the Workspace's Changes panel when it is in the pane's folder; and
|
||||||
* Stop in Send's place while the main agent works, pressing Esc in the pane;
|
* Stop in Send's place while the main agent works (its hooks, or the pane's
|
||||||
|
* own busy line / fresh output), pressing Esc in the pane;
|
||||||
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
* and the menu the CLI asks with, shown as buttons that press its keys, a
|
||||||
* message sent meanwhile typed as its "Type something" answer.
|
* message sent meanwhile typed as its "Type something" answer; a Codex
|
||||||
|
* terminal's Model / Permission mode buttons typing its own pickers.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -22,11 +24,14 @@ import {
|
|||||||
answerRunMenu,
|
answerRunMenu,
|
||||||
chooseRunOption,
|
chooseRunOption,
|
||||||
interruptRun,
|
interruptRun,
|
||||||
|
onPaneOutput,
|
||||||
|
readRunBusy,
|
||||||
readRunMenu,
|
readRunMenu,
|
||||||
submitToRun,
|
submitToRun,
|
||||||
type PaneMenu,
|
type PaneMenu,
|
||||||
} from "../../../lib/panePaste";
|
} from "../../../lib/panePaste";
|
||||||
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
import { OPEN_IN_CHANGES_EVENT } from "../../../lib/openInChanges";
|
||||||
|
import { eventBus } from "../../../lib/eventBus";
|
||||||
|
|
||||||
vi.mock("../../conversation/ConversationView", () => ({
|
vi.mock("../../conversation/ConversationView", () => ({
|
||||||
// Stands in for a turn's "Edited N files" card: a click names a file.
|
// Stands in for a turn's "Edited N files" card: a click names a file.
|
||||||
@@ -51,8 +56,12 @@ vi.mock("../../../lib/panePaste", () => ({
|
|||||||
submitToRun: vi.fn(() => true),
|
submitToRun: vi.fn(() => true),
|
||||||
interruptRun: vi.fn(() => true),
|
interruptRun: vi.fn(() => true),
|
||||||
readRunMenu: vi.fn(() => null),
|
readRunMenu: vi.fn(() => null),
|
||||||
|
readRunBusy: vi.fn(() => false),
|
||||||
chooseRunOption: vi.fn(() => true),
|
chooseRunOption: vi.fn(() => true),
|
||||||
answerRunMenu: vi.fn(() => true),
|
answerRunMenu: vi.fn(() => true),
|
||||||
|
stepRunMenu: vi.fn(() => true),
|
||||||
|
notePaneFocus: vi.fn(),
|
||||||
|
onPaneOutput: vi.fn(() => () => {}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -212,6 +221,54 @@ describe("PaneChat — Stop", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Stop while the pane's own busy line shows, with no hook saying so", async () => {
|
||||||
|
// omp, Cursor or Codex without its hooks: only the terminal knows.
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(true);
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
expect(readRunBusy).toHaveBeenCalledWith("agenthub-lane-1", "claude");
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts a tool with no known busy line as working while its pane draws", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(null);
|
||||||
|
const fns: (() => void)[] = [];
|
||||||
|
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||||
|
fns.push(fn);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||||
|
expect(screen.queryByTestId("pane-chat-stop")).toBeNull();
|
||||||
|
fns.forEach((fn) => fn());
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(800);
|
||||||
|
});
|
||||||
|
expect(await screen.findByTestId("pane-chat-stop")).toBeInTheDocument();
|
||||||
|
// Quiet for longer than OUTPUT_BUSY_MS: at rest again.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2800);
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("pane-chat-stop")).toBeNull());
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.mocked(readRunBusy).mockReturnValue(false);
|
||||||
|
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Send for a session at rest", async () => {
|
it("keeps Send for a session at rest", async () => {
|
||||||
// With session_id the server answers EVERY agent of the session (its
|
// With session_id the server answers EVERY agent of the session (its
|
||||||
// status filter does not apply): the main agent is there, waiting.
|
// status filter does not apply): the main agent is there, waiting.
|
||||||
@@ -267,7 +324,7 @@ describe("PaneChat — model, effort and permission mode", () => {
|
|||||||
renderChat();
|
renderChat();
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||||
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
fireEvent.click(await screen.findByRole("menuitemradio", { name: /Sonnet/ }));
|
||||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet");
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "/model sonnet", []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
it("shows the mode the pane shows, and asks the server to step it", async () => {
|
||||||
@@ -430,7 +487,7 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts");
|
expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "half typed @src/a.ts ", []);
|
||||||
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
await waitFor(() => expect(localStorage.getItem("agenthub.chat.draft.s1")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -462,7 +519,10 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
expect(submitToRun).toHaveBeenCalledWith(
|
expect(submitToRun).toHaveBeenCalledWith(
|
||||||
"agenthub-lane-1",
|
"agenthub-lane-1",
|
||||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts'
|
// A trailing space: an @ mention at the end would leave claude's file
|
||||||
|
// suggester open, and it takes the Enter.
|
||||||
|
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts ',
|
||||||
|
[]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -490,7 +550,7 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
expect(box).toHaveValue("hello");
|
expect(box).toHaveValue("hello");
|
||||||
confirmDialog.mockResolvedValueOnce(true);
|
confirmDialog.mockResolvedValueOnce(true);
|
||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello"));
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hello", []));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends without asking once the session has started", async () => {
|
it("sends without asking once the session has started", async () => {
|
||||||
@@ -499,7 +559,7 @@ describe("PaneChat — the unsent prompt", () => {
|
|||||||
renderChat();
|
renderChat();
|
||||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||||
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi"));
|
await waitFor(() => expect(submitToRun).toHaveBeenCalledWith("agenthub-lane-1", "hi", []));
|
||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
expect(confirmDialog).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -550,4 +610,103 @@ describe("PaneChat — a menu the CLI asks with", () => {
|
|||||||
vi.mocked(readRunMenu).mockReturnValue(null);
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("re-reads its transcript when the pane draws, at most once a second", async () => {
|
||||||
|
// Every listener the chat put on the pane (the re-read, the busy clock).
|
||||||
|
const fns: (() => void)[] = [];
|
||||||
|
const drew = () => fns.forEach((fn) => fn());
|
||||||
|
vi.mocked(onPaneOutput).mockImplementation((_runId, fn) => {
|
||||||
|
fns.push(fn);
|
||||||
|
return () => {};
|
||||||
|
});
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const off = eventBus.subscribe((m) => {
|
||||||
|
if (m.type === "transcript_changed") seen.push(m.data);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
await waitFor(() => expect(fns.length).toBeGreaterThan(0));
|
||||||
|
vi.useFakeTimers();
|
||||||
|
drew();
|
||||||
|
drew();
|
||||||
|
drew();
|
||||||
|
expect(seen).toEqual([{ session_id: "s1" }]);
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(1100);
|
||||||
|
});
|
||||||
|
expect(seen).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
off();
|
||||||
|
vi.mocked(onPaneOutput).mockImplementation(() => () => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const PERMISSION: PaneMenu = {
|
||||||
|
question: "Do you want to proceed?",
|
||||||
|
detail: ["Bash command", "npm test", "Run the tests"],
|
||||||
|
options: [
|
||||||
|
{ n: 1, label: "Yes", hint: "", checked: null },
|
||||||
|
{ n: 2, label: "Yes, and don't ask again for npm test", hint: "", checked: null },
|
||||||
|
{ n: 3, label: "No, and tell Claude what to do differently (esc)", hint: "", checked: null },
|
||||||
|
],
|
||||||
|
cursor: 0,
|
||||||
|
multi: false,
|
||||||
|
typeAt: -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("lays a permission prompt out as a card, each answer its own key", async () => {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||||
|
vi.mocked(chooseRunOption).mockClear();
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
const card = await screen.findByTestId("pane-chat-permission");
|
||||||
|
expect(card).toHaveTextContent("Bash command");
|
||||||
|
expect(card).toHaveTextContent("npm test");
|
||||||
|
expect(card).toHaveTextContent("Do you want to proceed?");
|
||||||
|
const options = screen.getAllByTestId("pane-chat-permission-option");
|
||||||
|
// The key hint is dropped from the label.
|
||||||
|
expect(options.map((o) => o.textContent)).toEqual([
|
||||||
|
"Yes",
|
||||||
|
"Yes, and don't ask again for npm test",
|
||||||
|
"No, and tell Claude what to do differently",
|
||||||
|
]);
|
||||||
|
fireEvent.click(options[1]!);
|
||||||
|
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 1);
|
||||||
|
} finally {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies with a note: picks the No row, then sends the note as the next message", async () => {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(PERMISSION);
|
||||||
|
vi.mocked(chooseRunOption).mockClear();
|
||||||
|
vi.mocked(submitToRun).mockClear();
|
||||||
|
try {
|
||||||
|
renderChat();
|
||||||
|
fireEvent.click(await screen.findByTestId("pane-chat-permission-tell"));
|
||||||
|
const field = screen.getByTestId("pane-chat-permission-note");
|
||||||
|
fireEvent.change(field, { target: { value: "use pnpm" } });
|
||||||
|
fireEvent.keyDown(field, { key: "Enter" });
|
||||||
|
expect(chooseRunOption).toHaveBeenCalledWith("agenthub-lane-1", PERMISSION, 2);
|
||||||
|
await waitFor(() => expect(submitToRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||||
|
expect(vi.mocked(submitToRun).mock.calls[0]![1]).toContain("use pnpm");
|
||||||
|
} finally {
|
||||||
|
vi.mocked(readRunMenu).mockReturnValue(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PaneChat — a Codex terminal", () => {
|
||||||
|
it("types /model and /permissions, whose pickers come back as menu cards", () => {
|
||||||
|
renderChat({ tool: "codex" });
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-codex-model"));
|
||||||
|
fireEvent.click(screen.getByTestId("pane-chat-codex-permissions"));
|
||||||
|
expect(vi.mocked(submitToRun).mock.calls.map((c) => c[1])).toEqual(["/model", "/permissions"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers neither to a Claude pane", () => {
|
||||||
|
renderChat();
|
||||||
|
expect(screen.queryByTestId("pane-chat-codex-model")).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ModelPicker,
|
ModelPicker,
|
||||||
PASTE_AS_FILE_CHARS,
|
PASTE_AS_FILE_CHARS,
|
||||||
PromptEditor,
|
PromptEditor,
|
||||||
|
promptForPane,
|
||||||
promptWithImages,
|
promptWithImages,
|
||||||
type PromptImage,
|
type PromptImage,
|
||||||
} from "../RunSetup";
|
} from "../RunSetup";
|
||||||
@@ -55,7 +56,11 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
|||||||
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
return { flag: id in lists, models: lists[id] ?? [], defaultModel: null };
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
run: { files: r({ items: [] }), dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }) },
|
run: {
|
||||||
|
files: r({ items: [] }),
|
||||||
|
dropFile: r({ path: "/tmp/agenthub-drops-1/a.png" }),
|
||||||
|
localPaths: r({ sizes: [1] }),
|
||||||
|
},
|
||||||
ccConfig: {
|
ccConfig: {
|
||||||
commands: r({
|
commands: r({
|
||||||
items: [
|
items: [
|
||||||
@@ -341,6 +346,120 @@ describe("PromptEditor — newlines and images", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attaches a local file manager's files by their own paths, uploading nothing", async () => {
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value="read"
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("read", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||||
|
const data: Record<string, string> = { "text/uri-list": "file:///home/u/demo.mp4" };
|
||||||
|
fireEvent.drop(box(), {
|
||||||
|
dataTransfer: { files: [big], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("demo.mp4")).toBeTruthy();
|
||||||
|
expect(api.run.localPaths).toHaveBeenCalledWith(["/home/u/demo.mp4"]);
|
||||||
|
expect(api.run.dropFile).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("read @/home/u/demo.mp4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uploads a dropped file whose path the server does not have (an SSH tunnel's laptop)", async () => {
|
||||||
|
vi.mocked(api.run.localPaths).mockResolvedValueOnce({ sizes: [null] });
|
||||||
|
const onImagesChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={onImagesChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const doc = new File(["x"], "spec.pdf", { type: "application/pdf" });
|
||||||
|
const data: Record<string, string> = { "text/uri-list": "file:///Users/me/spec.pdf" };
|
||||||
|
fireEvent.drop(box(), {
|
||||||
|
dataTransfer: { files: [doc], types: ["Files"], getData: (k: string) => data[k] ?? "" },
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(api.run.dropFile).toHaveBeenCalledWith(doc, undefined));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the files that uploaded when another one is refused", async () => {
|
||||||
|
vi.mocked(api.run.dropFile)
|
||||||
|
.mockResolvedValueOnce({ path: "/tmp/agenthub-drops-1/a-ok.txt" })
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("too big"), { code: "ETOOBIG" }));
|
||||||
|
function Harness() {
|
||||||
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={images}
|
||||||
|
onImagesChange={setImages}
|
||||||
|
/>
|
||||||
|
<output data-testid="sent">{promptWithImages("", images)}</output>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
render(<Harness />);
|
||||||
|
fireEvent.drop(box(), {
|
||||||
|
dataTransfer: { files: [new File(["x"], "ok.txt"), new File(["y"], "huge.iso")] },
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("ok.txt")).toBeTruthy();
|
||||||
|
expect(screen.getByTestId("sent").textContent).toBe("/tmp/agenthub-drops-1/a-ok.txt");
|
||||||
|
expect(screen.getByRole("alert").textContent).toMatch(/too large/);
|
||||||
|
// Anything attached afterwards — here a Files panel row — ends the error.
|
||||||
|
const data: Record<string, string> = {
|
||||||
|
[ATTACH_MIME]: JSON.stringify({ path: "@src/a.ts", name: "a.ts" }),
|
||||||
|
};
|
||||||
|
fireEvent.drop(box(), { dataTransfer: { getData: (k: string) => data[k] ?? "" } });
|
||||||
|
expect(await screen.findByText("a.ts")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a file over the server's limit is too big, and how to attach it", async () => {
|
||||||
|
vi.mocked(api.run.dropFile).mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("too big"), { code: "ETOOBIG", status: 413, maxBytes: 20971520 })
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<PromptEditor
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
onSubmit={() => {}}
|
||||||
|
placeholder="Ask anything…"
|
||||||
|
slash={false}
|
||||||
|
fileCwd="/home/u"
|
||||||
|
images={[]}
|
||||||
|
onImagesChange={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.drop(box(), { dataTransfer: { files: [new File(["x"], "huge.iso")] } });
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||||
|
/too large to upload \(max 20 MB\).*Choose a file on this computer/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
it("attaches a Files panel row dropped on it as an @path chip, once", () => {
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const [images, setImages] = useState<PromptImage[]>([]);
|
const [images, setImages] = useState<PromptImage[]>([]);
|
||||||
@@ -586,3 +705,51 @@ describe("ModelMenu", () => {
|
|||||||
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
|
expect(onChange).toHaveBeenCalledWith("claude-sonnet-5-5");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("promptForPane — images apart, every other file in the text", () => {
|
||||||
|
it("keeps @-mentions and other files in the text, and takes images out", () => {
|
||||||
|
// A Windows path inside a paste of text stayed a plain path in claude; a
|
||||||
|
// paste that is only the path becomes [Image #n].
|
||||||
|
const images: PromptImage[] = [
|
||||||
|
{
|
||||||
|
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||||
|
name: "image.png",
|
||||||
|
},
|
||||||
|
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||||
|
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||||
|
{ path: "/data/drops/0123456789abcdef.JPG", name: "b.jpg" },
|
||||||
|
];
|
||||||
|
expect(promptForPane(" look ", images)).toEqual({
|
||||||
|
text: 'look /data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG"',
|
||||||
|
files: [
|
||||||
|
"C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||||
|
"/data/drops/0123456789abcdef.JPG",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands claude every file as an @ mention in one paste, with a trailing space", () => {
|
||||||
|
// A bare pasted path stayed plain text on Windows, and a path in the
|
||||||
|
// command-line prompt is never an image; claude reads an @ mention at submit.
|
||||||
|
const images: PromptImage[] = [
|
||||||
|
{
|
||||||
|
path: "C:\\Users\\u\\.claude\\agenthub\\drops\\f326fc666d3bdd92-image.png",
|
||||||
|
name: "image.png",
|
||||||
|
},
|
||||||
|
{ path: "/data/drops/0123456789abcdef-log.txt", name: "log.txt" },
|
||||||
|
{ path: '@"/home/u/My Docs/a.PNG"', name: "a.PNG" },
|
||||||
|
{ path: "C:\\Users\\Jo Do\\drops\\0123456789abcdef.png", name: "b.png" },
|
||||||
|
];
|
||||||
|
expect(promptForPane(" look ", images, true)).toEqual({
|
||||||
|
text:
|
||||||
|
"look @C:/Users/u/.claude/agenthub/drops/f326fc666d3bdd92-image.png " +
|
||||||
|
'@/data/drops/0123456789abcdef-log.txt @"/home/u/My Docs/a.PNG" ' +
|
||||||
|
'@"C:/Users/Jo Do/drops/0123456789abcdef.png" ',
|
||||||
|
files: [],
|
||||||
|
});
|
||||||
|
expect(promptForPane("just text", [], true)).toEqual({ text: "just text", files: [] });
|
||||||
|
expect(promptWithImages("see", [images[1]!], true)).toBe(
|
||||||
|
"see @/data/drops/0123456789abcdef-log.txt"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,8 +26,14 @@ const inputMock = vi.fn();
|
|||||||
// The newest terminal's hidden input, where the browser's IME types.
|
// The newest terminal's hidden input, where the browser's IME types.
|
||||||
let textarea: HTMLTextAreaElement;
|
let textarea: HTMLTextAreaElement;
|
||||||
const dropFileMock = vi.fn();
|
const dropFileMock = vi.fn();
|
||||||
|
const localPathsMock = vi.fn();
|
||||||
vi.mock("../../../lib/api", () => ({
|
vi.mock("../../../lib/api", () => ({
|
||||||
api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } },
|
api: {
|
||||||
|
run: {
|
||||||
|
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
|
||||||
|
localPaths: (paths: string[]) => localPathsMock(paths),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
const loadAddonMock = vi.fn();
|
const loadAddonMock = vi.fn();
|
||||||
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
// Mutable stand-ins for the two live terminal facts the wheel handler reads.
|
||||||
@@ -231,6 +237,24 @@ describe("TerminalView", () => {
|
|||||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
|
||||||
|
// psmux (Windows) never turns mouse tracking on for its client, so xterm
|
||||||
|
// never reports the wheel and the pane could not be scrolled at all.
|
||||||
|
termState.modes.mouseTrackingMode = "none";
|
||||||
|
termState.buffer.active.type = "alternate";
|
||||||
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
|
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||||
|
ws.onmessage?.({ data: '{"type":"wheel"}' });
|
||||||
|
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
|
||||||
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||||
|
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||||
|
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||||
|
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
||||||
|
termState.modes.mouseTrackingMode = "any";
|
||||||
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||||
|
expect(ws.sent).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||||
@@ -494,6 +518,23 @@ describe("TerminalView", () => {
|
|||||||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pastes a local file manager's own paths, uploading nothing", async () => {
|
||||||
|
dropFileMock.mockReset();
|
||||||
|
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
|
||||||
|
pasteMock.mockClear();
|
||||||
|
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
|
||||||
|
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
|
||||||
|
const data: Record<string, string> = {
|
||||||
|
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
|
||||||
|
};
|
||||||
|
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||||||
|
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
|
||||||
|
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
|
||||||
|
expect(dropFileMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("pastes every path of several dropped files, images or not", async () => {
|
it("pastes every path of several dropped files, images or not", async () => {
|
||||||
dropFileMock
|
dropFileMock
|
||||||
.mockReset()
|
.mockReset()
|
||||||
|
|||||||
@@ -9,17 +9,24 @@
|
|||||||
* boot/login while the switch is on. Between them, the status-area icon switch
|
* boot/login while the switch is on. Between them, the status-area icon switch
|
||||||
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
* (`GET/PUT /api/settings/tray`, on by default — server/lib/tray.js). Last,
|
||||||
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
* **Uninstall AgentHub**: `POST /api/settings/uninstall` removes AgentHub from
|
||||||
* the machine, database included (scripts/uninstall.js).
|
* the machine, database included (scripts/uninstall.js). Quit is a plain
|
||||||
|
* button and Uninstall a danger one, both the same width with an icon, and
|
||||||
|
* each spins while its request runs.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Loader2, Power, Trash2 } from "lucide-react";
|
||||||
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
import { api, type AutostartStatus, type TrayStatus } from "../../lib/api";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
import { SettingsRow, SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
// The two action buttons share one width, so they line up under the switches.
|
||||||
|
const ACTION = "min-w-[6.5rem] justify-center text-xs";
|
||||||
|
const ICON = "h-3.5 w-3.5 shrink-0";
|
||||||
|
|
||||||
export function AutostartSettings() {
|
export function AutostartSettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
const [status, setStatus] = useState<AutostartStatus | null>(null);
|
||||||
@@ -126,9 +133,11 @@ export function AutostartSettings() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="quit-agenthub"
|
data-testid="quit-agenthub"
|
||||||
disabled={quitting}
|
disabled={quitting}
|
||||||
|
aria-busy={quitting}
|
||||||
onClick={() => void quit()}
|
onClick={() => void quit()}
|
||||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
className={`btn-secondary ${ACTION}`}
|
||||||
>
|
>
|
||||||
|
{quitting ? <Loader2 className={ICON + " animate-spin"} /> : <Power className={ICON} />}
|
||||||
{t("quit.button")}
|
{t("quit.button")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -141,9 +150,15 @@ export function AutostartSettings() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="uninstall-agenthub"
|
data-testid="uninstall-agenthub"
|
||||||
disabled={uninstalling}
|
disabled={uninstalling}
|
||||||
|
aria-busy={uninstalling}
|
||||||
onClick={() => void uninstall()}
|
onClick={() => void uninstall()}
|
||||||
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
className={`btn-danger ${ACTION}`}
|
||||||
>
|
>
|
||||||
|
{uninstalling ? (
|
||||||
|
<Loader2 className={ICON + " animate-spin"} />
|
||||||
|
) : (
|
||||||
|
<Trash2 className={ICON} />
|
||||||
|
)}
|
||||||
{t("uninstall.button")}
|
{t("uninstall.button")}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,31 +2,186 @@
|
|||||||
* @file GitIdentitySettings.tsx
|
* @file GitIdentitySettings.tsx
|
||||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||||
* commit author (name + email in the account's global git config — prefilled
|
* commit author (name + email in the account's global git config — prefilled
|
||||||
* from Settings → Account while unset, since a member's commits fail without
|
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
|
||||||
* one), the SSH
|
* since a member's commits fail without one; a new SSH key is labelled with
|
||||||
* key a clone (Add workspace → Clone) and the pushes from that clone use —
|
* that email), the SSH
|
||||||
* make one, copy its public half to add to GitHub/GitLab, replace or delete
|
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||||
* it — and, for a user with their own Linux account (S3), their own HTTPS
|
* an ED25519 and/or an RSA one (AWS CodeCommit takes only RSA) — make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||||
|
* it, and whether git typed in the account's own terminal uses it too (a
|
||||||
|
* switch over `core.sshCommand` in its `~/.gitconfig`, issue #82), the SSH
|
||||||
|
* login name per host (AWS CodeCommit's SSH key ID, a `url.<…>.insteadOf` in
|
||||||
|
* that same file, so a plain `ssh://git-codecommit…` URL works), and for
|
||||||
|
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
|
||||||
|
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
|
||||||
|
* each tied to the one key it uses (the SSH part is split into tabs: one per
|
||||||
|
* kind of host, the extra keys, the other hosts) — and, for
|
||||||
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. The private key and
|
||||||
* every token are write-only: the server never returns them. A host identity
|
* every token are write-only: the server never returns them. A host identity
|
||||||
* seen by a non-admin is read-only.
|
* seen by a non-admin is read-only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Cloud,
|
||||||
|
Copy,
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
KeySquare,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { useAuth } from "../AuthGate";
|
import { useAuth } from "../AuthGate";
|
||||||
|
import { PageTabs } from "../PageTabs";
|
||||||
|
import { Switch } from "../Switch";
|
||||||
|
|
||||||
const INPUT =
|
const INPUT =
|
||||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||||
const BTN =
|
const BTN =
|
||||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||||
|
|
||||||
|
const CODECOMMIT_RE = /^git-codecommit\.([a-z0-9-]+)\.amazonaws\.com$/;
|
||||||
|
// The regions AWS CodeCommit runs in.
|
||||||
|
const AWS_REGIONS = [
|
||||||
|
"ap-southeast-1",
|
||||||
|
"ap-southeast-2",
|
||||||
|
"ap-southeast-3",
|
||||||
|
"ap-northeast-1",
|
||||||
|
"ap-northeast-2",
|
||||||
|
"ap-northeast-3",
|
||||||
|
"ap-south-1",
|
||||||
|
"ap-east-1",
|
||||||
|
"us-east-1",
|
||||||
|
"us-east-2",
|
||||||
|
"us-west-1",
|
||||||
|
"us-west-2",
|
||||||
|
"ca-central-1",
|
||||||
|
"eu-west-1",
|
||||||
|
"eu-west-2",
|
||||||
|
"eu-west-3",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-north-1",
|
||||||
|
"eu-south-1",
|
||||||
|
"sa-east-1",
|
||||||
|
"me-south-1",
|
||||||
|
"af-south-1",
|
||||||
|
"il-central-1",
|
||||||
|
];
|
||||||
|
|
||||||
|
function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 text-[11px] text-fg-secondary">
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-3 text-[10px] text-fg-primary">
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Code({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind of git host and the key it takes: no key → a Generate button;
|
||||||
|
* a key → step 1 (copy its public half) followed by the host's own steps.
|
||||||
|
*/
|
||||||
|
function KeyCard({
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
type,
|
||||||
|
k,
|
||||||
|
id,
|
||||||
|
busy,
|
||||||
|
onGenerate,
|
||||||
|
onRemove,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
sub: string;
|
||||||
|
type: "ed25519" | "rsa";
|
||||||
|
k: Key | undefined;
|
||||||
|
id: GitIdentity;
|
||||||
|
busy: boolean;
|
||||||
|
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
|
||||||
|
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
return (
|
||||||
|
<section aria-label={title} className="space-y-2 rounded border border-border p-2.5">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium text-fg-primary">{title}</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">{sub}</div>
|
||||||
|
</div>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onGenerate(type)}
|
||||||
|
>
|
||||||
|
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||||
|
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{k && id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void onRemove(type)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{k ? (
|
||||||
|
<>
|
||||||
|
<Step n={1}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="flex-1">{t("gitIdentity.stepCopy")}</span>
|
||||||
|
<button type="button" className={BTN} onClick={() => void copyText(k.publicKey)}>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
|
||||||
|
value={k.publicKey}
|
||||||
|
rows={type === "rsa" ? 3 : 2}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
|
||||||
|
/>
|
||||||
|
<div className="truncate font-mono text-[10px] text-fg-muted">{k.fingerprint}</div>
|
||||||
|
</Step>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -37,6 +192,8 @@ export function GitIdentitySettings() {
|
|||||||
const me = auth.mode === "server" ? auth.user : null;
|
const me = auth.mode === "server" ? auth.user : null;
|
||||||
// null = not edited: the field shows the saved value, else the account's.
|
// null = not edited: the field shows the saved value, else the account's.
|
||||||
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
||||||
|
// null = not picked: opens on the first kind of host that has a key.
|
||||||
|
const [sshTab, setSshTab] = useState<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -65,23 +222,28 @@ export function GitIdentitySettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||||
const [keyType, setKeyType] = useState<"ed25519" | "rsa">("ed25519");
|
// An account may hold one of each; an older server answers `ssh` only.
|
||||||
const generate = async () => {
|
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||||
if (
|
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||||
id?.ssh &&
|
// The default key of a type (a named key sits beside it).
|
||||||
!(await confirmDialog({
|
const keyOf = (type: "ed25519" | "rsa") =>
|
||||||
title: t("gitIdentity.replaceConfirm"),
|
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||||
confirmLabel: t("gitIdentity.replace"),
|
const named = keys.filter((k) => k.name);
|
||||||
}))
|
const keyLabel = (keyId: string | null | undefined) => {
|
||||||
)
|
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||||
return;
|
if (!k) return t("gitIdentity.anyKey");
|
||||||
await run(
|
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||||
() => api.gitIdentity.generateKey(Boolean(id?.ssh), keyType),
|
};
|
||||||
|
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
|
||||||
|
const makeNamed = async () => {
|
||||||
|
if (!newKey) return;
|
||||||
|
const ok = await run(
|
||||||
|
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
|
||||||
t("gitIdentity.keyMade")
|
t("gitIdentity.keyMade")
|
||||||
);
|
);
|
||||||
|
if (ok) setNewKey(null);
|
||||||
};
|
};
|
||||||
|
const removeNamed = async (keyId: string) => {
|
||||||
const removeKey = async () => {
|
|
||||||
if (
|
if (
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
title: t("gitIdentity.removeKeyConfirm"),
|
title: t("gitIdentity.removeKeyConfirm"),
|
||||||
@@ -89,7 +251,30 @@ export function GitIdentitySettings() {
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
await run(() => api.gitIdentity.removeKey());
|
await run(() => api.gitIdentity.removeKeyById(keyId));
|
||||||
|
};
|
||||||
|
const generate = async (type: "ed25519" | "rsa") => {
|
||||||
|
const taken = !!keyOf(type);
|
||||||
|
if (
|
||||||
|
taken &&
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.replaceConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.replace"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(() => api.gitIdentity.generateKey(taken, type), t("gitIdentity.keyMade"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeKey = async (type: "ed25519" | "rsa") => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.removeKeyConfirm"),
|
||||||
|
confirmLabel: t("gitIdentity.remove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(() => api.gitIdentity.removeKey(type));
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveLogin = async () => {
|
const saveLogin = async () => {
|
||||||
@@ -109,6 +294,76 @@ export function GitIdentitySettings() {
|
|||||||
if (ok) setForm(null);
|
if (ok) setForm(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The SSH login name per host (`url.<…>.insteadOf`), so a plain
|
||||||
|
// `ssh://host/…` URL logs in. AWS CodeCommit's (user = the IAM SSH key ID,
|
||||||
|
// one host per region) gets its own form under the RSA key; anything else
|
||||||
|
// is the "other hosts" form.
|
||||||
|
const sshUsers = id?.sshUsers ?? [];
|
||||||
|
const awsRows = sshUsers.flatMap((r) => {
|
||||||
|
const m = CODECOMMIT_RE.exec(r.host);
|
||||||
|
return m ? [{ ...r, region: m[1] ?? "" }] : [];
|
||||||
|
});
|
||||||
|
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
|
||||||
|
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
|
||||||
|
const saveAws = async () => {
|
||||||
|
let user = aws.keyId.trim();
|
||||||
|
let host = `git-codecommit.${aws.region}.amazonaws.com`;
|
||||||
|
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
|
||||||
|
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
|
||||||
|
if (m?.[1] && m[2]) {
|
||||||
|
user = m[1];
|
||||||
|
host = m[2].toLowerCase();
|
||||||
|
}
|
||||||
|
const alias = aws.alias.trim().toLowerCase();
|
||||||
|
const ok = await run(
|
||||||
|
() =>
|
||||||
|
api.gitIdentity.setSshUser({
|
||||||
|
host,
|
||||||
|
user,
|
||||||
|
...(alias ? { alias } : {}),
|
||||||
|
...(aws.key ? { key: aws.key } : {}),
|
||||||
|
}),
|
||||||
|
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
|
||||||
|
);
|
||||||
|
if (ok) setAws({ ...aws, keyId: "", alias: "" });
|
||||||
|
};
|
||||||
|
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
|
||||||
|
const saveOther = async () => {
|
||||||
|
let host = other.host.trim();
|
||||||
|
const m = /^ssh:\/\/(?:[^@/]+@)?([^/]+)/i.exec(host);
|
||||||
|
if (m?.[1]) host = m[1];
|
||||||
|
const alias = other.alias.trim().toLowerCase();
|
||||||
|
const ok = await run(
|
||||||
|
() =>
|
||||||
|
api.gitIdentity.setSshUser({
|
||||||
|
host,
|
||||||
|
user: other.user.trim(),
|
||||||
|
...(alias ? { alias } : {}),
|
||||||
|
...(other.key ? { key: other.key } : {}),
|
||||||
|
}),
|
||||||
|
t("gitIdentity.otherSaved", { host: alias || host })
|
||||||
|
);
|
||||||
|
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
|
||||||
|
};
|
||||||
|
/** The key picker of a login form: every key, or one. */
|
||||||
|
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.loginKey")}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<option value="">{t("gitIdentity.anyKey")}</option>
|
||||||
|
{keys
|
||||||
|
.filter((k) => k.id && (!only || k.type === only))
|
||||||
|
.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{keyLabel(k.id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
|
||||||
const removeLogin = async (host: string) => {
|
const removeLogin = async (host: string) => {
|
||||||
if (
|
if (
|
||||||
!(await confirmDialog({
|
!(await confirmDialog({
|
||||||
@@ -130,13 +385,38 @@ export function GitIdentitySettings() {
|
|||||||
const member = id.account === "member";
|
const member = id.account === "member";
|
||||||
const authorShown = author ?? {
|
const authorShown = author ?? {
|
||||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||||
email: id.author.email ?? me?.email ?? "",
|
email: id.author.email ?? me?.email ?? id.defaultEmail ?? "",
|
||||||
};
|
};
|
||||||
const authorUnset = !id.author.name || !id.author.email;
|
const authorUnset = !id.author.name || !id.author.email;
|
||||||
const authorDirty =
|
const authorDirty =
|
||||||
authorUnset ||
|
authorUnset ||
|
||||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||||
authorShown.email.trim() !== (id.author.email ?? "");
|
authorShown.email.trim() !== (id.author.email ?? "");
|
||||||
|
// The SSH keys, one tab per kind of host — every section at once was a wall.
|
||||||
|
const sshTabs = [
|
||||||
|
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
|
||||||
|
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
|
||||||
|
...(named.length > 0 || id.writable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "more",
|
||||||
|
label: named.length
|
||||||
|
? `${t("gitIdentity.tabMore")} (${named.length})`
|
||||||
|
: t("gitIdentity.tabMore"),
|
||||||
|
Icon: KeySquare,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
|
||||||
|
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const activeSshTab =
|
||||||
|
sshTab && sshTabs.some((x) => x.id === sshTab)
|
||||||
|
? sshTab
|
||||||
|
: !keyOf("ed25519") && keyOf("rsa")
|
||||||
|
? "aws"
|
||||||
|
: "git";
|
||||||
const saveAuthor = async () => {
|
const saveAuthor = async () => {
|
||||||
const ok = await run(
|
const ok = await run(
|
||||||
() =>
|
() =>
|
||||||
@@ -151,7 +431,7 @@ export function GitIdentitySettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="max-w-2xl text-xs text-fg-muted">
|
<p className="text-xs text-fg-muted">
|
||||||
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -162,7 +442,7 @@ export function GitIdentitySettings() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
|
className="space-y-2 rounded-lg border border-border p-3"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void saveAuthor();
|
void saveAuthor();
|
||||||
@@ -202,50 +482,217 @@ export function GitIdentitySettings() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
{t("gitIdentity.sshTitle")}
|
{t("gitIdentity.sshTitle")}
|
||||||
</h3>
|
</h3>
|
||||||
{id.writable && (
|
</div>
|
||||||
<select
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||||
aria-label={t("gitIdentity.keyType")}
|
|
||||||
value={keyType}
|
<PageTabs
|
||||||
disabled={busy}
|
ariaLabel={t("gitIdentity.sshTitle")}
|
||||||
onChange={(e) => setKeyType(e.target.value === "rsa" ? "rsa" : "ed25519")}
|
tabs={sshTabs}
|
||||||
className="select !py-1 !text-xs"
|
activeId={activeSshTab}
|
||||||
|
onSelect={setSshTab}
|
||||||
>
|
>
|
||||||
<option value="ed25519">{t("gitIdentity.keyEd25519")}</option>
|
{activeSshTab === "git" && (
|
||||||
<option value="rsa">{t("gitIdentity.keyRsa")}</option>
|
<KeyCard
|
||||||
</select>
|
title={t("gitIdentity.gitHubTitle")}
|
||||||
|
sub={t("gitIdentity.gitHubSub")}
|
||||||
|
type="ed25519"
|
||||||
|
k={keyOf("ed25519")}
|
||||||
|
id={id}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={generate}
|
||||||
|
onRemove={removeKey}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.gitHubStepClone")}
|
||||||
|
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||||
|
</Step>
|
||||||
|
</KeyCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSshTab === "aws" && (
|
||||||
|
<KeyCard
|
||||||
|
title={t("gitIdentity.awsTitle")}
|
||||||
|
sub={t("gitIdentity.awsSub")}
|
||||||
|
type="rsa"
|
||||||
|
k={keyOf("rsa")}
|
||||||
|
id={id}
|
||||||
|
busy={busy}
|
||||||
|
onGenerate={generate}
|
||||||
|
onRemove={removeKey}
|
||||||
|
>
|
||||||
|
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
|
||||||
|
<Step n={3}>
|
||||||
|
{t("gitIdentity.awsStepKeyId")}
|
||||||
|
{id.sshUsers === undefined ? null : (
|
||||||
|
<>
|
||||||
|
{awsRows.map((r) => (
|
||||||
|
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{r.alias ? `${r.alias} · ` : ""}
|
||||||
|
{r.region} · {r.user} · {keyLabel(r.key)}
|
||||||
|
</span>
|
||||||
{id.writable && (
|
{id.writable && (
|
||||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
<button
|
||||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
type="button"
|
||||||
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||||
|
onClick={() =>
|
||||||
|
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{id.ssh ? (
|
))}
|
||||||
<>
|
{id.writable && (
|
||||||
<textarea
|
<form
|
||||||
readOnly
|
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||||
aria-label={t("gitIdentity.publicKey")}
|
onSubmit={(e) => {
|
||||||
value={id.ssh.publicKey}
|
e.preventDefault();
|
||||||
rows={3}
|
void saveAws();
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
}}
|
||||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.awsKeyId")}
|
||||||
|
placeholder="APKA…"
|
||||||
|
autoComplete="off"
|
||||||
|
value={aws.keyId}
|
||||||
|
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.awsRegion")}
|
||||||
|
value={aws.region}
|
||||||
|
onChange={(e) => setAws({ ...aws, region: e.target.value })}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
{AWS_REGIONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.alias")}
|
||||||
|
title={t("gitIdentity.aliasHint")}
|
||||||
|
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={aws.alias}
|
||||||
|
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Step>
|
||||||
|
{awsRows.length > 0 && (
|
||||||
|
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.awsReady")}
|
||||||
|
{awsRows.map((r) => (
|
||||||
|
<Code key={r.alias ?? r.host}>
|
||||||
|
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||||
|
</Code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</KeyCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeSshTab === "more" && (
|
||||||
|
<section
|
||||||
|
aria-label={t("gitIdentity.moreKeysTitle")}
|
||||||
|
className="space-y-2 rounded border border-border p-2.5"
|
||||||
|
>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-xs font-medium text-fg-primary">
|
||||||
|
{t("gitIdentity.moreKeysTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
|
||||||
|
</div>
|
||||||
|
{id.writable && !newKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setNewKey({ name: "", type: "rsa" })}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.newKey")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newKey && (
|
||||||
|
<form
|
||||||
|
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void makeNamed();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.keyName")}
|
||||||
|
placeholder={t("gitIdentity.keyNamePlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
value={newKey.name}
|
||||||
|
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.keyType")}
|
||||||
|
value={newKey.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
|
||||||
|
}
|
||||||
|
className="select !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<option value="rsa">RSA 4096 (AWS)</option>
|
||||||
|
<option value="ed25519">ED25519</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
|
||||||
|
{t("gitIdentity.generate")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
|
||||||
|
{t("gitCreds.cancel")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
{named.map((k) => (
|
||||||
|
<div
|
||||||
|
key={k.id}
|
||||||
|
className="space-y-1 rounded border border-border-light p-2"
|
||||||
|
data-testid="git-named-key"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-mono text-[11px] font-medium text-fg-primary">
|
||||||
|
{k.name}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||||
|
{k.type === "rsa" ? "RSA" : "ED25519"}
|
||||||
|
</span>
|
||||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||||
{id.ssh.type === "rsa" ? "RSA 4096 · " : id.ssh.type ? "ED25519 · " : ""}
|
{k.fingerprint}
|
||||||
{id.ssh.fingerprint}
|
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={BTN}
|
className={BTN}
|
||||||
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
onClick={() => void copyText(k.publicKey)}
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.copy")}
|
{t("gitIdentity.copy")}
|
||||||
@@ -255,22 +702,127 @@ export function GitIdentitySettings() {
|
|||||||
type="button"
|
type="button"
|
||||||
className={`${BTN} hover:text-status-danger`}
|
className={`${BTN} hover:text-status-danger`}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => void removeKey()}
|
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||||
|
onClick={() => void removeNamed(k.id!)}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
{t("gitIdentity.remove")}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
<textarea
|
||||||
</>
|
readOnly
|
||||||
) : (
|
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
value={k.publicKey}
|
||||||
|
rows={2}
|
||||||
|
onFocus={(e) => e.currentTarget.select()}
|
||||||
|
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeSshTab === "other" && (
|
||||||
|
<section
|
||||||
|
aria-label={t("gitIdentity.otherTitle")}
|
||||||
|
className="space-y-1.5 rounded border border-border p-2.5"
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-fg-primary">
|
||||||
|
{t("gitIdentity.otherTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="text-[10px] text-fg-muted">{t("gitIdentity.otherHint")}</div>
|
||||||
|
{otherRows.map((r) => (
|
||||||
|
<div key={r.alias ?? r.host} className="flex items-center gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
|
||||||
|
{r.alias ? `ssh://${r.alias}/ → ` : ""}
|
||||||
|
{r.user}@{r.host} · {keyLabel(r.key)}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
|
||||||
|
onClick={() =>
|
||||||
|
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{id.writable && (
|
||||||
|
<form
|
||||||
|
className="grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void saveOther();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherHost")}
|
||||||
|
placeholder="git.example.com"
|
||||||
|
value={other.host}
|
||||||
|
onChange={(e) => setOther({ ...other, host: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.otherUser")}
|
||||||
|
placeholder={t("gitIdentity.otherUser")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={other.user}
|
||||||
|
onChange={(e) => setOther({ ...other, user: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.alias")}
|
||||||
|
title={t("gitIdentity.aliasHint")}
|
||||||
|
placeholder={t("gitIdentity.aliasPlaceholder")}
|
||||||
|
autoComplete="off"
|
||||||
|
value={other.alias}
|
||||||
|
onChange={(e) => setOther({ ...other, alias: e.target.value })}
|
||||||
|
className={`${INPUT} font-mono`}
|
||||||
|
/>
|
||||||
|
{keySelect(other.key, (key) => setOther({ ...other, key }))}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy || !other.host.trim() || !other.user.trim()}
|
||||||
|
>
|
||||||
|
{t("gitIdentity.awsSave")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</PageTabs>
|
||||||
|
|
||||||
|
{terminal && (
|
||||||
|
<div className="flex items-start gap-2 rounded border border-border p-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[11px] text-fg-primary">{t("gitIdentity.terminal")}</div>
|
||||||
|
<div className="text-[10px] text-fg-muted">
|
||||||
|
{terminal === "foreign"
|
||||||
|
? t("gitIdentity.terminalForeign")
|
||||||
|
: t("gitIdentity.terminalHint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label={t("gitIdentity.terminal")}
|
||||||
|
checked={terminal === "on"}
|
||||||
|
disabled={busy || !id.writable || terminal === "foreign"}
|
||||||
|
onChange={(on) => void run(() => api.gitIdentity.setTerminalSsh(on))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.privateNote")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{member && (
|
{member && (
|
||||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
<div className="space-y-2 rounded-lg border border-border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
{t("gitIdentity.httpsTitle")}
|
{t("gitIdentity.httpsTitle")}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
|
|||||||
import { SagiAvatar } from "../Sagi/SagiAvatar";
|
import { SagiAvatar } from "../Sagi/SagiAvatar";
|
||||||
import { PetSprite } from "../Sagi/PetSprite";
|
import { PetSprite } from "../Sagi/PetSprite";
|
||||||
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
|
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
|
||||||
import { usePrefersReducedMotion } from "../Sagi/Sagi";
|
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { usePets, useWornPet } from "../Sagi/usePets";
|
import { usePets, useWornPet } from "../Sagi/usePets";
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* @file RetentionSettings.tsx
|
||||||
|
* @description Settings → Data → Session cleanup, under the manual purge: the
|
||||||
|
* OPT-IN automatic cleanup (`AGENTHUB_RETENTION_DAYS`, 0 = off, the default)
|
||||||
|
* that the hourly maintenance applies, the database's size and free space, and
|
||||||
|
* **Compact now** — a full VACUUM behind a confirm, polled through
|
||||||
|
* `GET /api/settings/retention` while it runs, then toasted. Backed by
|
||||||
|
* `server/lib/db-retention.js`; a value the environment set is read-only.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { AlertTriangle, Minimize2, RefreshCw } from "lucide-react";
|
||||||
|
import { api } from "../../lib/api";
|
||||||
|
import type { RetentionState } from "../../lib/api";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
|
||||||
|
export function RetentionSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const [state, setState] = useState<RetentionState | null>(null);
|
||||||
|
const [days, setDays] = useState("0");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.settings.retention
|
||||||
|
.get()
|
||||||
|
.then((s) => {
|
||||||
|
setState(s);
|
||||||
|
setDays(String(s.days));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// A Compact takes a while on a large database: poll until it ends.
|
||||||
|
const compacting = !!state?.compacting;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!compacting) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
api.settings.retention
|
||||||
|
.get()
|
||||||
|
.then((s) => {
|
||||||
|
setState(s); // the draft keeps its value
|
||||||
|
if (s.compacting || !s.last_compact) return;
|
||||||
|
if (s.last_compact.ok) {
|
||||||
|
toast.success(
|
||||||
|
t("retention.compactDone", {
|
||||||
|
before: formatBytes(s.last_compact.size_before ?? 0),
|
||||||
|
after: formatBytes(s.last_compact.size_after ?? 0),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(t("retention.compactFailed", { error: s.last_compact.error }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [compacting, t]);
|
||||||
|
|
||||||
|
const max = state?.max_days ?? 3650;
|
||||||
|
const n = Number(days);
|
||||||
|
const valid = days.trim() !== "" && Number.isInteger(n) && n >= 0 && n <= max;
|
||||||
|
const changed = state !== null && valid && n !== state.days;
|
||||||
|
const owned = !!state?.env_owned.days;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const s = await api.settings.retention.save(n);
|
||||||
|
setState(s);
|
||||||
|
setDays(String(s.days));
|
||||||
|
toast.success(t("retention.saved"));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compact() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t("retention.compactConfirmTitle"),
|
||||||
|
message: t("retention.compactConfirmBody"),
|
||||||
|
confirmLabel: t("retention.compact"),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
setState(await api.settings.retention.compact());
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purged = state?.last_run?.retention?.purged_sessions;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border pt-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-fg-secondary">{t("retention.title")}</h4>
|
||||||
|
<p className="text-xs text-fg-muted">{t("retention.description")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||||
|
<label htmlFor="retention-days" className="text-xs text-fg-secondary block mb-2">
|
||||||
|
{t("retention.days")}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="retention-days"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max={max}
|
||||||
|
step="1"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={days}
|
||||||
|
disabled={!state || owned}
|
||||||
|
onChange={(e) => setDays(e.target.value)}
|
||||||
|
aria-invalid={!valid || undefined}
|
||||||
|
aria-describedby={!valid ? "retention-days-error" : undefined}
|
||||||
|
className="input w-20 text-sm text-right font-mono"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-fg-muted">{t("common:days")}</span>
|
||||||
|
</div>
|
||||||
|
{!valid && (
|
||||||
|
<p id="retention-days-error" className="mt-1 text-[11px] text-status-danger">
|
||||||
|
{t("retention.invalid", { max })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled={saving || !changed || owned}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{saving ? t("homeDir.saving") : t("homeDir.save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-fg-secondary">
|
||||||
|
{state && state.days > 0 ? t("retention.on", { days: state.days }) : t("retention.off")}
|
||||||
|
{state?.last_run && purged !== undefined && (
|
||||||
|
<> {t("retention.lastRun", { time: formatDateTime(state.last_run.at), n: purged })}</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{owned && (
|
||||||
|
<p className="text-xs text-status-warning flex items-start gap-1.5">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
|
||||||
|
{t("privacy.envOwned")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void compact()}
|
||||||
|
disabled={!state || compacting}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
|
||||||
|
>
|
||||||
|
{compacting ? (
|
||||||
|
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
|
||||||
|
) : (
|
||||||
|
<Minimize2 className="w-3 h-3 inline mr-1" />
|
||||||
|
)}
|
||||||
|
{compacting ? t("retention.compacting") : t("retention.compact")}
|
||||||
|
</button>
|
||||||
|
{state?.space && (
|
||||||
|
<span className="text-xs text-fg-muted">
|
||||||
|
{t("retention.space", {
|
||||||
|
size: formatBytes(state.db_size),
|
||||||
|
free: formatBytes(state.space.free_bytes),
|
||||||
|
mode: state.space.auto_vacuum,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionDefaultsSettings — Settings → AI tools → Session defaults: the
|
||||||
|
* account's own starting choices for a new Claude Code session (permission
|
||||||
|
* mode, Bypass included) and a new Codex session (sandbox, approval, bypass),
|
||||||
|
* which the workspace home's prompt box starts from (`lib/sessionDefaults.ts`,
|
||||||
|
* synced per account). Turning bypass on as a default asks first; a pick in
|
||||||
|
* the prompt box still changes only that one session.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ShieldAlert } from "lucide-react";
|
||||||
|
import type { CodexApproval, CodexSandbox } from "../../lib/api";
|
||||||
|
import { useAiTools } from "../../hooks/useAiTools";
|
||||||
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
|
import {
|
||||||
|
CODEX_APPROVALS,
|
||||||
|
CODEX_SANDBOXES,
|
||||||
|
DEFAULT_MODES,
|
||||||
|
readCodexDefaults,
|
||||||
|
readDefaultMode,
|
||||||
|
writeCodexDefaults,
|
||||||
|
writeDefaultMode,
|
||||||
|
type DefaultMode,
|
||||||
|
} from "../../lib/sessionDefaults";
|
||||||
|
import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow } from "./SettingsRow";
|
||||||
|
|
||||||
|
const MODE_KEYS: Record<DefaultMode, string> = {
|
||||||
|
auto: "Auto",
|
||||||
|
acceptEdits: "AcceptEdits",
|
||||||
|
default: "Default",
|
||||||
|
plan: "Plan",
|
||||||
|
bypassPermissions: "Bypass",
|
||||||
|
};
|
||||||
|
const SANDBOX_KEYS: Record<CodexSandbox, string> = {
|
||||||
|
"read-only": "sandboxReadOnly",
|
||||||
|
"workspace-write": "sandboxWorkspaceWrite",
|
||||||
|
"danger-full-access": "sandboxFullAccess",
|
||||||
|
};
|
||||||
|
const APPROVAL_KEYS: Record<CodexApproval, string> = {
|
||||||
|
"on-request": "approvalOnRequest",
|
||||||
|
never: "approvalNever",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Danger({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-start gap-2 py-2 text-xs text-status-danger">
|
||||||
|
<ShieldAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionDefaultsSettings() {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const aiTools = useAiTools();
|
||||||
|
const shown = (id: string) => aiTools.tools.some((x) => x.id === id && x.enabled);
|
||||||
|
const [mode, setMode] = useState<DefaultMode>(() => readDefaultMode() as DefaultMode);
|
||||||
|
const [codex, setCodex] = useState(readCodexDefaults);
|
||||||
|
|
||||||
|
const askBypass = (tool: string) =>
|
||||||
|
confirmDialog({
|
||||||
|
title:
|
||||||
|
tool === "Codex"
|
||||||
|
? t("sessionDefaults.codexConfirmTitle")
|
||||||
|
: t("sessionDefaults.confirmTitle", { tool }),
|
||||||
|
message: t("sessionDefaults.confirmBody", { tool }),
|
||||||
|
confirmLabel: t("sessionDefaults.confirmLabel"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickMode = async (m: DefaultMode) => {
|
||||||
|
if (m === "bypassPermissions" && !(await askBypass("Claude Code"))) return;
|
||||||
|
writeDefaultMode(m);
|
||||||
|
setMode(m);
|
||||||
|
};
|
||||||
|
const patchCodex = (patch: Partial<typeof codex>) => {
|
||||||
|
writeCodexDefaults(patch);
|
||||||
|
setCodex((c) => ({ ...c, ...patch }));
|
||||||
|
};
|
||||||
|
const pickCodexBypass = async (on: boolean) => {
|
||||||
|
if (on && !(await askBypass("Codex"))) return;
|
||||||
|
patchCodex({ bypass: on });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!shown("claude") && !shown("codex")) return null;
|
||||||
|
return (
|
||||||
|
<SettingsSection
|
||||||
|
id="session-defaults"
|
||||||
|
title={t("sessionDefaults.title")}
|
||||||
|
description={t("sessionDefaults.description")}
|
||||||
|
>
|
||||||
|
{shown("claude") && (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.claudeMode")}
|
||||||
|
description={t("sessionDefaults.claudeModeDesc")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.claudeMode")}
|
||||||
|
value={mode}
|
||||||
|
onChange={(m) => void pickMode(m)}
|
||||||
|
options={DEFAULT_MODES.map((m) => ({
|
||||||
|
value: m,
|
||||||
|
label: t(`lanes:home.permission${MODE_KEYS[m]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{mode === "bypassPermissions" && <Danger>{t("run:hint.permissionWarning")}</Danger>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{shown("codex") && (
|
||||||
|
<>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.codexSandbox")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.codexSandbox")}
|
||||||
|
value={codex.sandbox}
|
||||||
|
onChange={(sandbox) => patchCodex({ sandbox })}
|
||||||
|
options={CODEX_SANDBOXES.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: t(`run:fields.${SANDBOX_KEYS[v]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label={t("sessionDefaults.codexApproval")}
|
||||||
|
control={
|
||||||
|
<SettingsSegmented
|
||||||
|
ariaLabel={t("sessionDefaults.codexApproval")}
|
||||||
|
value={codex.approval}
|
||||||
|
onChange={(approval) => patchCodex({ approval })}
|
||||||
|
options={CODEX_APPROVALS.map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: t(`run:fields.${APPROVAL_KEYS[v]}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsSwitchRow
|
||||||
|
label={t("sessionDefaults.codexBypass")}
|
||||||
|
description={t("sessionDefaults.codexBypassDesc")}
|
||||||
|
checked={codex.bypass}
|
||||||
|
onChange={(on) => void pickCodexBypass(on)}
|
||||||
|
/>
|
||||||
|
{codex.bypass && <Danger>{t("run:hint.bypassWarning", { tool: "Codex" })}</Danger>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
* switch row (`SettingsSwitchRow`) and a segmented radio group
|
* switch row (`SettingsSwitchRow`) and a segmented radio group
|
||||||
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
|
* (`SettingsSegmented`, arrow keys + one roving tab stop). Rows divide
|
||||||
* themselves with a hairline, none after the last, so a section is just its
|
* themselves with a hairline, none after the last, so a section is just its
|
||||||
* rows in order.
|
* rows in order. A string description shows its `backticked` words as code,
|
||||||
|
* and a `disabled` row dims its label, so an unavailable control reads as one.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -32,30 +33,55 @@ export function SettingsSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `text` with every `backticked` run drawn as inline code (a command to type). */
|
||||||
|
export function withCode(text: string): ReactNode {
|
||||||
|
const parts = text.split(/`([^`]+)`/);
|
||||||
|
if (parts.length === 1) return text;
|
||||||
|
return parts.map((p, i) =>
|
||||||
|
i % 2 ? (
|
||||||
|
<code
|
||||||
|
key={i}
|
||||||
|
className="whitespace-nowrap rounded border border-border bg-surface-2 px-1 py-px font-mono text-[11px] text-fg-secondary"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</code>
|
||||||
|
) : (
|
||||||
|
p
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsRow({
|
export function SettingsRow({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
control,
|
control,
|
||||||
alignTop = false,
|
alignTop = false,
|
||||||
|
disabled = false,
|
||||||
id,
|
id,
|
||||||
}: {
|
}: {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
control: ReactNode;
|
control: ReactNode;
|
||||||
alignTop?: boolean;
|
alignTop?: boolean;
|
||||||
|
/** The control cannot be used here: the label is dimmed with it. */
|
||||||
|
disabled?: boolean;
|
||||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||||
id?: string;
|
id?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={id}
|
id={id}
|
||||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-6 border-b border-border/60 last:border-b-0 ${
|
||||||
description ? "py-3" : "py-2"
|
description ? "py-3" : "py-2"
|
||||||
} ${alignTop ? "items-start" : "items-center"}`}
|
} ${alignTop ? "items-start" : "items-center"}`}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-fg-secondary">{label}</p>
|
<p className={`text-sm ${disabled ? "text-fg-muted" : "text-fg-secondary"}`}>{label}</p>
|
||||||
{description && <p className="mt-0.5 text-xs text-fg-muted">{description}</p>}
|
{description && (
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted">
|
||||||
|
{typeof description === "string" ? withCode(description) : description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{control}</div>
|
<div className="shrink-0">{control}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,6 +108,7 @@ export function SettingsSwitchRow({
|
|||||||
id={id}
|
id={id}
|
||||||
label={label}
|
label={label}
|
||||||
description={description}
|
description={description}
|
||||||
|
disabled={disabled}
|
||||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
* @file Tests for Settings → Git → Your git identity: a user with no key
|
* @file Tests for Settings → Git → Your git identity: a user with no key
|
||||||
* makes one, a key shows only its public half and copies it, replacing asks
|
* makes one, a key shows only its public half and copies it, replacing asks
|
||||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||||
* view leaves HTTPS to the shared Git credentials table.
|
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
|
||||||
|
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
|
||||||
|
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||||
|
* the sections never mix; a named key
|
||||||
|
* is made beside the default ones, and a second AWS account is saved under
|
||||||
|
* its own name with the one key it uses.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { GitIdentitySettings } from "../GitIdentitySettings";
|
import { GitIdentitySettings } from "../GitIdentitySettings";
|
||||||
import { api, type GitIdentity } from "../../../lib/api";
|
import { api, type GitIdentity } from "../../../lib/api";
|
||||||
@@ -19,6 +24,10 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
generateKey: vi.fn(),
|
generateKey: vi.fn(),
|
||||||
removeKey: vi.fn(),
|
removeKey: vi.fn(),
|
||||||
|
removeKeyById: vi.fn(),
|
||||||
|
setTerminalSsh: vi.fn(),
|
||||||
|
setSshUser: vi.fn(),
|
||||||
|
removeSshUser: vi.fn(),
|
||||||
saveHttps: vi.fn(),
|
saveHttps: vi.fn(),
|
||||||
saveAuthor: vi.fn(),
|
saveAuthor: vi.fn(),
|
||||||
removeHttps: vi.fn(),
|
removeHttps: vi.fn(),
|
||||||
@@ -39,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Opens one tab of the SSH keys. */
|
||||||
|
const tab = async (name: string | RegExp) =>
|
||||||
|
userEvent.click(await screen.findByRole("tab", { name }));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -48,10 +61,11 @@ describe("GitIdentitySettings", () => {
|
|||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||||
|
await userEvent.click(within(gh).getByRole("button", { name: "Generate key" }));
|
||||||
expect(confirmDialog).not.toHaveBeenCalled();
|
expect(confirmDialog).not.toHaveBeenCalled();
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
||||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("copies the public key and asks before replacing it", async () => {
|
it("copies the public key and asks before replacing it", async () => {
|
||||||
@@ -85,16 +99,86 @@ describe("GitIdentitySettings", () => {
|
|||||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("makes an RSA key when that type is picked (AWS CodeCommit takes only RSA)", async () => {
|
it("makes the RSA key from the AWS CodeCommit section", async () => {
|
||||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
await userEvent.selectOptions(await screen.findByLabelText("Key type"), "rsa");
|
await tab("AWS CodeCommit");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Generate SSH key" }));
|
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: "Generate key" }));
|
||||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa");
|
||||||
expect(await screen.findByText(/^RSA 4096 · /)).toBeInTheDocument();
|
expect(await screen.findByLabelText("Public key RSA")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps each key in its own section and deletes each on its own", async () => {
|
||||||
|
const RSA = {
|
||||||
|
type: "rsa" as const,
|
||||||
|
publicKey: "ssh-rsa AAAAB3 an@agenthub",
|
||||||
|
fingerprint: "SHA256:r",
|
||||||
|
};
|
||||||
|
const ED = { ...KEY, type: "ed25519" as const };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ ssh: ED, sshKeys: [ED, RSA], sshTerminal: "on" })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeKey).mockResolvedValue(view({ ssh: ED, sshKeys: [ED] }));
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const gh = await screen.findByRole("region", { name: /GitHub/ });
|
||||||
|
expect(within(gh).getByLabelText("Public key ED25519")).toHaveValue(ED.publicKey);
|
||||||
|
// One tab at a time: the other host's section is not on screen.
|
||||||
|
expect(screen.queryByRole("region", { name: "AWS CodeCommit" })).toBeNull();
|
||||||
|
expect(within(gh).queryByText(/IAM/)).toBeNull();
|
||||||
|
await tab("AWS CodeCommit");
|
||||||
|
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
expect(screen.queryByRole("region", { name: /GitHub/ })).toBeNull();
|
||||||
|
expect(within(aws).getByLabelText("Public key RSA")).toHaveValue(RSA.publicKey);
|
||||||
|
expect(within(aws).queryByText(/GitHub/)).toBeNull();
|
||||||
|
expect(screen.getByRole("switch", { name: /terminal/ })).toBeChecked();
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(api.gitIdentity.removeKey).toHaveBeenCalledWith("rsa"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the CodeCommit SSH key ID for a region and shows the plain clone", async () => {
|
||||||
|
const RSA = { type: "rsa" as const, publicKey: "ssh-rsa AAAAB3 x", fingerprint: "SHA256:r" };
|
||||||
|
const H = "git-codecommit.eu-west-1.amazonaws.com";
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA], sshUsers: [{ host: H, user: "APKA1" }] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeSshUser).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const aws = await screen.findByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
await userEvent.type(within(aws).getByLabelText("SSH key ID"), " APKA1 ");
|
||||||
|
await userEvent.selectOptions(within(aws).getByLabelText("Region"), "eu-west-1");
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||||
|
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||||
|
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||||
|
// A CodeCommit host never shows up again under "other hosts".
|
||||||
|
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||||
|
expect(api.gitIdentity.removeSshUser).toHaveBeenCalledWith(H);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a user for any other SSH host under the advanced section", async () => {
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY, sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({ ssh: KEY, sshUsers: [{ host: "git.example.com", user: "deploy" }] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
await tab("Other hosts");
|
||||||
|
await userEvent.type(screen.getByLabelText("Host"), "ssh://git.example.com/x");
|
||||||
|
await userEvent.type(screen.getByLabelText("User name"), "deploy");
|
||||||
|
const form = screen.getByLabelText("Host").closest("form")!;
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: "git.example.com",
|
||||||
|
user: "deploy",
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("deploy@git.example.com · Any key")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns while the commit author is unset and saves it", async () => {
|
it("warns while the commit author is unset and saves it", async () => {
|
||||||
@@ -118,8 +202,73 @@ describe("GitIdentitySettings", () => {
|
|||||||
view({ account: "host", writable: false, ssh: KEY })
|
view({ account: "host", writable: false, ssh: KEY })
|
||||||
);
|
);
|
||||||
render(<GitIdentitySettings />);
|
render(<GitIdentitySettings />);
|
||||||
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
|
expect(await screen.findByLabelText(/^Public key/)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("makes a named key and saves a second AWS account under its name with that key", async () => {
|
||||||
|
const RSA = {
|
||||||
|
id: "id_rsa",
|
||||||
|
type: "rsa" as const,
|
||||||
|
name: null,
|
||||||
|
publicKey: "ssh-rsa A x",
|
||||||
|
fingerprint: "SHA256:r",
|
||||||
|
};
|
||||||
|
const PROD = {
|
||||||
|
id: "id_rsa_aws-prod",
|
||||||
|
type: "rsa" as const,
|
||||||
|
name: "aws-prod",
|
||||||
|
publicKey: "ssh-rsa B prod",
|
||||||
|
fingerprint: "SHA256:p",
|
||||||
|
};
|
||||||
|
const H = "git-codecommit.ap-southeast-1.amazonaws.com";
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [RSA], sshUsers: [] }));
|
||||||
|
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA, PROD], sshUsers: [] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [RSA, PROD],
|
||||||
|
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
|
view({ sshKeys: [RSA], sshUsers: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
// Only an RSA key: the AWS tab opens first.
|
||||||
|
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
|
||||||
|
await tab("More keys");
|
||||||
|
let more = screen.getByRole("region", { name: "More SSH keys" });
|
||||||
|
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
|
||||||
|
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
|
||||||
|
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
|
||||||
|
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
|
||||||
|
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
|
||||||
|
|
||||||
|
await tab("AWS CodeCommit");
|
||||||
|
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||||
|
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
|
||||||
|
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
|
||||||
|
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
|
||||||
|
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||||
|
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
|
||||||
|
host: H,
|
||||||
|
user: "APKA2",
|
||||||
|
alias: "aws-prod",
|
||||||
|
key: "id_rsa_aws-prod",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await tab("More keys (1)");
|
||||||
|
more = screen.getByRole("region", { name: "More SSH keys" });
|
||||||
|
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* @file SessionDefaultsSettings.test.tsx
|
||||||
|
* @description Session defaults: Bypass becomes Claude's default permission
|
||||||
|
* mode or Codex's default bypass only after the confirm, a cancelled confirm
|
||||||
|
* keeps the old default, the plain modes save at once, and the pet chat's
|
||||||
|
* safe read never returns bypass.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { SessionDefaultsSettings } from "../SessionDefaultsSettings";
|
||||||
|
import { confirmDialog } from "../../../lib/confirm";
|
||||||
|
import { readCodexDefaults, readDefaultMode } from "../../../lib/sessionDefaults";
|
||||||
|
|
||||||
|
const aiTools = vi.hoisted(() => ({
|
||||||
|
tools: [
|
||||||
|
{ id: "claude", label: "Claude Code", installed: true, enabled: true },
|
||||||
|
{ id: "codex", label: "Codex", installed: true, enabled: true },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hooks/useAiTools", () => ({ useAiTools: () => aiTools }));
|
||||||
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
||||||
|
const confirm = vi.mocked(confirmDialog);
|
||||||
|
|
||||||
|
const radio = (name: RegExp) => screen.getAllByRole("radio", { name })[0]!;
|
||||||
|
|
||||||
|
describe("SessionDefaultsSettings", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
confirm.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a plain mode without asking", () => {
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
fireEvent.click(radio(/plan/i));
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
expect(readDefaultMode()).toBe("plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes Bypass Claude's default only once confirmed", async () => {
|
||||||
|
confirm.mockResolvedValueOnce(false);
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
fireEvent.click(radio(/bypass/i));
|
||||||
|
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
|
||||||
|
expect(readDefaultMode()).toBe("auto");
|
||||||
|
|
||||||
|
confirm.mockResolvedValueOnce(true);
|
||||||
|
fireEvent.click(radio(/bypass/i));
|
||||||
|
await waitFor(() => expect(readDefaultMode()).toBe("bypassPermissions"));
|
||||||
|
expect(readDefaultMode({ safe: true })).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns Codex's default bypass on only once confirmed, off without asking", async () => {
|
||||||
|
confirm.mockResolvedValueOnce(true);
|
||||||
|
render(<SessionDefaultsSettings />);
|
||||||
|
const sw = screen.getByRole("switch");
|
||||||
|
fireEvent.click(sw);
|
||||||
|
await waitFor(() => expect(readCodexDefaults().bypass).toBe(true));
|
||||||
|
fireEvent.click(screen.getByRole("switch"));
|
||||||
|
await waitFor(() => expect(readCodexDefaults().bypass).toBe(false));
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows nothing when neither Claude nor Codex is enabled", () => {
|
||||||
|
const saved = aiTools.tools;
|
||||||
|
aiTools.tools = [];
|
||||||
|
const { container } = render(<SessionDefaultsSettings />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
aiTools.tools = saved;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,26 +8,31 @@
|
|||||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||||
* API key or a pasted `auth.json` — or removes one, each time with their own
|
* API key or a pasted `auth.json` — or removes one, each time with their own
|
||||||
* password again; an analyst only reads. The secret only goes IN: the server
|
* password again; an analyst only reads. The secret only goes IN: the server
|
||||||
* never sends it back.
|
* never sends it back. An account's Edit (pencil) picks the users who run on
|
||||||
|
* it (`PUT /api/accounts/:name/users`): their sessions skip the balancer, and
|
||||||
|
* a Claude login goes into their home at once — no restart.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
import { KeyRound, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
type AccountPin,
|
||||||
type AccountPool,
|
type AccountPool,
|
||||||
type PoolAccountInput,
|
type PoolAccountInput,
|
||||||
type PoolProfile,
|
type PoolProfile,
|
||||||
type PoolTool,
|
type PoolTool,
|
||||||
|
type ServerUserUsage,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
import { isSubmitEnter } from "../../lib/keys";
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useSpin } from "../../hooks/useSpin";
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
import { ConfirmModal } from "../ConfirmModal";
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
import { Checkbox } from "../Checkbox";
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
tool: PoolTool;
|
tool: PoolTool;
|
||||||
@@ -59,6 +64,8 @@ function rowsOf(pool: AccountPool): Row[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pinsOf = (pool: AccountPool, tool: PoolTool): AccountPin[] => pool.pins?.[tool] ?? [];
|
||||||
|
|
||||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
const err = e as Error & { code?: string };
|
const err = e as Error & { code?: string };
|
||||||
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
return err.code && t(`pool.errors.${err.code}`, { defaultValue: "" })
|
||||||
@@ -72,6 +79,7 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [removing, setRemoving] = useState<Row | null>(null);
|
const [removing, setRemoving] = useState<Row | null>(null);
|
||||||
|
const [editing, setEditing] = useState<Row | null>(null);
|
||||||
|
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
(force = false) =>
|
(force = false) =>
|
||||||
@@ -148,6 +156,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
<div className="text-[11px] text-fg-muted">
|
<div className="text-[11px] text-fg-muted">
|
||||||
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
{t("pool.added", { at: formatDateTime(r.addedAt) })}
|
||||||
</div>
|
</div>
|
||||||
|
{pool && pinsOf(pool, r.tool).some((p) => p.account === r.name) && (
|
||||||
|
<div className="text-[11px] text-fg-secondary">
|
||||||
|
{t("pool.pinned", {
|
||||||
|
users: pinsOf(pool, r.tool)
|
||||||
|
.filter((p) => p.account === r.name)
|
||||||
|
.map((p) => p.username)
|
||||||
|
.join(", "),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
{t(`pool.kind.${r.kind}`)}
|
{t(`pool.kind.${r.kind}`)}
|
||||||
@@ -188,7 +206,16 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="px-5 py-2 text-right">
|
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(r)}
|
||||||
|
title={t("pool.edit", { name: r.name })}
|
||||||
|
aria-label={t("pool.edit", { name: r.name })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRemoving(r)}
|
onClick={() => setRemoving(r)}
|
||||||
@@ -206,7 +233,6 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{adding && (
|
{adding && (
|
||||||
<AddAccountDialog
|
<AddAccountDialog
|
||||||
onClose={() => setAdding(false)}
|
onClose={() => setAdding(false)}
|
||||||
@@ -228,10 +254,163 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{editing && pool && (
|
||||||
|
<EditAccountDialog
|
||||||
|
pool={pool}
|
||||||
|
row={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onDone={(r) => {
|
||||||
|
const left = r.added.filter(
|
||||||
|
(u) => u.switched === "own-login" || u.switched === "expired"
|
||||||
|
);
|
||||||
|
toast.success(t("pool.editDone", { name: editing.name }));
|
||||||
|
for (const u of left) {
|
||||||
|
toast.info(
|
||||||
|
t(`pool.pinDone.${u.switched}`, { user: u.username, account: editing.name })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One account's Edit: tick the users whose sessions run on it. A user already
|
||||||
|
* on another account of the tool moves here when ticked; unticking one sends
|
||||||
|
* them back to the least-used account. Saved in one request, password again.
|
||||||
|
*/
|
||||||
|
function EditAccountDialog({
|
||||||
|
pool,
|
||||||
|
row,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
pool: AccountPool;
|
||||||
|
row: Row;
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (r: Awaited<ReturnType<typeof api.accountPool.setUsers>>) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const pins = pinsOf(pool, row.tool);
|
||||||
|
const initial = pins.filter((p) => p.account === row.name).map((p) => p.userId);
|
||||||
|
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||||
|
const [chosen, setChosen] = useState<Set<number>>(() => new Set(initial));
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users
|
||||||
|
.list()
|
||||||
|
.then((r) => setUsers(r.users))
|
||||||
|
.catch(() => setUsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const changed = chosen.size !== initial.length || initial.some((id) => !chosen.has(id));
|
||||||
|
const ready = changed && current.length > 0;
|
||||||
|
const toggle = (id: number, on: boolean) =>
|
||||||
|
setChosen((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!ready || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
onDone(await api.accountPool.setUsers(row.tool, row.name, [...chosen], current));
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={t("pool.edit", { name: row.name })}
|
||||||
|
message={t("pool.editHint")}
|
||||||
|
confirmLabel={t("pool.save")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
destructive={false}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!ready}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.target as HTMLElement).tagName === "INPUT" && isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{t("pool.users")}</span>
|
||||||
|
<div className="mt-1 max-h-56 overflow-y-auto rounded-md border border-border divide-y divide-border">
|
||||||
|
{users === null && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.loadingUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-xs text-fg-muted">{t("pool.noUsers")}</p>
|
||||||
|
)}
|
||||||
|
{users?.map((u) => {
|
||||||
|
const elsewhere = pins.find((p) => p.userId === u.id && p.account !== row.name);
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="px-3 py-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={chosen.has(u.id)}
|
||||||
|
onChange={(on) => toggle(u.id, on)}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{u.username}
|
||||||
|
{u.disabled && (
|
||||||
|
<span className="ml-1 text-fg-muted">({t("disabled")})</span>
|
||||||
|
)}
|
||||||
|
{elsewhere && (
|
||||||
|
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||||
|
{t("pool.onAccount", { account: elsewhere.account })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
labelClassName="text-sm text-fg-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type ClaudeSource = "profile" | "credentials" | "token";
|
type ClaudeSource = "profile" | "credentials" | "token";
|
||||||
|
|
||||||
function AddAccountDialog({
|
function AddAccountDialog({
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberTmuxCard.tsx
|
||||||
|
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
||||||
|
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||||
|
* beside the installed one, its sessions, whether an agent of theirs is
|
||||||
|
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||||
|
* An admin restarts one member's server, or every idle outdated one at once
|
||||||
|
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||||
|
* time with their own password again; a restart ends that member's panes,
|
||||||
|
* and the next start runs the installed tmux. An analyst only reads.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
||||||
|
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import { toast } from "../../lib/toast";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
|
import { useSpin } from "../../hooks/useSpin";
|
||||||
|
import { ConfirmModal } from "../ConfirmModal";
|
||||||
|
|
||||||
|
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||||
|
const err = e as Error & { code?: string };
|
||||||
|
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||||
|
? t(`tmux.errors.${err.code}`)
|
||||||
|
: err.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||||
|
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||||
|
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||||
|
|
||||||
|
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [data, setData] = useState<MemberTmux | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// A member row = restart that one; "idle" = every idle outdated member.
|
||||||
|
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
() =>
|
||||||
|
api.users
|
||||||
|
.tmux()
|
||||||
|
.then((r) => {
|
||||||
|
setData(r);
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => setError(e.message)),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [spinning, refresh] = useSpin(load);
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!data?.available) return null;
|
||||||
|
const rows = data.users.filter((u) => u.running);
|
||||||
|
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
||||||
|
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card" data-testid="member-tmux">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||||
|
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
||||||
|
<p className="text-xs text-fg-muted">
|
||||||
|
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={spinning}
|
||||||
|
aria-busy={spinning}
|
||||||
|
title={t("tmux.refresh")}
|
||||||
|
aria-label={t("tmux.refresh")}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{isAdmin && idleOutdated.length > 0 && (
|
||||||
|
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
||||||
|
<RotateCcw className="w-4 h-4" />{" "}
|
||||||
|
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!data.matched && (
|
||||||
|
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
||||||
|
{t("tmux.notMatched")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[560px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left">
|
||||||
|
<th className={th}>{t("tmux.user")}</th>
|
||||||
|
<th className={th}>{t("tmux.version")}</th>
|
||||||
|
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
||||||
|
<th className={th}>{t("tmux.agents")}</th>
|
||||||
|
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
|
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
||||||
|
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||||
|
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||||
|
{u.startedAt && (
|
||||||
|
<div className="text-[11px] text-fg-muted">
|
||||||
|
{t("tmux.since", {
|
||||||
|
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||||
|
{u.sessions ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||||
|
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
||||||
|
</td>
|
||||||
|
{isAdmin && (
|
||||||
|
<td className="px-5 py-2 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRestarting(u)}
|
||||||
|
title={t("tmux.restart", { name: u.username })}
|
||||||
|
aria-label={t("tmux.restart", { name: u.username })}
|
||||||
|
className="btn-icon"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{restarting && (
|
||||||
|
<RestartDialog
|
||||||
|
target={restarting}
|
||||||
|
idle={idleOutdated}
|
||||||
|
onClose={() => setRestarting(null)}
|
||||||
|
onDone={(msg) => {
|
||||||
|
toast.success(msg);
|
||||||
|
setRestarting(null);
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RestartDialog({
|
||||||
|
target,
|
||||||
|
idle,
|
||||||
|
onClose,
|
||||||
|
onDone,
|
||||||
|
}: {
|
||||||
|
target: MemberTmuxRow | "idle";
|
||||||
|
idle: MemberTmuxRow[];
|
||||||
|
onClose: () => void;
|
||||||
|
onDone: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("users");
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const one = target === "idle" ? null : target;
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!current || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (one) {
|
||||||
|
await api.users.tmuxRestart(one.id, current);
|
||||||
|
onDone(t("tmux.done", { name: one.username }));
|
||||||
|
} else {
|
||||||
|
const r = await api.users.tmuxRestartIdle(current);
|
||||||
|
// A server that would not stop needs root: say which, the rest went.
|
||||||
|
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||||
|
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(errorText(t, e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||||
|
message={
|
||||||
|
one
|
||||||
|
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||||
|
name: one.username,
|
||||||
|
count: one.sessions ?? 0,
|
||||||
|
})
|
||||||
|
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||||
|
}
|
||||||
|
confirmLabel={t("tmux.restartButton")}
|
||||||
|
cancelLabel={t("common:cancel")}
|
||||||
|
busy={busy}
|
||||||
|
disabled={!current}
|
||||||
|
onConfirm={() => void submit()}
|
||||||
|
onCancel={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mt-3 space-y-3"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="input w-full text-sm"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label={t("yourPassword")}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
* admin adds an account only with their own password typed (the token goes
|
* admin adds an account only with their own password typed (the token goes
|
||||||
* in once and is not shown back) — a saved login moved in (the server's own
|
* in once and is not shown back) — a saved login moved in (the server's own
|
||||||
* included), or a setup-token — and removes one the same way, and an analyst
|
* included), or a setup-token — and removes one the same way, and an analyst
|
||||||
* sees no add or remove control.
|
* sees no add or remove control. An account's Edit ticks the users who run on
|
||||||
|
* it, saved with the admin's own password; the users show on the account.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -19,10 +20,12 @@ const accountPool = vi.hoisted(() => ({
|
|||||||
add: vi.fn(),
|
add: vi.fn(),
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
profiles: vi.fn(),
|
profiles: vi.fn(),
|
||||||
|
setUsers: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const users = vi.hoisted(() => ({ list: vi.fn() }));
|
||||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
return { ...actual, api: { accountPool } };
|
return { ...actual, api: { accountPool, users } };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AccountPoolCard } from "../AccountPoolCard";
|
import { AccountPoolCard } from "../AccountPoolCard";
|
||||||
@@ -66,6 +69,12 @@ const POOL: AccountPool = {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
for (const fn of Object.values(accountPool)) fn.mockReset();
|
for (const fn of Object.values(accountPool)) fn.mockReset();
|
||||||
accountPool.list.mockResolvedValue(POOL);
|
accountPool.list.mockResolvedValue(POOL);
|
||||||
|
users.list.mockResolvedValue({
|
||||||
|
users: [
|
||||||
|
{ id: 7, username: "mia", disabled: false },
|
||||||
|
{ id: 8, username: "leo", disabled: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
accountPool.profiles.mockResolvedValue({
|
accountPool.profiles.mockResolvedValue({
|
||||||
profiles: [
|
profiles: [
|
||||||
{
|
{
|
||||||
@@ -158,4 +167,43 @@ describe("AccountPoolCard", () => {
|
|||||||
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
expect(accountPool.remove).toHaveBeenCalledWith("codex", "cx-1", "boss-pass")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets who runs on an account from its Edit, with the admin's own password", async () => {
|
||||||
|
accountPool.list.mockResolvedValue({
|
||||||
|
...POOL,
|
||||||
|
pins: {
|
||||||
|
claude: [
|
||||||
|
{ userId: 8, username: "leo", account: "team-1" },
|
||||||
|
{ userId: 7, username: "mia", account: "team-0" },
|
||||||
|
],
|
||||||
|
codex: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
accountPool.setUsers.mockResolvedValue({
|
||||||
|
added: [{ userId: 7, username: "mia", switched: "switched" }],
|
||||||
|
removed: [{ userId: 8, username: "leo" }],
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<AccountPoolCard isAdmin />);
|
||||||
|
expect(await screen.findByText("Used by: leo")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /switch a user/i })).toBeNull();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit team-1" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
const leo = await screen.findByRole("checkbox", { name: /leo/ });
|
||||||
|
const mia = screen.getByRole("checkbox", { name: /mia/ });
|
||||||
|
expect(leo).toHaveAttribute("aria-checked", "true");
|
||||||
|
expect(mia).toHaveAttribute("aria-checked", "false");
|
||||||
|
expect(screen.getByText("now on team-0")).toBeInTheDocument();
|
||||||
|
await user.click(mia);
|
||||||
|
await user.click(leo);
|
||||||
|
const save = Array.from(dialog.querySelectorAll("button")).find(
|
||||||
|
(b) => b.textContent === "Save"
|
||||||
|
)!;
|
||||||
|
expect(save).toBeDisabled();
|
||||||
|
await user.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||||
|
await user.click(save);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(accountPool.setUsers).toHaveBeenCalledWith("claude", "team-1", [7], "boss-pass")
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* @file MemberTmuxCard.test.tsx
|
||||||
|
* @description The Users page's members' tmux card: hidden without one Linux
|
||||||
|
* user per account, lists each running member server with its version and a
|
||||||
|
* badge when it is older than the installed tmux, warns until tmux-member is
|
||||||
|
* installed, and an admin restarts one member — or every idle outdated one,
|
||||||
|
* the working member left out — only with their own password typed; an
|
||||||
|
* analyst sees no restart control.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import type { MemberTmux } from "../../../lib/api";
|
||||||
|
|
||||||
|
const users = vi.hoisted(() => ({
|
||||||
|
tmux: vi.fn(),
|
||||||
|
tmuxRestart: vi.fn(),
|
||||||
|
tmuxRestartIdle: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
|
return { ...actual, api: { users } };
|
||||||
|
});
|
||||||
|
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { MemberTmuxCard } from "../MemberTmuxCard";
|
||||||
|
|
||||||
|
const DATA: MemberTmux = {
|
||||||
|
available: true,
|
||||||
|
installed: "3.8",
|
||||||
|
matched: true,
|
||||||
|
users: [
|
||||||
|
{ id: 2, username: "mia", running: true, version: "3.6", sessions: 2, busy: 0, outdated: true },
|
||||||
|
{ id: 3, username: "zed", running: true, version: "3.6", sessions: 1, busy: 1, outdated: true },
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
username: "ann",
|
||||||
|
running: true,
|
||||||
|
version: "3.8",
|
||||||
|
sessions: 3,
|
||||||
|
busy: 0,
|
||||||
|
outdated: false,
|
||||||
|
},
|
||||||
|
{ id: 5, username: "bob", running: false, busy: 0, outdated: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
users.tmux.mockResolvedValue(DATA);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MemberTmuxCard", () => {
|
||||||
|
it("renders nothing without one Linux user per account", async () => {
|
||||||
|
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||||
|
const { container } = render(<MemberTmuxCard isAdmin />);
|
||||||
|
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists running servers and marks the ones older than the installed tmux", async () => {
|
||||||
|
render(<MemberTmuxCard isAdmin={false} />);
|
||||||
|
expect(await screen.findByText("mia")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("bob")).toBeNull();
|
||||||
|
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
||||||
|
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||||
|
// An analyst reads only.
|
||||||
|
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns until tmux-member is installed", async () => {
|
||||||
|
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts one member only with the admin's password", async () => {
|
||||||
|
users.tmuxRestart.mockResolvedValue({});
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||||
|
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||||
|
const go = screen.getByRole("button", { name: "Restart" });
|
||||||
|
expect(go).toBeDisabled();
|
||||||
|
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||||
|
await userEvent.click(go);
|
||||||
|
await waitFor(() => expect(users.tmuxRestart).toHaveBeenCalledWith(2, "pw"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||||
|
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
||||||
|
render(<MemberTmuxCard isAdmin />);
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||||
|
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||||
|
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Restart" }));
|
||||||
|
await waitFor(() => expect(users.tmuxRestartIdle).toHaveBeenCalledWith("pw"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* @file usePrefersReducedMotion.ts
|
||||||
|
* @description The OS "reduce motion" preference, followed live. Its own
|
||||||
|
* module so a settings screen can read it without importing the pet overlay
|
||||||
|
* (`components/Sagi/Sagi.tsx`), whose chat and pane UI would otherwise be
|
||||||
|
* pulled into every page's first bundle.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** The OS "reduce motion" preference, followed live. */
|
||||||
|
export function usePrefersReducedMotion(): boolean {
|
||||||
|
const [reduced, setReduced] = useState(
|
||||||
|
() =>
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||||
|
if (!mq) return;
|
||||||
|
const onChange = () => setReduced(mq.matches);
|
||||||
|
mq.addEventListener?.("change", onChange);
|
||||||
|
return () => mq.removeEventListener?.("change", onChange);
|
||||||
|
}, []);
|
||||||
|
return reduced;
|
||||||
|
}
|
||||||
@@ -115,6 +115,7 @@ export const i18nReady = i18n
|
|||||||
"splash",
|
"splash",
|
||||||
"lanes",
|
"lanes",
|
||||||
"users",
|
"users",
|
||||||
|
"admin",
|
||||||
"onboarding",
|
"onboarding",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"title": "Administration",
|
||||||
|
"description": "Accounts and the server AgentHub runs on.",
|
||||||
|
"tabs": {
|
||||||
|
"users": "Users",
|
||||||
|
"server": "Server"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"title": "Server",
|
||||||
|
"uptime": "up {{v}}",
|
||||||
|
"uptimeDays": "{{d}} d {{h}} h",
|
||||||
|
"uptimeHours": "{{h}} h {{m}} min",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"cores": "{{n}} cores · load {{load}}",
|
||||||
|
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||||
|
"memory": "Memory",
|
||||||
|
"swap": "Swap",
|
||||||
|
"homesDisk": "Homes disk",
|
||||||
|
"homesDiskHelp": "The disk that holds the users' home folders.",
|
||||||
|
"free": "free",
|
||||||
|
"usageTitle": "Usage by user",
|
||||||
|
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
||||||
|
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||||
|
"who": "User",
|
||||||
|
"processes": "Processes",
|
||||||
|
"home": "Home",
|
||||||
|
"sampling": "Sampling…",
|
||||||
|
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||||
|
"systemGroup": "System (root and other accounts)",
|
||||||
|
"disks": "Disks",
|
||||||
|
"mount": "Mount",
|
||||||
|
"device": "Device",
|
||||||
|
"used": "Used",
|
||||||
|
"homesBadge": "homes",
|
||||||
|
"gpus": "GPUs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@
|
|||||||
"shareDone": "Shared to the Hub",
|
"shareDone": "Shared to the Hub",
|
||||||
"unshareDone": "Removed from the Hub",
|
"unshareDone": "Removed from the Hub",
|
||||||
"open": "Open",
|
"open": "Open",
|
||||||
|
"copyLink": "Copy link",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
|
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"installs": "{{count}} installs",
|
"installs": "{{count}} installs",
|
||||||
"installed": "Installed to {{path}}",
|
"installed": "Installed to {{path}}",
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||||
"deleteConfirm": "Delete",
|
"deleteConfirm": "Delete",
|
||||||
"installTitle": "Install “{{title}}”?",
|
"installTitle": "Install “{{title}}”?",
|
||||||
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/. A skill can carry scripts your agent will run — only install what you trust:",
|
"installSkillBody": "These files are written to your own ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/, so every AI tool and session finds it. A skill can carry scripts your agent will run — only install what you trust:",
|
||||||
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||||
"installConfirm": "Install",
|
"installConfirm": "Install",
|
||||||
"replaceTitle": "Replace your skill “{{name}}”?",
|
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||||
@@ -68,7 +70,8 @@
|
|||||||
"form.save": "Add",
|
"form.save": "Add",
|
||||||
"form.cancel": "Cancel",
|
"form.cancel": "Cancel",
|
||||||
"added": "Added",
|
"added": "Added",
|
||||||
"files": "{{count}} files",
|
"files_one": "{{count}} file",
|
||||||
|
"files_other": "{{count}} files",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"fromSession": "From a session",
|
"fromSession": "From a session",
|
||||||
"form.saveEdit": "Save",
|
"form.saveEdit": "Save",
|
||||||
@@ -76,7 +79,7 @@
|
|||||||
"skill.tab.write": "Write new",
|
"skill.tab.write": "Write new",
|
||||||
"skill.tab.existing": "From my skills",
|
"skill.tab.existing": "From my skills",
|
||||||
"skill.name": "Skill name",
|
"skill.name": "Skill name",
|
||||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
|
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ (and ~/.agents/skills/) and runs as /{{slug}}",
|
||||||
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
||||||
"skill.description": "Description — when Claude should use this skill",
|
"skill.description": "Description — when Claude should use this skill",
|
||||||
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
||||||
@@ -108,5 +111,68 @@
|
|||||||
"file.fromComputer": "From your computer (≤ 25 MB)",
|
"file.fromComputer": "From your computer (≤ 25 MB)",
|
||||||
"file.upload": "Upload",
|
"file.upload": "Upload",
|
||||||
"file.tooBig": "The file is larger than 25 MB.",
|
"file.tooBig": "The file is larger than 25 MB.",
|
||||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
|
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote",
|
||||||
|
"editTitle": "Edit “{{title}}”",
|
||||||
|
"edit.title": "Title",
|
||||||
|
"edit.description": "Description",
|
||||||
|
"previewFailed": "Could not load this file.",
|
||||||
|
"truncated": "Showing the first 1 MB — download the file to see all of it.",
|
||||||
|
"tab.all": "All",
|
||||||
|
"installedBadge": "Installed",
|
||||||
|
"installsTitle": "Installs",
|
||||||
|
"installsYou": "You",
|
||||||
|
"installsUnknown": "A removed user",
|
||||||
|
"installsReplaced": "replaced an older copy",
|
||||||
|
"installsPluginWhere": "claude plugin install --scope user",
|
||||||
|
"installsUnrecorded": "{{count}} earlier installs were counted before details were kept",
|
||||||
|
"installsNoneOfYours": "You have not installed it — only your own installs are listed.",
|
||||||
|
"reinstall": "Reinstall",
|
||||||
|
"reinstallHint": "Write this version over your installed copy",
|
||||||
|
"reinstallTitle": "Reinstall “{{title}}”?",
|
||||||
|
"reinstallBody": "Your installed ~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are replaced with this version. The current copy is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||||
|
"uninstall": "Uninstall",
|
||||||
|
"uninstallHint": "Remove it from your account",
|
||||||
|
"uninstallTitle": "Uninstall “{{title}}”?",
|
||||||
|
"uninstallSkillBody": "~/.claude/skills/{{name}}/ and ~/.agents/skills/{{name}}/ are moved to ~/.claude/agenthub-replaced-skills/, not deleted. Sessions already open keep it until they reload.",
|
||||||
|
"uninstallPluginBody": "Runs claude plugin uninstall {{name}} for your account; the plugin's data folder is kept. Open sessions keep it until /reload-plugins.",
|
||||||
|
"uninstalled": "Uninstalled {{name}}",
|
||||||
|
"deleteBodyShared": "It is shared, so it also disappears from the Hub for everyone. To keep it but stop sharing, use Unshare instead. Anything already installed from it stays installed.",
|
||||||
|
"deleteBodyKeep": "It leaves your library but stays in the Hub, because it is shared. To take it off the Hub too, use Remove from Hub afterwards (or Unshare first).",
|
||||||
|
"deleteKept": "Removed from your library — still in the Hub",
|
||||||
|
"hubOnly": "Hub only",
|
||||||
|
"hubOnlyHint": "You removed it from your library; it stays shared in the Hub",
|
||||||
|
"hubRemove": "Remove from Hub",
|
||||||
|
"hubRemoveTitle": "Remove “{{title}}” from the Hub?",
|
||||||
|
"hubRemoveBody": "It is no longer in your library, so it is deleted for good. Anything already installed from it stays installed.",
|
||||||
|
"edit.otherFiles": "Its other {{count}} file(s) are kept as they are.",
|
||||||
|
"edit.reinstallNote": "Your installed copy keeps the old version until you Reinstall it.",
|
||||||
|
"preview.instructions": "Instructions",
|
||||||
|
"preview.frontmatter": "Frontmatter (SKILL.md header)",
|
||||||
|
"preview.whenUsed": "When Claude uses it",
|
||||||
|
"preview.noDescription": "No description",
|
||||||
|
"preview.installsAs": "Installs as ~/.claude/skills/{{slug}}/ and ~/.agents/skills/{{slug}}/",
|
||||||
|
"blurb.skill": "Reusable instructions Claude follows",
|
||||||
|
"blurb.plugin": "Bundles of skills, commands and hooks",
|
||||||
|
"blurb.page": "Pages your sessions published on claude.ai",
|
||||||
|
"blurb.file": "Documents and outputs worth keeping",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"categories": "Categories",
|
||||||
|
"seeAll": "See all",
|
||||||
|
"results_one": "{{count}} result",
|
||||||
|
"results_other": "{{count}} results",
|
||||||
|
"empty.title": "Your library is empty",
|
||||||
|
"empty.hubTitle": "The Hub is quiet",
|
||||||
|
"empty.searchTitle": "No matches",
|
||||||
|
"empty.search": "Nothing matches this search or category — try other words, or pick All.",
|
||||||
|
"details": "Details",
|
||||||
|
"more": "More actions",
|
||||||
|
"about": "About",
|
||||||
|
"stat.kind": "Type",
|
||||||
|
"stat.installs": "Installs",
|
||||||
|
"stat.files": "Files",
|
||||||
|
"stat.size": "Size",
|
||||||
|
"stat.updated": "Updated",
|
||||||
|
"detail.runAs": "Run it as",
|
||||||
|
"detail.market": "Marketplace",
|
||||||
|
"detail.added": "Added {{date}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,15 @@
|
|||||||
"clone.remember": "Remember this login for my account",
|
"clone.remember": "Remember this login for my account",
|
||||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||||
"clone.sshSettings": "Open Settings → Git",
|
"clone.sshSettings": "Open Settings → Git",
|
||||||
|
"clone.partial": "Partial clone (faster for big repos)",
|
||||||
|
"clone.partialHint": "Downloads every commit but a file's contents only when it is checked out (--filter=blob:none). Checking out an old revision later fetches from the remote.",
|
||||||
|
"clone.queued": "Waiting in the clone queue — #{{position}} of {{waiting}} ({{running}}/{{limit}} clones running)",
|
||||||
|
"clone.connecting": "Connecting…",
|
||||||
|
"clone.phase.remote": "Server preparing",
|
||||||
|
"clone.phase.receiving": "Downloading",
|
||||||
|
"clone.phase.resolving": "Unpacking",
|
||||||
|
"clone.phase.checkout": "Writing files",
|
||||||
|
"clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.",
|
||||||
"moreActions": "More actions",
|
"moreActions": "More actions",
|
||||||
"pipelinePicker.label": "Pipeline template",
|
"pipelinePicker.label": "Pipeline template",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||||
@@ -298,6 +307,7 @@
|
|||||||
"changes.undoCommitTitle": "Undo the last commit?",
|
"changes.undoCommitTitle": "Undo the last commit?",
|
||||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
"changes.undoCommitBody": "{{short}} \"{{subject}}\" is removed from the branch. Its changes stay staged, so you can edit them and commit again.",
|
||||||
"changes.undoCommitConfirm": "Undo commit",
|
"changes.undoCommitConfirm": "Undo commit",
|
||||||
|
"changes.undoCommitPushed": "The last commit is already on the remote branch (as of the last fetch); undoing it would rewrite shared history. Revert it instead — or Fetch first if the remote no longer has it.",
|
||||||
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
"changes.revertConfirm": "Add a commit on {{branch}} that undoes {{short}} \"{{subject}}\"? Files in this workspace change now.",
|
||||||
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
"changes.cherryPickConfirm": "Apply {{short}} \"{{subject}}\" from {{from}} onto {{branch}} as a new commit? Files in this workspace change now.",
|
||||||
"changes.amendTitle": "Amend the last commit?",
|
"changes.amendTitle": "Amend the last commit?",
|
||||||
@@ -421,6 +431,9 @@
|
|||||||
"home.cloneRun": "Clone",
|
"home.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Retry the failed ones",
|
"home.cloneRetry": "Retry the failed ones",
|
||||||
"home.cloneDone": "Added",
|
"home.cloneDone": "Added",
|
||||||
|
"home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.",
|
||||||
|
"home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).",
|
||||||
|
"home.cloneEnterLogin": "Enter a login",
|
||||||
"home.suggested": "Untracked worktrees",
|
"home.suggested": "Untracked worktrees",
|
||||||
"home.remove": "Remove",
|
"home.remove": "Remove",
|
||||||
"home.missing": "missing",
|
"home.missing": "missing",
|
||||||
@@ -430,7 +443,7 @@
|
|||||||
"tabs.new": "New tab",
|
"tabs.new": "New tab",
|
||||||
"tabs.newTerminal": "New terminal",
|
"tabs.newTerminal": "New terminal",
|
||||||
"tabs.terminal": "Terminal",
|
"tabs.terminal": "Terminal",
|
||||||
"tabs.close": "Close tab (stops its session)",
|
"tabs.close": "Close tab (stops and archives its session)",
|
||||||
"tabs.full": "All 10 sessions are in use — stop one first",
|
"tabs.full": "All 10 sessions are in use — stop one first",
|
||||||
"tabs.agentSettings": "Agent settings…",
|
"tabs.agentSettings": "Agent settings…",
|
||||||
"tabs.settings": "Settings",
|
"tabs.settings": "Settings",
|
||||||
@@ -465,7 +478,8 @@
|
|||||||
"panes.splitNew.right": "Split right with a new terminal",
|
"panes.splitNew.right": "Split right with a new terminal",
|
||||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||||
"panes.closeShort": "Move to own tab",
|
"panes.closeShort": "Move to own tab",
|
||||||
"panes.stop": "Stop session",
|
"panes.stop": "Stop and archive session",
|
||||||
|
"panes.sleep": "Sleep session (stop, keep to resume later)",
|
||||||
"changes.copyPath": "Copy relative path",
|
"changes.copyPath": "Copy relative path",
|
||||||
"changes.viewCommit": "View commit",
|
"changes.viewCommit": "View commit",
|
||||||
"changes.copySha": "Copy commit SHA",
|
"changes.copySha": "Copy commit SHA",
|
||||||
@@ -479,8 +493,13 @@
|
|||||||
"tabs.renameHint": "Double-click to rename",
|
"tabs.renameHint": "Double-click to rename",
|
||||||
"tabs.stopTitle": "Stop “{{name}}”?",
|
"tabs.stopTitle": "Stop “{{name}}”?",
|
||||||
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
|
"tabs.stopGroupTitle": "Stop the {{count}} sessions in this tab?",
|
||||||
"tabs.stopBody": "Closing the tab stops its session, even mid-turn. It can be resumed later from Recents.",
|
"tabs.stopBody": "Stopping ends the session, even mid-turn, and archives it: it leaves this workspace's list (Unarchive brings it back). To keep it for later, use Sleep instead.",
|
||||||
"tabs.stopConfirm": "Stop session",
|
"tabs.stopConfirm": "Stop session",
|
||||||
|
"tabs.sleep": "Sleep (stop, keep to resume later)",
|
||||||
|
"tabs.sleepTitle": "Sleep “{{name}}”?",
|
||||||
|
"tabs.sleepGroupTitle": "Sleep the {{count}} sessions in this tab?",
|
||||||
|
"tabs.sleepBody": "Sleep stops the session, even mid-turn, but keeps it in this workspace's list. Open it and send a message to resume it.",
|
||||||
|
"tabs.sleepConfirm": "Sleep",
|
||||||
"changes.loadMore": "Load more commits",
|
"changes.loadMore": "Load more commits",
|
||||||
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
"changes.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||||
"changes.splitter": "Resize the changes panel",
|
"changes.splitter": "Resize the changes panel",
|
||||||
@@ -512,6 +531,9 @@
|
|||||||
"changes.unversionedGroup": "Unversioned Files",
|
"changes.unversionedGroup": "Unversioned Files",
|
||||||
"changes.commitTicked": "Commit ({{count}})",
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
"changes.commitAndPush": "Commit and Push",
|
"changes.commitAndPush": "Commit and Push",
|
||||||
|
"changes.discardAll": "Discard all",
|
||||||
|
"changes.discardAllTitle": "Discard all changes?",
|
||||||
|
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
|
||||||
"changes.amendAndPush": "Amend and Push",
|
"changes.amendAndPush": "Amend and Push",
|
||||||
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
"changes.pushFailed": "Committed, but the push failed: {{error}}",
|
||||||
"changes.tickFile": "Include {{path}} in the commit",
|
"changes.tickFile": "Include {{path}} in the commit",
|
||||||
@@ -542,6 +564,9 @@
|
|||||||
"fx.refresh": "Refresh",
|
"fx.refresh": "Refresh",
|
||||||
"fx.newFolder": "New folder",
|
"fx.newFolder": "New folder",
|
||||||
"fx.newFile": "New file",
|
"fx.newFile": "New file",
|
||||||
|
"fx.cloneRepo": "Clone repository",
|
||||||
|
"fx.cloneSubmit": "Clone",
|
||||||
|
"fx.cloned": "Cloned into {{path}} — opened as workspace “{{workspace}}”.",
|
||||||
"fx.uploadFiles": "Upload files",
|
"fx.uploadFiles": "Upload files",
|
||||||
"fx.uploadFolder": "Upload a folder",
|
"fx.uploadFolder": "Upload a folder",
|
||||||
"fx.download": "Download",
|
"fx.download": "Download",
|
||||||
@@ -571,7 +596,7 @@
|
|||||||
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||||
"fx.pasted": "Pasted.",
|
"fx.pasted": "Pasted.",
|
||||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
|
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||||
"fx.conflictTitle": "Already there",
|
"fx.conflictTitle": "Already there",
|
||||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||||
"fx.conflictAll": "Do this for every remaining file",
|
"fx.conflictAll": "Do this for every remaining file",
|
||||||
@@ -585,6 +610,17 @@
|
|||||||
"fx.purge": "Delete forever",
|
"fx.purge": "Delete forever",
|
||||||
"fx.restored": "Restored.",
|
"fx.restored": "Restored.",
|
||||||
"fx.trashEmpty": "The Trash is empty.",
|
"fx.trashEmpty": "The Trash is empty.",
|
||||||
|
"fx.trashOf": "{{name}}'s Trash",
|
||||||
|
"fx.myTrash": "My Trash",
|
||||||
|
"fx.trashAt": "Trash ({{name}})",
|
||||||
|
"fx.trashFoldersHint": "Each folder is one user's Trash — open it to restore or delete forever.",
|
||||||
|
"fx.trashFolderEmpty": "Empty",
|
||||||
|
"fx.openTrash": "Open Trash",
|
||||||
|
"fx.purged": "Deleted forever.",
|
||||||
|
"fx.emptyTrashTitle": "Empty the Trash?",
|
||||||
|
"fx.emptyTrashBody": "All {{count}} items in {{where}} will be deleted forever. This cannot be undone.",
|
||||||
|
"fx.purgeTitle": "Delete forever?",
|
||||||
|
"fx.purgeBody": "“{{name}}” in {{where}} will be deleted forever. This cannot be undone.",
|
||||||
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
|
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
|
||||||
"changes.openInExplorer": "Open in File Explorer",
|
"changes.openInExplorer": "Open in File Explorer",
|
||||||
"fx.places": "Workspaces",
|
"fx.places": "Workspaces",
|
||||||
@@ -614,8 +650,9 @@
|
|||||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||||
"fx.myHome": "Home",
|
"fx.myHome": "Home",
|
||||||
"fx.userHomes": "Users' homes",
|
"fx.userHomes": "Users' homes",
|
||||||
"fx.readOnlyHome": "Another user's home — view and download only",
|
"fx.readOnlyHome": "Another user's home — view and download only; their Trash can be restored or emptied",
|
||||||
"fx.readOnly": "Read-only",
|
"fx.readOnly": "Read-only",
|
||||||
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it.",
|
||||||
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left."
|
"fx.homeEmpty": "Nothing in your home yet — its hidden files (.bashrc, .claude…) are not shown here. Your workspaces are on the left.",
|
||||||
|
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,5 +87,6 @@
|
|||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"now": "now",
|
"now": "now",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
|
"admin": "Administration",
|
||||||
"files": "Files"
|
"files": "Files"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
"permissionAuto": "auto (recommended - asks only for risky actions)",
|
||||||
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
"permissionBypass": "bypassPermissions (DANGER - allows everything without asking)",
|
||||||
"bypass": "Bypass permissions for this run",
|
"bypass": "Bypass permissions for this run",
|
||||||
|
"yolo": "YOLO",
|
||||||
|
"yoloHint": "YOLO mode for this session: no sandbox, no approvals (--dangerously-bypass-approvals-and-sandbox)",
|
||||||
|
"bypassShort": "Bypass",
|
||||||
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
"permissionDefault": "default (interactive prompts - sessions may hang headlessly)",
|
||||||
"permissionMode": "Permission mode",
|
"permissionMode": "Permission mode",
|
||||||
"permissionPlan": "plan (read-only planning)",
|
"permissionPlan": "plan (read-only planning)",
|
||||||
@@ -68,6 +71,8 @@
|
|||||||
"paste": "Drop to paste into the terminal",
|
"paste": "Drop to paste into the terminal",
|
||||||
"uploading": "Uploading…",
|
"uploading": "Uploading…",
|
||||||
"failed": "Could not attach the file",
|
"failed": "Could not attach the file",
|
||||||
|
"tooBig": "File too large to upload (max {{mb}} MB) — put it in the workspace folder and attach it from Files",
|
||||||
|
"tooBigLocal": "File too large to upload (max {{mb}} MB) — attach it by path instead: + → Choose a file on this computer",
|
||||||
"remove": "Remove attachment",
|
"remove": "Remove attachment",
|
||||||
"view": "View image"
|
"view": "View image"
|
||||||
},
|
},
|
||||||
@@ -84,6 +89,7 @@
|
|||||||
"findClose": "Close find"
|
"findClose": "Close find"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
"codexPicker": "Opens Codex's own picker — answer it in the chat",
|
||||||
"placeholder": "Write a message…",
|
"placeholder": "Write a message…",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"notSent": "The terminal is not connected — the message was not sent.",
|
"notSent": "The terminal is not connected — the message was not sent.",
|
||||||
@@ -94,6 +100,9 @@
|
|||||||
"notStopped": "The terminal is not connected — nothing was stopped.",
|
"notStopped": "The terminal is not connected — nothing was stopped.",
|
||||||
"add": "Add files, skills…",
|
"add": "Add files, skills…",
|
||||||
"upload": "Upload or add files",
|
"upload": "Upload or add files",
|
||||||
|
"pickLocal": "Choose a file on this computer",
|
||||||
|
"pickLocalHint": "Attach a file on this machine by its path — nothing is uploaded",
|
||||||
|
"pickLocalOpen": "The file picker is open on this computer's screen…",
|
||||||
"projectFiles": "Project files",
|
"projectFiles": "Project files",
|
||||||
"contextFiles": "Context files",
|
"contextFiles": "Context files",
|
||||||
"recentUploads": "Recent uploads",
|
"recentUploads": "Recent uploads",
|
||||||
@@ -131,7 +140,28 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"placeholder": "Type your answer…",
|
"placeholder": "Type your answer…",
|
||||||
"cancel": "Cancel (Esc)"
|
"cancel": "Cancel (Esc)",
|
||||||
|
"prev": "Previous question",
|
||||||
|
"next": "Next question"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"notePlaceholder": "Tell the model what to do instead…",
|
||||||
|
"send": "Send",
|
||||||
|
"denyWithNote": "Deny with a note"
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"preview": "Preview",
|
||||||
|
"code": "Code",
|
||||||
|
"copy": "Copy",
|
||||||
|
"download": "Download",
|
||||||
|
"share": "Save to Artifacts",
|
||||||
|
"shared": "Saved to your Artifacts library — share it from the Artifacts page.",
|
||||||
|
"cannotShare": "This file cannot be saved to Artifacts.",
|
||||||
|
"expand": "Full screen",
|
||||||
|
"collapse": "Exit full screen",
|
||||||
|
"older": "Older version",
|
||||||
|
"newer": "Newer version",
|
||||||
|
"version": "Version {{n}} of {{total}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -210,7 +210,36 @@
|
|||||||
"moreSteps_other": "+{{count}} more",
|
"moreSteps_other": "+{{count}} more",
|
||||||
"quiet": "no new activity for {{time}}",
|
"quiet": "no new activity for {{time}}",
|
||||||
"askDeclined": "Declined — no answer given",
|
"askDeclined": "Declined — no answer given",
|
||||||
"turnDone": "Done in {{time}}"
|
"turnDone": "Done in {{time}}",
|
||||||
|
"turnReplied": "Replied in {{time}}",
|
||||||
|
"agentsStill_one": "{{count}} agent still running",
|
||||||
|
"agentsStill_other": "{{count}} agents still running",
|
||||||
|
"agentsRunning_one": "{{count}} agent running in the background",
|
||||||
|
"agentsRunning_other": "{{count}} agents running in the background",
|
||||||
|
"agentContext": "{{tokens}} tokens",
|
||||||
|
"agent": {
|
||||||
|
"open": "View conversation",
|
||||||
|
"prompt": "Task",
|
||||||
|
"background": "background",
|
||||||
|
"toolUses_one": "{{count}} tool call",
|
||||||
|
"toolUses_other": "{{count}} tool calls",
|
||||||
|
"status": {
|
||||||
|
"running": "running",
|
||||||
|
"done": "done",
|
||||||
|
"error": "failed",
|
||||||
|
"stopped": "stopped"
|
||||||
|
},
|
||||||
|
"hide": "Hide conversation"
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"html": "Web page",
|
||||||
|
"svg": "Image · SVG",
|
||||||
|
"markdown": "Document",
|
||||||
|
"mermaid": "Diagram",
|
||||||
|
"react": "React component",
|
||||||
|
"csv": "Table",
|
||||||
|
"open": "Click to open"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -37,18 +37,14 @@
|
|||||||
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
"chooseDesc": "Featured pets come from codex-pets.net and are downloaded into this install the first time you pick one. Your choice and size are saved to your account.",
|
||||||
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
"uploadHint": "An image (PNG, JPEG, GIF, WebP — no SVG) is shown still; a .codex-pet.zip (pet.json + a 1536×1872 spritesheet.webp) is animated by mood.",
|
||||||
"panel": {
|
"panel": {
|
||||||
"label": "{{name}} companion",
|
|
||||||
"live": "Live",
|
"live": "Live",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"close": "Close {{name}}",
|
|
||||||
"statLive": "live",
|
"statLive": "live",
|
||||||
"statWaiting": "waiting",
|
"statWaiting": "waiting",
|
||||||
"statErrored": "errored",
|
"statErrored": "errored",
|
||||||
"mute": "Mute",
|
"mute": "Mute",
|
||||||
"unmute": "Unmute",
|
"unmute": "Unmute",
|
||||||
"clearAlerts": "Clear alerts",
|
"clearAlerts": "Clear alerts",
|
||||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
|
||||||
"ask": "Ask {{name}}",
|
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"rename": "Click to rename {{name}}",
|
"rename": "Click to rename {{name}}",
|
||||||
"renameLabel": "Pet name",
|
"renameLabel": "Pet name",
|
||||||
@@ -217,9 +213,12 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Chat with {{name}}",
|
"label": "Chat with {{name}}",
|
||||||
"back": "Back",
|
|
||||||
"close": "Close {{name}}",
|
"close": "Close {{name}}",
|
||||||
"new": "New chat",
|
"new": "New chat",
|
||||||
|
"status": "Status — click to show the sessions that need you",
|
||||||
|
"endTitle": "End this chat?",
|
||||||
|
"endBody": "It is still writing a reply. A new chat stops this session.",
|
||||||
|
"endConfirm": "Stop and start new",
|
||||||
"openWorkspace": "Open in workspace",
|
"openWorkspace": "Open in workspace",
|
||||||
"typing": "typing…",
|
"typing": "typing…",
|
||||||
"placeholder": "Ask {{name}} anything…",
|
"placeholder": "Ask {{name}} anything…",
|
||||||
@@ -901,8 +900,8 @@
|
|||||||
"title": "Your git identity",
|
"title": "Your git identity",
|
||||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||||
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
|
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
|
||||||
"sshTitle": "SSH key",
|
"sshTitle": "SSH keys",
|
||||||
"generate": "Generate SSH key",
|
"generate": "Generate key",
|
||||||
"replace": "Replace key",
|
"replace": "Replace key",
|
||||||
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
|
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
|
||||||
"keyMade": "SSH key ready — add its public key to your git host.",
|
"keyMade": "SSH key ready — add its public key to your git host.",
|
||||||
@@ -910,14 +909,51 @@
|
|||||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — RSA only). Paste the whole line as one line. The private key never leaves the server.",
|
"noKey": "No key yet. Generate one to start.",
|
||||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
|
||||||
"httpsTitle": "HTTPS logins",
|
"httpsTitle": "HTTPS logins",
|
||||||
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
||||||
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
|
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again.",
|
||||||
|
"terminal": "Use this key for git in the terminal",
|
||||||
|
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||||
|
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||||
|
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||||
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
|
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||||
|
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
||||||
|
"awsTitle": "AWS CodeCommit",
|
||||||
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
|
"stepCopy": "Copy the public key.",
|
||||||
|
"awsStepUpload": "In the AWS console: IAM → Users → your user → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, and paste it.",
|
||||||
|
"awsStepKeyId": "AWS then shows an SSH key ID (it starts with APKA). Paste it here and pick the region of your repositories:",
|
||||||
|
"awsKeyId": "SSH key ID",
|
||||||
|
"awsRegion": "Region",
|
||||||
|
"awsSave": "Save",
|
||||||
|
"awsSaved": "SSH key ID saved for {{region}}",
|
||||||
|
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
||||||
|
"otherTitle": "Other SSH hosts (advanced)",
|
||||||
|
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||||
|
"otherHost": "Host",
|
||||||
|
"otherUser": "User name",
|
||||||
|
"otherSaved": "SSH user saved for {{host}}",
|
||||||
|
"privateNote": "Only the public key is ever shown; the private key never leaves the server.",
|
||||||
|
"anyKey": "Any key",
|
||||||
|
"defaultEd": "ED25519 (default)",
|
||||||
|
"defaultRsa": "RSA (default)",
|
||||||
|
"loginKey": "Key",
|
||||||
|
"alias": "Account name",
|
||||||
|
"aliasPlaceholder": "name (optional)",
|
||||||
|
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||||
|
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
|
||||||
|
"moreKeysTitle": "More SSH keys",
|
||||||
|
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
|
||||||
|
"newKey": "New key",
|
||||||
|
"keyName": "Key name",
|
||||||
|
"keyNamePlaceholder": "e.g. aws-prod",
|
||||||
"keyType": "Key type",
|
"keyType": "Key type",
|
||||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
"tabGit": "GitHub · GitLab",
|
||||||
"keyRsa": "RSA 4096 (AWS CodeCommit, older servers)"
|
"tabMore": "More keys",
|
||||||
|
"tabOther": "Other hosts"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1180,6 +1216,23 @@
|
|||||||
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
"ignoreHint": "One absolute path per line. Hooks from these folders, and anything under them, are dropped whole; history import and the Codex reader skip their sessions. AGENTHUB_IGNORE_CWD sets the same list.",
|
||||||
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
"envOwned": "A value here is set by the environment the dashboard started in, and cannot be changed from this page."
|
||||||
},
|
},
|
||||||
|
"retention": {
|
||||||
|
"title": "Automatic cleanup",
|
||||||
|
"description": "Off by default. When on, the hourly maintenance deletes finished sessions (completed, error, abandoned) that started more than this many days ago — with their events, agents and token usage — a little at a time.",
|
||||||
|
"days": "Delete sessions older than",
|
||||||
|
"off": "Automatic cleanup is off.",
|
||||||
|
"on": "Sessions older than {{days}} days are deleted every hour.",
|
||||||
|
"invalid": "A whole number from 0 (off) to {{max}}",
|
||||||
|
"saved": "Automatic cleanup saved",
|
||||||
|
"lastRun": "Last run {{time}}: {{n}} session(s) deleted",
|
||||||
|
"space": "Database {{size}} · {{free}} free inside it · auto-vacuum: {{mode}}",
|
||||||
|
"compact": "Compact now",
|
||||||
|
"compacting": "Compacting…",
|
||||||
|
"compactConfirmTitle": "Compact the database?",
|
||||||
|
"compactConfirmBody": "This rewrites the whole database file to give free space back to the disk and turns on incremental auto-vacuum. On a large database it takes a while, and new hook events wait until it finishes.",
|
||||||
|
"compactDone": "Database compacted: {{before}} → {{after}}",
|
||||||
|
"compactFailed": "Compact failed: {{error}}"
|
||||||
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Daily backup",
|
"title": "Daily backup",
|
||||||
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
|
"description": "A full copy of the database once a day, into a folder you choose — best on another disk, so a failed drive does not take the backups with it.",
|
||||||
@@ -1249,11 +1302,25 @@
|
|||||||
},
|
},
|
||||||
"chatUi": {
|
"chatUi": {
|
||||||
"title": "Chat UI",
|
"title": "Chat UI",
|
||||||
"description": "How a new Claude or Codex session opens in its workspace. Saved for your account only.",
|
"description": "How a new Claude, Codex, omp, Cursor or Antigravity session opens in its workspace. Saved for your account only.",
|
||||||
"label": "Open new sessions as a chat",
|
"label": "Open new sessions as a chat",
|
||||||
"labelDesc": "A Claude or Codex session first shows its conversation like Claude Desktop, with a message box, instead of its terminal. Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and menus still need the terminal.",
|
"labelDesc": "A new session first shows its conversation like Claude Desktop, with a message box, instead of its terminal (the terminal stays underneath). Each session then keeps its own view: the chat/terminal button beside a pane's split buttons switches it. Permission prompts and questions are answered from cards in the chat.",
|
||||||
"saveFailed": "Could not save: {{message}}"
|
"saveFailed": "Could not save: {{message}}"
|
||||||
},
|
},
|
||||||
|
"sessionDefaults": {
|
||||||
|
"title": "Session defaults",
|
||||||
|
"description": "What a new session starts with in a workspace's prompt box. Saved for your account only; a pick in the prompt box changes only that one session.",
|
||||||
|
"claudeMode": "Claude Code permission mode",
|
||||||
|
"claudeModeDesc": "Bypass permissions lets every new Claude session run any tool without asking.",
|
||||||
|
"codexSandbox": "Codex sandbox",
|
||||||
|
"codexApproval": "Codex approval",
|
||||||
|
"codexBypass": "Codex: YOLO mode",
|
||||||
|
"codexBypassDesc": "Every new Codex session starts in YOLO mode — `--dangerously-bypass-approvals-and-sandbox`: no sandbox, no approvals.",
|
||||||
|
"confirmTitle": "Make bypass the default for {{tool}}?",
|
||||||
|
"confirmBody": "Every new {{tool}} session you start will run commands and edit files without asking, until you change this back. Use it only with folders and prompts you trust.",
|
||||||
|
"confirmLabel": "Make it the default",
|
||||||
|
"codexConfirmTitle": "Make YOLO the default for Codex?"
|
||||||
|
},
|
||||||
"agentInstructions": {
|
"agentInstructions": {
|
||||||
"title": "My agent instructions",
|
"title": "My agent instructions",
|
||||||
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
"description": "Your own rules in Markdown — coding style, language, workflow. Every new session in a workspace you own starts with them, before that workspace's own instructions. Saved for your account only; sessions already running keep what they started with.",
|
||||||
|
|||||||
@@ -31,5 +31,9 @@
|
|||||||
"installedTitle": "Version {{version}} is installed but not running",
|
"installedTitle": "Version {{version}} is installed but not running",
|
||||||
"later": "Not now",
|
"later": "Not now",
|
||||||
"skipVersion": "Skip this version",
|
"skipVersion": "Skip this version",
|
||||||
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered."
|
"skipVersionHint": "Don't ask again about this version; a newer one will still be offered.",
|
||||||
|
"autoFix": "Auto fix",
|
||||||
|
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||||
|
"autoFixWorkspace": "AgentHub update",
|
||||||
|
"autoFixError": "Could not start the Auto fix session"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
"EACTIVE": "This server's Claude Code keeps that login in the macOS Keychain. Switch it to another profile first.",
|
||||||
"ENOTFOUND": "That saved login no longer exists.",
|
"ENOTFOUND": "That saved login no longer exists.",
|
||||||
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
"ESTALE": "That login was already refreshed elsewhere. Log in again and save it anew.",
|
||||||
"EUPSTREAM": "Anthropic could not be reached. Try again."
|
"EUPSTREAM": "Anthropic could not be reached. Try again.",
|
||||||
|
"ENOUSER": "That user no longer exists."
|
||||||
},
|
},
|
||||||
"source": "From",
|
"source": "From",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -196,7 +197,20 @@
|
|||||||
"token": "The sk-ant-… that `claude setup-token` printed.",
|
"token": "The sk-ant-… that `claude setup-token` printed.",
|
||||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||||
},
|
},
|
||||||
"refreshFailed": "refresh failed"
|
"refreshFailed": "refresh failed",
|
||||||
|
"pinned": "Used by: {{users}}",
|
||||||
|
"loadingUsers": "Loading…",
|
||||||
|
"pinDone": {
|
||||||
|
"own-login": "{{user}} signed in with their own login, which was left alone",
|
||||||
|
"expired": "{{account}} has expired — {{user}}'s new sessions fall back to the least-used account"
|
||||||
|
},
|
||||||
|
"edit": "Edit {{name}}",
|
||||||
|
"editHint": "Tick the users whose sessions run on this account instead of the least-used one — even when it is full. A user on another account moves here. A Claude login is written into their home at once: sessions already running move to it at their next login check, with no restart. A setup-token or Codex account applies to new sessions. Untick to send a user back to the least-used account.",
|
||||||
|
"save": "Save",
|
||||||
|
"users": "Users on this account",
|
||||||
|
"noUsers": "No users yet.",
|
||||||
|
"onAccount": "now on {{account}}",
|
||||||
|
"editDone": "{{name}} saved"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Delete user",
|
"button": "Delete user",
|
||||||
@@ -204,5 +218,40 @@
|
|||||||
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
"body": "This deletes {{name}} for good: their login, {{count}} workspace(s), and their Linux account with the whole home folder (/srv/agenthub/users/{{user}}) — every file and all source code in it. It cannot be undone. Session history and the audit log stay.",
|
||||||
"confirm": "Delete for good",
|
"confirm": "Delete for good",
|
||||||
"needsPassword": "Enter your own password below first."
|
"needsPassword": "Enter your own password below first."
|
||||||
|
},
|
||||||
|
"tmux": {
|
||||||
|
"title": "Members' tmux",
|
||||||
|
"description": "Each member's own tmux server keeps the version it started with until its last pane closes. Installed: tmux {{version}}.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"notMatched": "Run `agenthub server install-system` once: until then an older member server is asked with the installed tmux, which can leave it stuck at 100% CPU.",
|
||||||
|
"empty": "No member has a tmux server running.",
|
||||||
|
"user": "User",
|
||||||
|
"version": "tmux",
|
||||||
|
"sessions": "Sessions",
|
||||||
|
"agents": "Agents",
|
||||||
|
"stuck": "not answering",
|
||||||
|
"outdated": "older than installed",
|
||||||
|
"since": "since {{at}}",
|
||||||
|
"working_one": "{{count}} working",
|
||||||
|
"working_other": "{{count}} working",
|
||||||
|
"idle": "idle",
|
||||||
|
"restart": "Restart {{name}}'s tmux",
|
||||||
|
"restartIdle_one": "Restart {{count}} idle member",
|
||||||
|
"restartIdle_other": "Restart {{count}} idle members",
|
||||||
|
"restartIdleTitle": "Restart idle members' tmux",
|
||||||
|
"restartHint": "Every pane of {{name}} closes ({{count}} sessions). Their Claude sessions resume from the workspace, and the next start runs the installed tmux.",
|
||||||
|
"restartHintBusy": "An agent of {{name}} is working right now and will be stopped. Every pane of theirs closes ({{count}} sessions); their Claude sessions resume from the workspace.",
|
||||||
|
"restartIdleHint": "Restarts the tmux of every member on an older version with no agent working: {{names}}. Their panes close; their Claude sessions resume from the workspace.",
|
||||||
|
"restartButton": "Restart",
|
||||||
|
"done": "{{name}}'s tmux restarted",
|
||||||
|
"doneIdle": "Restarted: {{names}}",
|
||||||
|
"errors": {
|
||||||
|
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rowMenu": {
|
||||||
|
"openHome": "Open home in Files",
|
||||||
|
"copyUsername": "Copy username",
|
||||||
|
"copyEmail": "Copy email"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"title": "Quản trị",
|
||||||
|
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||||
|
"tabs": {
|
||||||
|
"users": "Người dùng",
|
||||||
|
"server": "Máy chủ"
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"title": "Máy chủ",
|
||||||
|
"uptime": "đã chạy {{v}}",
|
||||||
|
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||||
|
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||||
|
"cpu": "CPU",
|
||||||
|
"cores": "{{n}} lõi · tải {{load}}",
|
||||||
|
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||||
|
"memory": "Bộ nhớ",
|
||||||
|
"swap": "Swap",
|
||||||
|
"homesDisk": "Ổ chứa thư mục người dùng",
|
||||||
|
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
||||||
|
"free": "trống",
|
||||||
|
"usageTitle": "Mức sử dụng theo người dùng",
|
||||||
|
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
||||||
|
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||||
|
"who": "Người dùng",
|
||||||
|
"processes": "Tiến trình",
|
||||||
|
"home": "Thư mục home",
|
||||||
|
"sampling": "Đang lấy mẫu…",
|
||||||
|
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||||
|
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||||
|
"disks": "Ổ đĩa",
|
||||||
|
"mount": "Điểm gắn",
|
||||||
|
"device": "Thiết bị",
|
||||||
|
"used": "Đã dùng",
|
||||||
|
"homesBadge": "home",
|
||||||
|
"gpus": "GPU"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@
|
|||||||
"shareDone": "Đã chia sẻ lên Hub",
|
"shareDone": "Đã chia sẻ lên Hub",
|
||||||
"unshareDone": "Đã gỡ khỏi Hub",
|
"unshareDone": "Đã gỡ khỏi Hub",
|
||||||
"open": "Mở",
|
"open": "Mở",
|
||||||
|
"copyLink": "Sao chép link",
|
||||||
"preview": "Xem",
|
"preview": "Xem",
|
||||||
"download": "Tải về",
|
"download": "Tải về",
|
||||||
|
"noPreview": "Không xem trước được loại file này — hãy tải về để mở.",
|
||||||
"install": "Cài",
|
"install": "Cài",
|
||||||
"installs": "{{count}} lượt cài",
|
"installs": "{{count}} lượt cài",
|
||||||
"installed": "Đã cài vào {{path}}",
|
"installed": "Đã cài vào {{path}}",
|
||||||
@@ -41,7 +43,7 @@
|
|||||||
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
"deleteBody": "Mục này bị gỡ khỏi thư viện của bạn và Hub. Những bản người khác đã cài vẫn giữ nguyên.",
|
||||||
"deleteConfirm": "Xoá",
|
"deleteConfirm": "Xoá",
|
||||||
"installTitle": "Cài “{{title}}”?",
|
"installTitle": "Cài “{{title}}”?",
|
||||||
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ của chính bạn. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
"installSkillBody": "Các file này được ghi vào ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ của chính bạn, để mọi AI tool và mọi phiên đều dùng được. Skill có thể kèm script mà agent sẽ chạy — chỉ cài thứ bạn tin:",
|
||||||
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
"installPluginBody": "Chạy dưới tài khoản của bạn: claude plugin marketplace add {{market}} và claude plugin install {{name}} --scope user. Chỉ cài thứ bạn tin.",
|
||||||
"installConfirm": "Cài",
|
"installConfirm": "Cài",
|
||||||
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||||
@@ -68,7 +70,8 @@
|
|||||||
"form.save": "Thêm",
|
"form.save": "Thêm",
|
||||||
"form.cancel": "Huỷ",
|
"form.cancel": "Huỷ",
|
||||||
"added": "Đã thêm",
|
"added": "Đã thêm",
|
||||||
"files": "{{count}} file",
|
"files_one": "{{count}} file",
|
||||||
|
"files_other": "{{count}} file",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"fromSession": "Từ một phiên",
|
"fromSession": "Từ một phiên",
|
||||||
"form.saveEdit": "Lưu",
|
"form.saveEdit": "Lưu",
|
||||||
@@ -76,7 +79,7 @@
|
|||||||
"skill.tab.write": "Viết mới",
|
"skill.tab.write": "Viết mới",
|
||||||
"skill.tab.existing": "Từ skill có sẵn",
|
"skill.tab.existing": "Từ skill có sẵn",
|
||||||
"skill.name": "Tên skill",
|
"skill.name": "Tên skill",
|
||||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
|
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ (và ~/.agents/skills/) và gọi bằng /{{slug}}",
|
||||||
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
|
"skill.slugBad": "Chỉ dùng chữ, số, '.', '_' hoặc '-'",
|
||||||
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
|
"skill.description": "Mô tả — khi nào Claude nên dùng skill này",
|
||||||
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
"skill.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
||||||
@@ -108,5 +111,68 @@
|
|||||||
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
||||||
"file.upload": "Tải lên",
|
"file.upload": "Tải lên",
|
||||||
"file.tooBig": "File lớn hơn 25 MB.",
|
"file.tooBig": "File lớn hơn 25 MB.",
|
||||||
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi"
|
"file.fromWorkspace": "Hoặc ghim tài liệu do các phiên của workspace đã ghi",
|
||||||
|
"editTitle": "Sửa “{{title}}”",
|
||||||
|
"edit.title": "Tiêu đề",
|
||||||
|
"edit.description": "Mô tả",
|
||||||
|
"previewFailed": "Không tải được file này.",
|
||||||
|
"truncated": "Chỉ hiển thị 1 MB đầu — tải file về để xem toàn bộ.",
|
||||||
|
"tab.all": "Tất cả",
|
||||||
|
"installedBadge": "Đã cài",
|
||||||
|
"installsTitle": "Lượt cài",
|
||||||
|
"installsYou": "Bạn",
|
||||||
|
"installsUnknown": "Người dùng đã xoá",
|
||||||
|
"installsReplaced": "thay bản cũ",
|
||||||
|
"installsPluginWhere": "claude plugin install --scope user",
|
||||||
|
"installsUnrecorded": "{{count}} lượt cài trước đây chỉ được đếm, không lưu chi tiết",
|
||||||
|
"installsNoneOfYours": "Bạn chưa cài — chỉ hiện các lượt cài của bạn.",
|
||||||
|
"reinstall": "Cài lại",
|
||||||
|
"reinstallHint": "Ghi bản này đè lên bản bạn đã cài",
|
||||||
|
"reinstallTitle": "Cài lại “{{title}}”?",
|
||||||
|
"reinstallBody": "Bản đã cài ở ~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được thay bằng bản này. Bản hiện tại được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||||
|
"uninstall": "Gỡ cài đặt",
|
||||||
|
"uninstallHint": "Gỡ khỏi tài khoản của bạn",
|
||||||
|
"uninstallTitle": "Gỡ cài đặt “{{title}}”?",
|
||||||
|
"uninstallSkillBody": "~/.claude/skills/{{name}}/ và ~/.agents/skills/{{name}}/ sẽ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá. Các phiên đang mở vẫn giữ nó cho tới khi tải lại.",
|
||||||
|
"uninstallPluginBody": "Chạy claude plugin uninstall {{name}} cho tài khoản của bạn; thư mục dữ liệu của plugin được giữ lại. Các phiên đang mở vẫn giữ nó cho tới khi /reload-plugins.",
|
||||||
|
"uninstalled": "Đã gỡ {{name}}",
|
||||||
|
"deleteBodyShared": "Mục này đang được chia sẻ nên cũng sẽ biến mất khỏi Hub với mọi người. Muốn giữ lại nhưng thôi chia sẻ thì dùng Bỏ chia sẻ. Những gì đã cài từ nó vẫn được giữ.",
|
||||||
|
"deleteBodyKeep": "Mục này rời khỏi thư viện của bạn nhưng vẫn ở trên Hub vì đang được chia sẻ. Muốn gỡ khỏi Hub luôn thì sau đó bấm Gỡ khỏi Hub (hoặc Bỏ chia sẻ trước).",
|
||||||
|
"deleteKept": "Đã xoá khỏi thư viện — vẫn còn trên Hub",
|
||||||
|
"hubOnly": "Chỉ trên Hub",
|
||||||
|
"hubOnlyHint": "Bạn đã xoá khỏi thư viện; mục này vẫn được chia sẻ trên Hub",
|
||||||
|
"hubRemove": "Gỡ khỏi Hub",
|
||||||
|
"hubRemoveTitle": "Gỡ “{{title}}” khỏi Hub?",
|
||||||
|
"hubRemoveBody": "Mục này không còn trong thư viện của bạn nên sẽ bị xoá hẳn. Những gì đã cài từ nó vẫn được giữ.",
|
||||||
|
"edit.otherFiles": "{{count}} file khác của skill được giữ nguyên.",
|
||||||
|
"edit.reinstallNote": "Bản bạn đã cài vẫn là bản cũ cho đến khi bấm Cài lại.",
|
||||||
|
"preview.instructions": "Hướng dẫn",
|
||||||
|
"preview.frontmatter": "Frontmatter (phần đầu SKILL.md)",
|
||||||
|
"preview.whenUsed": "Khi nào Claude dùng",
|
||||||
|
"preview.noDescription": "Chưa có mô tả",
|
||||||
|
"preview.installsAs": "Cài vào ~/.claude/skills/{{slug}}/ và ~/.agents/skills/{{slug}}/",
|
||||||
|
"blurb.skill": "Bộ hướng dẫn tái sử dụng cho Claude",
|
||||||
|
"blurb.plugin": "Gói skill, lệnh và hook",
|
||||||
|
"blurb.page": "Trang mà phiên đã publish lên claude.ai",
|
||||||
|
"blurb.file": "Tài liệu và kết quả đáng giữ lại",
|
||||||
|
"clearSearch": "Xoá tìm kiếm",
|
||||||
|
"categories": "Danh mục",
|
||||||
|
"seeAll": "Xem tất cả",
|
||||||
|
"results_one": "{{count}} kết quả",
|
||||||
|
"results_other": "{{count}} kết quả",
|
||||||
|
"empty.title": "Thư viện đang trống",
|
||||||
|
"empty.hubTitle": "Hub chưa có gì",
|
||||||
|
"empty.searchTitle": "Không tìm thấy",
|
||||||
|
"empty.search": "Không có mục nào khớp tìm kiếm hoặc danh mục này — thử từ khác, hoặc chọn Tất cả.",
|
||||||
|
"details": "Chi tiết",
|
||||||
|
"more": "Thao tác khác",
|
||||||
|
"about": "Giới thiệu",
|
||||||
|
"stat.kind": "Loại",
|
||||||
|
"stat.installs": "Lượt cài",
|
||||||
|
"stat.files": "File",
|
||||||
|
"stat.size": "Dung lượng",
|
||||||
|
"stat.updated": "Cập nhật",
|
||||||
|
"detail.runAs": "Gọi bằng",
|
||||||
|
"detail.market": "Marketplace",
|
||||||
|
"detail.added": "Thêm lúc {{date}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,15 @@
|
|||||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||||
|
"clone.partial": "Clone một phần (nhanh hơn với repo lớn)",
|
||||||
|
"clone.partialHint": "Tải toàn bộ commit nhưng chỉ tải nội dung file khi cần checkout (--filter=blob:none). Checkout một phiên bản cũ sau này sẽ tải thêm từ remote.",
|
||||||
|
"clone.queued": "Đang chờ trong hàng clone — thứ {{position}}/{{waiting}} ({{running}}/{{limit}} clone đang chạy)",
|
||||||
|
"clone.connecting": "Đang kết nối…",
|
||||||
|
"clone.phase.remote": "Máy chủ đang chuẩn bị",
|
||||||
|
"clone.phase.receiving": "Đang tải về",
|
||||||
|
"clone.phase.resolving": "Đang giải nén",
|
||||||
|
"clone.phase.checkout": "Đang ghi file",
|
||||||
|
"clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.",
|
||||||
"moreActions": "Thêm hành động",
|
"moreActions": "Thêm hành động",
|
||||||
"pipelinePicker.label": "Mẫu pipeline",
|
"pipelinePicker.label": "Mẫu pipeline",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||||
@@ -298,6 +307,7 @@
|
|||||||
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
"changes.undoCommitTitle": "Hoàn tác commit cuối?",
|
||||||
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
"changes.undoCommitBody": "{{short}} \"{{subject}}\" sẽ bị gỡ khỏi nhánh. Thay đổi của nó vẫn được giữ ở trạng thái staged để sửa và commit lại.",
|
||||||
"changes.undoCommitConfirm": "Hoàn tác commit",
|
"changes.undoCommitConfirm": "Hoàn tác commit",
|
||||||
|
"changes.undoCommitPushed": "Commit cuối đã có trên nhánh remote (theo lần fetch gần nhất); hoàn tác sẽ viết lại lịch sử đã chia sẻ. Hãy Revert — hoặc Fetch trước nếu remote không còn commit này.",
|
||||||
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
"changes.revertConfirm": "Thêm một commit trên {{branch}} để đảo ngược {{short}} \"{{subject}}\"? File trong workspace sẽ thay đổi ngay.",
|
||||||
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
"changes.cherryPickConfirm": "Áp {{short}} \"{{subject}}\" từ {{from}} vào {{branch}} thành một commit mới? File trong workspace sẽ thay đổi ngay.",
|
||||||
"changes.amendTitle": "Amend commit cuối?",
|
"changes.amendTitle": "Amend commit cuối?",
|
||||||
@@ -421,6 +431,9 @@
|
|||||||
"home.cloneRun": "Clone",
|
"home.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||||
"home.cloneDone": "Đã thêm",
|
"home.cloneDone": "Đã thêm",
|
||||||
|
"home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.",
|
||||||
|
"home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).",
|
||||||
|
"home.cloneEnterLogin": "Nhập login",
|
||||||
"home.suggested": "Worktree chưa theo dõi",
|
"home.suggested": "Worktree chưa theo dõi",
|
||||||
"home.remove": "Gỡ",
|
"home.remove": "Gỡ",
|
||||||
"home.missing": "không còn",
|
"home.missing": "không còn",
|
||||||
@@ -430,7 +443,7 @@
|
|||||||
"tabs.new": "Tab mới",
|
"tabs.new": "Tab mới",
|
||||||
"tabs.newTerminal": "Terminal mới",
|
"tabs.newTerminal": "Terminal mới",
|
||||||
"tabs.terminal": "Terminal",
|
"tabs.terminal": "Terminal",
|
||||||
"tabs.close": "Đóng tab (dừng phiên của nó)",
|
"tabs.close": "Đóng tab (dừng và lưu trữ phiên của nó)",
|
||||||
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
"tabs.full": "Đã dùng hết 10 session — hãy dừng bớt một session",
|
||||||
"tabs.agentSettings": "Cài đặt agent…",
|
"tabs.agentSettings": "Cài đặt agent…",
|
||||||
"tabs.settings": "Cài đặt",
|
"tabs.settings": "Cài đặt",
|
||||||
@@ -465,7 +478,8 @@
|
|||||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||||
"panes.closeShort": "Tách ra tab riêng",
|
"panes.closeShort": "Tách ra tab riêng",
|
||||||
"panes.stop": "Dừng phiên",
|
"panes.stop": "Dừng và lưu trữ phiên",
|
||||||
|
"panes.sleep": "Cho phiên ngủ (dừng, giữ lại để resume sau)",
|
||||||
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
"changes.copyPath": "Sao chép đường dẫn tương đối",
|
||||||
"changes.viewCommit": "Xem commit",
|
"changes.viewCommit": "Xem commit",
|
||||||
"changes.copySha": "Sao chép SHA của commit",
|
"changes.copySha": "Sao chép SHA của commit",
|
||||||
@@ -479,8 +493,13 @@
|
|||||||
"tabs.renameHint": "Nhấp đúp để đổi tên",
|
"tabs.renameHint": "Nhấp đúp để đổi tên",
|
||||||
"tabs.stopTitle": "Dừng “{{name}}”?",
|
"tabs.stopTitle": "Dừng “{{name}}”?",
|
||||||
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
|
"tabs.stopGroupTitle": "Dừng {{count}} session trong tab này?",
|
||||||
"tabs.stopBody": "Đóng tab sẽ dừng phiên của nó, kể cả khi đang chạy dở. Bạn có thể tiếp tục (resume) sau từ mục Gần đây.",
|
"tabs.stopBody": "Dừng sẽ kết thúc phiên, kể cả khi đang chạy dở, và lưu trữ nó: phiên rời khỏi danh sách của workspace (bấm Bỏ lưu trữ để đưa lại). Muốn giữ lại để dùng tiếp thì hãy chọn Ngủ.",
|
||||||
"tabs.stopConfirm": "Dừng phiên",
|
"tabs.stopConfirm": "Dừng phiên",
|
||||||
|
"tabs.sleep": "Ngủ (dừng, giữ lại để resume sau)",
|
||||||
|
"tabs.sleepTitle": "Cho “{{name}}” ngủ?",
|
||||||
|
"tabs.sleepGroupTitle": "Cho {{count}} session trong tab này ngủ?",
|
||||||
|
"tabs.sleepBody": "Ngủ sẽ dừng phiên, kể cả khi đang chạy dở, nhưng vẫn giữ nó trong danh sách của workspace. Mở phiên và gửi tin nhắn để resume.",
|
||||||
|
"tabs.sleepConfirm": "Ngủ",
|
||||||
"changes.loadMore": "Tải thêm commit",
|
"changes.loadMore": "Tải thêm commit",
|
||||||
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
"changes.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||||
@@ -512,6 +531,9 @@
|
|||||||
"changes.unversionedGroup": "File chưa theo dõi",
|
"changes.unversionedGroup": "File chưa theo dõi",
|
||||||
"changes.commitTicked": "Commit ({{count}})",
|
"changes.commitTicked": "Commit ({{count}})",
|
||||||
"changes.commitAndPush": "Commit và Push",
|
"changes.commitAndPush": "Commit và Push",
|
||||||
|
"changes.discardAll": "Bỏ tất cả",
|
||||||
|
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
|
||||||
|
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
|
||||||
"changes.amendAndPush": "Amend và Push",
|
"changes.amendAndPush": "Amend và Push",
|
||||||
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
|
||||||
"changes.tickFile": "Đưa {{path}} vào commit",
|
"changes.tickFile": "Đưa {{path}} vào commit",
|
||||||
@@ -542,6 +564,9 @@
|
|||||||
"fx.refresh": "Làm mới",
|
"fx.refresh": "Làm mới",
|
||||||
"fx.newFolder": "Thư mục mới",
|
"fx.newFolder": "Thư mục mới",
|
||||||
"fx.newFile": "File mới",
|
"fx.newFile": "File mới",
|
||||||
|
"fx.cloneRepo": "Clone repository",
|
||||||
|
"fx.cloneSubmit": "Clone",
|
||||||
|
"fx.cloned": "Đã clone vào {{path}} — đã mở thành workspace “{{workspace}}”.",
|
||||||
"fx.uploadFiles": "Tải file lên",
|
"fx.uploadFiles": "Tải file lên",
|
||||||
"fx.uploadFolder": "Tải cả thư mục lên",
|
"fx.uploadFolder": "Tải cả thư mục lên",
|
||||||
"fx.download": "Tải về",
|
"fx.download": "Tải về",
|
||||||
@@ -571,7 +596,7 @@
|
|||||||
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
|
||||||
"fx.pasted": "Đã dán.",
|
"fx.pasted": "Đã dán.",
|
||||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
|
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||||
"fx.conflictTitle": "Đã có sẵn",
|
"fx.conflictTitle": "Đã có sẵn",
|
||||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||||
@@ -585,6 +610,17 @@
|
|||||||
"fx.purge": "Xoá vĩnh viễn",
|
"fx.purge": "Xoá vĩnh viễn",
|
||||||
"fx.restored": "Đã khôi phục.",
|
"fx.restored": "Đã khôi phục.",
|
||||||
"fx.trashEmpty": "Thùng rác trống.",
|
"fx.trashEmpty": "Thùng rác trống.",
|
||||||
|
"fx.trashOf": "Thùng rác của {{name}}",
|
||||||
|
"fx.myTrash": "Thùng rác của tôi",
|
||||||
|
"fx.trashAt": "Thùng rác ({{name}})",
|
||||||
|
"fx.trashFoldersHint": "Mỗi thư mục là Thùng rác của một user — mở ra để khôi phục hoặc xoá vĩnh viễn.",
|
||||||
|
"fx.trashFolderEmpty": "Trống",
|
||||||
|
"fx.openTrash": "Mở Thùng rác",
|
||||||
|
"fx.purged": "Đã xoá vĩnh viễn.",
|
||||||
|
"fx.emptyTrashTitle": "Dọn sạch Thùng rác?",
|
||||||
|
"fx.emptyTrashBody": "Toàn bộ {{count}} mục trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||||
|
"fx.purgeTitle": "Xoá vĩnh viễn?",
|
||||||
|
"fx.purgeBody": "“{{name}}” trong {{where}} sẽ bị xoá vĩnh viễn, không khôi phục được.",
|
||||||
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
|
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
|
||||||
"changes.openInExplorer": "Mở trong Quản lý file",
|
"changes.openInExplorer": "Mở trong Quản lý file",
|
||||||
"fx.places": "Workspace",
|
"fx.places": "Workspace",
|
||||||
@@ -614,8 +650,9 @@
|
|||||||
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
|
||||||
"fx.myHome": "Thư mục home",
|
"fx.myHome": "Thư mục home",
|
||||||
"fx.userHomes": "Home của các user",
|
"fx.userHomes": "Home của các user",
|
||||||
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
|
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về; Thùng rác của họ thì khôi phục hoặc xoá được",
|
||||||
"fx.readOnly": "Chỉ đọc",
|
"fx.readOnly": "Chỉ đọc",
|
||||||
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được.",
|
||||||
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái."
|
"fx.homeEmpty": "Home của bạn chưa có thư mục nào — các file ẩn (.bashrc, .claude…) không hiện ở đây. Các workspace nằm ở danh sách bên trái.",
|
||||||
|
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,5 +87,6 @@
|
|||||||
"sidebar": "Thanh bên",
|
"sidebar": "Thanh bên",
|
||||||
"now": "bây giờ",
|
"now": "bây giờ",
|
||||||
"users": "Người dùng",
|
"users": "Người dùng",
|
||||||
|
"admin": "Quản trị",
|
||||||
"files": "Quản lý file"
|
"files": "Quản lý file"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@
|
|||||||
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
"permissionAuto": "auto (khuyên dùng - chỉ hỏi khi hành động rủi ro)",
|
||||||
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
"permissionBypass": "bypassPermissions (NGUY HIỂM - cho phép tất cả mà không hỏi)",
|
||||||
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
"bypass": "Bỏ qua xin quyền cho lần chạy này",
|
||||||
|
"yolo": "YOLO",
|
||||||
|
"yoloHint": "Chế độ YOLO cho phiên này: không sandbox, không hỏi phê duyệt (--dangerously-bypass-approvals-and-sandbox)",
|
||||||
|
"bypassShort": "Bỏ qua quyền",
|
||||||
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
"permissionDefault": "default (prompt tương tác - headless sẽ treo)",
|
||||||
"permissionMode": "Chế độ quyền",
|
"permissionMode": "Chế độ quyền",
|
||||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||||
@@ -68,6 +71,8 @@
|
|||||||
"paste": "Thả để dán vào terminal",
|
"paste": "Thả để dán vào terminal",
|
||||||
"uploading": "Đang tải lên…",
|
"uploading": "Đang tải lên…",
|
||||||
"failed": "Không đính kèm được file",
|
"failed": "Không đính kèm được file",
|
||||||
|
"tooBig": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đặt file vào thư mục workspace rồi đính kèm từ Files",
|
||||||
|
"tooBigLocal": "File quá lớn để tải lên (tối đa {{mb}} MB) — hãy đính kèm bằng đường dẫn: + → Chọn file trên máy này",
|
||||||
"remove": "Bỏ đính kèm",
|
"remove": "Bỏ đính kèm",
|
||||||
"view": "Xem ảnh"
|
"view": "Xem ảnh"
|
||||||
},
|
},
|
||||||
@@ -84,6 +89,7 @@
|
|||||||
"findClose": "Đóng tìm kiếm"
|
"findClose": "Đóng tìm kiếm"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
|
"codexPicker": "Mở bộ chọn của chính Codex — trả lời ngay trong chat",
|
||||||
"placeholder": "Viết tin nhắn…",
|
"placeholder": "Viết tin nhắn…",
|
||||||
"send": "Gửi",
|
"send": "Gửi",
|
||||||
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
"notSent": "Terminal chưa kết nối — tin nhắn chưa được gửi.",
|
||||||
@@ -94,6 +100,9 @@
|
|||||||
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
"notStopped": "Terminal chưa kết nối — chưa ngưng được.",
|
||||||
"add": "Thêm file, skill…",
|
"add": "Thêm file, skill…",
|
||||||
"upload": "Tải lên hoặc thêm file",
|
"upload": "Tải lên hoặc thêm file",
|
||||||
|
"pickLocal": "Chọn file trên máy này",
|
||||||
|
"pickLocalHint": "Đính kèm file trên máy này bằng đường dẫn — không tải lên gì cả",
|
||||||
|
"pickLocalOpen": "Hộp chọn file đang mở trên màn hình máy này…",
|
||||||
"projectFiles": "File trong dự án",
|
"projectFiles": "File trong dự án",
|
||||||
"contextFiles": "File ngữ cảnh",
|
"contextFiles": "File ngữ cảnh",
|
||||||
"recentUploads": "Đã tải lên gần đây",
|
"recentUploads": "Đã tải lên gần đây",
|
||||||
@@ -131,7 +140,28 @@
|
|||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"placeholder": "Nhập câu trả lời của bạn…",
|
"placeholder": "Nhập câu trả lời của bạn…",
|
||||||
"cancel": "Huỷ (Esc)"
|
"cancel": "Huỷ (Esc)",
|
||||||
|
"prev": "Câu trước",
|
||||||
|
"next": "Câu tiếp"
|
||||||
|
},
|
||||||
|
"request": {
|
||||||
|
"notePlaceholder": "Nói cho model biết nên làm gì thay vào đó…",
|
||||||
|
"send": "Gửi",
|
||||||
|
"denyWithNote": "Từ chối kèm lời nhắn"
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"preview": "Xem trước",
|
||||||
|
"code": "Mã",
|
||||||
|
"copy": "Sao chép",
|
||||||
|
"download": "Tải xuống",
|
||||||
|
"share": "Lưu vào Artifacts",
|
||||||
|
"shared": "Đã lưu vào thư viện Artifacts của bạn — chia sẻ từ trang Artifacts.",
|
||||||
|
"cannotShare": "Không lưu được file này vào Artifacts.",
|
||||||
|
"expand": "Toàn màn hình",
|
||||||
|
"collapse": "Thoát toàn màn hình",
|
||||||
|
"older": "Phiên bản cũ hơn",
|
||||||
|
"newer": "Phiên bản mới hơn",
|
||||||
|
"version": "Phiên bản {{n}}/{{total}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"interrupted": {
|
"interrupted": {
|
||||||
|
|||||||
@@ -210,7 +210,36 @@
|
|||||||
"moreSteps_other": "+{{count}} việc nữa",
|
"moreSteps_other": "+{{count}} việc nữa",
|
||||||
"quiet": "không có hoạt động mới trong {{time}}",
|
"quiet": "không có hoạt động mới trong {{time}}",
|
||||||
"askDeclined": "Đã từ chối — không trả lời",
|
"askDeclined": "Đã từ chối — không trả lời",
|
||||||
"turnDone": "Xong trong {{time}}"
|
"turnDone": "Xong trong {{time}}",
|
||||||
|
"turnReplied": "Đã trả lời trong {{time}}",
|
||||||
|
"agentsStill_one": "còn {{count}} agent đang chạy",
|
||||||
|
"agentsStill_other": "còn {{count}} agent đang chạy",
|
||||||
|
"agentsRunning_one": "{{count}} agent đang chạy nền",
|
||||||
|
"agentsRunning_other": "{{count}} agent đang chạy nền",
|
||||||
|
"agentContext": "{{tokens}} token",
|
||||||
|
"agent": {
|
||||||
|
"open": "Xem hội thoại",
|
||||||
|
"prompt": "Nhiệm vụ",
|
||||||
|
"background": "chạy nền",
|
||||||
|
"toolUses_one": "{{count}} lần gọi tool",
|
||||||
|
"toolUses_other": "{{count}} lần gọi tool",
|
||||||
|
"status": {
|
||||||
|
"running": "đang chạy",
|
||||||
|
"done": "xong",
|
||||||
|
"error": "lỗi",
|
||||||
|
"stopped": "đã dừng"
|
||||||
|
},
|
||||||
|
"hide": "Ẩn hội thoại"
|
||||||
|
},
|
||||||
|
"artifact": {
|
||||||
|
"html": "Trang web",
|
||||||
|
"svg": "Ảnh · SVG",
|
||||||
|
"markdown": "Tài liệu",
|
||||||
|
"mermaid": "Sơ đồ",
|
||||||
|
"react": "Component React",
|
||||||
|
"csv": "Bảng",
|
||||||
|
"open": "Bấm để mở"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overview": {
|
"overview": {
|
||||||
"agent": "Agent",
|
"agent": "Agent",
|
||||||
|
|||||||
@@ -37,18 +37,14 @@
|
|||||||
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
"chooseDesc": "Pet nổi bật lấy từ codex-pets.net và được tải về máy ở lần đầu bạn chọn. Lựa chọn và kích thước được lưu vào tài khoản của bạn.",
|
||||||
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
"uploadHint": "Ảnh (PNG, JPEG, GIF, WebP — không nhận SVG) hiển thị tĩnh; file .codex-pet.zip (pet.json + spritesheet.webp 1536×1872) chuyển động theo tâm trạng.",
|
||||||
"panel": {
|
"panel": {
|
||||||
"label": "Trợ lý {{name}}",
|
|
||||||
"live": "Trực tuyến",
|
"live": "Trực tuyến",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"close": "Đóng {{name}}",
|
|
||||||
"statLive": "đang chạy",
|
"statLive": "đang chạy",
|
||||||
"statWaiting": "đang chờ",
|
"statWaiting": "đang chờ",
|
||||||
"statErrored": "lỗi",
|
"statErrored": "lỗi",
|
||||||
"mute": "Tắt tiếng",
|
"mute": "Tắt tiếng",
|
||||||
"unmute": "Bật tiếng",
|
"unmute": "Bật tiếng",
|
||||||
"clearAlerts": "Xoá cảnh báo",
|
"clearAlerts": "Xoá cảnh báo",
|
||||||
"askPlaceholder": "Hỏi {{name}}… (vd: có lỗi nào không?)",
|
|
||||||
"ask": "Hỏi {{name}}",
|
|
||||||
"send": "Gửi",
|
"send": "Gửi",
|
||||||
"rename": "Bấm để đổi tên {{name}}",
|
"rename": "Bấm để đổi tên {{name}}",
|
||||||
"renameLabel": "Tên pet",
|
"renameLabel": "Tên pet",
|
||||||
@@ -217,9 +213,12 @@
|
|||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"label": "Trò chuyện với {{name}}",
|
"label": "Trò chuyện với {{name}}",
|
||||||
"back": "Quay lại",
|
|
||||||
"close": "Đóng {{name}}",
|
"close": "Đóng {{name}}",
|
||||||
"new": "Cuộc trò chuyện mới",
|
"new": "Cuộc trò chuyện mới",
|
||||||
|
"status": "Trạng thái — bấm để xem các phiên cần bạn",
|
||||||
|
"endTitle": "Kết thúc chat này?",
|
||||||
|
"endBody": "Nó vẫn đang trả lời. Chat mới sẽ dừng phiên này.",
|
||||||
|
"endConfirm": "Dừng và bắt đầu mới",
|
||||||
"openWorkspace": "Mở trong workspace",
|
"openWorkspace": "Mở trong workspace",
|
||||||
"typing": "đang trả lời…",
|
"typing": "đang trả lời…",
|
||||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||||
@@ -902,7 +901,7 @@
|
|||||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||||
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
|
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
|
||||||
"sshTitle": "SSH key",
|
"sshTitle": "SSH key",
|
||||||
"generate": "Tạo SSH key",
|
"generate": "Tạo key",
|
||||||
"replace": "Thay key",
|
"replace": "Thay key",
|
||||||
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
|
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
|
||||||
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
||||||
@@ -910,14 +909,51 @@
|
|||||||
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
||||||
"publicKey": "Public key",
|
"publicKey": "Public key",
|
||||||
"copy": "Copy public key",
|
"copy": "Copy public key",
|
||||||
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys; AWS: IAM → Security credentials → SSH public keys — chỉ nhận RSA). Dán nguyên dòng, trên một dòng. Private key không bao giờ rời khỏi server.",
|
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
|
||||||
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
|
||||||
"httpsTitle": "Đăng nhập HTTPS",
|
"httpsTitle": "Đăng nhập HTTPS",
|
||||||
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
||||||
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
|
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại.",
|
||||||
|
"terminal": "Dùng key này cho git trong terminal",
|
||||||
|
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||||
|
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||||
|
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||||
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||||
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
|
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||||
|
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
||||||
|
"awsTitle": "AWS CodeCommit",
|
||||||
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
|
"stepCopy": "Copy public key.",
|
||||||
|
"awsStepUpload": "Trên AWS console: IAM → Users → user của bạn → Security credentials → SSH public keys for AWS CodeCommit → Upload SSH public key, rồi dán vào.",
|
||||||
|
"awsStepKeyId": "AWS sẽ hiện một SSH key ID (bắt đầu bằng APKA). Dán vào đây và chọn region chứa repository:",
|
||||||
|
"awsKeyId": "SSH key ID",
|
||||||
|
"awsRegion": "Region",
|
||||||
|
"awsSave": "Lưu",
|
||||||
|
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||||
|
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
||||||
|
"otherTitle": "Host SSH khác (nâng cao)",
|
||||||
|
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||||
|
"otherHost": "Host",
|
||||||
|
"otherUser": "Tên user",
|
||||||
|
"otherSaved": "Đã lưu SSH user cho {{host}}",
|
||||||
|
"privateNote": "Chỉ public key được hiển thị; private key không bao giờ rời khỏi server.",
|
||||||
|
"anyKey": "Mọi key",
|
||||||
|
"defaultEd": "ED25519 (mặc định)",
|
||||||
|
"defaultRsa": "RSA (mặc định)",
|
||||||
|
"loginKey": "Key",
|
||||||
|
"alias": "Tên tài khoản",
|
||||||
|
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||||
|
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||||
|
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
|
||||||
|
"moreKeysTitle": "Thêm SSH key",
|
||||||
|
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
|
||||||
|
"newKey": "Key mới",
|
||||||
|
"keyName": "Tên key",
|
||||||
|
"keyNamePlaceholder": "vd aws-prod",
|
||||||
"keyType": "Loại key",
|
"keyType": "Loại key",
|
||||||
"keyEd25519": "ED25519 (GitHub, GitLab, Gitea)",
|
"tabGit": "GitHub · GitLab",
|
||||||
"keyRsa": "RSA 4096 (AWS CodeCommit, server cũ)"
|
"tabMore": "Key khác",
|
||||||
|
"tabOther": "Host khác"
|
||||||
},
|
},
|
||||||
"gitCreds": {
|
"gitCreds": {
|
||||||
"title": "Git credentials",
|
"title": "Git credentials",
|
||||||
@@ -1180,6 +1216,23 @@
|
|||||||
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
|
"ignoreHint": "Mỗi dòng một đường dẫn tuyệt đối. Hook từ các thư mục này, và mọi thứ bên trong, bị bỏ hoàn toàn; việc nhập lịch sử và bộ đọc Codex cũng bỏ qua phiên của chúng. AGENTHUB_IGNORE_CWD đặt cùng danh sách này.",
|
||||||
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
|
"envOwned": "Một giá trị ở đây do môi trường khởi động dashboard đặt, và không thể đổi từ trang này."
|
||||||
},
|
},
|
||||||
|
"retention": {
|
||||||
|
"title": "Tự động dọn dẹp",
|
||||||
|
"description": "Mặc định tắt. Khi bật, bảo trì hằng giờ xóa các phiên đã kết thúc (hoàn tất, lỗi, bỏ dở) bắt đầu cách đây hơn số ngày này — cùng sự kiện, agent và token của chúng — từng ít một.",
|
||||||
|
"days": "Xóa phiên cũ hơn",
|
||||||
|
"off": "Tự động dọn dẹp đang tắt.",
|
||||||
|
"on": "Phiên cũ hơn {{days}} ngày được xóa mỗi giờ.",
|
||||||
|
"invalid": "Số nguyên từ 0 (tắt) đến {{max}}",
|
||||||
|
"saved": "Đã lưu tự động dọn dẹp",
|
||||||
|
"lastRun": "Lần chạy gần nhất {{time}}: đã xóa {{n}} phiên",
|
||||||
|
"space": "Cơ sở dữ liệu {{size}} · {{free}} trống bên trong · auto-vacuum: {{mode}}",
|
||||||
|
"compact": "Nén ngay",
|
||||||
|
"compacting": "Đang nén…",
|
||||||
|
"compactConfirmTitle": "Nén cơ sở dữ liệu?",
|
||||||
|
"compactConfirmBody": "Thao tác này ghi lại toàn bộ tệp cơ sở dữ liệu để trả dung lượng trống cho ổ đĩa và bật auto-vacuum tăng dần. Với cơ sở dữ liệu lớn sẽ mất một lúc, và sự kiện hook mới phải chờ đến khi xong.",
|
||||||
|
"compactDone": "Đã nén cơ sở dữ liệu: {{before}} → {{after}}",
|
||||||
|
"compactFailed": "Nén thất bại: {{error}}"
|
||||||
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"title": "Backup hằng ngày",
|
"title": "Backup hằng ngày",
|
||||||
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
|
"description": "Mỗi ngày một bản sao đầy đủ của cơ sở dữ liệu vào thư mục bạn chọn — tốt nhất là trên một ổ đĩa khác, để hỏng một ổ không mất luôn bản backup.",
|
||||||
@@ -1249,11 +1302,25 @@
|
|||||||
},
|
},
|
||||||
"chatUi": {
|
"chatUi": {
|
||||||
"title": "Giao diện chat",
|
"title": "Giao diện chat",
|
||||||
"description": "Cách một session Claude hoặc Codex mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
"description": "Cách một session Claude, Codex, omp, Cursor hoặc Antigravity mới mở trong workspace. Chỉ lưu cho tài khoản của bạn.",
|
||||||
"label": "Mở session mới dạng chat",
|
"label": "Mở session mới dạng chat",
|
||||||
"labelDesc": "Session Claude hoặc Codex sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal. Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và menu vẫn cần terminal.",
|
"labelDesc": "Session mới sẽ mở hội thoại như Claude Desktop, kèm ô nhập tin, thay cho terminal (terminal vẫn chạy bên dưới). Sau đó mỗi session giữ dạng xem riêng: nút chat/terminal cạnh nút split của pane để chuyển. Hỏi quyền và câu hỏi được trả lời bằng thẻ ngay trong chat.",
|
||||||
"saveFailed": "Không lưu được: {{message}}"
|
"saveFailed": "Không lưu được: {{message}}"
|
||||||
},
|
},
|
||||||
|
"sessionDefaults": {
|
||||||
|
"title": "Mặc định cho phiên mới",
|
||||||
|
"description": "Phiên mới bắt đầu với các lựa chọn này trong ô prompt của workspace. Chỉ lưu cho tài khoản của bạn; chọn khác trong ô prompt chỉ đổi cho phiên đó.",
|
||||||
|
"claudeMode": "Chế độ quyền của Claude Code",
|
||||||
|
"claudeModeDesc": "Bỏ qua quyền cho mọi phiên Claude mới chạy mọi tool mà không hỏi.",
|
||||||
|
"codexSandbox": "Sandbox của Codex",
|
||||||
|
"codexApproval": "Phê duyệt của Codex",
|
||||||
|
"codexBypass": "Codex: chế độ YOLO",
|
||||||
|
"codexBypassDesc": "Mọi phiên Codex mới chạy ở chế độ YOLO — `--dangerously-bypass-approvals-and-sandbox`: không sandbox, không hỏi phê duyệt.",
|
||||||
|
"confirmTitle": "Đặt bỏ qua quyền làm mặc định cho {{tool}}?",
|
||||||
|
"confirmBody": "Mọi phiên {{tool}} mới bạn mở sẽ chạy lệnh và sửa file mà không hỏi, cho tới khi bạn đổi lại. Chỉ dùng với thư mục và prompt bạn tin tưởng.",
|
||||||
|
"confirmLabel": "Đặt làm mặc định",
|
||||||
|
"codexConfirmTitle": "Đặt YOLO làm mặc định cho Codex?"
|
||||||
|
},
|
||||||
"agentInstructions": {
|
"agentInstructions": {
|
||||||
"title": "Hướng dẫn agent của tôi",
|
"title": "Hướng dẫn agent của tôi",
|
||||||
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
|
"description": "Quy tắc riêng của bạn bằng Markdown — style code, ngôn ngữ, quy trình. Mọi session mới trong workspace của bạn đều nhận chúng, trước hướng dẫn riêng của workspace. Chỉ lưu cho tài khoản của bạn; session đang chạy giữ nội dung lúc khởi động.",
|
||||||
|
|||||||
@@ -31,5 +31,9 @@
|
|||||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
||||||
"later": "Để sau",
|
"later": "Để sau",
|
||||||
"skipVersion": "Bỏ qua phiên bản này",
|
"skipVersion": "Bỏ qua phiên bản này",
|
||||||
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo."
|
"skipVersionHint": "Không hỏi lại về phiên bản này; phiên bản mới hơn vẫn sẽ được thông báo.",
|
||||||
|
"autoFix": "Tự sửa",
|
||||||
|
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||||
|
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||||
|
"autoFixError": "Không mở được phiên Tự sửa"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,7 +166,8 @@
|
|||||||
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
"EACTIVE": "Claude Code của server giữ login này trong macOS Keychain. Hãy chuyển nó sang profile khác trước.",
|
||||||
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
"ENOTFOUND": "Login đã lưu đó không còn nữa.",
|
||||||
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
"ESTALE": "Login này đã được làm mới ở nơi khác. Đăng nhập lại và lưu lại.",
|
||||||
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại."
|
"EUPSTREAM": "Không kết nối được Anthropic. Thử lại.",
|
||||||
|
"ENOUSER": "User đó không còn tồn tại."
|
||||||
},
|
},
|
||||||
"source": "Nguồn",
|
"source": "Nguồn",
|
||||||
"sources": {
|
"sources": {
|
||||||
@@ -196,7 +197,20 @@
|
|||||||
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
"token": "Chuỗi sk-ant-… mà `claude setup-token` in ra.",
|
||||||
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
"codex": "OpenAI API key (sk-…), hoặc toàn bộ ~/.codex/auth.json sau khi `codex login` bằng tài khoản ChatGPT."
|
||||||
},
|
},
|
||||||
"refreshFailed": "làm mới lỗi"
|
"refreshFailed": "làm mới lỗi",
|
||||||
|
"pinned": "Đang dùng: {{users}}",
|
||||||
|
"loadingUsers": "Đang tải…",
|
||||||
|
"pinDone": {
|
||||||
|
"own-login": "{{user}} đang dùng login riêng của họ, nên không bị ghi đè",
|
||||||
|
"expired": "{{account}} đã hết hạn — phiên mới của {{user}} quay về tài khoản ít tải nhất"
|
||||||
|
},
|
||||||
|
"edit": "Sửa {{name}}",
|
||||||
|
"editHint": "Tick các user mà phiên của họ chạy trên tài khoản này thay vì tài khoản ít tải nhất — kể cả khi nó đã đầy. User đang ở tài khoản khác sẽ chuyển sang đây. Login Claude được ghi ngay vào home của họ: phiên đang chạy chuyển sang ở lần kiểm tra đăng nhập kế tiếp, không cần khởi động lại. Setup-token hoặc tài khoản Codex áp dụng cho phiên mới. Bỏ tick để trả user về tài khoản ít tải nhất.",
|
||||||
|
"save": "Lưu",
|
||||||
|
"users": "User dùng tài khoản này",
|
||||||
|
"noUsers": "Chưa có user nào.",
|
||||||
|
"onAccount": "đang ở {{account}}",
|
||||||
|
"editDone": "Đã lưu {{name}}"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
"button": "Xoá user",
|
"button": "Xoá user",
|
||||||
@@ -204,5 +218,40 @@
|
|||||||
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
"body": "Thao tác này xoá vĩnh viễn {{name}}: tài khoản đăng nhập, {{count}} workspace, và tài khoản Linux cùng toàn bộ thư mục home (/srv/agenthub/users/{{user}}) — mọi file và source code trong đó. Không thể khôi phục. Lịch sử session và audit log vẫn được giữ.",
|
||||||
"confirm": "Xoá vĩnh viễn",
|
"confirm": "Xoá vĩnh viễn",
|
||||||
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
"needsPassword": "Hãy nhập mật khẩu của bạn ở ô bên dưới trước."
|
||||||
|
},
|
||||||
|
"tmux": {
|
||||||
|
"title": "Tmux của member",
|
||||||
|
"description": "Mỗi member có tmux server riêng, giữ phiên bản lúc nó khởi động cho đến khi pane cuối cùng đóng. Bản đã cài: tmux {{version}}.",
|
||||||
|
"refresh": "Làm mới",
|
||||||
|
"notMatched": "Chạy `agenthub server install-system` một lần: trước đó, server cũ của member được hỏi bằng tmux bản mới và có thể kẹt ở 100% CPU.",
|
||||||
|
"empty": "Không member nào đang chạy tmux server.",
|
||||||
|
"user": "Người dùng",
|
||||||
|
"version": "tmux",
|
||||||
|
"sessions": "Session",
|
||||||
|
"agents": "Agent",
|
||||||
|
"stuck": "không trả lời",
|
||||||
|
"outdated": "cũ hơn bản cài",
|
||||||
|
"since": "từ {{at}}",
|
||||||
|
"working_one": "{{count}} đang chạy",
|
||||||
|
"working_other": "{{count}} đang chạy",
|
||||||
|
"idle": "rảnh",
|
||||||
|
"restart": "Khởi động lại tmux của {{name}}",
|
||||||
|
"restartIdle_one": "Khởi động lại {{count}} member đang rảnh",
|
||||||
|
"restartIdle_other": "Khởi động lại {{count}} member đang rảnh",
|
||||||
|
"restartIdleTitle": "Khởi động lại tmux của member đang rảnh",
|
||||||
|
"restartHint": "Mọi pane của {{name}} sẽ đóng ({{count}} session). Phiên Claude của họ resume lại được từ workspace, và lần Start sau sẽ chạy tmux bản đã cài.",
|
||||||
|
"restartHintBusy": "Agent của {{name}} đang chạy và sẽ bị dừng. Mọi pane của họ sẽ đóng ({{count}} session); phiên Claude resume lại được từ workspace.",
|
||||||
|
"restartIdleHint": "Khởi động lại tmux của mọi member đang chạy bản cũ mà không có agent nào đang chạy: {{names}}. Pane của họ sẽ đóng; phiên Claude resume lại được từ workspace.",
|
||||||
|
"restartButton": "Khởi động lại",
|
||||||
|
"done": "Đã khởi động lại tmux của {{name}}",
|
||||||
|
"doneIdle": "Đã khởi động lại: {{names}}",
|
||||||
|
"errors": {
|
||||||
|
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rowMenu": {
|
||||||
|
"openHome": "Mở home trong Files",
|
||||||
|
"copyUsername": "Sao chép username",
|
||||||
|
"copyEmail": "Sao chép email"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* @file artifactView.test.ts
|
||||||
|
* @description What counts as an artifact and how it is shown: kinds by
|
||||||
|
* name, a React file only when written whole, a file's versions rebuilt from
|
||||||
|
* the conversation's Write/Edit/MultiEdit inputs (a failed call skipped, an
|
||||||
|
* edit to unseen text unknown), CSV rows, and the sandboxed documents.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
artifactDocument,
|
||||||
|
artifactKind,
|
||||||
|
artifactVersions,
|
||||||
|
frameSandbox,
|
||||||
|
isArtifact,
|
||||||
|
parseCsv,
|
||||||
|
} from "../artifactView";
|
||||||
|
import type { TranscriptMessage } from "../types";
|
||||||
|
|
||||||
|
const use = (id: string, name: string, input: Record<string, unknown>): TranscriptMessage => ({
|
||||||
|
type: "assistant",
|
||||||
|
timestamp: `2026-10-21T00:00:0${id}.000Z`,
|
||||||
|
content: [{ type: "tool_use", id, name, input } as TranscriptMessage["content"][number]],
|
||||||
|
});
|
||||||
|
const result = (id: string, isError = false): TranscriptMessage => ({
|
||||||
|
type: "user",
|
||||||
|
sender: "tool",
|
||||||
|
timestamp: null,
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool_result",
|
||||||
|
id,
|
||||||
|
output: "",
|
||||||
|
is_error: isError,
|
||||||
|
} as TranscriptMessage["content"][number],
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifactView", () => {
|
||||||
|
it("knows an artifact by its name", () => {
|
||||||
|
expect(artifactKind("/a/page.HTML")).toBe("html");
|
||||||
|
expect(artifactKind("d.mmd")).toBe("mermaid");
|
||||||
|
expect(artifactKind("App.tsx")).toBe("react");
|
||||||
|
expect(artifactKind("x.go")).toBe("code");
|
||||||
|
expect(isArtifact("report.md", false)).toBe(true);
|
||||||
|
expect(isArtifact("App.tsx", false)).toBe(false);
|
||||||
|
expect(isArtifact("App.tsx", true)).toBe(true);
|
||||||
|
expect(isArtifact("server.js", true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||||
|
const msgs = [
|
||||||
|
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||||
|
result("1"),
|
||||||
|
use("2", "Edit", { file_path: "/w/a.md", old_string: "old", new_string: "new" }),
|
||||||
|
result("2"),
|
||||||
|
use("3", "Edit", { file_path: "/w/a.md", old_string: "missing", new_string: "x" }),
|
||||||
|
result("3", true),
|
||||||
|
use("4", "MultiEdit", {
|
||||||
|
file_path: "/w/a.md",
|
||||||
|
edits: [{ old_string: "# A", new_string: "# B" }],
|
||||||
|
}),
|
||||||
|
result("4"),
|
||||||
|
use("5", "Write", { file_path: "/w/other.md", content: "no" }),
|
||||||
|
];
|
||||||
|
const v = artifactVersions(msgs, "/w/a.md");
|
||||||
|
expect(v.map((x) => x.content)).toEqual([
|
||||||
|
"# A\nold line\n",
|
||||||
|
"# A\nnew line\n",
|
||||||
|
"# B\nnew line\n",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says an edit to text it never saw is of unknown content", () => {
|
||||||
|
const v = artifactVersions(
|
||||||
|
[use("1", "Edit", { file_path: "a.md", old_string: "a", new_string: "b" })],
|
||||||
|
"a.md"
|
||||||
|
);
|
||||||
|
expect(v).toHaveLength(1);
|
||||||
|
expect(v[0]!.content).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads CSV with quotes and newlines inside them", () => {
|
||||||
|
expect(parseCsv('a,b\n"x, y","say ""hi""\nthere"\n')).toEqual([
|
||||||
|
["a", "b"],
|
||||||
|
["x, y", 'say "hi"\nthere'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds sandboxed documents that keep the source inert inside a script", () => {
|
||||||
|
const doc = artifactDocument("react", "export default () => <p>a</script>b</p>", false)!;
|
||||||
|
expect(doc).toContain("babel");
|
||||||
|
expect(doc).not.toContain("a</script>b");
|
||||||
|
expect(artifactDocument("mermaid", "graph TD; A-->B<img>", true)).toContain(
|
||||||
|
"A-->B<img>"
|
||||||
|
);
|
||||||
|
expect(artifactDocument("code", "x", false)).toBeNull();
|
||||||
|
expect(frameSandbox("svg")).toBe("");
|
||||||
|
expect(frameSandbox("html")).not.toContain("allow-same-origin");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
chooseRunOption,
|
chooseRunOption,
|
||||||
parsePaneMenu,
|
parsePaneMenu,
|
||||||
pasteToLane,
|
pasteToLane,
|
||||||
|
readRunBusy,
|
||||||
readRunMenu,
|
readRunMenu,
|
||||||
registerPane,
|
registerPane,
|
||||||
takeRunInput,
|
takeRunInput,
|
||||||
@@ -160,6 +161,33 @@ describe("panePaste", () => {
|
|||||||
expect(m.typeAt).toBe(2);
|
expect(m.typeAt).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Several questions: the unnumbered row is Next until the last one, and the
|
||||||
|
// tab bar says which questions have an answer.
|
||||||
|
it("reads a multi-question multiSelect with its Next row and question tabs", () => {
|
||||||
|
const m = parsePaneMenu(MULTI_Q)!;
|
||||||
|
expect(m.options.map((o) => [o.n, o.label, o.checked])).toEqual([
|
||||||
|
[1, "JWT HS256 + default secret", true],
|
||||||
|
[2, "Product code issue 2 lần", true],
|
||||||
|
[3, "Type something", false],
|
||||||
|
[null, "Next", null],
|
||||||
|
[4, "Chat about this", null],
|
||||||
|
]);
|
||||||
|
expect(m.options[2]!.hint).toBe("");
|
||||||
|
expect(m.cursor).toBe(0);
|
||||||
|
expect(m.tabs).toEqual([
|
||||||
|
{ label: "Critical", done: true },
|
||||||
|
{ label: "High 1/3", done: false },
|
||||||
|
{ label: "High 2/3", done: false },
|
||||||
|
{ label: "High 3/3", done: false },
|
||||||
|
]);
|
||||||
|
expect(m.tab).toBe(0);
|
||||||
|
expect(MENU_CURSOR_ROW.test("❯ Next")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads no tabs for a single question", () => {
|
||||||
|
expect(parsePaneMenu(ASK)!.tabs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
// The "Type something" row once something is typed in it: its label is the
|
// The "Type something" row once something is typed in it: its label is the
|
||||||
// text. It is still the row right above "Chat about this".
|
// text. It is still the row right above "Chat about this".
|
||||||
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
it("finds Type something by its place once text is typed in it, and replaces that text", () => {
|
||||||
@@ -188,8 +216,11 @@ describe("panePaste", () => {
|
|||||||
const c = parsePaneMenu(PERMISSION)!;
|
const c = parsePaneMenu(PERMISSION)!;
|
||||||
expect(c.question).toBe("Do you want to proceed?");
|
expect(c.question).toBe("Do you want to proceed?");
|
||||||
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
expect(c.options.map((o) => o.label)).toEqual(["Yes", "Yes, and always allow", "No"]);
|
||||||
|
// The tool and its command, drawn over the dashed rule.
|
||||||
|
expect(c.detail).toEqual(["Bash command", "touch probe-file.txt"]);
|
||||||
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
const x = parsePaneMenu(CODEX_APPROVAL)!;
|
||||||
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
expect(x.question).toBe("Would you like to run the following command?\n$ touch probe.txt");
|
||||||
|
expect(x.detail).toBeUndefined();
|
||||||
expect(x.options).toHaveLength(3);
|
expect(x.options).toHaveLength(3);
|
||||||
expect(x.cursor).toBe(0);
|
expect(x.cursor).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -207,6 +238,35 @@ describe("panePaste", () => {
|
|||||||
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
|
expect(m.options.map((o) => o.n)).toEqual([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads Claude's unnumbered trust prompt and picks by arrows", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const m = parsePaneMenu(TRUST)!;
|
||||||
|
expect(m.options.map((o) => o.label)).toEqual(["No, exit", "Yes, I trust this folder"]);
|
||||||
|
expect(m.options.every((o) => o.n === null)).toBe(true);
|
||||||
|
expect(m.cursor).toBe(0);
|
||||||
|
expect(m.question.split("\n")[0]).toBe("Accessing workspace:");
|
||||||
|
expect(m.question).toContain("Security guide");
|
||||||
|
const keys: string[] = [];
|
||||||
|
const off = registerPane(
|
||||||
|
"agenthub-lane-12",
|
||||||
|
() => {},
|
||||||
|
() => true,
|
||||||
|
(k) => keys.push(k)
|
||||||
|
);
|
||||||
|
expect(chooseRunOption("agenthub-lane-12", m, 1)).toBe(true);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(keys).toEqual(["\x1b[B", "\r"]);
|
||||||
|
off();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds no unnumbered menu in Claude's input box or a wrapped prompt above it", () => {
|
||||||
|
expect(parsePaneMenu(CLAUDE)).toBeNull();
|
||||||
|
expect(
|
||||||
|
parsePaneMenu(["❯ a long prompt that", " wrapped onto two rows", "", RULE, "❯\u00a0", RULE])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
|
it("finds no menu in a reply's numbered list or a typed `1.`", () => {
|
||||||
// No cursor on the list.
|
// No cursor on the list.
|
||||||
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
||||||
@@ -288,6 +348,23 @@ const MULTI = [
|
|||||||
"",
|
"",
|
||||||
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||||
];
|
];
|
||||||
|
const MULTI_Q = [
|
||||||
|
RULE,
|
||||||
|
"← ☒ Critical □ High 1/3 □ High 2/3 □ High 3/3 ✔ Submit →",
|
||||||
|
"",
|
||||||
|
"Critical (đã verify trong code). Chọn issue để log BUG:",
|
||||||
|
"",
|
||||||
|
"❯ 1. [✔] JWT HS256 + default secret",
|
||||||
|
" pkg/middleware/auth.go:156,184 — HMAC only",
|
||||||
|
" 2. [✔] Product code issue 2 lần",
|
||||||
|
" gift product_code_repository.go:57 — không FOR UPDATE",
|
||||||
|
" 3. [ ] Type something",
|
||||||
|
" Next",
|
||||||
|
RULE,
|
||||||
|
" 4. Chat about this",
|
||||||
|
"",
|
||||||
|
"Enter to select · Tab/Arrow keys to navigate · Esc to cancel",
|
||||||
|
];
|
||||||
const PERMISSION = [
|
const PERMISSION = [
|
||||||
" Bash command",
|
" Bash command",
|
||||||
" touch probe-file.txt",
|
" touch probe-file.txt",
|
||||||
@@ -313,12 +390,35 @@ const CODEX_APPROVAL = [
|
|||||||
" enteresc",
|
" enteresc",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Claude Code's folder-trust prompt: no numbers, its footer dim (left out).
|
||||||
|
const TRUST = [
|
||||||
|
RULE,
|
||||||
|
" Accessing workspace:",
|
||||||
|
"",
|
||||||
|
" /home/me",
|
||||||
|
"",
|
||||||
|
" Quick safety check: Is this a project you created or one you trust?",
|
||||||
|
"",
|
||||||
|
" Security guide",
|
||||||
|
"",
|
||||||
|
" ❯ No, exit",
|
||||||
|
" Yes, I trust this folder",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
describe("MENU_CURSOR_ROW", () => {
|
describe("MENU_CURSOR_ROW", () => {
|
||||||
// The chat's 700 ms poll reads the screen cell by cell only when a row
|
// The chat's 700 ms poll reads the screen cell by cell only when a row
|
||||||
// matches it, so every menu parsePaneMenu finds must have such a row.
|
// matches it, so every menu parsePaneMenu finds must have such a row.
|
||||||
it("matches a row of every menu, and no row of an idle prompt", () => {
|
it("matches a row of every menu, and no row of an idle prompt", () => {
|
||||||
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
|
const has = (rows: string[]) => rows.some((r) => MENU_CURSOR_ROW.test(r));
|
||||||
for (const menu of [ASK, MULTI, PERMISSION, CODEX_APPROVAL]) {
|
for (const menu of [
|
||||||
|
ASK,
|
||||||
|
MULTI,
|
||||||
|
PERMISSION,
|
||||||
|
CODEX_APPROVAL,
|
||||||
|
[...TRUST, " Enter to confirm · Esc to cancel"],
|
||||||
|
]) {
|
||||||
expect(parsePaneMenu(menu)).not.toBeNull();
|
expect(parsePaneMenu(menu)).not.toBeNull();
|
||||||
expect(has(menu)).toBe(true);
|
expect(has(menu)).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -326,3 +426,51 @@ describe("MENU_CURSOR_ROW", () => {
|
|||||||
expect(has(CODEX)).toBe(false);
|
expect(has(CODEX)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readRunBusy", () => {
|
||||||
|
it("reads each tool's own busy line, and nothing for a tool it does not know", () => {
|
||||||
|
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||||
|
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||||
|
// Claude's permission footer is not a turn at work.
|
||||||
|
rows = ["Esc to cancel · Tab to amend"];
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(false);
|
||||||
|
rows = ["Thinking… (3s · esc to cancel)"];
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "antigravity")).toBe(true);
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "omp")).toBeNull();
|
||||||
|
off();
|
||||||
|
expect(readRunBusy("agenthub-lane-8", "claude")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Codex's pickers", () => {
|
||||||
|
it("reads /permissions with each row's description column as its hint", () => {
|
||||||
|
const menu = parsePaneMenu([
|
||||||
|
" >_ OpenAI Codex (v0.163.0)",
|
||||||
|
"",
|
||||||
|
" Update Model Permissions",
|
||||||
|
"",
|
||||||
|
"› 1. Ask for approval (current) Read and edit workspace files and run commands, with approval req",
|
||||||
|
" access or edits outside the workspace",
|
||||||
|
" 2. Approve for me Only ask for actions detected as potentially unsafe",
|
||||||
|
" 3. Full Access Use with caution: Codex can edit files",
|
||||||
|
" without approval",
|
||||||
|
" 4. Read Only Read workspace files",
|
||||||
|
"",
|
||||||
|
" enter select · esc back",
|
||||||
|
]);
|
||||||
|
expect(menu?.question).toBe("Update Model Permissions");
|
||||||
|
expect(menu?.cursor).toBe(0);
|
||||||
|
expect(menu?.options.map((o) => [o.n, o.label, o.hint])).toEqual([
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
"Ask for approval (current)",
|
||||||
|
"Read and edit workspace files and run commands, with approval req access or edits outside the workspace",
|
||||||
|
],
|
||||||
|
[2, "Approve for me", "Only ask for actions detected as potentially unsafe"],
|
||||||
|
[3, "Full Access", "Use with caution: Codex can edit files without approval"],
|
||||||
|
[4, "Read Only", "Read workspace files"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { claudePastes } from "../promptAttach";
|
||||||
|
|
||||||
|
describe("claudePastes", () => {
|
||||||
|
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
|
||||||
|
expect(
|
||||||
|
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
|
||||||
|
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a Windows or spaced image path a mention", () => {
|
||||||
|
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
|
||||||
|
'@C:/u/drops/a.png @"/home/u/my shot.png"',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* @file sessionChange.test.ts
|
||||||
|
* @description Unit tests for `sessionChangeMatcher`: fires on a new session
|
||||||
|
* and on a change of the keyed columns, never on a re-broadcast that moved none.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sessionChangeMatcher } from "../sessionChange";
|
||||||
|
import type { WSMessage } from "../types";
|
||||||
|
|
||||||
|
const msg = (type: string, data: unknown) => ({ type, data, timestamp: "" }) as WSMessage;
|
||||||
|
|
||||||
|
describe("sessionChangeMatcher", () => {
|
||||||
|
it("fires on a new session and on a state change, not on a same-state re-broadcast", () => {
|
||||||
|
const m = sessionChangeMatcher();
|
||||||
|
expect(m(msg("session_created", { id: "a", status: "active" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "active", name: "x" }))).toBe(false);
|
||||||
|
expect(
|
||||||
|
m(msg("session_updated", { id: "a", status: "active", awaiting_input_since: "t" }))
|
||||||
|
).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "completed" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", { id: "a", status: "completed", archived_at: "t" }))).toBe(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an unseen or unkeyable session as a change, and ignores other types", () => {
|
||||||
|
const m = sessionChangeMatcher();
|
||||||
|
expect(m(msg("session_updated", { id: "b", status: "active" }))).toBe(true);
|
||||||
|
expect(m(msg("session_updated", null))).toBe(true);
|
||||||
|
expect(m(msg("new_event", { session_id: "b" }))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user