Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7a88b74016 | |||
| 8ed5018a2f | |||
| aacd63b85b | |||
| 22de5009ff |
+13
-1
@@ -72,9 +72,21 @@
|
||||
# DASHBOARD_SESSION_SYNC_MS=30000
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# own ~/.gemini/antigravity-cli/conversations/*.db (0 disables; default: 15000).
|
||||
# DASHBOARD_ANTIGRAVITY_POLL_MS=15000
|
||||
|
||||
@@ -11,3 +11,4 @@ client/package-lock.json
|
||||
*.md
|
||||
*.yml
|
||||
*.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/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/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. |
|
||||
@@ -1066,7 +1066,7 @@ flowchart TD
|
||||
- Uses Node.js `http` module directly — no dependencies.
|
||||
- Resolution order is **env override → discovery file → default**:
|
||||
- `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`.
|
||||
- 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
|
||||
path, and broadcasts `session_created` for newly imported sessions /
|
||||
`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.
|
||||
|
||||
### 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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -1654,7 +1663,9 @@ the binary's own proto descriptors.
|
||||
|
||||
**Sessions and tokens** (`server/lib/antigravity-usage.js`,
|
||||
`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
|
||||
read-only whenever its mtime (or its non-empty `-wal`'s) changes;
|
||||
`antigravity_conversations` holds the mtime read, so a session the user deleted
|
||||
@@ -1743,7 +1754,8 @@ is ever inferred.
|
||||
## Cursor Sessions
|
||||
|
||||
`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 —
|
||||
at `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl`
|
||||
(`AGENTHUB_CURSOR_DIR` overrides `~/.cursor`). Each becomes a `sessions` row with
|
||||
@@ -2225,7 +2237,7 @@ sequenceDiagram
|
||||
| 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/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/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. |
|
||||
@@ -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.
|
||||
- **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"]
|
||||
AVATAR["SagiAvatar.tsx<br/>SVG mascot; data-mood drives CSS;<br/>cursor-tracking pupils"]
|
||||
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"]
|
||||
end
|
||||
|
||||
@@ -2386,8 +2398,8 @@ flowchart LR
|
||||
SUB --> DERIVED["derived state<br/>{ mood, status, bubble }"]
|
||||
DERIVED --> AVATAR["SagiAvatar"]
|
||||
DERIVED --> BUBBLE["SpeechBubble"]
|
||||
DERIVED --> PANEL["SagiPanel"]
|
||||
PANEL -->|"unmatched Ask"| ENSURE["POST /api/lanes/ensure<br/>(adopt 'Sagi' lane, cwd = home)"]
|
||||
DERIVED --> PANEL["PetChat (+ SagiInfo)"]
|
||||
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=…)"]
|
||||
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 }`. |
|
||||
| **`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). |
|
||||
| **`SagiPanel.tsx`** | Pure presentational panel: the pet's name (click to rename — `agenthub.pet.names`, synced to the account), a pat button, status readout, a **Needs you** list of errored/waiting sessions by name, Mute and Clear alerts, and the Ask box. |
|
||||
| **`PetChat.tsx`** | The pet's chat window (Ask handoff, or **Chat with …** in the panel), styled as a soft accent gradient with chat bubbles. The first message starts a session of the user's default AI tool (`agenthub.defaultTool`; Claude or Codex — any other tool still opens its pane in the workspace) in the "Sagi" workspace's first free tab; replies are read from the session's transcript every 1.5 s, and follow-ups and menu answers (`PaneMenuCard`) go through a `TerminalView` attached off-screen (`submitToRun`), so no new route exists. The chat is kept in `localStorage` (`agenthub.pet.chat`) while its pane lives; an ended pane is never resumed — the next message starts a new session. |
|
||||
| **`Sagi.tsx`** | Shell component. A pat (the panel's heart, the Ask box, or stroking the pet — four left/right turns of the pointer over it within 1.2 s) shows hearts and a line ON the pet, so it reads while the panel or chat is open. Mounted once in `client/src/components/Layout.tsx` as a sibling of `UpdateNotifier`. Owns open/closed state, the `⌘B` / `Esc` shortcuts, reduced-motion detection, and navigation. |
|
||||
| **`intents.ts`** | Local Q&A over the cached status snapshot, in English and Vietnamese keywords, answering in the UI language and naming the sessions it counts (also: who are you, hello, thanks, pat). Queries that don't match a local intent open `PetChat`; with a default tool it cannot read back, `Sagi.tsx` instead calls `POST /api/lanes/ensure` (adopting the "Sagi" lane whose cwd defaults to `$HOME`), then `POST /api/run` to spawn a fresh tmux-backed `claude` session in that lane with the prompt as its `initialPrompt`, then navigates to `/run?laneId=…&autostart=1` so the live run is observable. |
|
||||
| **`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 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 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 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. |
|
||||
| **`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. |
|
||||
|
||||
+407
-82
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Requires Node **>= 22.5** (a plugin install has no native `better-sqlite3`, so
|
||||
the server stores data through `node:sqlite`). Full behavior, including the
|
||||
uninstall cleanup (`agenthub uninstall`), is in [`docs/PLUGINS.md`](docs/PLUGINS.md).
|
||||
the server stores data through `node:sqlite`), plus `git` and `tmux` from
|
||||
[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
|
||||
dashboard, run it from source, or stay on Node 20.
|
||||
dashboard or run it from source.
|
||||
|
||||
## Requirements
|
||||
|
||||
| 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 |
|
||||
| Claude Code | 2.x+ | Required for hook integration |
|
||||
| Git | Any | Cloning the repository, and every lane operation (worktrees, branch/status reads) |
|
||||
@@ -76,7 +79,7 @@ Check it:
|
||||
```bash
|
||||
tmux -V # e.g. tmux 3.4
|
||||
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
|
||||
@@ -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`
|
||||
```
|
||||
|
||||
- **Node:** a plugin install needs **>= 22.5** (it stores data through
|
||||
`node:sqlite`); a checkout still runs on Node 20. The `agenthub` launcher
|
||||
- **Node:** **>= 22.5** (the plugin stores data through `node:sqlite`; a
|
||||
checkout needs the same). The `agenthub` launcher
|
||||
pins the node that installed it and falls back to `node` on `PATH` when a
|
||||
`brew upgrade` or `nvm` switch deletes that binary.
|
||||
- **Starts at login once you turn it on (LaunchAgent).** Off by default; with
|
||||
@@ -172,24 +175,20 @@ cd AgentHub
|
||||
## Step 2 — Install dependencies
|
||||
|
||||
```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
|
||||
npm install
|
||||
```
|
||||
|
||||
`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
|
||||
```
|
||||
`better-sqlite3` gyp/compile warnings here are harmless — see
|
||||
[Troubleshooting](#better-sqlite3-errors-during-npm-install--npm-run-setup).
|
||||
|
||||
---
|
||||
|
||||
@@ -234,7 +233,7 @@ After starting a Claude Code session, you should see:
|
||||
- **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**
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
`~/.claude/projects/` on startup, so if you've used Claude Code on this
|
||||
machine before, your historical sessions, agents, events, token counts,
|
||||
and cost totals should already be visible in the Sessions list.
|
||||
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.
|
||||
|
||||
To bring in history from another machine, a backup, or a `.tar.gz`
|
||||
archive a teammate sent you, use **Settings → Import History** in the
|
||||
UI. It supports three modes:
|
||||
### Pick the right mode
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
UI["Settings →<br/>Import History"] --> M1["Rescan default folder<br/>~/.claude/projects"]
|
||||
UI --> M2["Scan a folder<br/>any absolute path"]
|
||||
UI --> M3["Upload files<br/>.jsonl / .zip / .tar.gz / .gz"]
|
||||
M1 --> P["Same parser as live<br/>hook ingestion"]
|
||||
M2 --> P
|
||||
M3 --> P
|
||||
P --> DB[("SQLite<br/>sessions + tokens + cost")]
|
||||
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"]
|
||||
|
||||
style UI fill:#a855f7,stroke:#c084fc,color:#fff
|
||||
style P fill:#f59e0b,stroke:#fbbf24,color:#000
|
||||
style DB fill:#10b981,stroke:#34d399,color:#fff
|
||||
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
|
||||
```
|
||||
|
||||
Re-imports are idempotent: sessions are deduplicated by UUID and
|
||||
compaction baselines preserve pre-compaction token totals, so running
|
||||
the importer twice never double-counts tokens or cost.
|
||||
### Step-by-step: moving history from one machine to another
|
||||
|
||||
Verify it worked by opening the **Analytics** page and checking that
|
||||
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
|
||||
**On the source machine**, bundle the projects folder:
|
||||
|
||||
If you regularly import very large archives, these environment variables
|
||||
can be raised (the defaults are generous for typical use):
|
||||
```bash
|
||||
# macOS / Linux
|
||||
tar -czf claude-history.tar.gz -C ~/.claude projects
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------------------- | ------- | ----------------------------------------------------------- |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
||||
| `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) |
|
||||
# Windows (PowerShell, via built-in tar)
|
||||
tar -czf claude-history.tar.gz -C "$env:USERPROFILE\.claude" projects
|
||||
```
|
||||
|
||||
Set them before `npm run dev` or `npm start`:
|
||||
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 |
|
||||
| ----------------------------------- | ------- | ---------------------------------------------------------- |
|
||||
| `AGENTHUB_IMPORT_MAX_BYTES` | 1 GB | Maximum size per uploaded file |
|
||||
| `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) |
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -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/rules/`
|
||||
- `skills/` (shipped as the `agenthub` plugin's skills)
|
||||
- Codex project packs live under `.codex/`:
|
||||
- `.claude/rules/*`
|
||||
- `skills/*` (the `agenthub` plugin's own skills)
|
||||
- Codex:
|
||||
- `AGENTS.md`
|
||||
- `.codex/rules/`
|
||||
- `.codex/agents/`
|
||||
- `.codex/skills/`
|
||||
- `.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 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
|
||||
|
||||
### `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)
|
||||
2. **`node:sqlite`** — Node.js built-in SQLite module, used automatically on Node 22+ when `better-sqlite3` is unavailable
|
||||
Then run `/agenthub-doctor` from Claude Code — it checks Node, the runtime
|
||||
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
|
||||
- **Install build tools** so `better-sqlite3` can compile from source:
|
||||
- **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
|
||||
- **macOS:** `xcode-select --install`
|
||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
||||
Only a **checkout** install sees these — the plugin installs with
|
||||
`--ignore-scripts` and never builds `better-sqlite3`. They are **harmless**:
|
||||
`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.
|
||||
|
||||
Then run: `npm rebuild better-sqlite3`
|
||||
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`
|
||||
- **Linux:** `sudo apt install python3 make g++` (Debian/Ubuntu) or equivalent
|
||||
|
||||
### "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
|
||||
|
||||
@@ -375,7 +643,7 @@ Ensure both server and client dependencies are installed:
|
||||
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
|
||||
rm -rf node_modules client/node_modules
|
||||
@@ -409,9 +677,66 @@ that case.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -1,422 +1,14 @@
|
||||
# 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.
|
||||
|
||||
## How it works
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
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) |
|
||||
|
||||
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
|
||||
```
|
||||
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
|
||||
was here is there:
|
||||
|
||||
| Was in SETUP.md | Now in INSTALL.md |
|
||||
|---|---|
|
||||
| How it works | [How it works](./INSTALL.md#how-it-works) |
|
||||
| Hook auto-installation, environment variables, agent extensions, PWA | [Configuration](./INSTALL.md#configuration) |
|
||||
| Database (clear, Settings page, seed) | [Database](./INSTALL.md#database) |
|
||||
| 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) |
|
||||
|
||||
+70
-12
@@ -1284,12 +1284,14 @@ async function cmdDoctor() {
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available } = await get("/api/run/tmux");
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
console.log(
|
||||
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. ` +
|
||||
!available
|
||||
? `${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`
|
||||
: upgrade
|
||||
? `${c.yellow("!")} tmux ${upgrade}`
|
||||
: `${c.green("✔")} tmux available to the server`
|
||||
);
|
||||
} catch {
|
||||
console.log(`${c.dim("·")} tmux not reported by this server`);
|
||||
@@ -3932,21 +3934,73 @@ async function cmdServer(sub, flags) {
|
||||
} else {
|
||||
console.log(c.dim(" an admin already exists — add more with `agenthub users add`"));
|
||||
}
|
||||
const wasServer = auth.isServerMode();
|
||||
settingsEnv.writeSetting("AGENTHUB_MODE", "server");
|
||||
envNote("AGENTHUB_MODE");
|
||||
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(
|
||||
" 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(
|
||||
" 2. Serve it over HTTPS through a reverse proxy that keeps the Host header,\n" +
|
||||
" and run setup with --hostname <that name> — see docs/SERVER-MODE.md."
|
||||
);
|
||||
console.log(
|
||||
c.dim(" Members see only their own workspaces, but every pane still runs as this")
|
||||
);
|
||||
console.log(c.dim(" one Linux account — use it with people you trust (docs/SERVER-MODE.md)."));
|
||||
if (shared) {
|
||||
console.log(
|
||||
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(" 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(
|
||||
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(
|
||||
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;
|
||||
@@ -4232,7 +4286,7 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
}
|
||||
pool.resetQuota();
|
||||
table(
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "quota", "state"],
|
||||
["codex account", "kind", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4241,6 +4295,8 @@ async function cmdCodexAccounts(sub, name, flags) {
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.tokens5h,
|
||||
a.tokens7d,
|
||||
a.quota
|
||||
? a.quota.windows.map((w) => `${w.label} ${Math.round(w.usedPercent)}%`).join(" ")
|
||||
: "–",
|
||||
@@ -4281,7 +4337,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
await pool.refreshQuotas({ force: true });
|
||||
const pct = (w) => (w?.utilization != null ? `${Math.round(w.utilization)}%` : "–");
|
||||
table(
|
||||
["account", "live", "$ 5h", "$ 7d", "quota 5h", "quota wk", "state"],
|
||||
["account", "live", "$ 5h", "$ 7d", "tok 5h", "tok 7d", "quota 5h", "quota wk", "state"],
|
||||
pool
|
||||
.poolStatus()
|
||||
.map((a) => [
|
||||
@@ -4289,6 +4345,8 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
a.livePanes,
|
||||
a.spend5h.toFixed(2),
|
||||
a.spend7d.toFixed(2),
|
||||
a.tokens5h,
|
||||
a.tokens7d,
|
||||
pct(a.quota?.fiveHour),
|
||||
pct(a.quota?.sevenDay),
|
||||
a.expired
|
||||
|
||||
+26
-5
@@ -64,6 +64,7 @@ import { GlobalTooltip } from "./components/Tooltip";
|
||||
import { Toaster } from "./components/Toaster";
|
||||
import { ConfirmHost } from "./components/ConfirmHost";
|
||||
import { RouteErrorBoundary, reloadOnce } from "./components/RouteErrorBoundary";
|
||||
import { usePermissions } from "./components/AuthGate";
|
||||
import { NotFound } from "./pages/NotFound";
|
||||
import { useWebSocket } from "./hooks/useWebSocket";
|
||||
import { useNotifications } from "./hooks/useNotifications";
|
||||
@@ -88,7 +89,10 @@ const SnapshotView = lazy(() =>
|
||||
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
|
||||
);
|
||||
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 })));
|
||||
|
||||
function PageFallback() {
|
||||
const { t } = useTranslation();
|
||||
@@ -108,6 +112,11 @@ function Page({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A page only a shared server needs (the File Explorer): local mode goes home. */
|
||||
function ServerOnly({ children }: { children: ReactNode }) {
|
||||
return usePermissions().local ? <Navigate to="/" replace /> : <>{children}</>;
|
||||
}
|
||||
|
||||
/** Keyed by the id so moving to another session starts from fresh state. */
|
||||
function SessionDetailRoute() {
|
||||
const { id } = useParams();
|
||||
@@ -212,21 +221,33 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="files"
|
||||
path="artifacts"
|
||||
element={
|
||||
<Page>
|
||||
<FilesPage />
|
||||
<Artifacts />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="users"
|
||||
path="files"
|
||||
element={
|
||||
<ServerOnly>
|
||||
<Page>
|
||||
<FilesPage />
|
||||
</Page>
|
||||
</ServerOnly>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin"
|
||||
element={
|
||||
<Page>
|
||||
<Users />
|
||||
<Administration />
|
||||
</Page>
|
||||
}
|
||||
/>
|
||||
{/* The Users page became Administration's first tab. */}
|
||||
<Route path="users" element={<Navigate to="/admin" replace />} />
|
||||
<Route
|
||||
path="settings/*"
|
||||
element={
|
||||
|
||||
@@ -63,6 +63,10 @@ export function usePermissions() {
|
||||
const isAdmin = local || user?.role === "admin";
|
||||
return {
|
||||
isAdmin,
|
||||
// Local mode: one person on their own machine — what only serves a
|
||||
// shared server (File Explorer, uploads, AgentHub's own git logins) is
|
||||
// hidden; their desktop and their own git config already do it.
|
||||
local,
|
||||
seesAll: local || (!!user && user.role !== "developer"),
|
||||
// A developer only ever sees their own rows, so everything shown is theirs.
|
||||
canEditLane: (lane?: { owner_id?: number | null } | null) =>
|
||||
|
||||
@@ -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 { Menu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -85,9 +85,15 @@ import { UpdateNotifier } from "./UpdateNotifier";
|
||||
import { InterruptedSessionsPrompt } from "./lanes/InterruptedSessionsPrompt";
|
||||
import { SagiMount } from "./Sagi/SagiMount";
|
||||
import { StatusBar } from "./StatusBar";
|
||||
import { OnboardingWizard } from "./onboarding/OnboardingWizard";
|
||||
import { useOnboarding } from "../lib/userPrefs";
|
||||
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";
|
||||
/** The default width, and the narrowest the edge drags to. */
|
||||
export const SIDEBAR_MIN = 240;
|
||||
@@ -136,6 +142,7 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
const { t } = useTranslation("nav");
|
||||
const [collapsed, setCollapsed] = useState(loadCollapsed);
|
||||
const [connectionOpen, setConnectionOpen] = useState(false);
|
||||
const onboarding = useOnboarding();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const drawer = useMedia(DRAWER_QUERY);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -177,7 +184,11 @@ export function Layout({ wsConnected }: LayoutProps) {
|
||||
<UpdateNotifier />
|
||||
<InterruptedSessionsPrompt />
|
||||
<SagiMount />
|
||||
<OnboardingWizard />
|
||||
{onboarding.loaded && !onboarding.onboardedAt && (
|
||||
<Suspense fallback={null}>
|
||||
<OnboardingWizard />
|
||||
</Suspense>
|
||||
)}
|
||||
<OnboardingTour />
|
||||
{drawer && drawerOpen && (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* @file PetChat.tsx
|
||||
* @description The pet's chat window. A question its Ask box cannot answer
|
||||
* locally opens here instead of leaving the page: the first message starts a
|
||||
* @description The pet's chat window — its whole UI: clicking the pet opens
|
||||
* 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
|
||||
* Codex — the two with a readable transcript) in the "Sagi" workspace, in its
|
||||
* 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
|
||||
* reaches the server. A menu the CLI asks with (a permission or trust prompt)
|
||||
* is read off that pane and answered with `PaneMenuCard`. The chat is kept in
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives; **New chat**
|
||||
* forgets it (the session stays in the workspace), **Open in workspace**
|
||||
* `localStorage` (`agenthub.pet.chat`) while its pane lives, so closing and
|
||||
* 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
|
||||
* is gone, the next message starts a new one.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
|
||||
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 type { TranscriptMessage } from "../../lib/types";
|
||||
import { readDefaultMode, readDefaultTool } from "../../lib/sessionDefaults";
|
||||
@@ -33,6 +38,8 @@ import {
|
||||
type PaneMenu,
|
||||
} from "../../lib/panePaste";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
import { TerminalView } from "../run/TerminalView";
|
||||
import { PaneMenuCard } from "../run/PaneChat";
|
||||
@@ -141,14 +148,21 @@ function toolSummary(names: string[]): string {
|
||||
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 {
|
||||
/** The pet's shown name, for the header and greeting. */
|
||||
name: string;
|
||||
/** A question to send as soon as the chat opens (the Ask box's handoff). */
|
||||
initialPrompt?: string | null;
|
||||
/** Called once the initial prompt has been taken, so a re-render never sends it twice. */
|
||||
onPromptTaken?: () => void;
|
||||
onBack: () => void;
|
||||
/** Shown in place of the plain name (the rename button). */
|
||||
title?: ReactNode;
|
||||
/** Extra header buttons, before Open in workspace (the pat heart). */
|
||||
headerActions?: ReactNode;
|
||||
/** 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;
|
||||
/** Opens the chat's pane in its workspace. */
|
||||
onOpenWorkspace: (laneId: number, runId: string) => void;
|
||||
@@ -158,9 +172,10 @@ interface PetChatProps {
|
||||
|
||||
export function PetChat({
|
||||
name,
|
||||
initialPrompt,
|
||||
onPromptTaken,
|
||||
onBack,
|
||||
title,
|
||||
headerActions,
|
||||
info,
|
||||
onLocal,
|
||||
onClose,
|
||||
onOpenWorkspace,
|
||||
onBusy,
|
||||
@@ -175,6 +190,8 @@ export function PetChat({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
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 remember = (ref: PetChatRef | null) => {
|
||||
@@ -248,7 +265,7 @@ export function PetChat({
|
||||
return () => window.clearInterval(timer);
|
||||
}, [chat]);
|
||||
|
||||
const lines = chatLines(messages);
|
||||
const lines = [...local, ...chatLines(messages)];
|
||||
// The pending line shows until the transcript has it.
|
||||
useEffect(() => {
|
||||
if (pending && lines.some((l) => l.role === "user" && l.text === pending.trim()))
|
||||
@@ -269,6 +286,19 @@ export function PetChat({
|
||||
const prompt = text.trim();
|
||||
if (!prompt || starting) return;
|
||||
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);
|
||||
if (chat) {
|
||||
// Typed into the attached pane; it may still be attaching.
|
||||
@@ -302,17 +332,35 @@ export function PetChat({
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[chat, starting, t]
|
||||
[chat, starting, t, onLocal]
|
||||
);
|
||||
|
||||
// The Ask box's question, sent once.
|
||||
const taken = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!initialPrompt || taken.current) return;
|
||||
taken.current = true;
|
||||
onPromptTaken?.();
|
||||
void send(initialPrompt);
|
||||
}, [initialPrompt, onPromptTaken, send]);
|
||||
// New chat: the old one's session is stopped, not left running unseen —
|
||||
// asking first only while it is still writing a reply.
|
||||
const newChat = async () => {
|
||||
if (chat) {
|
||||
if (
|
||||
busy &&
|
||||
!(await confirmDialog({
|
||||
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) => {
|
||||
e?.preventDefault();
|
||||
@@ -333,11 +381,11 @@ export function PetChat({
|
||||
>
|
||||
{/* header */}
|
||||
<div className="pet-chat-head flex items-center gap-1 px-2 py-2">
|
||||
<IconButton label={t("sagi.chat.back")} onClick={onBack}>
|
||||
<ArrowLeft size={16} />
|
||||
</IconButton>
|
||||
<span className="pl-1.5 text-base leading-none" aria-hidden>
|
||||
🐾
|
||||
</span>
|
||||
<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">
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
@@ -352,6 +400,7 @@ export function PetChat({
|
||||
{busy ? t("sagi.chat.typing") : toolLabel}
|
||||
</div>
|
||||
</div>
|
||||
{headerActions}
|
||||
{chat && (
|
||||
<IconButton
|
||||
label={t("sagi.chat.openWorkspace")}
|
||||
@@ -362,8 +411,8 @@ export function PetChat({
|
||||
)}
|
||||
<IconButton
|
||||
label={t("sagi.chat.new")}
|
||||
onClick={() => remember(null)}
|
||||
disabled={!chat || starting}
|
||||
onClick={() => void newChat()}
|
||||
disabled={(!chat && !local.length) || starting}
|
||||
>
|
||||
<SquarePen size={15} />
|
||||
</IconButton>
|
||||
@@ -372,6 +421,8 @@ export function PetChat({
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{info}
|
||||
|
||||
{/* conversation */}
|
||||
<div ref={listRef} className="flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{empty && (
|
||||
@@ -475,10 +526,9 @@ export function PetChat({
|
||||
e.preventDefault();
|
||||
submit();
|
||||
} else if (e.key === "Escape") {
|
||||
// Esc leaves the chat for the panel, not the whole mascot.
|
||||
e.stopPropagation();
|
||||
e.nativeEvent.stopImmediatePropagation();
|
||||
onBack();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,17 +5,20 @@
|
||||
* enabled; `Sagi` gates on the same preference itself, and the body (brain,
|
||||
* tick, WS reducer) runs only while it is on. Persists across routes and
|
||||
* shares the single WebSocket.
|
||||
* Owns the open/closed panel state, the ⌘B / Esc shortcuts, reduced-motion
|
||||
* 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
|
||||
* from useSagiBrain; the avatar is draggable (AssistiveTouch-style) via
|
||||
* useSagiPosition (a v2 Codex pet also looks toward the pointer), and the
|
||||
* bubble/panel render in a self-clamping flyout so
|
||||
* they never spill off any screen edge regardless of where the mascot is docked.
|
||||
*
|
||||
* The "do the job" path: an unmatched Ask query starts a claude session in
|
||||
* the "Sagi" workspace carrying it as the first prompt, then opens that
|
||||
* workspace's session view (`/workspace?lane=<id>&run=<runId>`) — no new LLM
|
||||
* backend. ⌘B/Ctrl+B is taken only while the mascot is shown and focus is not
|
||||
* The "do the job" path: an unmatched question is the chat's — it starts a
|
||||
* session of the default AI tool in the "Sagi" workspace; a default tool the
|
||||
* chat cannot read back starts there and its session view opens
|
||||
* (`/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).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -39,7 +42,7 @@
|
||||
* ## Internal dependencies
|
||||
* - `./SagiAvatar`
|
||||
* - `./SpeechBubble`
|
||||
* - `./SagiPanel`
|
||||
* - `./SagiInfo`
|
||||
* - `./useSagiBrain`
|
||||
* - `./useSagiPosition`
|
||||
* - `./intents`
|
||||
@@ -78,12 +81,13 @@ import {
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsHref } from "../../lib/settingsLink";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SagiAvatar } from "./SagiAvatar";
|
||||
import { PetSprite } from "./PetSprite";
|
||||
import { PetMenu } from "./PetMenu";
|
||||
import { SpeechBubble } from "./SpeechBubble";
|
||||
import { SagiPanel } from "./SagiPanel";
|
||||
import { PetNameButton, SagiInfo } from "./SagiInfo";
|
||||
import { useSagiBrain } from "./useSagiBrain";
|
||||
import { useSagiPosition } from "./useSagiPosition";
|
||||
import { useSagiEnabled } from "./SagiMount";
|
||||
@@ -94,7 +98,8 @@ import { api } from "../../lib/api";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
import { startInFreeTabDetailed } from "../lanes/startSession";
|
||||
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
|
||||
// STROKE_MS and with no button held, is a pat; then a pause before the next.
|
||||
@@ -126,38 +131,27 @@ interface Anchor {
|
||||
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
|
||||
* it inside the viewport. It measures itself (and re-measures on content/size
|
||||
* changes via ResizeObserver) so a tall panel near a screen edge slides fully
|
||||
* into view instead of being cropped.
|
||||
*/
|
||||
/** `front`: above the mascot itself — the pet menu, which may overlap it. */
|
||||
/**
|
||||
* `front`: above the mascot itself — the pet menu, which may overlap it.
|
||||
* `beside`: at the mascot's side instead of above it — the chat window, on the
|
||||
* screen side away from the edge it docks to, its bottom level with the pet's.
|
||||
*/
|
||||
function SagiFlyout({
|
||||
anchor,
|
||||
children,
|
||||
front = false,
|
||||
beside = false,
|
||||
}: {
|
||||
anchor: Anchor;
|
||||
children: ReactNode;
|
||||
front?: boolean;
|
||||
beside?: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [style, setStyle] = useState<CSSProperties>({ visibility: "hidden" });
|
||||
@@ -170,6 +164,18 @@ function SagiFlyout({
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
if (beside) {
|
||||
let left =
|
||||
anchor.side === "left"
|
||||
? anchor.left + anchor.size + FLYOUT_GAP
|
||||
: anchor.left - w - FLYOUT_GAP;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
let top = anchor.top + anchor.size - h;
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal: hug the avatar's docked edge, then clamp on-screen.
|
||||
let left = anchor.side === "left" ? anchor.left : anchor.left + anchor.size - w;
|
||||
left = Math.min(vw - w - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, left));
|
||||
@@ -182,7 +188,7 @@ function SagiFlyout({
|
||||
top = Math.min(vh - h - VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, top));
|
||||
|
||||
setStyle({ left, top, visibility: "visible" });
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp]);
|
||||
}, [anchor.left, anchor.top, anchor.size, anchor.side, anchor.openUp, beside]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
place();
|
||||
@@ -223,10 +229,8 @@ function SagiBody() {
|
||||
// The name it goes by: the user's nickname, the pet's own, else Sagi.
|
||||
const [name, setName] = useState(() => petName(sagiPrefs.getPet()));
|
||||
const [size, setSize] = useState(() => sagiPrefs.getSize());
|
||||
// The chat window (PetChat.tsx): the pet's whole UI.
|
||||
const [open, setOpen] = useState(false);
|
||||
// The chat window (PetChat.tsx), and the Ask question it should send.
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [chatPrompt, setChatPrompt] = useState<string | null>(null);
|
||||
// The right-click pet menu (`PetMenu.tsx`).
|
||||
const [menu, setMenu] = useState(false);
|
||||
const [hover, setHover] = useState(false);
|
||||
@@ -278,7 +282,6 @@ function SagiBody() {
|
||||
setOpen((v) => !v);
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -328,7 +331,6 @@ function SagiBody() {
|
||||
navigate(workspaceHref(s, runs) ?? `/sessions/${encodeURIComponent(s.id)}`);
|
||||
brain.dismissBubble();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
};
|
||||
api.run
|
||||
.list()
|
||||
@@ -338,21 +340,17 @@ function SagiBody() {
|
||||
[brain, navigate]
|
||||
);
|
||||
|
||||
const onAsk = useCallback(
|
||||
(query: string): string | null => {
|
||||
// Asked by the chat before any session runs.
|
||||
const onLocal = useCallback(
|
||||
(query: string): PetLocalReply => {
|
||||
const result = matchIntent(query, brain.status, { name, attention: brain.attention });
|
||||
if (result.kind === "answer") {
|
||||
if (result.moment) brain.playMoment(result.moment);
|
||||
return result.text;
|
||||
}
|
||||
// Handoff: a question the pet cannot answer goes to the chat window,
|
||||
// which starts a session of the default AI tool and shows it as chat.
|
||||
if (petChatTool()) {
|
||||
setChatPrompt(result.prompt);
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
return null;
|
||||
return { answer: result.text };
|
||||
}
|
||||
// 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
|
||||
// in the "Sagi" workspace and open its pane there, as before.
|
||||
const prompt = result.prompt;
|
||||
@@ -374,7 +372,7 @@ function SagiBody() {
|
||||
brain.setThinking(false);
|
||||
}
|
||||
})();
|
||||
return null;
|
||||
return { handled: true };
|
||||
},
|
||||
[brain, navigate, t, name]
|
||||
);
|
||||
@@ -413,57 +411,55 @@ function SagiBody() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The panel is too big to drag around, so it hides mid-drag. The bubble
|
||||
does not - it is Sagi talking, and it has to stay by its mouth. */}
|
||||
{!place.dragging && open && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SagiPanel
|
||||
{/* Beside the pet, riding along with it mid-drag too (SagiFlyout follows
|
||||
it 1:1). Closing unmounts it; its chat is remembered, so reopening
|
||||
re-attaches the same session. */}
|
||||
{open && (
|
||||
<SagiFlyout anchor={anchor} beside>
|
||||
<PetChat
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
onPat={brain.pat}
|
||||
attention={brain.attention}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
onAsk={onAsk}
|
||||
onOpenChat={() => {
|
||||
setOpen(false);
|
||||
setChatOpen(true);
|
||||
}}
|
||||
title={
|
||||
<PetNameButton
|
||||
name={name}
|
||||
ownName={"kind" in pet ? pet.name || "Sagi" : "Sagi"}
|
||||
nickname={sagiPrefs.getNickname(pet.id)}
|
||||
onRename={(next) => sagiPrefs.setNickname(pet.id, next)}
|
||||
/>
|
||||
}
|
||||
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}
|
||||
onOpenSession={openSession}
|
||||
status={brain.status}
|
||||
muted={brain.muted}
|
||||
onToggleMute={brain.toggleMute}
|
||||
onClearAlerts={brain.clearAlerts}
|
||||
onNavigate={onNavigate}
|
||||
defaultOpen={brain.attention.length > 0}
|
||||
/>
|
||||
}
|
||||
onLocal={onLocal}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{/* Kept mounted while dragging (only hidden): unmounting would detach
|
||||
its terminal and lose a half-typed message. */}
|
||||
{chatOpen && (
|
||||
<div style={place.dragging ? { display: "none" } : undefined}>
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<PetChat
|
||||
name={name}
|
||||
initialPrompt={chatPrompt}
|
||||
onPromptTaken={() => setChatPrompt(null)}
|
||||
onBack={() => {
|
||||
setChatOpen(false);
|
||||
setOpen(true);
|
||||
}}
|
||||
onClose={() => setChatOpen(false)}
|
||||
onOpenWorkspace={(laneId, runId) => {
|
||||
navigate(`/workspace?lane=${laneId}&run=${encodeURIComponent(runId)}`);
|
||||
setChatOpen(false);
|
||||
}}
|
||||
onBusy={onBusy}
|
||||
/>
|
||||
</SagiFlyout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!place.dragging && menu && (
|
||||
<SagiFlyout anchor={anchor} front>
|
||||
<PetMenu
|
||||
@@ -478,7 +474,7 @@ function SagiBody() {
|
||||
</SagiFlyout>
|
||||
)}
|
||||
|
||||
{!open && !menu && !chatOpen && brain.bubble && (
|
||||
{!open && !menu && brain.bubble && (
|
||||
<SagiFlyout anchor={anchor}>
|
||||
<SpeechBubble
|
||||
bubble={brain.bubble}
|
||||
@@ -506,18 +502,16 @@ function SagiBody() {
|
||||
// A drag just ended - swallow the synthetic click so the panel
|
||||
// doesn't toggle when the user only repositioned the avatar.
|
||||
if (place.consumeDrag()) return;
|
||||
if (chatOpen) setChatOpen(false);
|
||||
else setOpen((v) => !v);
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
// Right-click: the pet menu (hide, size, choose, upload, import).
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
setChatOpen(false);
|
||||
setMenu(true);
|
||||
}}
|
||||
aria-label={open || chatOpen ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open || chatOpen}
|
||||
aria-label={open ? t("sagi.closeAvatar", { name }) : t("sagi.open", { name })}
|
||||
aria-expanded={open}
|
||||
title={t("sagi.avatarTitle", { name })}
|
||||
>
|
||||
{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,403 +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,
|
||||
MessageCircle,
|
||||
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;
|
||||
/** Opens the chat window (PetChat). */
|
||||
onOpenChat: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SagiPanel({
|
||||
name,
|
||||
ownName,
|
||||
nickname,
|
||||
onRename,
|
||||
onPat,
|
||||
attention,
|
||||
onOpenSession,
|
||||
status,
|
||||
muted,
|
||||
onToggleMute,
|
||||
onClearAlerts,
|
||||
onNavigate,
|
||||
onAsk,
|
||||
onOpenChat,
|
||||
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">
|
||||
<div className="col-span-2">
|
||||
<ActionButton
|
||||
icon={MessageCircle}
|
||||
label={t("sagi.panel.chat", { name })}
|
||||
onClick={onOpenChat}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
* @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>
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useSagiPosition } from "../useSagiPosition";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { Sagi } from "../Sagi";
|
||||
import { eventBus } from "../../../lib/eventBus";
|
||||
import { api } from "../../../lib/api";
|
||||
import type { WSMessage, Session } from "../../../lib/types";
|
||||
|
||||
function renderSagi() {
|
||||
@@ -54,13 +55,13 @@ describe("Sagi widget", () => {
|
||||
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();
|
||||
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();
|
||||
|
||||
const input = within(panel).getByLabelText(/ask sagi/i);
|
||||
const input = within(panel).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "status" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
expect(within(panel).getByText(/live ·/i)).toBeInTheDocument();
|
||||
@@ -88,7 +89,7 @@ describe("Sagi widget", () => {
|
||||
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();
|
||||
act(() => {
|
||||
eventBus.publish({
|
||||
@@ -101,7 +102,7 @@ describe("Sagi widget", () => {
|
||||
expect(within(bubble).getByText("Fix login")).toBeInTheDocument();
|
||||
expect(within(bubble).getByRole("button", { name: "Open" })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const panel = screen.getByRole("dialog", { name: /sagi companion/i });
|
||||
const panel = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(panel).getByText("Needs you")).toBeInTheDocument();
|
||||
expect(within(panel).getByRole("button", { name: /Fix login/ })).toBeInTheDocument();
|
||||
});
|
||||
@@ -121,39 +122,54 @@ describe("Sagi widget", () => {
|
||||
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();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /rename sagi/i }));
|
||||
const input = screen.getByLabelText("Pet name");
|
||||
fireEvent.change(input, { target: { value: "Mochi" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByRole("dialog", { name: /mochi companion/i })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/ask mochi/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /chat with 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");
|
||||
const ensure = vi.spyOn(api.lanes, "ensure").mockReturnValue(new Promise(() => {}));
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
const input = screen.getByLabelText(/ask sagi/i);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
const input = within(chat).getByLabelText(/ask sagi anything/i);
|
||||
fireEvent.change(input, { target: { value: "write me a haiku" } });
|
||||
fireEvent.submit(input.closest("form")!);
|
||||
const chat = screen.getByRole("dialog", { name: /chat with sagi/i });
|
||||
expect(within(chat).getByText("write me a haiku")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("dialog", { name: /sagi companion/i })).not.toBeInTheDocument();
|
||||
expect(ensure).toHaveBeenCalledWith({ title: "Sagi" });
|
||||
ensure.mockRestore();
|
||||
});
|
||||
|
||||
it("opens the chat from the panel and goes back to it", () => {
|
||||
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 }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /chat with sagi/i }));
|
||||
expect(screen.getByRole("dialog", { name: /chat with sagi/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("dialog", { name: /sagi companion/i })).toBeInTheDocument();
|
||||
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 panel is open", () => {
|
||||
it("a pat shows its reaction on the pet even while the chat is open", () => {
|
||||
renderSagi();
|
||||
fireEvent.click(screen.getByRole("button", { name: /open sagi companion/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /pat sagi/i }));
|
||||
@@ -171,16 +187,16 @@ describe("Sagi widget", () => {
|
||||
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();
|
||||
act(() => {
|
||||
eventBus.publish(sessionMsg("a", "active"));
|
||||
eventBus.publish(sessionMsg("b", "active"));
|
||||
});
|
||||
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.
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -190,7 +206,7 @@ describe("Sagi widget", () => {
|
||||
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();
|
||||
const btn = screen.getByRole("button", { name: /open sagi companion/i });
|
||||
act(() => {
|
||||
@@ -198,7 +214,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 990, clientY: 700 });
|
||||
});
|
||||
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", () => {
|
||||
@@ -227,7 +243,7 @@ describe("Sagi widget", () => {
|
||||
fireEvent.pointerUp(btn, { clientX: 992, clientY: 701 });
|
||||
});
|
||||
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.
|
||||
expect(localStorage.getItem("agent-dashboard-sagi-pos")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sidebar.tsx
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot. It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @description The app's left sidebar: the brand, the navigation links and the workspace list, with Settings (on a workspace, that workspace's Settings tab — `lib/settingsLink.ts`) and the collapse/expand button pinned to its foot — in server mode with the signed-in account above them (its avatar, name and role, linking to Settings → Account, and Log out). It also owns the connection details modal (opened from the status bar), which carries the version and the update check — the sidebar itself has no footer, language or theme controls (those live in Settings → General / Appearance). The collapsed state is kept in localStorage; with none stored it starts collapsed below Tailwind's `lg`. Below `md` the Layout turns it into an off-canvas drawer (`drawer` prop): a modal dialog with the shared `useDialog` focus contract.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Gem,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Wifi,
|
||||
@@ -72,12 +73,13 @@ import {
|
||||
ChevronDown,
|
||||
FolderTree,
|
||||
LogOut,
|
||||
UsersRound,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { BRAND_LOGO, BRAND_MARK } from "../lib/brand";
|
||||
import { useAuth, usePermissions } from "./AuthGate";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { WorkspaceTree } from "./WorkspaceTree";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
@@ -91,6 +93,8 @@ const NAV_KEYS = [
|
||||
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
|
||||
// The file manager (FilesPage): browse, upload and download workspace files.
|
||||
{ to: "/files", icon: HardDrive, key: "nav:files" },
|
||||
// What agents produced, and (server mode) the Hub others shared (Artifacts.tsx).
|
||||
{ to: "/artifacts", icon: Gem, key: "nav:artifacts" },
|
||||
] as const;
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
@@ -197,7 +201,8 @@ export function Sidebar({
|
||||
location.pathname.startsWith("/settings") ||
|
||||
new URLSearchParams(location.search).has("settings");
|
||||
// Checking for (and applying) an update acts on the host: an admin's.
|
||||
const canUpdate = usePermissions().isAdmin;
|
||||
const perms = usePermissions();
|
||||
const canUpdate = perms.isAdmin;
|
||||
const asideRef = useRef<HTMLElement>(null);
|
||||
const drawerOpen = Boolean(drawer?.open);
|
||||
useDialog(drawerOpen, asideRef, drawer?.onClose);
|
||||
@@ -472,12 +477,13 @@ export function Sidebar({
|
||||
<div className="flex-1 min-h-0 relative flex">
|
||||
<nav ref={navRef} className="flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 space-y-px">
|
||||
{[
|
||||
...NAV_KEYS,
|
||||
// Server mode: the Users page, for an admin or analyst.
|
||||
// The file manager serves a shared server; locally it is the desktop's.
|
||||
...NAV_KEYS.filter((n) => !(perms.local && n.to === "/files")),
|
||||
// Server mode: Administration (users, server), for an admin or analyst.
|
||||
...(auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
auth.state.user.role !== "developer"
|
||||
? [{ to: "/users", icon: UsersRound, key: "nav:users" }]
|
||||
? [{ to: "/admin", icon: ShieldCheck, key: "nav:admin" }]
|
||||
: []),
|
||||
].map(({ to, icon: Icon, key }) => {
|
||||
const label = t(key);
|
||||
@@ -535,6 +541,59 @@ export function Sidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server mode: who is signed in — avatar, name and role, opening
|
||||
Settings → Account — and Log out. On the rail they stack. */}
|
||||
{auth.state.mode === "server" &&
|
||||
auth.state.user &&
|
||||
(() => {
|
||||
const user = auth.state.user;
|
||||
const name = user.displayName || user.username;
|
||||
const role = t(`users:roles.${user.role}`);
|
||||
const signedIn = t("common:auth.signedInAs", { name, role });
|
||||
return (
|
||||
<div
|
||||
data-testid="sidebar-account"
|
||||
className={`flex flex-shrink-0 gap-1 border-t border-border px-2 py-2 ${
|
||||
collapsed ? "flex-col items-center" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<NavLink
|
||||
to={settingsHref("account", location)}
|
||||
title={signedIn}
|
||||
aria-label={signedIn}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-md transition-colors hover:bg-surface-3 ${
|
||||
collapsed ? "justify-center p-1" : "flex-1 px-1.5 py-1"
|
||||
}`}
|
||||
>
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={user.avatarAt}
|
||||
className={collapsed ? "h-7 w-7 text-[11px]" : "h-8 w-8 text-xs"}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="min-w-0 leading-tight">
|
||||
<span className="block truncate text-sm font-medium text-fg-primary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-fg-muted">{role}</span>
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${signedIn} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "w-full py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Settings and the collapse toggle stick to the foot, out of the
|
||||
scrolling nav. On the rail they stack. */}
|
||||
<div
|
||||
@@ -550,22 +609,6 @@ export function Sidebar({
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{t("nav:settings")}</span>}
|
||||
</NavLink>
|
||||
{auth.state.mode === "server" && auth.state.user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void auth.logout()}
|
||||
title={`${t("common:auth.signedInAs", {
|
||||
name: auth.state.user.displayName || auth.state.user.username,
|
||||
role: auth.state.user.role,
|
||||
})} — ${t("common:auth.logout")}`}
|
||||
aria-label={t("common:auth.logout")}
|
||||
className={`inline-flex items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary ${
|
||||
collapsed ? "py-2" : "h-8 w-8 flex-shrink-0"
|
||||
}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
* 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
|
||||
* 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
|
||||
* whenever the URL names another workspace or session. Each figure carries its
|
||||
* `LIVE_RUNS_EVENT`), all re-read at once whenever the URL names another
|
||||
* 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
|
||||
* 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 —
|
||||
@@ -31,9 +35,15 @@ import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
|
||||
import { AiToolIcon } from "./AiToolIcon";
|
||||
import { useLiveRefresh } from "../hooks/useLiveRefresh";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { sessionChangeMatcher } from "../lib/sessionChange";
|
||||
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;
|
||||
/** …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_TOOL_LABEL: Record<ToolQuota["id"], string> = {
|
||||
claude: "Claude",
|
||||
@@ -384,51 +394,57 @@ export function StatusBar({
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
useEffect(loadCounts, [loadCounts]);
|
||||
useLiveRefresh(loadCounts, {
|
||||
intervalMs: 15_000,
|
||||
match: (msg) => msg.type === "session_updated" || msg.type === "session_created",
|
||||
});
|
||||
// Only a frame that moves a session between the counted states (a new
|
||||
// session, a status / waiting / archived change) re-reads: a session row is
|
||||
// re-broadcast on almost every hook.
|
||||
const [countsChanged] = useState(() => sessionChangeMatcher());
|
||||
useLiveRefresh(loadCounts, { intervalMs: COUNTS_FALLBACK_MS, match: countsChanged });
|
||||
|
||||
// Bumped on every lane/session switch: an answer for the previous one is dropped.
|
||||
const costGen = useRef(0);
|
||||
const loadCosts = useCallback(() => {
|
||||
const gen = costGen.current;
|
||||
const live = () => gen === costGen.current;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live() && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live()) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live() && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live() && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, [lane, sessionId]);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
costGen.current++;
|
||||
// Another workspace or session: never show the previous one's figure.
|
||||
setLaneCost(null);
|
||||
setSessionCost(null);
|
||||
const tick = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
api.pricing
|
||||
.totalCost({ date: localDate() })
|
||||
.then((r) => live && setToday(r))
|
||||
.catch(() => undefined);
|
||||
api.budgets
|
||||
.status()
|
||||
.then(({ budgets }) => {
|
||||
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
|
||||
if (live) setBudget(hit[0] ?? null);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
if (lane !== null) {
|
||||
api.pricing
|
||||
.totalCost({ lane })
|
||||
.then((r) => live && setLaneCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (sessionId) {
|
||||
api.pricing
|
||||
.sessionCost(sessionId)
|
||||
.then((r) => live && setSessionCost(r))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, COST_POLL_MS);
|
||||
document.addEventListener("visibilitychange", tick);
|
||||
if (document.visibilityState === "visible") loadCosts();
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", tick);
|
||||
costGen.current++;
|
||||
};
|
||||
}, [lane, sessionId]);
|
||||
}, [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 (
|
||||
<footer
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
* — 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
|
||||
* 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
|
||||
* for this page load only (never while applying); "Skip this version" is the
|
||||
* one persistent, per-version dismiss.
|
||||
* background process can do for it. Escape, the backdrop and × close it for
|
||||
* this page load only (never while applying); "Skip this version" is the one
|
||||
* 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
|
||||
* - Initial fetch via `api.updates.status()` on mount.
|
||||
@@ -81,7 +85,18 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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 { eventBus } from "../lib/eventBus";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -89,6 +104,7 @@ import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { usePermissions } from "./AuthGate";
|
||||
import { startInFreeTab } from "./lanes/startSession";
|
||||
|
||||
/** `localStorage` key storing the dismissed release tag (or upstream 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}. */
|
||||
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
|
||||
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 [status, setStatus] = useState<UpdateStatusPayload | null>(null);
|
||||
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".
|
||||
const [closedKey, setClosedKey] = useState<string | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
@@ -139,6 +181,8 @@ export function UpdateNotifier() {
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [fixing, setFixing] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
|
||||
setStatus(s);
|
||||
@@ -200,7 +244,7 @@ export function UpdateNotifier() {
|
||||
dismissedSha !== dismissKey &&
|
||||
closedKey !== dismissKey
|
||||
);
|
||||
const busy = applying || restarting;
|
||||
const busy = applying || restarting || fixing;
|
||||
|
||||
const closeForNow = useCallback(() => {
|
||||
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;
|
||||
|
||||
const refLabel = status.remote_ref || "origin";
|
||||
@@ -493,16 +560,9 @@ export function UpdateNotifier() {
|
||||
title={t("skipVersionHint")}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5" aria-hidden />
|
||||
{t("skipVersion")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeForNow}
|
||||
disabled={busy}
|
||||
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t("later")}
|
||||
</button>
|
||||
{status.manual_command ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -514,11 +574,28 @@ export function UpdateNotifier() {
|
||||
{copied ? t("copied") : t("copy")}
|
||||
</button>
|
||||
) : 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) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyNow}
|
||||
disabled={applying || restarting}
|
||||
disabled={busy}
|
||||
className="btn-primary disabled:opacity-70"
|
||||
>
|
||||
{applying || restarting ? (
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
* Workspace page's own poll (`LIVE_RUNS_EVENT`). Owns its own Add-workspace and
|
||||
* destructive modals (lazy-loaded on first open) so the row menu works from any page. Which rows are
|
||||
* expanded lives in `projectTreePrefs.ts` (`expanded`). Server mode, admin or
|
||||
* analyst: EVERY workspace, grouped under its owner (`owner_display_name`,
|
||||
* else `owner_name`; yours
|
||||
* analyst: EVERY workspace, grouped under its owner (their avatar —
|
||||
* `owner_avatar_at`, `UserAvatar` — and `owner_display_name`, else `owner_name`; yours
|
||||
* first, no owner last, groups foldable — `collapsedOwners`); one this user
|
||||
* may not change (`canEditLane`) is read only — no menu, no new session, and a
|
||||
* session opens its analysis page (`/sessions/<id>`), never a pane.
|
||||
@@ -49,6 +49,7 @@ import { useProjectTreePrefs, writeProjectTreePrefs } from "../lib/projectTreePr
|
||||
import { LANE_DRAG_TYPE, WorkspaceRow, isLaneDrag, workspaceName } from "./lanes/WorkspaceRow";
|
||||
import { WorkspaceSessionList } from "./lanes/WorkspaceSessionList";
|
||||
import { WorkspaceRowMenu, type WorkspaceMenuAction } from "./lanes/WorkspaceRowMenu";
|
||||
import { UserAvatar } from "./users/UserAvatar";
|
||||
|
||||
// The sidebar is in the entry chunk; its two modals (and the run form the add
|
||||
// modal pulls in) load when first opened.
|
||||
@@ -366,12 +367,14 @@ export function WorkspaceTree() {
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-surface-4 text-[9px] font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{ownerShown(lane)[0] ?? "?"}
|
||||
</span>
|
||||
<UserAvatar
|
||||
name={ownerShown(lane) || "?"}
|
||||
userId={lane.owner_id ?? undefined}
|
||||
avatarAt={lane.owner_avatar_at}
|
||||
letters={1}
|
||||
className="h-4 w-4 text-[9px]"
|
||||
initialsClassName="bg-surface-4 text-fg-secondary"
|
||||
/>
|
||||
<span className="truncate">{ownerLabel}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">
|
||||
{sorted.filter((l) => ownerKey(l) === owner).length}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @file Sidebar.account.test.tsx
|
||||
* @description The sidebar's account row (server mode): the signed-in user's
|
||||
* avatar — or initials without one — name and role, linking to Settings →
|
||||
* Account, beside Sign out; local mode shows no row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
state: { mode: "local" } as Record<string, unknown>,
|
||||
logout: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("../AuthGate", () => ({
|
||||
useAuth: () => auth,
|
||||
usePermissions: () => ({
|
||||
isAdmin: auth.state.mode !== "server",
|
||||
local: auth.state.mode !== "server",
|
||||
seesAll: true,
|
||||
canEditLane: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { Sidebar } from "../Sidebar";
|
||||
|
||||
const renderSidebar = (collapsed = false) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<Sidebar
|
||||
wsConnected
|
||||
collapsed={collapsed}
|
||||
onToggle={() => {}}
|
||||
connectionOpen={false}
|
||||
onConnectionOpenChange={() => {}}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
describe("Sidebar account row", () => {
|
||||
it("shows the avatar, name and role, linking to Settings → Account", () => {
|
||||
auth.state = {
|
||||
mode: "server",
|
||||
user: { id: 2, username: "mia", role: "developer", displayName: "Mia Tran", avatarAt: "t1" },
|
||||
};
|
||||
renderSidebar();
|
||||
const row = screen.getByTestId("sidebar-account");
|
||||
expect(row.textContent).toContain("Mia Tran");
|
||||
expect(row.textContent).toContain("Developer");
|
||||
expect(within(row).getByTestId("user-avatar-img").getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=t1"
|
||||
);
|
||||
expect(within(row).getAllByRole("link")[0]?.getAttribute("href")).toBe("/settings/account");
|
||||
expect(within(row).getByRole("button", { name: "Sign out" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to initials, and keeps only the avatar on the rail", () => {
|
||||
auth.state = { mode: "server", user: { id: 3, username: "leo", role: "admin" } };
|
||||
renderSidebar(true);
|
||||
const row = screen.getByTestId("sidebar-account");
|
||||
expect(within(row).getByTestId("user-avatar-initials").textContent).toBe("le");
|
||||
expect(row.textContent).not.toContain("Admin");
|
||||
});
|
||||
|
||||
it("has no account row in local mode", () => {
|
||||
auth.state = { mode: "local" };
|
||||
renderSidebar();
|
||||
expect(screen.queryByTestId("sidebar-account")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,8 @@ describe("Sidebar", () => {
|
||||
expect(hrefs).toContain("/sessions");
|
||||
expect(hrefs).toContain("/settings");
|
||||
expect(hrefs).not.toContain("/activity");
|
||||
// The file manager serves a shared server; a local dashboard has none.
|
||||
expect(hrefs).not.toContain("/files");
|
||||
});
|
||||
|
||||
describe("update status line, in the connection details modal", () => {
|
||||
|
||||
@@ -19,6 +19,18 @@ import type { UpdateStatusPayload } from "../../lib/types";
|
||||
const statusMock = vi.fn();
|
||||
const checkMock = 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) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -30,6 +42,7 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
check: (...args: unknown[]) => checkMock(...args),
|
||||
apply: (...args: unknown[]) => applyMock(...args),
|
||||
},
|
||||
lanes: { ensure: (...args: unknown[]) => ensureMock(...args) },
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -56,6 +69,9 @@ beforeEach(() => {
|
||||
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
applyMock.mockReset();
|
||||
ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true });
|
||||
startMock.mockReset().mockResolvedValue("agenthub-lane-7-1");
|
||||
navigateMock.mockReset();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
@@ -274,7 +290,7 @@ describe("UpdateNotifier — closing vs skipping", () => {
|
||||
|
||||
it("Check for updates re-opens a modal closed with Later", async () => {
|
||||
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(() =>
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file WorkspaceTree.owners.test.tsx
|
||||
* @description Server mode, the sidebar workspace list for an analyst: every
|
||||
* user's workspaces, grouped under their owner (yours first, no owner last),
|
||||
* user's workspaces, grouped under their owner (with their avatar, else an
|
||||
* initial; yours first, no owner last),
|
||||
* groups folding and remembered; another user's workspace is read only — no
|
||||
* menu, no new session, and a session opens its analysis page, never a pane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -30,7 +31,11 @@ const lane = (id: number, owner: number | null, name: string | null, title: stri
|
||||
}) as unknown as Lane;
|
||||
|
||||
const lanes = [
|
||||
{ ...lane(1, 3, "bob", "Bob app"), owner_display_name: "Bob Nguyen" } as Lane,
|
||||
{
|
||||
...lane(1, 3, "bob", "Bob app"),
|
||||
owner_display_name: "Bob Nguyen",
|
||||
owner_avatar_at: "t1",
|
||||
} as Lane,
|
||||
lane(2, null, null, "Old"),
|
||||
lane(3, ME.id, "ana", "Mine"),
|
||||
lane(4, 1, "admin", "Admin site"),
|
||||
@@ -61,6 +66,11 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
})),
|
||||
},
|
||||
run: { list: vi.fn(async () => ({ items: [] })) },
|
||||
users: {
|
||||
avatarUrl: (id: number, at?: string | null) =>
|
||||
at ? `/api/users/${id}/avatar?v=${at}` : null,
|
||||
},
|
||||
me: { avatarUrl: () => null },
|
||||
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
||||
},
|
||||
};
|
||||
@@ -118,6 +128,15 @@ describe("WorkspaceTree for an analyst", () => {
|
||||
await screen.findByTestId("workspace-row-1");
|
||||
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
||||
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
||||
// Bob set a picture: shown beside the name; Ana did not: her initial.
|
||||
expect(
|
||||
within(screen.getByTestId("workspace-owner-3"))
|
||||
.getByTestId("user-avatar-img")
|
||||
.getAttribute("src")
|
||||
).toBe("/api/users/3/avatar?v=t1");
|
||||
expect(
|
||||
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
|
||||
).toHaveTextContent("a");
|
||||
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
||||
});
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("WorkspaceTree", () => {
|
||||
let menu = screen.getByTestId("session-menu-s-old");
|
||||
// Ended: no pane to split or close.
|
||||
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"));
|
||||
expect(screen.queryByTestId("session-menu-s-old")).toBeNull();
|
||||
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"));
|
||||
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 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 () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
/**
|
||||
* @file ArtifactAddPanels.tsx
|
||||
* @description The Artifacts page's Add dialogs for a skill, a plugin and an
|
||||
* uploaded file (server/lib/artifacts.js):
|
||||
* - `SkillAddPanel` — three ways to make a skill: **Upload** (one Markdown
|
||||
* file that becomes SKILL.md, several files, a whole folder — picked or
|
||||
* dropped — or a .zip/.skill; an upload holding several skills makes one
|
||||
* artifact each), **Write** (name, description, instructions) and
|
||||
* **Existing** (a folder in the user's ~/.claude/skills or a workspace's
|
||||
* .claude/skills). The user names the skill; the name is previewed as the
|
||||
* folder and `/command` it installs as (`skillSlug`, the server's rule).
|
||||
* - `PluginAddPanel` — paste a marketplace link (a git https link, a forge
|
||||
* web link, `owner/repo` or a marketplace.json link), read its plugin list
|
||||
* 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.
|
||||
* - `FileUploadPanel` — one file from the user's computer (≤ 25 MB).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileText, FolderOpen, Package, Sparkles, Upload, X } from "lucide-react";
|
||||
import { api, type MarketplaceInfo } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
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 label = "block text-[11px] font-medium text-fg-secondary";
|
||||
const hint = "text-[11px] text-fg-muted";
|
||||
|
||||
/** The server's skill folder rule (server/lib/artifacts.js `skillSlug`). */
|
||||
export function skillSlug(text: string): string {
|
||||
return text
|
||||
.normalize("NFD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.replace(/đ/g, "d")
|
||||
.replace(/Đ/g, "D")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/^[-._]+|-+$/g, "")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/** `field:` of a Markdown file's frontmatter, or "". */
|
||||
export function frontmatterField(text: string, name: string): string {
|
||||
const fm = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
||||
if (!fm) return "";
|
||||
const m = new RegExp(`^${name}:\\s*(.*)$`, "m").exec(fm[1] ?? "");
|
||||
return (m?.[1] ?? "").trim().replace(/^(["'])(.*)\1$/, "$2");
|
||||
}
|
||||
|
||||
type Picked = { file: File; path: string };
|
||||
|
||||
/** A small file's text ("" on failure), only to prefill the name/description. */
|
||||
function readText(file: File): Promise<string> {
|
||||
if (file.size > 1024 * 1024) return Promise.resolve("");
|
||||
return new Promise((ok) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => ok(typeof r.result === "string" ? r.result : "");
|
||||
r.onerror = () => ok("");
|
||||
r.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
const JUNK = /(^|\/)(__MACOSX|\.DS_Store|Thumbs\.db|desktop\.ini|\.git|node_modules)(\/|$)/;
|
||||
const isSkillMd = (p: string) => /(^|\/)skill\.md$/i.test(p);
|
||||
const isArchive = (p: string) => /\.(zip|skill)$/i.test(p);
|
||||
const isText = (p: string) => /\.(md|markdown|txt)$/i.test(p);
|
||||
|
||||
/** Every file under what was dropped, folders walked (webkitGetAsEntry). */
|
||||
async function droppedFiles(e: DragEvent): Promise<Picked[]> {
|
||||
const items = Array.from(e.dataTransfer.items || []);
|
||||
const entries = items
|
||||
.map((i) => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null))
|
||||
.filter((x): x is FileSystemEntry => !!x);
|
||||
if (!entries.length) {
|
||||
return Array.from(e.dataTransfer.files).map((file) => ({ file, path: file.name }));
|
||||
}
|
||||
const out: Picked[] = [];
|
||||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((ok, fail) =>
|
||||
(entry as FileSystemFileEntry).file(ok, fail)
|
||||
);
|
||||
out.push({ file, path: prefix + entry.name });
|
||||
return;
|
||||
}
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
// readEntries answers in batches until an empty one.
|
||||
for (;;) {
|
||||
const batch = await new Promise<FileSystemEntry[]>((ok, fail) =>
|
||||
reader.readEntries(ok, fail)
|
||||
);
|
||||
if (!batch.length) break;
|
||||
for (const child of batch) await walk(child, `${prefix}${entry.name}/`);
|
||||
}
|
||||
};
|
||||
for (const entry of entries) await walk(entry, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What an upload will make, guessed the way the server decides it. */
|
||||
function describeUpload(files: Picked[]): {
|
||||
kind: "none" | "single-md" | "archive" | "one" | "many" | "no-skill-md";
|
||||
count: number;
|
||||
folder: string;
|
||||
} {
|
||||
const real = files.filter((f) => !JUNK.test(f.path));
|
||||
if (!real.length) return { kind: "none", count: 0, folder: "" };
|
||||
if (real.length === 1 && isArchive(real[0]!.path))
|
||||
return { kind: "archive", count: 0, folder: "" };
|
||||
const roots = [
|
||||
...new Set(
|
||||
real.filter((f) => isSkillMd(f.path)).map((f) => f.path.split("/").slice(0, -1).join("/"))
|
||||
),
|
||||
].sort((a, b) => a.length - b.length);
|
||||
const under = (dir: string, p: string) => dir === "" || p === dir || p.startsWith(`${dir}/`);
|
||||
const top = roots.filter((r, i) => !roots.slice(0, i).some((o) => under(o, r)));
|
||||
if (!top.length) {
|
||||
return real.length === 1 && isText(real[0]!.path)
|
||||
? { kind: "single-md", count: 1, folder: "" }
|
||||
: { kind: "no-skill-md", count: 0, folder: "" };
|
||||
}
|
||||
if (top.length === 1) return { kind: "one", count: 1, folder: top[0]!.split("/").pop() ?? "" };
|
||||
return { kind: "many", count: top.length, folder: "" };
|
||||
}
|
||||
|
||||
function Tabs<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: { id: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex rounded border border-border bg-surface-2 p-0.5">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === o.id}
|
||||
onClick={() => onChange(o.id)}
|
||||
className={`flex-1 rounded px-2 py-1 text-xs ${
|
||||
value === o.id
|
||||
? "bg-accent-solid text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The name field with the folder/command it becomes. */
|
||||
function SkillNameField({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
required,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const slug = skillSlug(value);
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<label className={label}>
|
||||
{t("skill.name")}
|
||||
{required ? " *" : ""}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder="release-notes"
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{value.trim() && (
|
||||
<p className={hint} data-testid="skill-slug">
|
||||
{slug ? t("skill.slugHint", { slug }) : t("skill.slugBad")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Skill ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function SkillAddPanel({
|
||||
lanes,
|
||||
onAdded,
|
||||
onClose,
|
||||
}: {
|
||||
lanes: Lane[];
|
||||
onAdded: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [mode, setMode] = useState<"upload" | "write" | "existing">("upload");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [descTouched, setDescTouched] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [content, setContent] = useState("");
|
||||
const [files, setFiles] = useState<Picked[]>([]);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
// `webkitdirectory` is not in React's typings.
|
||||
useEffect(() => {
|
||||
folderInput.current?.setAttribute("webkitdirectory", "");
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "existing") return;
|
||||
api.artifacts
|
||||
.skills(laneId ?? undefined)
|
||||
.then((r) => setSkills(r.items))
|
||||
.catch(() => setSkills([]));
|
||||
}, [mode, laneId]);
|
||||
|
||||
const shape = useMemo(() => describeUpload(files), [files]);
|
||||
|
||||
// Fill the name and description from what was picked, unless typed.
|
||||
useEffect(() => {
|
||||
if (!files.length) return;
|
||||
const md =
|
||||
files.find((f) => isSkillMd(f.path) && !JUNK.test(f.path)) ??
|
||||
(shape.kind === "single-md" ? files.find((f) => !JUNK.test(f.path)) : undefined);
|
||||
const stem = (p: string) => (p.split("/").pop() ?? "").replace(/\.[^.]+$/, "");
|
||||
let live = true;
|
||||
void (async () => {
|
||||
const text = md ? await readText(md.file) : "";
|
||||
if (!live) return;
|
||||
const fmName = frontmatterField(text, "name");
|
||||
const fmDesc = frontmatterField(text, "description");
|
||||
if (!nameTouched) {
|
||||
const guess =
|
||||
shape.kind === "single-md"
|
||||
? fmName || stem(md?.path ?? "")
|
||||
: shape.kind === "one"
|
||||
? fmName || shape.folder
|
||||
: shape.kind === "archive"
|
||||
? stem(files[0]!.path)
|
||||
: "";
|
||||
setName(guess);
|
||||
}
|
||||
if (!descTouched && fmDesc) setDescription(fmDesc);
|
||||
})();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// Refill only when the pick changes, never on a typed name.
|
||||
}, [files, shape]);
|
||||
|
||||
const pick = (list: FileList | null) => {
|
||||
if (!list?.length) return;
|
||||
setFiles(
|
||||
Array.from(list).map((file) => ({
|
||||
file,
|
||||
path: (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<number>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const n = await fn();
|
||||
toast.success(n > 1 ? t("skill.addedMany", { count: n }) : t("added"));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const many = shape.kind === "many";
|
||||
const total = files.reduce((n, f) => n + f.file.size, 0);
|
||||
const meta = (
|
||||
<>
|
||||
<label className={label}>
|
||||
{t("skill.description")}
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
rows={2}
|
||||
value={description}
|
||||
disabled={many}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
onChange={(e) => {
|
||||
setDescTouched(true);
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={title}
|
||||
disabled={many}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ id: "upload", label: t("skill.tab.upload") },
|
||||
{ id: "write", label: t("skill.tab.write") },
|
||||
{ id: "existing", label: t("skill.tab.existing") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{mode === "upload" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
const r = await api.artifacts.uploadSkills(files, {
|
||||
name: many ? undefined : name.trim(),
|
||||
description: many ? undefined : description.trim(),
|
||||
title: many ? undefined : title.trim(),
|
||||
});
|
||||
return r.items.length;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="skill-drop"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
void droppedFiles(e).then((list) => list.length && setFiles(list));
|
||||
}}
|
||||
className={`rounded-lg border border-dashed p-4 text-center ${
|
||||
dragging ? "border-accent bg-accent/10" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<Upload className="mx-auto mb-1 h-5 w-5 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("skill.dropHint")}</p>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" /> {t("skill.pickFiles")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
onClick={() => folderInput.current?.click()}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" /> {t("skill.pickFolder")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="skill-files"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="skill-folder"
|
||||
onChange={(e) => {
|
||||
pick(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="rounded border border-border bg-surface-2 p-2">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<p className="flex-1 text-xs text-fg-primary" data-testid="skill-shape">
|
||||
{t(`skill.shape.${shape.kind}`, { count: shape.count })}
|
||||
</p>
|
||||
<span className={hint}>
|
||||
{t("files", { count: files.length })} · {Math.ceil(total / 1024)} KB
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("skill.clear")}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setNameTouched(false);
|
||||
setDescTouched(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<ul className="max-h-28 overflow-auto font-mono text-[11px] text-fg-secondary">
|
||||
{files.slice(0, 200).map((f) => (
|
||||
<li
|
||||
key={f.path}
|
||||
className={`truncate ${JUNK.test(f.path) ? "line-through opacity-50" : ""}`}
|
||||
>
|
||||
{f.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{many ? (
|
||||
<p className={hint}>{t("skill.manyHint")}</p>
|
||||
) : (
|
||||
<SkillNameField
|
||||
value={name}
|
||||
onChange={(v) => {
|
||||
setNameTouched(true);
|
||||
setName(v);
|
||||
}}
|
||||
required={shape.kind === "single-md"}
|
||||
/>
|
||||
)}
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={
|
||||
busy ||
|
||||
!files.length ||
|
||||
shape.kind === "no-skill-md" ||
|
||||
(shape.kind === "single-md" && !skillSlug(name))
|
||||
}
|
||||
>
|
||||
{busy ? t("skill.uploading") : t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === "write" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
await api.artifacts.writeSkill({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
content,
|
||||
title: title.trim() || undefined,
|
||||
});
|
||||
return 1;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SkillNameField value={name} onChange={setName} required />
|
||||
<label className={label}>
|
||||
{t("skill.description")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5`}
|
||||
rows={2}
|
||||
value={description}
|
||||
placeholder={t("skill.descriptionHint")}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("skill.content")} *
|
||||
<textarea
|
||||
className={`${field} mt-0.5 font-mono text-xs`}
|
||||
rows={10}
|
||||
value={content}
|
||||
placeholder={t("skill.contentHint")}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className={label}>
|
||||
{t("form.title")}
|
||||
<input
|
||||
className={`${field} mt-0.5`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={
|
||||
busy ||
|
||||
!skillSlug(name) ||
|
||||
!content.trim() ||
|
||||
(!description.trim() && !frontmatterField(content, "description"))
|
||||
}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{mode === "existing" && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={field}
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">{t("form.pickWorkspace")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{skills && skills.length === 0 && <p className={hint}>{t("form.noSkills")}</p>}
|
||||
{skills && skills.length > 0 && (
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await api.artifacts.addSkill({
|
||||
name: s.name,
|
||||
...(s.from === "workspace" && laneId ? { laneId } : {}),
|
||||
title: title || undefined,
|
||||
description: description || undefined,
|
||||
});
|
||||
return 1;
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={2}
|
||||
placeholder={t("form.description")}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Plugin ────────────────────────────────────────────────────────────────
|
||||
|
||||
const MANUAL_CODES = new Set(["ENEEDLOGIN", "ESSHMARKET", "ENOTMARKET", "EMARKETFETCH"]);
|
||||
|
||||
export function PluginAddPanel({ onAdded, onClose }: { onAdded: () => void; onClose: () => void }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [link, setLink] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [info, setInfo] = useState<MarketplaceInfo | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [have, setHave] = useState<Set<string>>(new Set());
|
||||
const [manual, setManual] = useState(false);
|
||||
const [key, setKey] = useState("");
|
||||
const [market, setMarket] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.list({ scope: "mine", kind: "plugin" })
|
||||
.then((r) => setHave(new Set(r.items.map((i) => i.source ?? ""))))
|
||||
.catch(() => setHave(new Set()));
|
||||
}, []);
|
||||
|
||||
const read = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const r = await api.artifacts.inspectMarket(link.trim());
|
||||
setInfo(r);
|
||||
setPicked(
|
||||
new Set(
|
||||
r.plugins
|
||||
.filter((p) => !have.has(p.key))
|
||||
.slice(0, 1)
|
||||
.map((p) => p.key)
|
||||
)
|
||||
);
|
||||
setMarket(r.market);
|
||||
} catch (err) {
|
||||
const e = err as Error & { code?: string };
|
||||
setError(e.message);
|
||||
if (MANUAL_CODES.has(e.code ?? "")) {
|
||||
setManual(true);
|
||||
setMarket(link.trim());
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addPicked = async () => {
|
||||
if (!info) return;
|
||||
setBusy(true);
|
||||
let n = 0;
|
||||
try {
|
||||
for (const p of info.plugins.filter((x) => picked.has(x.key))) {
|
||||
await api.artifacts.addPlugin({
|
||||
key: p.key,
|
||||
market: info.market,
|
||||
title: p.name,
|
||||
description: p.description,
|
||||
});
|
||||
n++;
|
||||
}
|
||||
toast.success(t("plugin.addedMany", { count: n }));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
if (n) onAdded();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (k: string) =>
|
||||
setPicked((s) => {
|
||||
const next = new Set(s);
|
||||
if (next.has(k)) next.delete(k);
|
||||
else next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
className="space-y-1"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (link.trim()) void read();
|
||||
}}
|
||||
>
|
||||
<label className={label}>
|
||||
{t("plugin.link")}
|
||||
<div className="mt-0.5 flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
value={link}
|
||||
autoFocus
|
||||
placeholder="https://git.example.com/org/claude-skills.git"
|
||||
onChange={(e) => setLink(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !link.trim()}
|
||||
>
|
||||
{busy && !info ? t("plugin.reading") : t("plugin.read")}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<p className={hint}>{t("plugin.linkHint")}</p>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded border border-status-danger/40 px-2 py-1 text-xs text-status-danger"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info && (
|
||||
<div className="space-y-2" data-testid="market-plugins">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-fg-primary">
|
||||
<Package className="mr-1 inline h-4 w-4 text-fg-muted" />
|
||||
{info.name}
|
||||
</p>
|
||||
{info.description && <p className={hint}>{info.description}</p>}
|
||||
</div>
|
||||
{info.plugins.length === 0 ? (
|
||||
<p className={hint}>{t("plugin.none")}</p>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex items-center gap-2 text-xs text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.size === info.plugins.length}
|
||||
onChange={(e) =>
|
||||
setPicked(new Set(e.target.checked ? info.plugins.map((p) => p.key) : []))
|
||||
}
|
||||
/>
|
||||
{t("plugin.all")}
|
||||
</label>
|
||||
<ul className="max-h-64 space-y-1 overflow-auto">
|
||||
{info.plugins.map((p) => (
|
||||
<li key={p.key}>
|
||||
<label className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 hover:bg-surface-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={picked.has(p.key)}
|
||||
onChange={() => toggle(p.key)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium text-fg-primary">{p.name}</span>
|
||||
<span className="ml-1.5 font-mono text-[10px] text-fg-muted">{p.key}</span>
|
||||
{p.version && (
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">v{p.version}</span>
|
||||
)}
|
||||
{have.has(p.key) && (
|
||||
<span className="ml-1.5 rounded bg-surface-3 px-1 text-[10px] text-fg-secondary">
|
||||
{t("plugin.have")}
|
||||
</span>
|
||||
)}
|
||||
{p.description && (
|
||||
<span className="block text-[11px] text-fg-muted">{p.description}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
disabled={busy || picked.size === 0}
|
||||
onClick={() => void addPicked()}
|
||||
>
|
||||
{t("plugin.addPicked", { count: picked.size })}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border-light pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-accent hover:underline"
|
||||
aria-expanded={manual}
|
||||
onClick={() => setManual((v) => !v)}
|
||||
>
|
||||
{t("plugin.manual")}
|
||||
</button>
|
||||
{manual && (
|
||||
<form
|
||||
className="mt-2 space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.artifacts.addPlugin({
|
||||
key: key.trim(),
|
||||
market: market.trim(),
|
||||
title,
|
||||
description,
|
||||
});
|
||||
toast.success(t("added"));
|
||||
onAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="my-plugin@my-marketplace"
|
||||
aria-label={t("form.pluginKey")}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="owner/repo · https://… · git@host:org/repo.git"
|
||||
aria-label={t("form.market")}
|
||||
value={market}
|
||||
onChange={(e) => setMarket(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={2}
|
||||
placeholder={t("form.description")}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={busy || !key.trim() || !market.trim()}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────
|
||||
|
||||
export function FileUploadPanel({ onAdded }: { onAdded: () => void }) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const tooBig = !!file && file.size > 25 * 1024 * 1024;
|
||||
return (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.artifacts.uploadFile(file, { title: title.trim() || undefined });
|
||||
toast.success(t("added"));
|
||||
setFile(null);
|
||||
setTitle("");
|
||||
onAdded();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className={label}>
|
||||
{t("file.fromComputer")}
|
||||
<input
|
||||
type="file"
|
||||
data-testid="file-upload"
|
||||
className="mt-0.5 block w-full text-xs text-fg-secondary"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
{tooBig && <p className="text-xs text-status-danger">{t("file.tooBig")}</p>}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary shrink-0 text-sm"
|
||||
disabled={busy || !file || tooBig}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" /> {t("file.upload")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
* `onOpenTerminal`) and hears how full the context is (`onContext`) and
|
||||
* whether its session has started (`onNotStarted`: no row yet — the CLI may
|
||||
* be asking in the terminal, e.g. to trust the folder, which this then says).
|
||||
* 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.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -79,6 +83,9 @@ import type { TranscriptMessage, TranscriptInfo, WSMessage } from "../../lib/typ
|
||||
// hook event. A short visibility-gated poll closes that gap and also rescues
|
||||
// the conversation from missed/late WebSocket frames.
|
||||
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
|
||||
// mid-session appear in the dropdown without a page reload.
|
||||
const TRANSCRIPTS_REFRESH_MS = 15000;
|
||||
@@ -110,6 +117,8 @@ interface ConversationViewProps {
|
||||
onContext?: (tokens: number, model: string | null) => void;
|
||||
/** A live pane's session has no row yet: the CLI has not started it. */
|
||||
onNotStarted?: (notStarted: boolean) => void;
|
||||
/** The session has ended (and no pane holds it): poll slowly, if at all. */
|
||||
ended?: boolean;
|
||||
}
|
||||
|
||||
function ConversationViewPane({
|
||||
@@ -124,6 +133,7 @@ function ConversationViewPane({
|
||||
stopped = 0,
|
||||
onContext,
|
||||
onNotStarted,
|
||||
ended = false,
|
||||
}: ConversationViewProps) {
|
||||
const { t } = useTranslation("sessions");
|
||||
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
|
||||
@@ -188,14 +198,18 @@ function ConversationViewPane({
|
||||
}
|
||||
}
|
||||
loadTranscripts();
|
||||
const interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) loadTranscripts();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
// 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();
|
||||
}, TRANSCRIPTS_REFRESH_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [sessionId]);
|
||||
}, [sessionId, ended]);
|
||||
|
||||
// Sync external initialTranscriptId to internal state
|
||||
useEffect(() => {
|
||||
@@ -355,9 +369,12 @@ function ConversationViewPane({
|
||||
let interval: number | null = null;
|
||||
function start() {
|
||||
if (interval !== null) return;
|
||||
interval = window.setInterval(() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
}, POLL_INTERVAL_MS);
|
||||
interval = window.setInterval(
|
||||
() => {
|
||||
if (isShown(onScreen)) fetchNewMessages();
|
||||
},
|
||||
ended ? ENDED_POLL_INTERVAL_MS : POLL_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
function stop() {
|
||||
if (interval !== null) {
|
||||
@@ -382,7 +399,7 @@ function ConversationViewPane({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [fetchNewMessages, onScreen]);
|
||||
}, [fetchNewMessages, onScreen, ended]);
|
||||
|
||||
// Manual refresh - surfaces a control in the toolbar so users can force
|
||||
// a sync without reloading the page.
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
* subset of CommonMark + GFM that actually appears in Claude Code transcripts:
|
||||
* fenced code blocks, ATX headings, ordered/unordered lists, task lists, blockquotes,
|
||||
* horizontal rules, simple tables, inline code, bold, italic, strikethrough, links,
|
||||
* and auto-linked URLs. An absolute file path (bare or in inline code) opens
|
||||
* through `OpenFileContext` when the page provides one; `linkify` does the
|
||||
* same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* and auto-linked URLs. A file path — absolute, or relative with a folder and
|
||||
* an extension (`server/lib/x.js`), with or without a `:line` suffix, bare, in
|
||||
* inline code or as a markdown link's target — opens through `OpenFileContext`
|
||||
* when the page provides one (the page resolves a relative one against its own
|
||||
* folder); `linkify` does the same links for plain text. To make a reply easy to scan, bold is coloured
|
||||
* (accent), standalone numbers (87%, 3.5s, v2.7.1, dates) are tinted and
|
||||
* tabular, and a flag label ("Lưu ý:", "Warning:", "Lỗi:") takes the colour
|
||||
* of how serious it is.
|
||||
@@ -82,7 +84,12 @@ const HR_RE = /^\s*(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const UL_RE = /^(\s*)([-*+])\s+(.*)$/;
|
||||
const OL_RE = /^(\s*)(\d+)\.\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[] {
|
||||
// Trim leading/trailing pipes, then split, respecting escaped pipes.
|
||||
@@ -169,7 +176,7 @@ function parseBlocks(src: string): Block[] {
|
||||
}
|
||||
|
||||
// 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 aligns = parseAlignments(lines[i + 1]!);
|
||||
i += 2;
|
||||
@@ -225,7 +232,8 @@ function parseBlocks(src: string): Block[] {
|
||||
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];
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
@@ -237,7 +245,8 @@ function parseBlocks(src: string): Block[] {
|
||||
HR_RE.test(nl) ||
|
||||
UL_RE.test(nl) ||
|
||||
OL_RE.test(nl) ||
|
||||
QUOTE_RE.test(nl)
|
||||
QUOTE_RE.test(nl) ||
|
||||
isTableStart(lines, i)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
@@ -256,7 +265,19 @@ const LINK_CLASS =
|
||||
"text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all";
|
||||
const URL_RE = /^https?:\/\/[^\s<>()]+[^\s<>().,!?;:'"]/;
|
||||
// Two segments at least, so "/" or "/s" stays text; a trailing full stop is prose.
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}/;
|
||||
// A `:12` or `:12:5` line suffix belongs to the link (Claude writes `path:line`).
|
||||
const PATH_RE = /^(?:\/[^\s/<>()'"`,;:]*[^\s/<>()'"`,;:.!?]){2,}(?::\d+(?::\d+)?)?/;
|
||||
// A relative path needs a folder AND an extension starting with a letter
|
||||
// (`docs/API.md`, `./a/b.ts:9`), so "and/or" and "3/4.5" stay text; a first segment with an inner dot is a host
|
||||
// (`github.com/x.js`), not a folder — a leading one (`.claude/…`) is a folder.
|
||||
const REL_PATH_RE =
|
||||
/^(?:\.{1,2}\/)*(?:\.?[\w@-][\w@-]*\/)(?:[\w.@-]+\/)*[\w.@-]*[\w@-]\.[A-Za-z][A-Za-z0-9]{0,9}(?::\d+(?::\d+)?)?(?![\p{L}\p{N}_/])/u;
|
||||
/** A path at the start of `text`, absolute or relative, or null. */
|
||||
const matchPath = (text: string) => PATH_RE.exec(text)?.[0] ?? REL_PATH_RE.exec(text)?.[0] ?? null;
|
||||
/** `a/b.ts:12:5` → `a/b.ts`: what is opened. */
|
||||
export const stripLine = (p: string) => p.replace(/(?::\d+){1,2}$/, "");
|
||||
// A markdown link target that is a page, not a file.
|
||||
const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
// A link starts a word: "and/or" or "a/b/c" stays text.
|
||||
const atWordStart = (text: string, i: number) => i === 0 || /[\s([{"'`]/.test(text[i - 1]!);
|
||||
// A number worth spotting: 42, 1,234.5, 87%, $1.20, 3.5s, 12MB, v2.7.1, 2026-10-12, 12:30,
|
||||
@@ -284,6 +305,20 @@ function UrlLink({ url }: { url: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A markdown link's file target as a path: `%20` decoded, `#L12` → `:12`. */
|
||||
function decodeHref(href: string): string {
|
||||
let p = href
|
||||
.replace(/^file:\/\//i, "")
|
||||
.replace(/#L(\d+)(?:-L?\d+)?$/, ":$1")
|
||||
.replace(/#.*$/, "");
|
||||
try {
|
||||
p = decodeURIComponent(p);
|
||||
} catch {
|
||||
/* a stray % stays as written */
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** A path as a button that opens it, or as it was when nothing can. */
|
||||
function PathLink({ path, children }: { path: string; children: React.ReactNode }) {
|
||||
const open = React.useContext(OpenFileContext);
|
||||
@@ -292,7 +327,7 @@ function PathLink({ path, children }: { path: string; children: React.ReactNode
|
||||
<button
|
||||
type="button"
|
||||
title={path}
|
||||
onClick={() => open(path)}
|
||||
onClick={() => open(stripLine(path))}
|
||||
className={`${LINK_CLASS} text-left`}
|
||||
>
|
||||
{children}
|
||||
@@ -307,7 +342,7 @@ export function linkify(text: string): React.ReactNode[] {
|
||||
for (let i = 0; i < text.length;) {
|
||||
const rest = text.slice(i);
|
||||
const url = atWordStart(text, i) && URL_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && PATH_RE.exec(rest)?.[0];
|
||||
const path = !url && atWordStart(text, i) && matchPath(rest);
|
||||
const hit = url || path;
|
||||
if (!hit) {
|
||||
buf += text[i++];
|
||||
@@ -358,13 +393,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
{codeM[1]}
|
||||
</code>
|
||||
);
|
||||
push(
|
||||
PATH_RE.exec(codeM[1]!)?.[0] === codeM[1] ? (
|
||||
<PathLink path={codeM[1]!}>{code}</PathLink>
|
||||
) : (
|
||||
code
|
||||
)
|
||||
);
|
||||
push(matchPath(codeM[1]!) === codeM[1] ? <PathLink path={codeM[1]!}>{code}</PathLink> : code);
|
||||
i += codeM[0].length;
|
||||
continue;
|
||||
}
|
||||
@@ -408,9 +437,21 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
// Markdown link
|
||||
const linkM = rest.match(/^\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/);
|
||||
if (linkM) {
|
||||
const href = linkM[2]!;
|
||||
// `[x.ts](src/x.ts#L12)` names a file: open it like a bare path.
|
||||
if (
|
||||
/^file:\/\//i.test(href) ||
|
||||
(!SCHEME_RE.test(href) && !href.startsWith("#") && !href.startsWith("//"))
|
||||
) {
|
||||
push(
|
||||
<PathLink path={decodeHref(href)}>{renderInline(linkM[1]!, `${baseKey}-l${n}`)}</PathLink>
|
||||
);
|
||||
i += linkM[0].length;
|
||||
continue;
|
||||
}
|
||||
push(
|
||||
<a
|
||||
href={linkM[2]!}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
@@ -431,10 +472,10 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
}
|
||||
|
||||
// An absolute path at a word start
|
||||
const pathM = atWordStart(text, i) ? PATH_RE.exec(rest) : null;
|
||||
const pathM = atWordStart(text, i) ? matchPath(rest) : null;
|
||||
if (pathM) {
|
||||
push(<PathLink path={pathM[0]}>{pathM[0]}</PathLink>);
|
||||
i += pathM[0].length;
|
||||
push(<PathLink path={pathM}>{pathM}</PathLink>);
|
||||
i += pathM.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,52 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent } from "../MarkdownContent";
|
||||
import { vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MarkdownContent, OpenFileContext } from "../MarkdownContent";
|
||||
import { resolveIn } from "../../../lib/resolvePath";
|
||||
|
||||
function withOpen(text: string) {
|
||||
const open = vi.fn();
|
||||
render(
|
||||
<OpenFileContext.Provider value={open}>
|
||||
<MarkdownContent text={text} />
|
||||
</OpenFileContext.Provider>
|
||||
);
|
||||
return open;
|
||||
}
|
||||
|
||||
describe("file links", () => {
|
||||
it("opens a relative path with its line suffix stripped", () => {
|
||||
const open = withOpen("See server/lib/x.js:42 for it.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "server/lib/x.js:42" }));
|
||||
expect(open).toHaveBeenCalledWith("server/lib/x.js");
|
||||
});
|
||||
|
||||
it("opens an absolute path with a line suffix and one in inline code", () => {
|
||||
const open = withOpen("At /a/b/c.ts:9 and `docs/API.md`.");
|
||||
fireEvent.click(screen.getByRole("button", { name: "/a/b/c.ts:9" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "docs/API.md" }));
|
||||
expect(open.mock.calls).toEqual([["/a/b/c.ts"], ["docs/API.md"]]);
|
||||
});
|
||||
|
||||
it("opens a markdown link to a file instead of navigating", () => {
|
||||
const open = withOpen("[the panel](client/src/A%20B.tsx#L12) and [web](https://x.dev)");
|
||||
fireEvent.click(screen.getByRole("button", { name: "the panel" }));
|
||||
expect(open).toHaveBeenCalledWith("client/src/A B.tsx");
|
||||
expect(screen.getByRole("link", { name: "web" })).toHaveAttribute("href", "https://x.dev");
|
||||
});
|
||||
|
||||
it("leaves prose with slashes as text", () => {
|
||||
withOpen("and/or, 3/4.5, github.com/x.js, TCP/IP.");
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("resolves a relative path against the pane's folder", () => {
|
||||
expect(resolveIn("/w/repo/", "./server/../docs/API.md")).toBe("/w/repo/docs/API.md");
|
||||
expect(resolveIn("/w/repo", "/etc/x.conf")).toBe("/etc/x.conf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MarkdownContent />", () => {
|
||||
it("renders fenced code blocks with the language label", () => {
|
||||
@@ -109,4 +153,18 @@ describe("<MarkdownContent />", () => {
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). The user also picks which CLI drives the lane
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* Settings → Git). There is no AI tool picker: the lane is created with the
|
||||
* default AI tool (Settings → AI Tools), and each new session picks its own
|
||||
* AI on the workspace home. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
* opened by the server, the in-app `FolderBrowseModal` where it cannot be) —
|
||||
@@ -24,7 +25,7 @@
|
||||
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, X } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { CwdAutocomplete } from "../run/RunSetup";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
@@ -32,7 +33,6 @@ import { api } from "../../lib/api";
|
||||
import type { CwdSuggestion } from "../../lib/api";
|
||||
import type { Lane, LaneWorktree } from "../../lib/types";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { readDefaultTool } from "../../lib/sessionDefaults";
|
||||
|
||||
/** Polls the lane until the background `git worktree add` finishes (status
|
||||
@@ -135,27 +135,18 @@ export function AddLaneModal({
|
||||
const [branchMode, setBranchMode] = useState<"new" | "existing">("new");
|
||||
const [base, setBase] = useState("");
|
||||
const [branchesError, setBranchesError] = useState<string | null>(null);
|
||||
// null = the default AI tool (Settings → AI Tools), resolved at submit time.
|
||||
const [pickedTool, setPickedTool] = useState<string | null>(null);
|
||||
// No picker: a workspace's tool is only its DEFAULT — each new session
|
||||
// picks its own AI on the workspace home. This is the default AI tool
|
||||
// (Settings → AI Tools), the account's own pick first while it is
|
||||
// installed and on.
|
||||
const aiTools = useAiTools();
|
||||
// The account's own pick (first-run setup), while it is installed and on.
|
||||
const ownTool = readDefaultTool();
|
||||
const defaultTool =
|
||||
const tool =
|
||||
(ownTool && aiTools.tools.some((x) => x.id === ownTool && x.installed && x.enabled)
|
||||
? ownTool
|
||||
: null) ??
|
||||
aiTools.defaultId ??
|
||||
"claude";
|
||||
const tool = pickedTool ?? defaultTool;
|
||||
// Installed + enabled, the default first.
|
||||
const toolChoices = aiTools.tools
|
||||
.filter((x) => x.installed && x.enabled)
|
||||
.sort((a, b) => Number(b.id === defaultTool) - Number(a.id === defaultTool));
|
||||
const [toolListOpen, setToolListOpen] = useState(false);
|
||||
const pickTool = (id: string) => {
|
||||
setPickedTool(id === defaultTool ? null : id);
|
||||
setToolListOpen(false);
|
||||
};
|
||||
const noTools = aiTools.loaded && aiTools.defaultId === null;
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -177,7 +168,6 @@ export function AddLaneModal({
|
||||
setWorktrees([]);
|
||||
setBranchMode("new");
|
||||
setBase("");
|
||||
setPickedTool(null);
|
||||
setBranchesError(null);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
@@ -501,64 +491,14 @@ export function AddLaneModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={toolListOpen}
|
||||
disabled={toolChoices.length < 2}
|
||||
onClick={() => setToolListOpen((o) => !o)}
|
||||
className="flex w-full items-center gap-2 rounded-md border border-border bg-surface-2 px-2.5 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-3 disabled:cursor-default disabled:hover:bg-surface-2"
|
||||
>
|
||||
<AiToolIcon tool={{ id: tool, label: aiTools.label(tool) }} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{aiTools.label(tool)}</span>
|
||||
{tool === defaultTool && (
|
||||
<span className="rounded-full bg-accent/15 px-2 py-0.5 text-[10px] font-medium text-accent">
|
||||
{t("addLaneToolDefaultBadge")}
|
||||
</span>
|
||||
)}
|
||||
{toolChoices.length > 1 && (
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={`h-4 w-4 text-fg-muted transition-transform ${toolListOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{toolListOpen && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("addLaneToolHint")}
|
||||
className="mt-1.5 grid grid-cols-2 gap-1.5 sm:grid-cols-3"
|
||||
>
|
||||
{toolChoices.map((x) => (
|
||||
<button
|
||||
key={x.id}
|
||||
type="button"
|
||||
aria-pressed={x.id === tool}
|
||||
onClick={() => pickTool(x.id)}
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1.5 text-left text-xs transition-colors ${
|
||||
x.id === tool
|
||||
? "border-accent bg-accent/10 text-accent"
|
||||
: "border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<AiToolIcon tool={x} className="h-5 w-5" />
|
||||
<span className="min-w-0 flex-1 truncate">{x.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{noTools && (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
<a href="/settings/agents" className="underline">
|
||||
{t("addLaneNoToolsLink")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @file CloneSourcesForm — a workspace's "Or clone repositories" box, under
|
||||
* its Folders card: one URL per line, each cloned BESIDE the workspace folder
|
||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
||||
* added as an extra folder, one after another. A host that wants a login
|
||||
* (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed
|
||||
* URLs with it, like Add workspace → Clone.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Loader2, X } from "lucide-react";
|
||||
import { api, type LaneSourcesView } from "../../lib/api";
|
||||
|
||||
type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string };
|
||||
type Login = { host: string; username: string; token: string; remember: boolean };
|
||||
|
||||
export function CloneSourcesForm({
|
||||
laneId,
|
||||
parent,
|
||||
onAdded,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Where the clones land: the workspace folder's parent, shown only. */
|
||||
parent: string;
|
||||
onAdded: (view: LaneSourcesView) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [text, setText] = useState("");
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [login, setLogin] = useState<Login | null>(null);
|
||||
|
||||
const urls = (s: string) => [
|
||||
...new Set(
|
||||
s
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
),
|
||||
];
|
||||
|
||||
const cloneAll = async (list: string[]) => {
|
||||
if (busy || !list.length) return;
|
||||
setBusy(true);
|
||||
const next: Row[] = list.map((url) => ({ url, state: "wait" }));
|
||||
const show = () => setRows([...next]);
|
||||
show();
|
||||
let asked: Login | null = null;
|
||||
for (const row of next) {
|
||||
row.state = "busy";
|
||||
show();
|
||||
try {
|
||||
const view = await api.lanes.cloneFolder(laneId, {
|
||||
url: row.url,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
row.state = "done";
|
||||
onAdded(view);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || row.url;
|
||||
row.state = "failed";
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
row.note = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
|
||||
asked ??= {
|
||||
host,
|
||||
username: login?.username ?? "",
|
||||
token: "",
|
||||
remember: login?.remember ?? true,
|
||||
};
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
row.note = t("clone.sshRefused", { host });
|
||||
} else {
|
||||
row.note = e.message || String(err);
|
||||
}
|
||||
}
|
||||
show();
|
||||
}
|
||||
if (asked) setLogin(asked);
|
||||
// What is left to do stays in the box; what was added leaves it.
|
||||
setText(
|
||||
next
|
||||
.filter((r) => r.state !== "done")
|
||||
.map((r) => r.url)
|
||||
.join("\n")
|
||||
);
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
const failed = rows.filter((r) => r.state === "failed").map((r) => r.url);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||
<textarea
|
||||
data-testid="workspace-clone-input"
|
||||
aria-label={t("home.cloneFolders")}
|
||||
placeholder={t("home.clonePlaceholder")}
|
||||
rows={3}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||
{login && (
|
||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 text-[11px] text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
{failed.length > 0 && !busy && (
|
||||
<button type="button" onClick={() => void cloneAll(failed)} className="btn-ghost text-xs">
|
||||
{t("home.cloneRetry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="workspace-clone-run"
|
||||
disabled={busy || !urls(text).length}
|
||||
onClick={() => void cloneAll(urls(text))}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
{busy ? t("clone.cloning") : t("home.cloneRun")}
|
||||
</button>
|
||||
</div>
|
||||
{rows.length > 0 && (
|
||||
<ul className="space-y-1" data-testid="workspace-clone-rows">
|
||||
{rows.map((r) => (
|
||||
<li key={r.url} className="text-[11px]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{r.state === "busy" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-fg-muted" />
|
||||
) : r.state === "done" ? (
|
||||
<Check
|
||||
className="h-3 w-3 shrink-0 text-status-success"
|
||||
aria-label={t("home.cloneDone")}
|
||||
/>
|
||||
) : r.state === "failed" ? (
|
||||
<X className="h-3 w-3 shrink-0 text-status-danger" />
|
||||
) : (
|
||||
<span className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-mono text-fg-secondary" title={r.url}>
|
||||
{r.url}
|
||||
</span>
|
||||
</span>
|
||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -82,6 +87,7 @@ import {
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
@@ -96,6 +102,11 @@ type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
||||
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
||||
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. */
|
||||
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. */
|
||||
@@ -250,6 +261,10 @@ export function FileBrowser({
|
||||
const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1");
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
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 [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null);
|
||||
const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null);
|
||||
@@ -269,10 +284,17 @@ export function FileBrowser({
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const folderInput = useRef<HTMLInputElement>(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
|
||||
// Back's history on every folder opened.
|
||||
useEffect(() => {
|
||||
setTarget(placeOf());
|
||||
const next = placeOf();
|
||||
if (samePlace(next, target) && initialPath === path) return;
|
||||
setTarget(next);
|
||||
setPathState(initialPath);
|
||||
setHistory({ back: [], fwd: [] });
|
||||
setSelected(new Set());
|
||||
@@ -326,7 +348,12 @@ export function FileBrowser({
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
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));
|
||||
} catch (e) {
|
||||
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
||||
@@ -334,10 +361,11 @@ export function FileBrowser({
|
||||
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
||||
);
|
||||
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
||||
if (view === "trash") setTrash([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [target, path, view, t]);
|
||||
}, [target, path, view, trashRoot, t]);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -631,11 +659,82 @@ export function FileBrowser({
|
||||
|
||||
const homeName = (who: string) =>
|
||||
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 laneName = (id: number) => {
|
||||
const l = lanes.find((x) => x.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 menuItem = ctx.menu?.item ?? null;
|
||||
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
||||
@@ -693,6 +792,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("", { home: "me" })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === "me"
|
||||
)}
|
||||
@@ -715,6 +815,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={t("fx.readOnlyHome")}
|
||||
onClick={() => go("", { home: h.id })}
|
||||
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
|
||||
className={placeClass(
|
||||
view === "files" && isHomePlace(target) && target.home === h.id
|
||||
)}
|
||||
@@ -735,6 +836,7 @@ export function FileBrowser({
|
||||
type="button"
|
||||
title={l.cwd}
|
||||
onClick={() => go("", l.id)}
|
||||
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
|
||||
className={placeClass(
|
||||
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
|
||||
)}
|
||||
@@ -744,17 +846,10 @@ export function FileBrowser({
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("trash");
|
||||
setSelected(new Set());
|
||||
}}
|
||||
className={placeClass(view === "trash")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{t("fx.trash")}
|
||||
<div className="border-t border-border p-2" data-testid="fx-trashes">
|
||||
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
|
||||
<Trash2 className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{t("fx.trash")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -770,8 +865,8 @@ export function FileBrowser({
|
||||
</HeadButton>
|
||||
<HeadButton
|
||||
label={t("fx.up")}
|
||||
disabled={view !== "files" || listing?.parent == null}
|
||||
onClick={goUp}
|
||||
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
|
||||
onClick={view === "trash" ? () => openTrash("*") : goUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</HeadButton>
|
||||
@@ -801,15 +896,36 @@ export function FileBrowser({
|
||||
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"
|
||||
>
|
||||
{view === "trash" ? (
|
||||
{view === "trash" && manyTrashes ? (
|
||||
<>
|
||||
<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")}
|
||||
</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" /> {t("fx.trash")}
|
||||
<Trash2 className="h-4 w-4" /> {trashLabel}
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go("")}
|
||||
onContextMenu={(e) => crumbCtx.open(e, "")}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
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 ${
|
||||
@@ -829,6 +945,7 @@ export function FileBrowser({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
|
||||
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
|
||||
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
|
||||
@@ -1145,7 +1262,13 @@ export function FileBrowser({
|
||||
{listing && !entries.length && !creating && !error && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
|
||||
<Folder className="h-16 w-16 opacity-40" />
|
||||
<p className="text-sm">{query ? t("fx.noMatch") : t("fx.empty")}</p>
|
||||
<p className="max-w-md text-center text-sm">
|
||||
{query
|
||||
? t("fx.noMatch")
|
||||
: isHomePlace(target) && !path
|
||||
? t("fx.homeEmpty")
|
||||
: t("fx.empty")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
|
||||
@@ -1159,12 +1282,26 @@ export function FileBrowser({
|
||||
</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
|
||||
items={trash}
|
||||
error={error}
|
||||
laneName={laneName}
|
||||
onRestore={(id) => void run(() => api.files.restore(target, id), t("fx.restored"))}
|
||||
onPurge={(id) => void run(() => api.files.purge(target, id))}
|
||||
onRestore={restoreItem}
|
||||
onPurge={(id) => void purge(id)}
|
||||
onRefresh={() => void load()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1340,6 +1477,78 @@ export function FileBrowser({
|
||||
</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 && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
|
||||
@@ -1641,20 +1850,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({
|
||||
items,
|
||||
error,
|
||||
laneName,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onRefresh,
|
||||
}: {
|
||||
items: FsTrashItem[];
|
||||
error: string | null;
|
||||
onRefresh: () => void;
|
||||
laneName: (id: number) => string;
|
||||
onRestore: (id: string) => void;
|
||||
onPurge: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const menu = useContextMenu<FsTrashItem | null>();
|
||||
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">
|
||||
<span className="flex-1">{t("fx.trashHint")}</span>
|
||||
<button
|
||||
@@ -1682,6 +2028,7 @@ function TrashView({
|
||||
key={i.id}
|
||||
className="border-b border-border/40 hover:bg-surface-2"
|
||||
data-testid="fx-trash-row"
|
||||
onContextMenu={(e) => menu.open(e, i)}
|
||||
>
|
||||
<td className="truncate px-4 py-1.5">
|
||||
<span className="flex items-center gap-2" title={i.path}>
|
||||
@@ -1722,13 +2069,44 @@ function TrashView({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!items.length && (
|
||||
{!items.length && !error && (
|
||||
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
|
||||
<Trash2 className="h-14 w-14 opacity-40" />
|
||||
<p className="text-sm">{t("fx.trashEmpty")}</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,9 +102,9 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
import { confirmDialog, confirmDiscard as askDiscard } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -810,8 +810,12 @@ export function LaneChangesPanel({
|
||||
wide = full,
|
||||
onDirtyChange,
|
||||
openRequest = null,
|
||||
filesChanged = 0,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** Bumped when files changed outside this panel (the header's File
|
||||
* Explorer): the tree is read again, its open folders kept open. */
|
||||
filesChanged?: number;
|
||||
/** Open this file (relative to the panel's folder); a new `nonce` asks again. */
|
||||
openRequest?: { path: string; nonce: number } | null;
|
||||
/** The whole-file editor holds unsaved text (true) or not (false). */
|
||||
@@ -855,6 +859,9 @@ export function LaneChangesPanel({
|
||||
// history and the tree too, not just the diff `load` re-reads.
|
||||
const [reread, setReread] = useState(0);
|
||||
const [range, setRange] = useState<LaneChangeRange>("work");
|
||||
// Local mode: the files are already on this machine — no upload, download
|
||||
// or File Explorer (the desktop's file manager does all three).
|
||||
const { local } = usePermissions();
|
||||
const [mode, setMode] = useState<Mode>("overview");
|
||||
const [openFile, setOpenFile] = useState<string | null>(null);
|
||||
// An image opens as a picture, never as text or a binary diff.
|
||||
@@ -870,6 +877,16 @@ export function LaneChangesPanel({
|
||||
const [mdPreview, setMdPreview] = useState(false);
|
||||
useEffect(() => setMdPreview(false), [openFile]);
|
||||
const [data, setData] = useState<LaneChanges | null>(null);
|
||||
// A folder that is not a repo (an empty new workspace, a parent of clones)
|
||||
// has no changes, review or history — only its files, which Files reads
|
||||
// from the disk. Open there instead of on a page that only says "no git".
|
||||
// Both reads must say so: the facts alone also read "unavailable" on a
|
||||
// network error or an older server.
|
||||
const notRepo = git?.available === false && data?.available === false;
|
||||
useEffect(() => {
|
||||
if (notRepo && (mode === "overview" || mode === "review" || mode === "history"))
|
||||
setMode(full ? "overview" : "browse");
|
||||
}, [notRepo, mode, full]);
|
||||
const [commits, setCommits] = useState<LaneCommit[] | null>(null);
|
||||
// A folder's children, keyed by its path ("" is the root) — fetched once,
|
||||
// on first expand, and kept so re-collapsing doesn't re-fetch.
|
||||
@@ -1078,19 +1095,32 @@ export function LaneChangesPanel({
|
||||
}, [mode, laneId, query, reread, full, treeRootLoaded]);
|
||||
|
||||
// A folder's children are fetched lazily, the first time it is expanded.
|
||||
const readDir = (path: string) =>
|
||||
api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
const toggleDir = (path: string) => {
|
||||
const opening = !expandedDirs.has(path);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(path)) next.add(path);
|
||||
return next;
|
||||
});
|
||||
if (path in treeChildren) return;
|
||||
void api.lanes
|
||||
.gitTree(target, path)
|
||||
.then(forLane(laneId, (r) => setTreeChildren((prev) => ({ ...prev, [path]: r.entries }))))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// Every OPEN reads the folder again (the cached level shows meanwhile):
|
||||
// files land in it from outside the panel — an agent, the terminal, the
|
||||
// File Explorer — and a level cached while it was empty stayed empty.
|
||||
if (opening) void readDir(path);
|
||||
};
|
||||
|
||||
// Files changed outside the panel: read the root and every open folder again.
|
||||
useEffect(() => {
|
||||
if (!filesChanged) return;
|
||||
void readDir("");
|
||||
for (const p of expandedDirs) void readDir(p);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filesChanged]);
|
||||
|
||||
const [treeBusy, setTreeBusy] = useState(false);
|
||||
|
||||
// "Expand all" is ONE read of the whole tree: a request per folder meant a
|
||||
@@ -1624,15 +1654,17 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||
{t("changes.showHistory")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
{!local && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-upload"
|
||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||
@@ -1640,7 +1672,7 @@ export function LaneChangesPanel({
|
||||
{t("changes.uploadHere")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
{!local && treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-explorer"
|
||||
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
|
||||
@@ -2134,9 +2166,9 @@ export function LaneChangesPanel({
|
||||
aria-label={t("changes.quickOpen")}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
{/* The File Explorer's button lives in the panel header now (beside
|
||||
Full screen); this instance serves the tree's "Open in File
|
||||
Explorer". */}
|
||||
<FileExplorer
|
||||
open={!!explorer}
|
||||
laneId={laneId}
|
||||
@@ -2265,14 +2297,16 @@ export function LaneChangesPanel({
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t("changes.back")}
|
||||
</button>
|
||||
) : (
|
||||
) : notRepo && full ? null : (
|
||||
<div className="flex min-w-0 items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{/* Files is the whole repo's tree; at full width it is the
|
||||
sidebar already, so no button. */}
|
||||
{(full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m) => (
|
||||
{(notRepo
|
||||
? (["browse"] as const)
|
||||
: full
|
||||
? (["overview", "review", "history"] as const)
|
||||
: (["overview", "browse", "review", "history"] as const)
|
||||
).map((m: Mode) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
@@ -2627,7 +2661,9 @@ export function LaneChangesPanel({
|
||||
browsePane
|
||||
) : !data ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("changes.loading")}</p>
|
||||
) : !data.available ? (
|
||||
) : !data.available && mode !== "file" ? (
|
||||
// A file still opens from Files: its whole-file read is the disk's,
|
||||
// not git's.
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{t(source ? "changes.unavailableFolder" : "changes.unavailable")}
|
||||
</p>
|
||||
@@ -2878,7 +2914,7 @@ export function LaneChangesPanel({
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{range !== "commit" && openFile && (
|
||||
{!local && range !== "commit" && openFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-download"
|
||||
@@ -2970,7 +3006,9 @@ export function LaneChangesPanel({
|
||||
collapse={!whole}
|
||||
revertLabel={t("changes.revertBlock")}
|
||||
value={draft}
|
||||
original={baseText ?? draft}
|
||||
// Outside a repo there is no HEAD: the edits are
|
||||
// shown against the text as it was opened.
|
||||
original={(notRepo ? text?.lines.join("\n") : baseText) ?? draft}
|
||||
lang={langFor(openFile ?? "")}
|
||||
label={t("changes.editorLabel", { path: openFile })}
|
||||
onChange={setDraft}
|
||||
|
||||
@@ -5,19 +5,20 @@
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* its analysis page, and end its live tab as the tab's buttons do (`endRuns`,
|
||||
* 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>
|
||||
*/
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { ContextMenu, MenuItem, MenuSeparator, type MenuPoint } from "../ContextMenu";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
|
||||
export function SessionMenu({
|
||||
laneId,
|
||||
@@ -50,24 +51,12 @@ export function SessionMenu({
|
||||
onClose();
|
||||
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;
|
||||
onClose();
|
||||
if (
|
||||
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);
|
||||
}
|
||||
void endRuns([run], { sleep, name: session?.name || t("tree.newSession"), t });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -105,8 +94,13 @@ export function SessionMenu({
|
||||
{(run || onArchive || onUnarchive) && <MenuSeparator />}
|
||||
{onUnarchive && <MenuItem onSelect={pick(onUnarchive)}>{t("tree.unarchive")}</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 && (
|
||||
<MenuItem danger onSelect={() => void closeTab()}>
|
||||
<MenuItem danger onSelect={() => endTab(false)}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
@@ -132,7 +132,11 @@ export function SessionReplay({
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ConversationView sessionId={sessionId} fill />
|
||||
<ConversationView
|
||||
sessionId={sessionId}
|
||||
fill
|
||||
ended={!livePane && !!session && session.status !== "active"}
|
||||
/>
|
||||
</div>
|
||||
{livePane ? (
|
||||
<div
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* /api/lanes/:id/sources…`, see server/lib/lane-sources.js). Shown as the
|
||||
* workspace home's right column and, `compact`, folded at the bottom of the
|
||||
* session view's Changes panel. Folder suggestions are the repo's untracked
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees.
|
||||
* worktrees from `GET /api/projects`, filtered by Settings → Worktrees; the
|
||||
* same form clones related repos beside the workspace (`CloneSourcesForm`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -21,6 +22,7 @@ import { useProjectTreePrefs } from "../../lib/projectTreePrefs";
|
||||
import { MarkdownPreview } from "../MarkdownPreview";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
import { CloneSourcesForm } from "./CloneSourcesForm";
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
@@ -436,6 +438,16 @@ function ContextBody({
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
{folderOpen && (
|
||||
<CloneSourcesForm
|
||||
laneId={lane.id}
|
||||
parent={lane.cwd.replace(/\/+$/, "").replace(/\/[^/]*$/, "") || "/"}
|
||||
onAdded={(next) => {
|
||||
setView(next);
|
||||
onSourcesChange?.(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -670,46 +670,18 @@ describe("AddLaneModal — create/update readme", () => {
|
||||
describe("AddLaneModal — no proxy here", () => {
|
||||
it("asks for no AI Proxy: a new session picks its own", async () => {
|
||||
renderModal({});
|
||||
await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("radio", { name: "AI Proxy" })).toBeNull();
|
||||
expect(api.aiProxies.list).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — tool selection", () => {
|
||||
it("sends the picked tool when adopting a repo", async () => {
|
||||
vi.mocked(api.lanes.ensure).mockResolvedValue({
|
||||
lane: laneFixture({ id: 23, kind: "adopted", status: "idle" }),
|
||||
created: true,
|
||||
});
|
||||
const onAdded = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderModal({ onAdded, onClose });
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Repo" }));
|
||||
const dirField = screen.getByLabelText("Directory");
|
||||
await focusField(user, dirField);
|
||||
await user.type(dirField, "/tmp/demo");
|
||||
// One button showing the default; it opens every installed + enabled tool.
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: "Default AI tool" });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.textContent)
|
||||
).toEqual(["Claude Code", "Codex", "Gemini"]);
|
||||
await user.click(within(list).getByRole("button", { name: "Codex" }));
|
||||
// The picked tool replaces the default on the button, and the list closes.
|
||||
expect(screen.getByRole("button", { name: "Codex", expanded: false })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Gemini" })).toBeNull();
|
||||
await user.click(screen.getByRole("button", { name: /add workspace|create/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.ensure).toHaveBeenCalledWith(expect.objectContaining({ tool: "codex" }))
|
||||
);
|
||||
it("offers no tool picker: a new session picks its own AI", async () => {
|
||||
renderModal({});
|
||||
await waitFor(() => expect(api.aiTools.list).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: /Claude Code/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Codex" })).toBeNull();
|
||||
});
|
||||
|
||||
it("says so, with a link to Settings, when no tool is installed and enabled", async () => {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
||||
* an extra folder, a host that wants a login opens the login row, and Retry
|
||||
* re-runs only the failed URLs with that login.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "../../../i18n";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { cloneFolder: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { api, type LaneSourcesView } from "../../../lib/api";
|
||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
||||
|
||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||
|
||||
describe("CloneSourcesForm", () => {
|
||||
beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset());
|
||||
|
||||
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.cloneFolder).mockImplementation(async (_id, body) => {
|
||||
if (body?.url.includes("private") && !body.credentials)
|
||||
throw Object.assign(new Error("login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "private.test" },
|
||||
});
|
||||
return view;
|
||||
});
|
||||
render(<CloneSourcesForm laneId={3} parent="/w" onAdded={onAdded} />);
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId("workspace-clone-input"),
|
||||
"https://h/a.git\nhttps://private.test/b.git\nhttps://h/a.git"
|
||||
);
|
||||
await user.click(screen.getByTestId("workspace-clone-run"));
|
||||
|
||||
await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(2));
|
||||
expect(onAdded).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText(/private\.test needs a login/)).toBeInTheDocument();
|
||||
// Only what is left stays in the box.
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("https://private.test/b.git");
|
||||
|
||||
await user.type(screen.getByLabelText("Username"), "dev");
|
||||
await user.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Retry the failed ones" }));
|
||||
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(2));
|
||||
expect(api.lanes.cloneFolder).toHaveBeenLastCalledWith(3, {
|
||||
url: "https://private.test/b.git",
|
||||
credentials: { username: "dev", password: "tok", remember: true },
|
||||
});
|
||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,11 @@
|
||||
* opens sub-folders, makes a folder inline, deletes into the Trash without a
|
||||
* confirm, copies in one workspace and pastes into another (`fromLane`),
|
||||
* 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>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -12,6 +16,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import { FileExplorer } from "../FileExplorer";
|
||||
import { api, type FsListing } from "../../../lib/api";
|
||||
import { toast } from "../../../lib/toast";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => false) }));
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
@@ -32,6 +39,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
copy: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
trash: vi.fn(),
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
@@ -70,6 +78,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
|
||||
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
|
||||
vi.mocked(api.files.allTrash).mockResolvedValue({ items: [], errors: [] });
|
||||
vi.mocked(api.files.homes).mockResolvedValue({
|
||||
homes: [
|
||||
{ id: "me", label: "An", readOnly: false },
|
||||
@@ -212,10 +221,178 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
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");
|
||||
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 () => {
|
||||
@@ -242,7 +419,7 @@ describe("FileExplorer", () => {
|
||||
const user = userEvent.setup();
|
||||
open();
|
||||
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(screen.queryByRole("button", { name: "New" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
|
||||
|
||||
@@ -24,6 +24,12 @@ import type { LaneChanges } from "../../../lib/types";
|
||||
|
||||
// The themed confirm needs its host (App); answer it here.
|
||||
// CodeMirror does not run in jsdom: the editor is a textarea over the same props.
|
||||
// Local mode unless a test says otherwise.
|
||||
const perms = vi.hoisted(() => ({ local: true }));
|
||||
vi.mock("../../AuthGate", async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import("../../AuthGate")>();
|
||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||
});
|
||||
vi.mock("../DiffEditor", () => ({
|
||||
default: (p: {
|
||||
value: string;
|
||||
@@ -146,6 +152,7 @@ function discardOf(path: string) {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
vi.mocked(api.lanes.git).mockReset().mockResolvedValue({ available: false });
|
||||
vi.mocked(api.lanes.changes).mockReset();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue(changes());
|
||||
@@ -310,16 +317,29 @@ describe("LaneChangesPanel", () => {
|
||||
await waitFor(() => expect(api.lanes.changes).toHaveBeenCalledWith(7, { range: "branch" }));
|
||||
});
|
||||
|
||||
it("says so, quietly, when the lane's directory is not a git repo", async () => {
|
||||
it("says so, quietly, beside the tree at full width, when the folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens on Files, its only tab, when the workspace folder is not a repo", async () => {
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
expect(
|
||||
await screen.findByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("lane-changes-browse")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Files" })).toHaveAttribute("aria-pressed", "true");
|
||||
for (const name of ["Changes", "Review", "History"])
|
||||
expect(screen.queryByRole("button", { name })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds an unversioned file to VCS, for that path alone, and ticks it", async () => {
|
||||
@@ -548,7 +568,38 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a folder again on every open, so files added since show (not a stale empty level)", async () => {
|
||||
let filled = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "src"
|
||||
? filled
|
||||
? [{ name: "app.ts", path: "src/app.ts", dir: false }]
|
||||
: []
|
||||
: [{ name: "src", path: "src", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.click(await within(sidebar).findByText("src"));
|
||||
await waitFor(() => expect(api.lanes.gitTree).toHaveBeenCalledWith(7, "src"));
|
||||
expect(within(sidebar).queryByText("app.ts")).toBeNull();
|
||||
// Source arrives from outside the panel (the File Explorer, a terminal).
|
||||
filled = true;
|
||||
await user.click(within(sidebar).getByText("src")); // close
|
||||
await user.click(within(sidebar).getByText("src")); // open again → read again
|
||||
expect(await within(sidebar).findByText("app.ts")).toBeInTheDocument();
|
||||
// And the header's File Explorer reports a change: open folders re-read.
|
||||
filled = false;
|
||||
rerender(<LaneChangesPanel laneId={7} full filesChanged={1} />);
|
||||
await waitFor(() => expect(within(sidebar).queryByText("app.ts")).toBeNull());
|
||||
});
|
||||
|
||||
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
||||
perms.local = false;
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
@@ -586,6 +637,25 @@ describe("LaneChangesPanel", () => {
|
||||
picked.mockRestore();
|
||||
});
|
||||
|
||||
it("offers no upload, download or File Explorer in local mode", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "out"
|
||||
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||
: [{ name: "out", path: "out", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
expect(screen.getByTestId("lane-changes-tree-menu")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("lane-changes-menu-upload")).toBeNull();
|
||||
expect(screen.queryByTestId("lane-changes-menu-explorer")).toBeNull();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
@@ -1728,6 +1798,24 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.getByTestId("lane-file-view")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a file from Files when the workspace folder is not a repo", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.changes).mockResolvedValue({
|
||||
available: false,
|
||||
range: "work",
|
||||
files: [],
|
||||
});
|
||||
render(<Tabbed laneId={7} />);
|
||||
await screen.findByTestId("lane-changes-browse");
|
||||
|
||||
await user.type(await screen.findByLabelText("Find a file by path…"), "main");
|
||||
await user.click(await screen.findByText("src/main.ts"));
|
||||
expect(await screen.findByTestId("lane-file-view")).toHaveTextContent("const whole = 1;");
|
||||
expect(
|
||||
screen.queryByText("This workspace's directory is not a readable git repository.")
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the overview rows mounted across re-renders (typing in the commit box)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} />);
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
* `~/.claude` (a memory file, a plugin's skill) through `GET /api/cc-config/file`,
|
||||
* an attached image or text file through `GET /api/run/drop/:name`, an image
|
||||
* the session read anywhere else through `GET /api/sessions/:id/image`, and a
|
||||
* text file it read or wrote anywhere else through `GET /api/sessions/:id/file`.
|
||||
* text file it read, wrote or named (a generated report) anywhere else through
|
||||
* `GET /api/sessions/:id/file`.
|
||||
* Anything else (or a file those routes refuse) shows its path only. A Windows path's `\` counts as `/` throughout.
|
||||
* `SkillListModal` is the same dialog over the session's skills, each one's
|
||||
* SKILL.md found by name (`plugin:name` from the plugin list, else the user's
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
* 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 /
|
||||
* 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
|
||||
* one is an AI tool, since it may be mid-turn; a shell closes at once. Arrow
|
||||
* X stops every session in it AND archives them (`endRuns`,
|
||||
* `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
|
||||
* 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}`)
|
||||
@@ -25,7 +28,10 @@
|
||||
* bar takes that pane out into a tab of its own there. Dropped on the MIDDLE
|
||||
* of another tab, a tab (or a pane header) joins that tab instead
|
||||
* (`joinGroup`: side by side, then under the right pane, then under the left
|
||||
* one, up to four panes). A right-click on a tab
|
||||
* one, up to four panes). A pane drag held over another tab for `SPRING_MS` shows that tab
|
||||
* (spring-loaded, as a file manager's folder), so the pane can be dropped on
|
||||
* any of its panes' edges; near either end of an overflowing bar a drag
|
||||
* scrolls it. A right-click on a tab
|
||||
* opens the same actions as a menu (`ContextMenu`). With tabs from every
|
||||
* workspace on, `tabs` spans every workspace (each run's `slot` a `paneKey`)
|
||||
* and `workspaceOf` names each tab's workspace before its own name. Settings
|
||||
@@ -45,19 +51,26 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useSettingsHref } from "../../lib/settingsLink";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { isSessionAwaitingInput, type Lane, type Session } from "../../lib/types";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { canSleep, endRuns } from "../../lib/stopSession";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { freeSlot, startInFreeTab } from "../lanes/startSession";
|
||||
import { PANE_DRAG_TYPE, RunIcon, startPaneDrag, type GroupsUpdate } from "./PaneGrid";
|
||||
import {
|
||||
PANE_DRAG_TYPE,
|
||||
RunIcon,
|
||||
paneDragSlot,
|
||||
setDragChip,
|
||||
startPaneDrag,
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import {
|
||||
type PaneGroups,
|
||||
@@ -155,6 +168,11 @@ interface Tab {
|
||||
|
||||
const URGENCY: RunState[] = ["awaiting", "error", "running"];
|
||||
|
||||
/** How long a pane drag rests on a tab before that tab is shown. */
|
||||
export const SPRING_MS = 600;
|
||||
/** Within this many px of an overflowing bar's end, a drag scrolls it. */
|
||||
const EDGE_SCROLL_PX = 40;
|
||||
|
||||
/** The Settings tab's key among the tabs' (a group's key is its slots). */
|
||||
const SETTINGS_KEY = "settings";
|
||||
|
||||
@@ -256,8 +274,59 @@ export function ConsoleTabs({
|
||||
if (!tab || dragged.current.some((s) => tab.slots.includes(s))) return null;
|
||||
return tab.slots.length + Math.max(dragged.current.length, 1) <= MAX_PANES ? i : null;
|
||||
};
|
||||
// A pane drag resting on a tab (its drawn index) shows it after SPRING_MS.
|
||||
const spring = useRef<{ at: number; timer: ReturnType<typeof setTimeout> } | null>(null);
|
||||
const stopSpring = () => {
|
||||
if (spring.current) clearTimeout(spring.current.timer);
|
||||
spring.current = null;
|
||||
};
|
||||
useEffect(() => stopSpring, []);
|
||||
const hoveredIndexAt = (x: number) => {
|
||||
const els = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-tab-index]") ?? [])];
|
||||
return els.findIndex((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
return x >= b.left && x <= b.right;
|
||||
});
|
||||
};
|
||||
const springAt = (e: DragEvent) => {
|
||||
const slot = paneDragSlot();
|
||||
const i = hoveredIndexAt(e.clientX);
|
||||
const tab = tabs[i];
|
||||
// Only a pane drag, onto a tab not on screen that does not hold it.
|
||||
const target =
|
||||
slot !== null &&
|
||||
tab &&
|
||||
!settings?.active &&
|
||||
tab.index !== groups.active &&
|
||||
!tab.slots.includes(slot)
|
||||
? i
|
||||
: null;
|
||||
if ((spring.current?.at ?? null) === target) return;
|
||||
stopSpring();
|
||||
if (target === null || !tab) return;
|
||||
const index = tab.index;
|
||||
spring.current = {
|
||||
at: target,
|
||||
timer: setTimeout(() => {
|
||||
spring.current = null;
|
||||
if (paneDragSlot() === null) return;
|
||||
onGroupsChange((g) =>
|
||||
g.active === index || !g.groups[index] ? g : { ...g, active: index }
|
||||
);
|
||||
}, SPRING_MS),
|
||||
};
|
||||
};
|
||||
// An overflowing bar has no scrollbar: near either end, a drag scrolls it.
|
||||
const edgeScroll = (x: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list || list.scrollWidth <= list.clientWidth) return;
|
||||
const b = list.getBoundingClientRect();
|
||||
if (x < b.left + EDGE_SCROLL_PX) list.scrollLeft -= 16;
|
||||
else if (x > b.right - EDGE_SCROLL_PX) list.scrollLeft += 16;
|
||||
};
|
||||
const endDrag = () => {
|
||||
dragged.current = [];
|
||||
stopSpring();
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
};
|
||||
@@ -266,6 +335,8 @@ export function ConsoleTabs({
|
||||
if (!isBarDrag(e)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
edgeScroll(e.clientX);
|
||||
springAt(e);
|
||||
const join = joinIndexAt(e.clientX);
|
||||
const at = join === null ? dropIndexAt(e.clientX) : null;
|
||||
setJoinAt((d) => (d === join ? d : join));
|
||||
@@ -273,6 +344,7 @@ export function ConsoleTabs({
|
||||
},
|
||||
onDragLeave: (e: DragEvent) => {
|
||||
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
|
||||
stopSpring();
|
||||
setDropAt(null);
|
||||
setJoinAt(null);
|
||||
},
|
||||
@@ -363,30 +435,10 @@ export function ConsoleTabs({
|
||||
}
|
||||
};
|
||||
|
||||
// An AI session may be mid-turn: stopping a tab holding one asks first. A
|
||||
// shell holds no agent.
|
||||
const close = async (tab: Tab) => {
|
||||
if (
|
||||
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);
|
||||
}
|
||||
}
|
||||
// Stop archives the tab's sessions; Sleep keeps them listed to resume
|
||||
// (`endRuns` asks first for an AI pane, which may be mid-turn).
|
||||
const close = async (tab: Tab, sleep = false) => {
|
||||
for (const id of await endRuns(tab.runs, { sleep, name: label(tab.focus), t })) onClosed(id);
|
||||
};
|
||||
|
||||
const startRename = (r: RunHandle) => {
|
||||
@@ -504,10 +556,11 @@ export function ConsoleTabs({
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragged.current = tab.slots;
|
||||
if (single) startPaneDrag(e, single.slot ?? 0);
|
||||
if (single) startPaneDrag(e, single.slot ?? 0, label(single));
|
||||
else {
|
||||
e.dataTransfer?.setData(TAB_DRAG_TYPE, String(tab.index));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
setDragChip(e, tabLabel(tab));
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => tabCtx.open(e, tab)}
|
||||
@@ -568,6 +621,20 @@ export function ConsoleTabs({
|
||||
<span className="truncate">{tabLabel(tab)}</span>
|
||||
{state && <RunDot state={state} />}
|
||||
</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
|
||||
type="button"
|
||||
data-testid={`console-tab-close-${tab.key}`}
|
||||
@@ -715,6 +782,14 @@ export function ConsoleTabs({
|
||||
</>
|
||||
)}
|
||||
<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))}>
|
||||
{t("tabs.close")}
|
||||
</MenuItem>
|
||||
|
||||
@@ -59,12 +59,19 @@ import { useTranslation } from "react-i18next";
|
||||
import { ArrowUp, FileText, Square, SquareCheck } from "lucide-react";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
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 { addAttachments, dragAttachments } from "../../lib/promptAttach";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { ChatOutline, outlineAttachment, useSessionOutline, type OutlineList } from "./ChatOutline";
|
||||
import { FileListModal, FilePreview, SkillListModal, type PreviewScope } from "./ChatFilePreview";
|
||||
import { resolveIn } from "../../lib/resolvePath";
|
||||
import { Select } from "../Select";
|
||||
import { Divider, showTerminal } from "./PaneGrid";
|
||||
import {
|
||||
@@ -471,7 +478,9 @@ export const PaneChat = memo(function PaneChat({
|
||||
// (ConversationView is memoized).
|
||||
const openTerminal = useCallback(() => showTerminal(runId, sessionId), [runId, sessionId]);
|
||||
const openFile = useCallback(
|
||||
(path: string) => {
|
||||
(named: string) => {
|
||||
// A relative path in a reply (`server/lib/x.js`) is relative to this pane's folder.
|
||||
const path = resolveIn(cwd, named);
|
||||
// The workspace's Changes panel when the file is in its folder;
|
||||
// else this view's own preview.
|
||||
if (laneId !== null && openInChanges(laneId, cwd, path)) return;
|
||||
@@ -488,7 +497,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
const [notStarted, setNotStarted] = useState(false);
|
||||
|
||||
// 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 (
|
||||
notStarted &&
|
||||
!(await confirmDialog({
|
||||
@@ -498,14 +507,15 @@ export const PaneChat = memo(function PaneChat({
|
||||
}))
|
||||
)
|
||||
return false;
|
||||
const ok = submitToRun(runId, line);
|
||||
const ok = submitToRun(runId, line, files);
|
||||
if (!ok) toast.error(t("chat.notSent"));
|
||||
return ok;
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!text || uploading) return;
|
||||
if (!(await command(text))) return;
|
||||
const out = promptForPane(prompt, images);
|
||||
if (!(await command(out.text, out.files))) return;
|
||||
setFollow((n) => n + 1);
|
||||
setPrompt("");
|
||||
images.forEach((i) => i.url && URL.revokeObjectURL(i.url));
|
||||
|
||||
@@ -10,11 +10,19 @@
|
||||
* there, on its middle to swap), pick another live session for it, split it
|
||||
* right or down, or take the pane OUT of this tab (its session keeps running
|
||||
* in a tab of its own — so its icon is not the tab's stop X), 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
|
||||
* 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.
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). A
|
||||
* Tabs from `ConsoleTabs` drag in the same way (`startPaneDrag`). While a
|
||||
* drag is on, the pane it carries is dimmed, the browser's ghost is a small
|
||||
* chip with its name (`setDragChip`) instead of a whole header, and the drop
|
||||
* preview names what the drop will do (`dropAction`). The dragged pane's
|
||||
* header stays mounted even once the tab bar has switched tabs under the
|
||||
* drag (`ConsoleTabs` spring-loads a hovered tab) — removing the drag's
|
||||
* source node would lose its `dragend`. A
|
||||
* right-click on a header offers the swap, a split with a new terminal and
|
||||
* the close as one menu. Dividers resize by pointer or by arrow keys; a drag
|
||||
* moves only the line and commits the ratio on release, so the layout is
|
||||
@@ -34,11 +42,13 @@ import {
|
||||
GripVertical,
|
||||
MessageSquare,
|
||||
Minimize2,
|
||||
Moon,
|
||||
Rows2,
|
||||
SquareTerminal,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { RunHandle } from "../../lib/api";
|
||||
import { canSleep } from "../../lib/stopSession";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
||||
import {
|
||||
EMPTY_GROUPS,
|
||||
@@ -63,17 +73,37 @@ import { RunDot, type RunState } from "./ConsoleTabs";
|
||||
import { setSessionChat, useSessionChat } from "../../lib/chatUi";
|
||||
import { readChatDraft, writeChatDraft } from "../../lib/chatDraft";
|
||||
import { pasteToRun, takeRunInput } from "../../lib/panePaste";
|
||||
import { promptWithImages } from "./RunSetup";
|
||||
import { promptForPane } from "./RunSetup";
|
||||
|
||||
export const PANE_DRAG_TYPE = "application/x-agenthub-pane";
|
||||
/** The slot being dragged: `dataTransfer` data cannot be read during dragover. */
|
||||
let dragSlot: number | null = null;
|
||||
|
||||
/** Starts dragging a pane or a tab (`slot`) onto the grid. */
|
||||
export function startPaneDrag(e: React.DragEvent, slot: number): void {
|
||||
/** The slot a pane drag carries, or null (no pane drag is on). */
|
||||
export const paneDragSlot = (): number | null => dragSlot;
|
||||
|
||||
/** The drag's ghost: a small chip naming what moves, not a snapshot of a
|
||||
* whole pane header or tab. Drawn off screen for the browser to copy. */
|
||||
export function setDragChip(e: React.DragEvent, label: string): void {
|
||||
const dt = e.dataTransfer;
|
||||
if (!label || typeof dt?.setDragImage !== "function") return;
|
||||
const chip = document.createElement("div");
|
||||
chip.textContent = label;
|
||||
chip.className =
|
||||
"fixed -top-96 left-0 max-w-[16rem] truncate rounded-md border border-accent/60 bg-surface-2 px-2.5 py-1 text-xs font-medium text-fg-primary shadow-lg";
|
||||
document.body.appendChild(chip);
|
||||
dt.setDragImage(chip, 12, 12);
|
||||
// The browser copies it during dragstart; it may go right after.
|
||||
window.setTimeout(() => chip.remove(), 0);
|
||||
}
|
||||
|
||||
/** Starts dragging a pane or a tab (`slot`) onto the grid; `label` names it
|
||||
* on the drag's chip. */
|
||||
export function startPaneDrag(e: React.DragEvent, slot: number, label?: string): void {
|
||||
dragSlot = slot;
|
||||
e.dataTransfer?.setData(PANE_DRAG_TYPE, String(slot));
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
|
||||
if (label) setDragChip(e, label);
|
||||
}
|
||||
|
||||
/** A run's icon: a shell's terminal glyph, else its AI tool's mark. */
|
||||
@@ -140,6 +170,14 @@ export function dropPreview(l: PaneLayout, src: number, slot: number, zone: Zone
|
||||
return layoutRects(next.root).panes.find((p) => p.slot === src) ?? null;
|
||||
}
|
||||
|
||||
/** What a drop does, as an i18n key (`lanes` namespace): the center swaps a
|
||||
* pane already shown, or shows one from another tab in the target's place
|
||||
* (the target gets a tab of its own); an edge places it on that side. */
|
||||
export function dropAction(l: PaneLayout, src: number, zone: Zone): string {
|
||||
if (zone !== "center") return `panes.drop.${zone}`;
|
||||
return leaves(l.root).includes(src) ? "panes.drop.swap" : "panes.drop.replace";
|
||||
}
|
||||
|
||||
/** A small anchored menu: a trigger button and a list that closes on a pick,
|
||||
* an outside click or Escape. */
|
||||
function PaneMenu({
|
||||
@@ -208,8 +246,8 @@ const ITEM =
|
||||
* the CLI's input box, without Enter. */
|
||||
export function showTerminal(runId: string, sessionId: string) {
|
||||
const draft = readChatDraft(sessionId);
|
||||
const text = promptWithImages(draft.prompt, draft.images);
|
||||
if (text && pasteToRun(runId, text)) writeChatDraft(sessionId, "", []);
|
||||
const { text, files } = promptForPane(draft.prompt, draft.images);
|
||||
if ((text || files.length) && pasteToRun(runId, text, files)) writeChatDraft(sessionId, "", []);
|
||||
setSessionChat(sessionId, false);
|
||||
}
|
||||
|
||||
@@ -368,6 +406,7 @@ export function PaneGrid({
|
||||
onNewSplit,
|
||||
stateOf,
|
||||
onStop,
|
||||
onSleep,
|
||||
keyOf = (slot) => slot,
|
||||
kept = [],
|
||||
}: {
|
||||
@@ -376,8 +415,10 @@ export function PaneGrid({
|
||||
keyOf?: (slot: number) => number;
|
||||
/** Panes kept mounted, always hidden — never in the layout. */
|
||||
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;
|
||||
/** 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). */
|
||||
stateOf?: (r: RunHandle) => RunState | null;
|
||||
/** Every slot to keep mounted; those not in the layout are hidden. */
|
||||
@@ -398,6 +439,8 @@ export function PaneGrid({
|
||||
const { t } = useTranslation("lanes");
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const [drop, setDrop] = useState<{ src: number; slot: number; zone: Zone } | null>(null);
|
||||
// The slot a pane drag carries (a header here, or a tab from the bar).
|
||||
const [dragging, setDragging] = useState<number | null>(null);
|
||||
const { panes, dividers } = layoutRects(layout.root);
|
||||
const shown = leaves(layout.root);
|
||||
const rectOf = new Map(panes.map((p) => [p.slot, p]));
|
||||
@@ -416,6 +459,7 @@ export function PaneGrid({
|
||||
const end = () => {
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
setDragging(null);
|
||||
};
|
||||
window.addEventListener("dragend", end);
|
||||
window.addEventListener("drop", end);
|
||||
@@ -479,6 +523,7 @@ export function PaneGrid({
|
||||
if (!isPaneDrag(e)) return;
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||
if (dragSlot !== dragging) setDragging(dragSlot);
|
||||
const z = zoneAt(e);
|
||||
const next = z && dragSlot !== null ? { src: dragSlot, ...z } : null;
|
||||
setDrop((d) =>
|
||||
@@ -495,6 +540,7 @@ export function PaneGrid({
|
||||
const z = zoneAt(e);
|
||||
dragSlot = null;
|
||||
setDrop(null);
|
||||
setDragging(null);
|
||||
if (!z || !Number.isInteger(src) || src === z.slot) return;
|
||||
onChange((l) => applyDrop(l, src, z.slot, z.zone));
|
||||
}}
|
||||
@@ -515,6 +561,7 @@ export function PaneGrid({
|
||||
const r = rectOf.get(slot);
|
||||
const run = runOf(slot);
|
||||
const focused = layout.focus === slot;
|
||||
const moving = dragging === slot;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
@@ -522,17 +569,23 @@ export function PaneGrid({
|
||||
data-focused={r && focused ? "true" : undefined}
|
||||
// A menu opened in this pane stays inside it (`menuRoom`).
|
||||
data-menu-bounds
|
||||
className={r ? "absolute flex flex-col" : "hidden"}
|
||||
data-dragging={moving ? "true" : undefined}
|
||||
className={r ? `absolute flex flex-col${moving ? " opacity-40" : ""}` : "hidden"}
|
||||
style={r ? pct(r) : undefined}
|
||||
onPointerDownCapture={() => r && focus(slot)}
|
||||
// Tabbing into a pane focuses it as a click does.
|
||||
onFocusCapture={() => r && focus(slot)}
|
||||
>
|
||||
{multi && r && (
|
||||
{/* The dragged pane's header stays while the drag lasts, even
|
||||
hidden in another tab: it is the drag's source node. */}
|
||||
{((multi && r) || moving) && (
|
||||
<div
|
||||
data-testid={`pane-header-${slot}`}
|
||||
draggable
|
||||
onDragStart={(e) => startPaneDrag(e, slot)}
|
||||
onDragStart={(e) => {
|
||||
startPaneDrag(e, slot, run ? label(run) : t("panes.empty"));
|
||||
setDragging(slot);
|
||||
}}
|
||||
onContextMenu={(e) => paneCtx.open(e, slot)}
|
||||
title={t("panes.drag")}
|
||||
className={`relative flex h-8 shrink-0 cursor-grab items-center gap-1 border-b border-border bg-surface-1 pl-1.5 pr-1 text-xs ${
|
||||
@@ -595,6 +648,18 @@ export function PaneGrid({
|
||||
>
|
||||
<Minimize2 className="h-3.5 w-3.5" />
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -650,6 +715,11 @@ export function PaneGrid({
|
||||
<MenuItem onSelect={pickPane((slot) => onChange((l) => closeInLayout(l, slot)))}>
|
||||
{t("panes.closeShort")}
|
||||
</MenuItem>
|
||||
{onSleep && canSleep(runOf(paneCtx.menu.item)) && (
|
||||
<MenuItem testId="pane-menu-sleep" onSelect={pickPane(onSleep)}>
|
||||
{t("panes.sleep")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{onStop && runOf(paneCtx.menu.item) && (
|
||||
<MenuItem testId="pane-menu-stop" onSelect={pickPane(onStop)}>
|
||||
{t("panes.stop")}
|
||||
@@ -667,12 +737,19 @@ export function PaneGrid({
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{preview && (
|
||||
{preview && drop && (
|
||||
<div
|
||||
data-testid="pane-drop-preview"
|
||||
className="pointer-events-none absolute z-20 rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
className="pointer-events-none absolute z-20 flex items-center justify-center rounded-md border-2 border-accent/60 bg-accent/15 transition-all duration-100"
|
||||
style={pct(preview)}
|
||||
/>
|
||||
>
|
||||
<span
|
||||
data-testid="pane-drop-label"
|
||||
className="max-w-[90%] truncate rounded-md bg-accent-solid px-2.5 py-1 text-xs font-medium text-white shadow-lg"
|
||||
>
|
||||
{t(dropAction(layout, drop.src, drop.zone))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
addAttachments,
|
||||
droppedAttachments,
|
||||
droppedSkill,
|
||||
droppedText,
|
||||
SKILL_MIME,
|
||||
rememberUploads,
|
||||
} from "../../lib/promptAttach";
|
||||
@@ -207,6 +208,23 @@ export function promptWithImages(text: string, images: PromptImage[]): string {
|
||||
return [text.trim(), ...images.map((i) => i.path)].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const IMAGE_PATH = /^[^@].*\.(png|jpe?g|gif|webp)$/i;
|
||||
|
||||
/** The prompt as pasted into a live pane: the text with every non-image file
|
||||
* in it (as `promptWithImages`), and the images' paths apart — each goes in
|
||||
* a paste of its own, the one shape `claude` turns into `[Image #n]` on every
|
||||
* platform (a Windows path inside a paste of text stayed a plain path). */
|
||||
export function promptForPane(
|
||||
text: string,
|
||||
images: PromptImage[]
|
||||
): { text: string; files: string[] } {
|
||||
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`. */
|
||||
export const PASTE_AS_FILE_CHARS = 5000;
|
||||
|
||||
@@ -412,7 +430,8 @@ export function PromptEditor({
|
||||
// else a `bare` editor's parent, the box every caller draws around it and
|
||||
// its toolbar — not only on the textarea: a panel row (`ATTACH_MIME`)
|
||||
// becomes an `@path` chip, a skill (`SKILL_MIME`) the prompt's leading
|
||||
// `/command`, a file is uploaded.
|
||||
// `/command`, a file is uploaded, a local `file://` list an `@path` chip
|
||||
// and any other text is inserted.
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropRef = useRef<(e: DragEvent) => void>(() => {});
|
||||
dropRef.current = (e) => {
|
||||
@@ -425,6 +444,17 @@ export function PromptEditor({
|
||||
e.preventDefault();
|
||||
onChange(`/${skill} ${value.replace(/^\s+/, "")}`);
|
||||
} else if (attachImages(e.dataTransfer?.files)) e.preventDefault();
|
||||
else {
|
||||
// Only text (a file manager's or an editor's `file://` list, a
|
||||
// selection, a link): what the terminal beneath would paste.
|
||||
// On the textarea itself the browser inserts text at the drop point.
|
||||
const dropped = droppedText(e.dataTransfer);
|
||||
if (!dropped || ("text" in dropped && e.target === taRef.current)) return;
|
||||
e.preventDefault();
|
||||
if ("attachments" in dropped)
|
||||
onImagesChange((prev) => addAttachments(prev, dropped.attachments));
|
||||
else insert(dropped.text);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const zone =
|
||||
@@ -433,7 +463,8 @@ export function PromptEditor({
|
||||
if (!zone) return;
|
||||
const over = (e: DragEvent) => {
|
||||
const types = e.dataTransfer?.types ?? [];
|
||||
if (![ATTACH_MIME, SKILL_MIME, "Files"].some((m) => types.includes(m))) return;
|
||||
const accepts = [ATTACH_MIME, SKILL_MIME, "Files", "text/uri-list", "text/plain"];
|
||||
if (!accepts.some((m) => types.includes(m))) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = "copy";
|
||||
};
|
||||
|
||||
@@ -355,9 +355,20 @@ export const TerminalView = memo(function TerminalView({
|
||||
// `false` also blocks tmux's own wheel scrolling. `x10` counts as off here
|
||||
// because that protocol reports button presses only, never the wheel — so
|
||||
// xterm takes the emulation path for it as well.
|
||||
term.attachCustomWheelEventHandler(() => {
|
||||
// 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 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";
|
||||
});
|
||||
|
||||
@@ -406,6 +417,16 @@ export const TerminalView = memo(function TerminalView({
|
||||
}, 0);
|
||||
return false;
|
||||
}
|
||||
// Ctrl+Z: a terminal sends ^Z (SIGTSTP), which SUSPENDS claude or
|
||||
// codex — the pane drops to its shell and the session looks closed,
|
||||
// with nothing on screen saying `fg` brings it back. In a browser
|
||||
// Ctrl+Z means undo, so it sends Ctrl+_ — undo in Claude Code's prompt
|
||||
// and in bash/readline — and never suspends the program.
|
||||
if (ev.ctrlKey && !ev.altKey && !ev.metaKey && !ev.shiftKey && key === "z") {
|
||||
ev.preventDefault();
|
||||
term.input("\x1f", true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -520,6 +541,8 @@ export const TerminalView = memo(function TerminalView({
|
||||
requestAnimationFrame(scaleToFit);
|
||||
} else if (msg.type === "lead") {
|
||||
takeLead();
|
||||
} else if (msg.type === "wheel") {
|
||||
ownWheel = true;
|
||||
} else if (msg.type === "exit") {
|
||||
ended = true;
|
||||
term.write(`\r\n${tRef.current("terminal.ended", { code: msg.code })}\r\n`);
|
||||
@@ -643,8 +666,12 @@ export const TerminalView = memo(function TerminalView({
|
||||
() =>
|
||||
registerPane(
|
||||
runId,
|
||||
(text, submit) => {
|
||||
termRef.current?.paste(text);
|
||||
(text, submit, files = []) => {
|
||||
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
|
||||
// detection never reads it as a newline inside the paste. A submit
|
||||
// comes from the pane's Chat view, which keeps the keyboard.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* @file ConsoleTabs.test.tsx
|
||||
* @description The tab bar's spring-loaded tabs: a pane drag resting on
|
||||
* another tab shows it after `SPRING_MS`, so the pane can be dropped on that
|
||||
* tab's panes; moving off before then, or resting on the tab already holding
|
||||
* the pane, shows nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, createEvent, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { RunHandle } from "../../../lib/api";
|
||||
import type { Lane } from "../../../lib/types";
|
||||
import { pane, split, type PaneGroups } from "../../../lib/paneLayout";
|
||||
import { ConsoleTabs, SPRING_MS } from "../ConsoleTabs";
|
||||
import { PANE_DRAG_TYPE, paneDragSlot } from "../PaneGrid";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
aiTools: {
|
||||
list: vi.fn().mockResolvedValue({ default: "claude", tools: [] }),
|
||||
models: vi.fn().mockResolvedValue({ flag: true, models: [], defaultModel: null }),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const LANE = { id: 1, tool: "claude", cwd: "/w", title: "w" } as unknown as Lane;
|
||||
const run = (slot: number): RunHandle => ({
|
||||
id: slot ? `agenthub-lane-1-${slot}` : "agenthub-lane-1",
|
||||
laneId: 1,
|
||||
slot,
|
||||
status: "running",
|
||||
cwd: "/w",
|
||||
model: null,
|
||||
permissionMode: null,
|
||||
effort: null,
|
||||
resumeSessionId: null,
|
||||
sessionId: null,
|
||||
startedAt: null,
|
||||
promptPreview: null,
|
||||
tool: "shell",
|
||||
});
|
||||
// Tab 0: pane 0 alone (on screen); tab 1: panes 1 and 2.
|
||||
const GROUPS: PaneGroups = {
|
||||
groups: [
|
||||
{ root: pane(0), focus: 0 },
|
||||
{ root: split("row", pane(1), pane(2)), focus: 1 },
|
||||
],
|
||||
active: 0,
|
||||
};
|
||||
|
||||
function setup() {
|
||||
let groups = GROUPS;
|
||||
const onGroupsChange = vi.fn((fn: (g: PaneGroups) => PaneGroups) => {
|
||||
groups = fn(groups);
|
||||
});
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ConsoleTabs
|
||||
lane={LANE}
|
||||
tabs={[run(0), run(1), run(2)]}
|
||||
groups={GROUPS}
|
||||
onGroupsChange={onGroupsChange}
|
||||
onOpen={vi.fn()}
|
||||
onClosed={vi.fn()}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
// jsdom lays nothing out: tab i spans x 100i … 100i+100.
|
||||
for (const el of document.querySelectorAll<HTMLElement>("[data-tab-index]")) {
|
||||
const i = Number(el.dataset.tabIndex);
|
||||
el.getBoundingClientRect = () =>
|
||||
({
|
||||
left: i * 100,
|
||||
right: i * 100 + 100,
|
||||
width: 100,
|
||||
top: 0,
|
||||
bottom: 40,
|
||||
height: 40,
|
||||
}) as DOMRect;
|
||||
}
|
||||
return { onGroupsChange, groups: () => groups };
|
||||
}
|
||||
|
||||
const dataTransfer = () => ({
|
||||
types: [PANE_DRAG_TYPE],
|
||||
setData: vi.fn(),
|
||||
getData: () => "",
|
||||
effectAllowed: "",
|
||||
dropEffect: "",
|
||||
});
|
||||
|
||||
function drag(type: "dragStart" | "dragOver", el: Element, clientX = 0) {
|
||||
const ev = createEvent[type](el, { dataTransfer: dataTransfer() });
|
||||
Object.defineProperty(ev, "clientX", { value: clientX });
|
||||
fireEvent(el, ev);
|
||||
}
|
||||
|
||||
const tabEl = (key: string) => screen.getByTestId(`console-tab-${key}`).parentElement as Element;
|
||||
|
||||
describe("ConsoleTabs spring-loaded tabs", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("a pane drag resting on another tab shows that tab", () => {
|
||||
const { onGroupsChange, groups } = setup();
|
||||
drag("dragStart", tabEl("0"));
|
||||
expect(paneDragSlot()).toBe(0);
|
||||
drag("dragOver", tabEl("1-2"), 150);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS - 1));
|
||||
expect(onGroupsChange).not.toHaveBeenCalled();
|
||||
act(() => void vi.advanceTimersByTime(1));
|
||||
expect(groups().active).toBe(1);
|
||||
});
|
||||
|
||||
it("moving off the tab first, or resting on the pane's own tab, shows nothing", () => {
|
||||
const { onGroupsChange } = setup();
|
||||
drag("dragStart", tabEl("0"));
|
||||
drag("dragOver", tabEl("1-2"), 150);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS / 2));
|
||||
drag("dragOver", tabEl("0"), 50);
|
||||
act(() => void vi.advanceTimersByTime(SPRING_MS * 2));
|
||||
expect(onGroupsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -267,7 +267,7 @@ describe("PaneChat — model, effort and permission mode", () => {
|
||||
renderChat();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Opus/ }));
|
||||
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 () => {
|
||||
@@ -430,10 +430,50 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("half typed");
|
||||
expect(screen.getByText("a.ts")).toBeInTheDocument();
|
||||
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());
|
||||
});
|
||||
|
||||
// A drag from outside the dashboard (a file manager, VS Code, a selection)
|
||||
// carries only text: the terminal beneath pastes it, so the chat takes it too.
|
||||
const textDrop = (data: Record<string, string>) => {
|
||||
const drop = new Event("drop", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(drop, "dataTransfer", {
|
||||
value: { types: Object.keys(data), getData: (t: string) => data[t] ?? "", files: [] },
|
||||
});
|
||||
act(() => {
|
||||
screen.getByTestId("pane-chat").dispatchEvent(drop);
|
||||
});
|
||||
return drop;
|
||||
};
|
||||
|
||||
it("attaches the local files of a dropped file:// list", async () => {
|
||||
renderChat();
|
||||
const drop = textDrop({
|
||||
"text/uri-list": "file:///home/u/My%20Docs/spec.md\r\nfile:///home/u/b.ts",
|
||||
"text/plain": "/home/u/My Docs/spec.md",
|
||||
});
|
||||
expect(drop.defaultPrevented).toBe(true);
|
||||
expect(await screen.findByText("spec.md")).toBeInTheDocument();
|
||||
expect(screen.getByText("b.ts")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), {
|
||||
target: { value: "read" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("pane-chat-send"));
|
||||
expect(submitToRun).toHaveBeenCalledWith(
|
||||
"agenthub-lane-1",
|
||||
'read @"/home/u/My Docs/spec.md" @/home/u/b.ts',
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts dropped plain text into the prompt", () => {
|
||||
renderChat();
|
||||
const drop = textDrop({ "text/plain": "src/lib/x.ts" });
|
||||
expect(drop.defaultPrevented).toBe(true);
|
||||
expect(screen.getByPlaceholderText("Write a message…")).toHaveValue("src/lib/x.ts");
|
||||
});
|
||||
|
||||
// Issue #73: before the session starts the CLI may be asking in the terminal
|
||||
// (trust this folder? — "No, exit" preselected); an Enter sent from here
|
||||
// would answer it, so a send asks first.
|
||||
@@ -451,7 +491,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
expect(box).toHaveValue("hello");
|
||||
confirmDialog.mockResolvedValueOnce(true);
|
||||
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 () => {
|
||||
@@ -460,7 +500,7 @@ describe("PaneChat — the unsent prompt", () => {
|
||||
renderChat();
|
||||
fireEvent.change(screen.getByPlaceholderText("Write a message…"), { target: { value: "hi" } });
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* @file PaneGrid.test.ts
|
||||
* @description The pane drop preview: it draws where the dragged pane will
|
||||
* actually land (`dropPreview` over `applyDrop`), not half of the target's
|
||||
* current box, and nothing for a drop that changes nothing.
|
||||
* current box, and nothing for a drop that changes nothing; and the label
|
||||
* naming what a drop does (`dropAction`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dropPreview } from "../PaneGrid";
|
||||
import { dropAction, dropPreview } from "../PaneGrid";
|
||||
import { pane, split, type PaneLayout } from "../../../lib/paneLayout";
|
||||
|
||||
const AB: PaneLayout = { root: split("row", pane(0), pane(1)), focus: 0 };
|
||||
@@ -34,3 +35,15 @@ describe("dropPreview", () => {
|
||||
expect(dropPreview(full, 5, 0, "left")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dropAction", () => {
|
||||
it("an edge places the pane on that side", () => {
|
||||
expect(dropAction(AB, 0, "left")).toBe("panes.drop.left");
|
||||
expect(dropAction(AB, 2, "bottom")).toBe("panes.drop.bottom");
|
||||
});
|
||||
|
||||
it("the center swaps a shown pane, or shows one from another tab in its place", () => {
|
||||
expect(dropAction(AB, 0, "center")).toBe("panes.drop.swap");
|
||||
expect(dropAction(AB, 2, "center")).toBe("panes.drop.replace");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ModelPicker,
|
||||
PASTE_AS_FILE_CHARS,
|
||||
PromptEditor,
|
||||
promptForPane,
|
||||
promptWithImages,
|
||||
type PromptImage,
|
||||
} from "../RunSetup";
|
||||
@@ -586,3 +587,23 @@ describe("ModelMenu", () => {
|
||||
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",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,6 +231,24 @@ describe("TerminalView", () => {
|
||||
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", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
@@ -520,9 +538,15 @@ describe("TerminalView", () => {
|
||||
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
|
||||
"text/plain": "src/a.ts",
|
||||
};
|
||||
const dataTransfer = { types: Object.keys(data), files: [], getData: (k: string) => data[k] ?? "" };
|
||||
const dataTransfer = {
|
||||
types: Object.keys(data),
|
||||
files: [],
|
||||
getData: (k: string) => data[k] ?? "",
|
||||
};
|
||||
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop to paste into the terminal");
|
||||
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
|
||||
"Drop to paste into the terminal"
|
||||
);
|
||||
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
|
||||
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
|
||||
expect(screen.queryByTestId("terminal-drop")).toBeNull();
|
||||
@@ -582,6 +606,24 @@ describe("TerminalView", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ^Z suspended claude: the pane fell to its shell and the session looked
|
||||
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
|
||||
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
|
||||
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
|
||||
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
|
||||
true
|
||||
);
|
||||
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
|
||||
});
|
||||
|
||||
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
|
||||
// every later key REPLACES the word — the event order captured on a Mac.
|
||||
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
* @file AccountSettings.tsx
|
||||
* @description Settings → Account (server mode, any signed-in role): the
|
||||
* account's own information (`GET /api/me/account` — username, role, member
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its display name
|
||||
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
|
||||
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
|
||||
* sidebar shows it at once), its display name
|
||||
* and email (`PATCH /api/me/account`), a password change that asks for the
|
||||
* current one (`POST /api/me/password` — this browser stays signed in, every
|
||||
* other sign-in ends) and "Sign out other devices" (`POST
|
||||
@@ -11,9 +13,17 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LogOut, RefreshCw } from "lucide-react";
|
||||
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type MyAccount } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
@@ -21,6 +31,7 @@ import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { ROLE_BADGE } from "../users/UserEditDialog";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { SettingsSection } from "./SettingsRow";
|
||||
|
||||
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
|
||||
@@ -42,8 +53,12 @@ export function AccountSettings() {
|
||||
(r: MyAccount) => {
|
||||
if (r.mode !== "server") return;
|
||||
setAccount(r);
|
||||
// The sidebar shows the display name: let it follow a save at once.
|
||||
updateUser({ displayName: r.user.displayName, email: r.user.email });
|
||||
// The sidebar shows the name and avatar: let them follow a save at once.
|
||||
updateUser({
|
||||
displayName: r.user.displayName,
|
||||
email: r.user.email,
|
||||
avatarAt: r.user.avatarAt ?? null,
|
||||
});
|
||||
},
|
||||
[updateUser]
|
||||
);
|
||||
@@ -72,7 +87,7 @@ export function AccountSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ProfileCard account={account} />
|
||||
<ProfileCard account={account} onSaved={apply} />
|
||||
<ProfileForm account={account} onSaved={apply} />
|
||||
<PasswordForm />
|
||||
<SignOutOthers logins={account.logins} onDone={load} />
|
||||
@@ -80,19 +95,84 @@ export function AccountSettings() {
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileCard({ account }: { account: ServerAccount }) {
|
||||
/** The largest avatar the server takes (server/lib/avatars.js). */
|
||||
const MAX_AVATAR_BYTES = 1024 * 1024;
|
||||
|
||||
function ProfileCard({
|
||||
account,
|
||||
onSaved,
|
||||
}: {
|
||||
account: ServerAccount;
|
||||
onSaved: (r: MyAccount) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("settings");
|
||||
const { user } = account;
|
||||
const shown = user.displayName || user.username;
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (op: () => Promise<MyAccount>, done: string) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
onSaved(await op());
|
||||
toast.success(done);
|
||||
} catch (e) {
|
||||
toast.error(errText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const pick = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = ""; // the same file can be picked again
|
||||
if (!file) return;
|
||||
if (file.size > MAX_AVATAR_BYTES) {
|
||||
toast.error(t("account.avatar.tooBig"));
|
||||
return;
|
||||
}
|
||||
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card p-5" data-testid="account-card">
|
||||
<div className="flex items-center gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-full bg-accent/15 text-lg font-semibold uppercase text-accent"
|
||||
>
|
||||
{shown.slice(0, 2)}
|
||||
</span>
|
||||
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
|
||||
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-6 w-6 justify-center p-0"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.change")}
|
||||
aria-label={t("account.avatar.change")}
|
||||
data-testid="account-avatar-change"
|
||||
>
|
||||
<Camera className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
{user.avatarAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost h-6 w-6 justify-center p-0"
|
||||
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
|
||||
disabled={busy}
|
||||
title={t("account.avatar.remove")}
|
||||
aria-label={t("account.avatar.remove")}
|
||||
data-testid="account-avatar-remove"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={pick}
|
||||
data-testid="account-avatar-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
|
||||
<p className="truncate text-xs text-fg-muted">
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* @file ArtifactsHubSettings — Settings → AI tools → Artifacts Hub: the
|
||||
* admin's server-wide switch that allows or blocks installing a shared skill
|
||||
* or plugin from the Hub into a user's own account (`GET/PUT
|
||||
* /api/artifacts/settings`). Renders nothing in local mode (there is no Hub)
|
||||
* or for anyone the server does not let manage it (`canManage`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type ArtifactSettings } from "../../lib/api";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ArtifactsHubSettings() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
if (!settings?.serverMode || !settings.canManage) return null;
|
||||
return (
|
||||
<SettingsSection id="artifacts-hub" title={t("title")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("installsSwitch")}
|
||||
description={t("installsSwitchHint")}
|
||||
checked={settings.installsEnabled}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -2,31 +2,186 @@
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* key a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it — and, for a user with their own Linux account (S3), their own HTTPS
|
||||
* from Settings → Account while unset, the email else `<username>@smartgift.vn`,
|
||||
* since a member's commits fail without one; a new SSH key is labelled with
|
||||
* that email), the SSH
|
||||
* keys a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* 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
|
||||
* 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
|
||||
* seen by a non-admin is read-only.
|
||||
* @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 { 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 { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
import { PageTabs } from "../PageTabs";
|
||||
import { Switch } from "../Switch";
|
||||
|
||||
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";
|
||||
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";
|
||||
|
||||
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() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -37,6 +192,8 @@ export function GitIdentitySettings() {
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
// null = not edited: the field shows the saved value, else the account's.
|
||||
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 () => {
|
||||
try {
|
||||
@@ -64,19 +221,29 @@ export function GitIdentitySettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (
|
||||
id?.ssh &&
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
|
||||
// ed25519 suits GitHub/GitLab/Gitea; AWS IAM (CodeCommit) accepts only RSA.
|
||||
// An account may hold one of each; an older server answers `ssh` only.
|
||||
const keys: Key[] = id ? (id.sshKeys ?? (id.ssh ? [id.ssh] : [])) : [];
|
||||
const terminal = id?.sshTerminal ?? id?.ssh?.terminal ?? null;
|
||||
// The default key of a type (a named key sits beside it).
|
||||
const keyOf = (type: "ed25519" | "rsa") =>
|
||||
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
|
||||
const named = keys.filter((k) => k.name);
|
||||
const keyLabel = (keyId: string | null | undefined) => {
|
||||
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
|
||||
if (!k) return t("gitIdentity.anyKey");
|
||||
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
|
||||
};
|
||||
|
||||
const removeKey = async () => {
|
||||
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")
|
||||
);
|
||||
if (ok) setNewKey(null);
|
||||
};
|
||||
const removeNamed = async (keyId: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
@@ -84,7 +251,30 @@ export function GitIdentitySettings() {
|
||||
}))
|
||||
)
|
||||
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 () => {
|
||||
@@ -104,6 +294,76 @@ export function GitIdentitySettings() {
|
||||
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) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
@@ -125,13 +385,38 @@ export function GitIdentitySettings() {
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
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 authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
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 ok = await run(
|
||||
() =>
|
||||
@@ -146,7 +431,7 @@ export function GitIdentitySettings() {
|
||||
|
||||
return (
|
||||
<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")}
|
||||
</p>
|
||||
|
||||
@@ -157,7 +442,7 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
|
||||
<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) => {
|
||||
e.preventDefault();
|
||||
void saveAuthor();
|
||||
@@ -197,62 +482,347 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</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">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.sshTitle")}
|
||||
</h3>
|
||||
{id.writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{id.ssh ? (
|
||||
<>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={t("gitIdentity.publicKey")}
|
||||
value={id.ssh.publicKey}
|
||||
rows={3}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{id.ssh.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void removeKey()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.sshIntro")}</p>
|
||||
|
||||
<PageTabs
|
||||
ariaLabel={t("gitIdentity.sshTitle")}
|
||||
tabs={sshTabs}
|
||||
activeId={activeSshTab}
|
||||
onSelect={setSshTab}
|
||||
>
|
||||
{activeSshTab === "git" && (
|
||||
<KeyCard
|
||||
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 && (
|
||||
<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="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAws();
|
||||
}}
|
||||
>
|
||||
<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="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">
|
||||
{k.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(k.publicKey)}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
|
||||
onClick={() => void removeNamed(k.id!)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
|
||||
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>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.httpsTitle")}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SettingsRow, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SagiAvatar } from "../Sagi/SagiAvatar";
|
||||
import { PetSprite } from "../Sagi/PetSprite";
|
||||
import { PET_SIZE, sagiPrefs } from "../Sagi/prefs";
|
||||
import { usePrefersReducedMotion } from "../Sagi/Sagi";
|
||||
import { usePrefersReducedMotion } from "../../hooks/usePrefersReducedMotion";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { toast } from "../../lib/toast";
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* @file AccountSettings.test.tsx
|
||||
* @description Settings → Account: shows the account's own information, saves
|
||||
* only a changed display name/email, refuses a password change whose two new
|
||||
* passwords differ, sends the current + new password, and asks before signing
|
||||
* the other devices out.
|
||||
* passwords differ, sends the current + new password, asks before signing
|
||||
* the other devices out, and uploads/removes the avatar.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -16,6 +16,9 @@ const me = vi.hoisted(() => ({
|
||||
updateAccount: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
signOutOthers: vi.fn(),
|
||||
setAvatar: vi.fn(),
|
||||
removeAvatar: vi.fn(),
|
||||
avatarUrl: (at?: string | null) => (at ? `/api/me/avatar?v=${at}` : null),
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -44,7 +47,7 @@ const account = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(me)) fn.mockReset();
|
||||
for (const fn of Object.values(me)) if ("mockReset" in fn) fn.mockReset();
|
||||
me.account.mockResolvedValue(account);
|
||||
me.updateAccount.mockResolvedValue(account);
|
||||
me.changePassword.mockResolvedValue({ ok: true });
|
||||
@@ -53,6 +56,26 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AccountSettings", () => {
|
||||
it("uploads a picked avatar and removes it again", async () => {
|
||||
const ue = userEvent.setup();
|
||||
const withAvatar = { ...account, user: { ...account.user, avatarAt: "2026-10-14T00:00:00Z" } };
|
||||
me.setAvatar.mockResolvedValue(withAvatar);
|
||||
me.removeAvatar.mockResolvedValue(account);
|
||||
render(<AccountSettings />);
|
||||
await screen.findByTestId("account-card");
|
||||
expect(screen.getByTestId("user-avatar-initials").textContent).toBe("Mi");
|
||||
expect(screen.queryByTestId("account-avatar-remove")).toBeNull();
|
||||
const file = new File([new Uint8Array([0x89, 0x50])], "me.png", { type: "image/png" });
|
||||
await ue.upload(screen.getByTestId("account-avatar-input"), file);
|
||||
await waitFor(() => expect(me.setAvatar).toHaveBeenCalledWith(file));
|
||||
expect((await screen.findByTestId("user-avatar-img")).getAttribute("src")).toBe(
|
||||
"/api/me/avatar?v=2026-10-14T00:00:00Z"
|
||||
);
|
||||
await ue.click(screen.getByTestId("account-avatar-remove"));
|
||||
await waitFor(() => expect(me.removeAvatar).toHaveBeenCalled());
|
||||
expect(await screen.findByTestId("user-avatar-initials")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the account's own information", async () => {
|
||||
render(<AccountSettings />);
|
||||
const card = await screen.findByTestId("account-card");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* @file Tests for ArtifactsHubSettings — the admin's Hub install switch in
|
||||
* Settings → AI tools: hidden in local mode and for a non-manager, and a
|
||||
* toggle saves through PUT /api/artifacts/settings.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { ArtifactsHubSettings } from "../ArtifactsHubSettings";
|
||||
import { api } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { artifacts: { settings: vi.fn(), setInstallsEnabled: vi.fn() } },
|
||||
}));
|
||||
|
||||
const settings = vi.mocked(api.artifacts.settings);
|
||||
const setInstalls = vi.mocked(api.artifacts.setInstallsEnabled);
|
||||
const base = { serverMode: true, canShare: true, installsEnabled: true, canManage: true };
|
||||
|
||||
describe("ArtifactsHubSettings", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("lets an admin turn Hub installs off", async () => {
|
||||
settings.mockResolvedValue(base);
|
||||
setInstalls.mockResolvedValue({ installsEnabled: false });
|
||||
render(<ArtifactsHubSettings />);
|
||||
const sw = await screen.findByRole("switch", { name: "Allow installing from the Hub" });
|
||||
fireEvent.click(sw);
|
||||
await waitFor(() => expect(setInstalls).toHaveBeenCalledWith(false));
|
||||
await waitFor(() => expect(sw).toHaveAttribute("aria-checked", "false"));
|
||||
});
|
||||
|
||||
it("renders nothing for a non-manager or in local mode", async () => {
|
||||
settings.mockResolvedValue({ ...base, canManage: false });
|
||||
const { container, unmount } = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
settings.mockResolvedValue({ ...base, serverMode: false });
|
||||
const r = render(<ArtifactsHubSettings />);
|
||||
await waitFor(() => expect(settings).toHaveBeenCalledTimes(2));
|
||||
expect(r.container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,16 @@
|
||||
* @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
|
||||
* 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>
|
||||
*/
|
||||
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 { GitIdentitySettings } from "../GitIdentitySettings";
|
||||
import { api, type GitIdentity } from "../../../lib/api";
|
||||
@@ -19,6 +24,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
removeKeyById: vi.fn(),
|
||||
setTerminalSsh: vi.fn(),
|
||||
setSshUser: vi.fn(),
|
||||
removeSshUser: vi.fn(),
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
@@ -39,6 +48,10 @@ const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Opens one tab of the SSH keys. */
|
||||
const tab = async (name: string | RegExp) =>
|
||||
userEvent.click(await screen.findByRole("tab", { name }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -48,10 +61,11 @@ describe("GitIdentitySettings", () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
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(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
|
||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519");
|
||||
expect(await screen.findByLabelText(/^Public key/)).toHaveValue(KEY.publicKey);
|
||||
});
|
||||
|
||||
it("copies the public key and asks before replacing it", async () => {
|
||||
@@ -62,7 +76,7 @@ describe("GitIdentitySettings", () => {
|
||||
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
|
||||
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true, "ed25519"));
|
||||
});
|
||||
|
||||
it("lets a member save an https login of their own", async () => {
|
||||
@@ -85,6 +99,88 @@ describe("GitIdentitySettings", () => {
|
||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("makes the RSA key from the AWS CodeCommit section", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
|
||||
view({ ssh: { ...KEY, type: "rsa", publicKey: "ssh-rsa AAAAB3 an@agenthub" } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await tab("AWS CodeCommit");
|
||||
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(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 () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
||||
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
||||
@@ -106,8 +202,73 @@ describe("GitIdentitySettings", () => {
|
||||
view({ account: "host", writable: false, ssh: KEY })
|
||||
);
|
||||
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: "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")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file AccountPoolCard.tsx
|
||||
* @description The Users page's account pool card (`GET /api/accounts`): the
|
||||
* Claude and Codex accounts every user's new sessions are spread over, each
|
||||
* with its live panes, $ in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* with its live panes, $ and tokens in the last 5 h / 7 d and quota — so nobody signs in
|
||||
* per user. An admin adds one — for Claude a login MOVED in from a saved
|
||||
* switch-user profile (`GET /api/accounts/profiles`), a pasted
|
||||
* `.credentials.json`, or a `claude setup-token` token; for Codex an OpenAI
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
type PoolProfile,
|
||||
type PoolTool,
|
||||
} from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { fmt, fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
@@ -40,6 +40,8 @@ type Row = {
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -175,9 +177,14 @@ export function AccountPoolCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
{r.quotaPercent == null ? "—" : `${Math.round(r.quotaPercent)}%`}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(r.spend5h)}</div>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{fmtCost(r.spend5h)}
|
||||
<span className="ml-1.5 text-[11px] text-fg-muted">
|
||||
{t("pool.tokens", { tokens: fmt(r.tokens5h) })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d) })}
|
||||
{t("pool.spend7d", { cost: fmtCost(r.spend7d), tokens: fmt(r.tokens7d) })}
|
||||
</div>
|
||||
</td>
|
||||
{isAdmin && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file UserAvatar.tsx
|
||||
* @description An account's round picture: the signed-in account's own
|
||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
||||
* or when the image fails to load — the first letters of its name. Used by the
|
||||
* sidebar's account row, its workspace owner groups and Settings → Account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
export function UserAvatar({
|
||||
name,
|
||||
avatarAt,
|
||||
userId,
|
||||
letters = 2,
|
||||
className = "h-7 w-7 text-[11px]",
|
||||
initialsClassName = "bg-accent/15 text-accent",
|
||||
}: {
|
||||
name: string;
|
||||
avatarAt?: string | null;
|
||||
/** Another account's (staff only); omitted = the signed-in account's own. */
|
||||
userId?: number;
|
||||
/** How many letters of the name stand in for a missing picture. */
|
||||
letters?: number;
|
||||
/** Size and font size: the parts that differ between call sites. */
|
||||
className?: string;
|
||||
/** The initials' colours. */
|
||||
initialsClassName?: string;
|
||||
}) {
|
||||
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
|
||||
// A broken image falls back to the initials, per URL.
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
|
||||
if (url && failed !== url) {
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
onError={() => setFailed(url)}
|
||||
className={`${base} object-cover`}
|
||||
data-testid="user-avatar-img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${base} ${initialsClassName} font-semibold uppercase`}
|
||||
data-testid="user-avatar-initials"
|
||||
>
|
||||
{name.slice(0, letters)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Eye, EyeOff, Plus, Sparkles, X } from "lucide-react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { api, type AuthUser, type ServerUserUsage, type UserPatch } from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
@@ -131,6 +132,38 @@ export function UserEditDialog({
|
||||
}
|
||||
};
|
||||
|
||||
/** Delete the user for good — asked first, with what goes, in red. */
|
||||
const remove = async () => {
|
||||
if (self || busy) return;
|
||||
if (!current) {
|
||||
setError(t("delete.needsPassword"));
|
||||
return;
|
||||
}
|
||||
const name = user.displayName || user.username;
|
||||
const ok = await confirmDialog({
|
||||
title: t("delete.title", { name }),
|
||||
message: t("delete.body", { name, count: owned?.length ?? 0, user: user.username }),
|
||||
confirmLabel: t("delete.confirm"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.users.remove(user.id, current);
|
||||
onDone(t("done.deleted", { name }));
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
@@ -459,8 +492,21 @@ export function UserEditDialog({
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!self && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove()}
|
||||
disabled={busy}
|
||||
data-testid="user-delete"
|
||||
className="mr-auto rounded-md border border-status-danger/50 px-3 py-1.5 text-sm text-status-danger hover:bg-status-danger/10 disabled:opacity-50"
|
||||
>
|
||||
{t("delete.button")}
|
||||
</button>
|
||||
)}
|
||||
{!changed && (
|
||||
<span className="mr-auto text-xs text-fg-muted">{t("edit.noChanges")}</span>
|
||||
<span className={`text-xs text-fg-muted ${self ? "mr-auto" : ""}`}>
|
||||
{t("edit.noChanges")}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t("common:cancel")}
|
||||
|
||||
@@ -40,6 +40,8 @@ const POOL: AccountPool = {
|
||||
livePanes: 2,
|
||||
spend5h: 1.5,
|
||||
spend7d: 12,
|
||||
tokens5h: 250_000,
|
||||
tokens7d: 3_400_000,
|
||||
quotaPercent: 42,
|
||||
full: false,
|
||||
},
|
||||
@@ -52,6 +54,8 @@ const POOL: AccountPool = {
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 0,
|
||||
tokens5h: 0,
|
||||
tokens7d: 0,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
},
|
||||
@@ -82,6 +86,8 @@ describe("AccountPoolCard", () => {
|
||||
expect(await screen.findByText("team-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("cx-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("42%")).toBeInTheDocument();
|
||||
expect(screen.getByText("250.0K tokens")).toBeInTheDocument();
|
||||
expect(screen.getByText("$12.00 · 3.4M tokens in 7 days")).toBeInTheDocument();
|
||||
expect(screen.getByText("team1@example.com")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /add account/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /remove/i })).toBeNull();
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,9 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ServerUserUsage } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn() }));
|
||||
const users = vi.hoisted(() => ({ get: vi.fn(), update: vi.fn(), remove: vi.fn() }));
|
||||
const confirmDialog = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog }));
|
||||
const lanes = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
@@ -122,6 +124,45 @@ describe("UserEditDialog", () => {
|
||||
expect(save().hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes a user after the admin's password and a red confirm that says what goes", async () => {
|
||||
users.remove.mockReset().mockResolvedValue({ user: mia });
|
||||
confirmDialog.mockReset().mockResolvedValue(true);
|
||||
const ue = userEvent.setup();
|
||||
const { onDone } = open();
|
||||
await screen.findByText("api");
|
||||
// No password yet: asked for it, nothing sent.
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Enter your own password");
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(confirmDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
destructive: true,
|
||||
message: expect.stringContaining("/srv/agenthub/users/mia"),
|
||||
})
|
||||
);
|
||||
await waitFor(() => expect(users.remove).toHaveBeenCalledWith(2, "boss-pass"));
|
||||
expect(onDone).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends nothing when the confirm is cancelled, and offers no delete for yourself", async () => {
|
||||
users.remove.mockReset();
|
||||
confirmDialog.mockReset().mockResolvedValue(false);
|
||||
const ue = userEvent.setup();
|
||||
open();
|
||||
await screen.findByText("api");
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
await ue.click(screen.getByRole("button", { name: "Delete user" }));
|
||||
expect(users.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("has no Delete button on your own account", async () => {
|
||||
open(mia, true);
|
||||
await screen.findByText("api");
|
||||
expect(screen.queryByRole("button", { name: "Delete user" })).toBeNull();
|
||||
});
|
||||
|
||||
it("generates a 16-character password", async () => {
|
||||
expect(generatePassword()).toHaveLength(16);
|
||||
const ue = userEvent.setup();
|
||||
|
||||
@@ -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,7 +115,9 @@ export const i18nReady = i18n
|
||||
"splash",
|
||||
"lanes",
|
||||
"users",
|
||||
"admin",
|
||||
"onboarding",
|
||||
"artifacts",
|
||||
],
|
||||
defaultNS: "common",
|
||||
interpolation: { escapeValue: false },
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"title": "Artifacts",
|
||||
"subtitle": "What your agents produced — claude.ai pages, files, skills and plugins — kept in one place.",
|
||||
"subtitleServer": "What your agents produced — claude.ai pages, files, skills and plugins. Share one and it appears in the Hub for everyone.",
|
||||
"tab.mine": "My library",
|
||||
"tab.hub": "Hub",
|
||||
"kind.all": "All",
|
||||
"kind.page": "Pages",
|
||||
"kind.file": "Files",
|
||||
"kind.skill": "Skills",
|
||||
"kind.plugin": "Plugins",
|
||||
"kindOne.page": "Page",
|
||||
"kindOne.file": "File",
|
||||
"kindOne.skill": "Skill",
|
||||
"kindOne.plugin": "Plugin",
|
||||
"search": "Search artifacts…",
|
||||
"add": "Add",
|
||||
"add.page": "claude.ai link",
|
||||
"add.file": "File from a workspace",
|
||||
"add.skill": "Skill",
|
||||
"add.plugin": "Plugin",
|
||||
"empty.mine": "Nothing here yet. Pages your sessions publish appear on their own; add files, skills and plugins with Add.",
|
||||
"empty.hub": "Nobody has shared anything yet.",
|
||||
"loading": "Loading…",
|
||||
"by": "by {{name}}",
|
||||
"shared": "Shared",
|
||||
"share": "Share",
|
||||
"unshare": "Unshare",
|
||||
"shareDone": "Shared to the Hub",
|
||||
"unshareDone": "Removed from the Hub",
|
||||
"open": "Open",
|
||||
"copyLink": "Copy link",
|
||||
"preview": "Preview",
|
||||
"download": "Download",
|
||||
"noPreview": "This file type cannot be shown here — download it to open it.",
|
||||
"install": "Install",
|
||||
"installs": "{{count}} installs",
|
||||
"installed": "Installed to {{path}}",
|
||||
"installedPlugin": "Installed {{name}} — start a new session (or /reload-plugins) to use it",
|
||||
"rename": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteTitle": "Delete “{{title}}”?",
|
||||
"deleteBody": "It is removed from your library and the Hub. Anything already installed from it stays installed.",
|
||||
"deleteConfirm": "Delete",
|
||||
"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:",
|
||||
"installPluginBody": "Runs, as your account: claude plugin marketplace add {{market}} and claude plugin install {{name}} --scope user. Only install what you trust.",
|
||||
"installConfirm": "Install",
|
||||
"replaceTitle": "Replace your skill “{{name}}”?",
|
||||
"replaceBody": "You already have a skill with that name. Yours is moved to ~/.claude/agenthub-replaced-skills/, not deleted.",
|
||||
"replaceConfirm": "Replace",
|
||||
"installsOff": "Installs are off",
|
||||
"installsSwitch": "Allow installing from the Hub",
|
||||
"installsSwitchHint": "Off: nobody can install a shared skill or plugin into their account.",
|
||||
"form.url": "claude.ai link",
|
||||
"form.title": "Title (optional)",
|
||||
"form.description": "Description (optional)",
|
||||
"form.workspace": "Workspace",
|
||||
"form.pickWorkspace": "Pick a workspace…",
|
||||
"form.noCandidates": "No documents written by this workspace's sessions yet.",
|
||||
"form.pinned": "Pinned",
|
||||
"form.pin": "Pin",
|
||||
"form.missing": "gone",
|
||||
"form.skillFrom": "Skill",
|
||||
"form.fromUser": "mine",
|
||||
"form.fromWorkspace": "workspace",
|
||||
"form.noSkills": "No skills found in your ~/.claude/skills or this workspace's .claude/skills.",
|
||||
"form.pluginKey": "Plugin (name@marketplace)",
|
||||
"form.market": "Marketplace (owner/repo or git URL)",
|
||||
"form.save": "Add",
|
||||
"form.cancel": "Cancel",
|
||||
"added": "Added",
|
||||
"files": "{{count}} files",
|
||||
"close": "Close",
|
||||
"fromSession": "From a session",
|
||||
"form.saveEdit": "Save",
|
||||
"skill.tab.upload": "Upload files",
|
||||
"skill.tab.write": "Write new",
|
||||
"skill.tab.existing": "From my skills",
|
||||
"skill.name": "Skill name",
|
||||
"skill.slugHint": "Installs as ~/.claude/skills/{{slug}}/ and runs as /{{slug}}",
|
||||
"skill.slugBad": "Use letters, digits, '.', '_' or '-'",
|
||||
"skill.description": "Description — when Claude should use this skill",
|
||||
"skill.descriptionHint": "e.g. Use when writing release notes from the git log",
|
||||
"skill.content": "Instructions (the body of SKILL.md)",
|
||||
"skill.contentHint": "Write the steps Claude should follow. You can also paste a whole SKILL.md, frontmatter included.",
|
||||
"skill.dropHint": "Drop a SKILL.md, any Markdown file, a skill folder, several files or a .zip/.skill here",
|
||||
"skill.pickFiles": "Choose files",
|
||||
"skill.pickFolder": "Choose folder",
|
||||
"skill.clear": "Clear",
|
||||
"skill.uploading": "Uploading…",
|
||||
"skill.shape.none": "Nothing to upload",
|
||||
"skill.shape.single-md": "This Markdown file becomes the skill's SKILL.md",
|
||||
"skill.shape.archive": "An archive — it is unpacked; each folder with a SKILL.md becomes a skill",
|
||||
"skill.shape.one": "One skill folder",
|
||||
"skill.shape.many": "{{count}} skills — each is added on its own, named by its folder",
|
||||
"skill.shape.no-skill-md": "No SKILL.md here — add one, or upload a single Markdown file",
|
||||
"skill.manyHint": "Several skills: each keeps its own folder name and the description in its SKILL.md.",
|
||||
"skill.addedMany": "Added {{count}} skills",
|
||||
"plugin.link": "Marketplace link",
|
||||
"plugin.linkHint": "A git repository with .claude-plugin/marketplace.json (https link, the forge's web link, or owner/repo for GitHub), or a link to a marketplace.json.",
|
||||
"plugin.read": "Read plugins",
|
||||
"plugin.reading": "Reading…",
|
||||
"plugin.none": "This marketplace lists no plugins.",
|
||||
"plugin.all": "Select all",
|
||||
"plugin.have": "already added",
|
||||
"plugin.addPicked": "Add {{count}} plugin(s)",
|
||||
"plugin.addedMany": "Added {{count}} plugin(s)",
|
||||
"plugin.manual": "Type name@marketplace by hand (ssh link, private repository…)",
|
||||
"file.fromComputer": "From your computer (≤ 25 MB)",
|
||||
"file.upload": "Upload",
|
||||
"file.tooBig": "The file is larger than 25 MB.",
|
||||
"file.fromWorkspace": "Or pin a document a workspace's sessions wrote"
|
||||
}
|
||||
@@ -31,10 +31,8 @@
|
||||
"addLaneSetupTitle": "Setup",
|
||||
"addLaneTitleLabel": "Title",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"addLaneToolDefaultBadge": "Default",
|
||||
"addLaneNoTools": "No AI tool is installed and enabled.",
|
||||
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
||||
"addLaneToolHint": "Default AI tool",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
||||
@@ -417,6 +415,12 @@
|
||||
"home.addFolder": "Add folder",
|
||||
"home.folderPath": "Absolute folder path",
|
||||
"home.add": "Add",
|
||||
"home.cloneFolders": "Or clone repositories",
|
||||
"home.cloneHint": "One URL per line — each is cloned into {{path}} with your own git login (Settings → Git) and added here.",
|
||||
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Retry the failed ones",
|
||||
"home.cloneDone": "Added",
|
||||
"home.suggested": "Untracked worktrees",
|
||||
"home.remove": "Remove",
|
||||
"home.missing": "missing",
|
||||
@@ -426,13 +430,19 @@
|
||||
"tabs.new": "New tab",
|
||||
"tabs.newTerminal": "New 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.agentSettings": "Agent settings…",
|
||||
"tabs.settings": "Settings",
|
||||
"tabs.closeSettings": "Close Settings",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: open in a split · drag onto a pane to place it",
|
||||
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap",
|
||||
"panes.drag": "Drag to move this pane — onto another pane's edge to split there, onto its middle to swap; hold it over another tab to open that tab",
|
||||
"panes.drop.left": "Place on the left",
|
||||
"panes.drop.right": "Place on the right",
|
||||
"panes.drop.top": "Place on top",
|
||||
"panes.drop.bottom": "Place below",
|
||||
"panes.drop.swap": "Swap places",
|
||||
"panes.drop.replace": "Show here — this pane moves to its own tab",
|
||||
"panes.pick": "Show another session in this pane",
|
||||
"panes.empty": "No session",
|
||||
"panes.split.right": "Split right",
|
||||
@@ -455,7 +465,8 @@
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
"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.viewCommit": "View commit",
|
||||
"changes.copySha": "Copy commit SHA",
|
||||
@@ -469,8 +480,13 @@
|
||||
"tabs.renameHint": "Double-click to rename",
|
||||
"tabs.stopTitle": "Stop “{{name}}”?",
|
||||
"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.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.commentPasted": "Pasted into tab {{tab}} — press Enter there to send it",
|
||||
"changes.splitter": "Resize the changes panel",
|
||||
@@ -561,7 +577,7 @@
|
||||
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
|
||||
"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.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.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
@@ -575,6 +591,17 @@
|
||||
"fx.purge": "Delete forever",
|
||||
"fx.restored": "Restored.",
|
||||
"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.openInExplorer": "Open in File Explorer",
|
||||
"fx.places": "Workspaces",
|
||||
@@ -604,7 +631,9 @@
|
||||
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
|
||||
"fx.myHome": "Home",
|
||||
"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.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.",
|
||||
"tmuxUpgrade": "Upgrade tmux: {{advice}}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"activityFeed": "Activity Feed",
|
||||
"artifacts": "Artifacts",
|
||||
"agents": "Agents",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
@@ -86,5 +87,6 @@
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users",
|
||||
"admin": "Administration",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"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": {
|
||||
"label": "{{name}} companion",
|
||||
"live": "Live",
|
||||
"offline": "Offline",
|
||||
"close": "Close {{name}}",
|
||||
"statLive": "live",
|
||||
"statWaiting": "waiting",
|
||||
"statErrored": "errored",
|
||||
"mute": "Mute",
|
||||
"unmute": "Unmute",
|
||||
"clearAlerts": "Clear alerts",
|
||||
"askPlaceholder": "Ask {{name}}… (e.g. any errors?)",
|
||||
"ask": "Ask {{name}}",
|
||||
"send": "Send",
|
||||
"rename": "Click to rename {{name}}",
|
||||
"renameLabel": "Pet name",
|
||||
@@ -57,8 +53,7 @@
|
||||
"attention": "Needs you",
|
||||
"attentionError": "errored",
|
||||
"attentionWaiting": "waiting on you",
|
||||
"attentionMore": "+{{count}} more in Sessions",
|
||||
"chat": "Chat with {{name}}"
|
||||
"attentionMore": "+{{count}} more in Sessions"
|
||||
},
|
||||
"bubbleDismiss": "Dismiss",
|
||||
"open": "Open {{name}} companion",
|
||||
@@ -218,9 +213,12 @@
|
||||
},
|
||||
"chat": {
|
||||
"label": "Chat with {{name}}",
|
||||
"back": "Back",
|
||||
"close": "Close {{name}}",
|
||||
"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",
|
||||
"typing": "typing…",
|
||||
"placeholder": "Ask {{name}} anything…",
|
||||
@@ -902,8 +900,8 @@
|
||||
"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.",
|
||||
"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",
|
||||
"generate": "Generate SSH key",
|
||||
"sshTitle": "SSH keys",
|
||||
"generate": "Generate 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.",
|
||||
"keyMade": "SSH key ready — add its public key to your git host.",
|
||||
@@ -911,11 +909,51 @@
|
||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||
"publicKey": "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). The private key never leaves the server.",
|
||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
||||
"noKey": "No key yet. Generate one to start.",
|
||||
"httpsTitle": "HTTPS logins",
|
||||
"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",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "More keys",
|
||||
"tabOther": "Other hosts"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1178,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.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1265,6 +1320,13 @@
|
||||
"appliesTo": "Applies to"
|
||||
},
|
||||
"account": {
|
||||
"avatar": {
|
||||
"change": "Change picture",
|
||||
"remove": "Remove picture",
|
||||
"saved": "Picture updated",
|
||||
"removed": "Picture removed",
|
||||
"tooBig": "The picture must be at most 1 MB"
|
||||
},
|
||||
"loadFailed": "Could not load your account: {{message}}",
|
||||
"memberSince": "Member since",
|
||||
"workspaces": "Workspaces",
|
||||
|
||||
@@ -31,5 +31,9 @@
|
||||
"installedTitle": "Version {{version}} is installed but not running",
|
||||
"later": "Not now",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
"sessions": "Sessions",
|
||||
"last7d": "7 days",
|
||||
"last30d": "30 days",
|
||||
"storage": "Storage",
|
||||
"storageHint": "{{files}} files · measured {{at}}",
|
||||
"storagePartial": "at least — part of the folder could not be read in time",
|
||||
"storageNone": "No home folder of their own",
|
||||
"storageMeasuring": "Measuring…",
|
||||
"disabled": "disabled",
|
||||
"you": "you",
|
||||
"tokens": "{{n}} tokens",
|
||||
@@ -57,9 +62,12 @@
|
||||
"enabled": "{{name}} enabled",
|
||||
"password": "{{name}}'s password reset",
|
||||
"workspace": "Workspace given to {{name}}",
|
||||
"updated": "{{name}} updated"
|
||||
"updated": "{{name}} updated",
|
||||
"deleted": "{{name}} was deleted."
|
||||
},
|
||||
"errors": {
|
||||
"EBADIMAGE": "Only a PNG, JPEG, GIF or WebP picture",
|
||||
"ETOOBIG": "The picture must be at most 1 MB",
|
||||
"EREAUTH": "Your password is wrong.",
|
||||
"ETOOMANY": "Too many wrong passwords. Try again in 15 minutes.",
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
@@ -72,7 +80,8 @@
|
||||
"ENOTOWNED": "That workspace is not this user's any more.",
|
||||
"EOWNERFOLDER": "That workspace's folder belongs to another user's account.",
|
||||
"EBADROLE": "Unknown role.",
|
||||
"ELOCALMODE": "Accounts are managed in server mode only."
|
||||
"ELOCALMODE": "Accounts are managed in server mode only.",
|
||||
"ELASTADMIN": "The last admin cannot be deleted."
|
||||
},
|
||||
"readOnly": "Analysts can view accounts but not change them.",
|
||||
"auditTitle": "Audit log",
|
||||
@@ -126,7 +135,8 @@
|
||||
"livePanes": "Live",
|
||||
"quota": "Quota",
|
||||
"spend": "5 hours",
|
||||
"spend7d": "{{cost}} in 7 days",
|
||||
"spend7d": "{{cost}} · {{tokens}} tokens in 7 days",
|
||||
"tokens": "{{tokens}} tokens",
|
||||
"added": "added {{at}}",
|
||||
"expired": "expired",
|
||||
"full": "full",
|
||||
@@ -187,5 +197,47 @@
|
||||
"codex": "An OpenAI API key (sk-…), or the whole ~/.codex/auth.json after `codex login` with a ChatGPT account."
|
||||
},
|
||||
"refreshFailed": "refresh failed"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Delete user",
|
||||
"title": "Delete {{name}}?",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"title": "Artifacts",
|
||||
"subtitle": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin — gom một chỗ.",
|
||||
"subtitleServer": "Những gì agent đã tạo ra — trang claude.ai, file, skill và plugin. Chia sẻ một mục là nó hiện trong Hub cho mọi người.",
|
||||
"tab.mine": "Thư viện của tôi",
|
||||
"tab.hub": "Hub",
|
||||
"kind.all": "Tất cả",
|
||||
"kind.page": "Trang",
|
||||
"kind.file": "File",
|
||||
"kind.skill": "Skill",
|
||||
"kind.plugin": "Plugin",
|
||||
"kindOne.page": "Trang",
|
||||
"kindOne.file": "File",
|
||||
"kindOne.skill": "Skill",
|
||||
"kindOne.plugin": "Plugin",
|
||||
"search": "Tìm artifact…",
|
||||
"add": "Thêm",
|
||||
"add.page": "Link claude.ai",
|
||||
"add.file": "File từ workspace",
|
||||
"add.skill": "Skill",
|
||||
"add.plugin": "Plugin",
|
||||
"empty.mine": "Chưa có gì. Trang mà phiên publish sẽ tự xuất hiện; thêm file, skill và plugin bằng nút Thêm.",
|
||||
"empty.hub": "Chưa ai chia sẻ gì.",
|
||||
"loading": "Đang tải…",
|
||||
"by": "của {{name}}",
|
||||
"shared": "Đã chia sẻ",
|
||||
"share": "Chia sẻ",
|
||||
"unshare": "Bỏ chia sẻ",
|
||||
"shareDone": "Đã chia sẻ lên Hub",
|
||||
"unshareDone": "Đã gỡ khỏi Hub",
|
||||
"open": "Mở",
|
||||
"copyLink": "Sao chép link",
|
||||
"preview": "Xem",
|
||||
"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",
|
||||
"installs": "{{count}} lượt cài",
|
||||
"installed": "Đã cài vào {{path}}",
|
||||
"installedPlugin": "Đã cài {{name}} — mở phiên mới (hoặc /reload-plugins) để dùng",
|
||||
"rename": "Sửa",
|
||||
"delete": "Xoá",
|
||||
"deleteTitle": "Xoá “{{title}}”?",
|
||||
"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á",
|
||||
"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:",
|
||||
"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",
|
||||
"replaceTitle": "Thay skill “{{name}}” của bạn?",
|
||||
"replaceBody": "Bạn đã có skill trùng tên. Bản cũ được chuyển vào ~/.claude/agenthub-replaced-skills/, không bị xoá.",
|
||||
"replaceConfirm": "Thay",
|
||||
"installsOff": "Đang tắt cài đặt",
|
||||
"installsSwitch": "Cho phép cài từ Hub",
|
||||
"installsSwitchHint": "Tắt: không ai cài được skill hay plugin chia sẻ vào tài khoản của mình.",
|
||||
"form.url": "Link claude.ai",
|
||||
"form.title": "Tiêu đề (tuỳ chọn)",
|
||||
"form.description": "Mô tả (tuỳ chọn)",
|
||||
"form.workspace": "Workspace",
|
||||
"form.pickWorkspace": "Chọn workspace…",
|
||||
"form.noCandidates": "Các phiên của workspace này chưa ghi tài liệu nào.",
|
||||
"form.pinned": "Đã ghim",
|
||||
"form.pin": "Ghim",
|
||||
"form.missing": "đã mất",
|
||||
"form.skillFrom": "Skill",
|
||||
"form.fromUser": "của tôi",
|
||||
"form.fromWorkspace": "workspace",
|
||||
"form.noSkills": "Không thấy skill nào trong ~/.claude/skills của bạn hay .claude/skills của workspace này.",
|
||||
"form.pluginKey": "Plugin (tên@marketplace)",
|
||||
"form.market": "Marketplace (owner/repo hoặc git URL)",
|
||||
"form.save": "Thêm",
|
||||
"form.cancel": "Huỷ",
|
||||
"added": "Đã thêm",
|
||||
"files": "{{count}} file",
|
||||
"close": "Đóng",
|
||||
"fromSession": "Từ một phiên",
|
||||
"form.saveEdit": "Lưu",
|
||||
"skill.tab.upload": "Tải file lên",
|
||||
"skill.tab.write": "Viết mới",
|
||||
"skill.tab.existing": "Từ skill có sẵn",
|
||||
"skill.name": "Tên skill",
|
||||
"skill.slugHint": "Cài vào ~/.claude/skills/{{slug}}/ và gọi bằng /{{slug}}",
|
||||
"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.descriptionHint": "VD: Dùng khi viết release notes từ git log",
|
||||
"skill.content": "Hướng dẫn (nội dung SKILL.md)",
|
||||
"skill.contentHint": "Viết các bước Claude cần làm. Có thể dán nguyên một file SKILL.md, kể cả frontmatter.",
|
||||
"skill.dropHint": "Kéo thả vào đây: SKILL.md, một file Markdown bất kỳ, thư mục skill, nhiều file hoặc file .zip/.skill",
|
||||
"skill.pickFiles": "Chọn file",
|
||||
"skill.pickFolder": "Chọn thư mục",
|
||||
"skill.clear": "Bỏ chọn",
|
||||
"skill.uploading": "Đang tải lên…",
|
||||
"skill.shape.none": "Chưa có gì để tải lên",
|
||||
"skill.shape.single-md": "File Markdown này sẽ thành SKILL.md của skill",
|
||||
"skill.shape.archive": "File nén — sẽ được giải nén; mỗi thư mục có SKILL.md thành một skill",
|
||||
"skill.shape.one": "Một thư mục skill",
|
||||
"skill.shape.many": "{{count}} skill — mỗi skill được thêm riêng, đặt tên theo thư mục",
|
||||
"skill.shape.no-skill-md": "Không có SKILL.md — thêm một file SKILL.md, hoặc chỉ tải lên một file Markdown",
|
||||
"skill.manyHint": "Nhiều skill: mỗi skill giữ tên thư mục và mô tả trong SKILL.md của nó.",
|
||||
"skill.addedMany": "Đã thêm {{count}} skill",
|
||||
"plugin.link": "Link marketplace",
|
||||
"plugin.linkHint": "Repo git có .claude-plugin/marketplace.json (link https, link trang web của repo, hoặc owner/repo với GitHub), hoặc link tới file marketplace.json.",
|
||||
"plugin.read": "Đọc danh sách plugin",
|
||||
"plugin.reading": "Đang đọc…",
|
||||
"plugin.none": "Marketplace này không có plugin nào.",
|
||||
"plugin.all": "Chọn tất cả",
|
||||
"plugin.have": "đã thêm",
|
||||
"plugin.addPicked": "Thêm {{count}} plugin",
|
||||
"plugin.addedMany": "Đã thêm {{count}} plugin",
|
||||
"plugin.manual": "Nhập tay name@marketplace (link ssh, repo riêng tư…)",
|
||||
"file.fromComputer": "Từ máy tính (≤ 25 MB)",
|
||||
"file.upload": "Tải lên",
|
||||
"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"
|
||||
}
|
||||
@@ -31,10 +31,8 @@
|
||||
"addLaneSetupTitle": "Thiết lập",
|
||||
"addLaneTitleLabel": "Tiêu đề",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"addLaneToolDefaultBadge": "Mặc định",
|
||||
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
||||
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
||||
"addLaneToolHint": "AI tool mặc định",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
||||
@@ -417,6 +415,12 @@
|
||||
"home.addFolder": "Thêm thư mục",
|
||||
"home.folderPath": "Đường dẫn tuyệt đối của thư mục",
|
||||
"home.add": "Thêm",
|
||||
"home.cloneFolders": "Hoặc clone repo",
|
||||
"home.cloneHint": "Mỗi dòng một URL — từng repo được clone vào {{path}} bằng login git của bạn (Cài đặt → Git) rồi thêm vào đây.",
|
||||
"home.clonePlaceholder": "https://host/owner/repo.git\ngit@host:owner/other.git",
|
||||
"home.cloneRun": "Clone",
|
||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||
"home.cloneDone": "Đã thêm",
|
||||
"home.suggested": "Worktree chưa theo dõi",
|
||||
"home.remove": "Gỡ",
|
||||
"home.missing": "không còn",
|
||||
@@ -426,13 +430,19 @@
|
||||
"tabs.new": "Tab mới",
|
||||
"tabs.newTerminal": "Terminal mới",
|
||||
"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.agentSettings": "Cài đặt agent…",
|
||||
"tabs.settings": "Cài đặt",
|
||||
"tabs.closeSettings": "Đóng Cài đặt",
|
||||
"tabs.splitHint": "Ctrl/Cmd+click: mở ở split · kéo thả vào một pane để đặt vị trí",
|
||||
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ",
|
||||
"panes.drag": "Kéo để di chuyển pane — thả vào cạnh pane khác để tách ở đó, vào giữa để đổi chỗ; giữ trên một tab khác để mở tab đó",
|
||||
"panes.drop.left": "Đặt bên trái",
|
||||
"panes.drop.right": "Đặt bên phải",
|
||||
"panes.drop.top": "Đặt phía trên",
|
||||
"panes.drop.bottom": "Đặt phía dưới",
|
||||
"panes.drop.swap": "Đổi chỗ",
|
||||
"panes.drop.replace": "Hiện ở đây — pane này sang tab riêng",
|
||||
"panes.pick": "Hiện session khác trong pane này",
|
||||
"panes.empty": "Không có session",
|
||||
"panes.split.right": "Tách sang phải",
|
||||
@@ -455,7 +465,8 @@
|
||||
"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.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.viewCommit": "Xem commit",
|
||||
"changes.copySha": "Sao chép SHA của commit",
|
||||
@@ -469,8 +480,13 @@
|
||||
"tabs.renameHint": "Nhấp đúp để đổi tên",
|
||||
"tabs.stopTitle": "Dừng “{{name}}”?",
|
||||
"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.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.commentPasted": "Đã dán vào tab {{tab}} — nhấn Enter ở đó để gửi",
|
||||
"changes.splitter": "Đổi độ rộng khung thay đổi",
|
||||
@@ -561,7 +577,7 @@
|
||||
"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.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.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
@@ -575,6 +591,17 @@
|
||||
"fx.purge": "Xoá vĩnh viễn",
|
||||
"fx.restored": "Đã khôi phục.",
|
||||
"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.openInExplorer": "Mở trong Quản lý file",
|
||||
"fx.places": "Workspace",
|
||||
@@ -604,7 +631,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.myHome": "Thư mục home",
|
||||
"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.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.",
|
||||
"tmuxUpgrade": "Cần nâng cấp tmux: {{advice}}"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"activityFeed": "Luồng hoạt động",
|
||||
"artifacts": "Artifacts",
|
||||
"agents": "Agent",
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
@@ -86,5 +87,6 @@
|
||||
"sidebar": "Thanh bên",
|
||||
"now": "bây giờ",
|
||||
"users": "Người dùng",
|
||||
"admin": "Quản trị",
|
||||
"files": "Quản lý file"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
"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": {
|
||||
"label": "Trợ lý {{name}}",
|
||||
"live": "Trực tuyến",
|
||||
"offline": "Ngoại tuyến",
|
||||
"close": "Đóng {{name}}",
|
||||
"statLive": "đang chạy",
|
||||
"statWaiting": "đang chờ",
|
||||
"statErrored": "lỗi",
|
||||
"mute": "Tắt tiếng",
|
||||
"unmute": "Bật tiếng",
|
||||
"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",
|
||||
"rename": "Bấm để đổi tên {{name}}",
|
||||
"renameLabel": "Tên pet",
|
||||
@@ -57,8 +53,7 @@
|
||||
"attention": "Cần bạn xem",
|
||||
"attentionError": "bị lỗi",
|
||||
"attentionWaiting": "đang chờ bạn",
|
||||
"attentionMore": "+{{count}} phiên khác trong Phiên",
|
||||
"chat": "Trò chuyện với {{name}}"
|
||||
"attentionMore": "+{{count}} phiên khác trong Phiên"
|
||||
},
|
||||
"bubbleDismiss": "Đóng",
|
||||
"open": "Mở trợ lý {{name}}",
|
||||
@@ -218,9 +213,12 @@
|
||||
},
|
||||
"chat": {
|
||||
"label": "Trò chuyện với {{name}}",
|
||||
"back": "Quay lại",
|
||||
"close": "Đóng {{name}}",
|
||||
"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",
|
||||
"typing": "đang trả lời…",
|
||||
"placeholder": "Hỏi {{name}} bất cứ điều gì…",
|
||||
@@ -903,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.",
|
||||
"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",
|
||||
"generate": "Tạo SSH key",
|
||||
"generate": "Tạo 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.",
|
||||
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
||||
@@ -911,11 +909,51 @@
|
||||
"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",
|
||||
"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). Private key không bao giờ rời khỏi server.",
|
||||
"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.",
|
||||
"noKey": "Chưa có key. Bấm Tạo key để bắt đầu.",
|
||||
"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.",
|
||||
"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",
|
||||
"tabGit": "GitHub · GitLab",
|
||||
"tabMore": "Key khác",
|
||||
"tabOther": "Host khác"
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
@@ -1178,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.",
|
||||
"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": {
|
||||
"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.",
|
||||
@@ -1265,6 +1320,13 @@
|
||||
"appliesTo": "Áp dụng cho"
|
||||
},
|
||||
"account": {
|
||||
"avatar": {
|
||||
"change": "Đổi ảnh đại diện",
|
||||
"remove": "Xoá ảnh đại diện",
|
||||
"saved": "Đã cập nhật ảnh đại diện",
|
||||
"removed": "Đã xoá ảnh đại diện",
|
||||
"tooBig": "Ảnh tối đa 1 MB"
|
||||
},
|
||||
"loadFailed": "Không tải được tài khoản: {{message}}",
|
||||
"memberSince": "Tham gia từ",
|
||||
"workspaces": "Workspace",
|
||||
|
||||
@@ -31,5 +31,9 @@
|
||||
"installedTitle": "Đã cài bản {{version}} nhưng chưa chạy",
|
||||
"later": "Để sau",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
"sessions": "Phiên",
|
||||
"last7d": "7 ngày",
|
||||
"last30d": "30 ngày",
|
||||
"storage": "Dung lượng",
|
||||
"storageHint": "{{files}} tệp · đo lúc {{at}}",
|
||||
"storagePartial": "ít nhất — một phần thư mục chưa đọc được kịp",
|
||||
"storageNone": "Không có thư mục home riêng",
|
||||
"storageMeasuring": "Đang đo…",
|
||||
"disabled": "đã khoá",
|
||||
"you": "bạn",
|
||||
"tokens": "{{n}} token",
|
||||
@@ -57,9 +62,12 @@
|
||||
"enabled": "Đã mở khoá {{name}}",
|
||||
"password": "Đã đặt lại mật khẩu cho {{name}}",
|
||||
"workspace": "Đã giao workspace cho {{name}}",
|
||||
"updated": "Đã cập nhật {{name}}"
|
||||
"updated": "Đã cập nhật {{name}}",
|
||||
"deleted": "Đã xoá {{name}}."
|
||||
},
|
||||
"errors": {
|
||||
"EBADIMAGE": "Chỉ nhận ảnh PNG, JPEG, GIF hoặc WebP",
|
||||
"ETOOBIG": "Ảnh tối đa 1 MB",
|
||||
"EREAUTH": "Mật khẩu của bạn không đúng.",
|
||||
"ETOOMANY": "Nhập sai mật khẩu quá nhiều lần. Thử lại sau 15 phút.",
|
||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||
@@ -72,7 +80,8 @@
|
||||
"ENOTOWNED": "Workspace đó không còn thuộc người này.",
|
||||
"EOWNERFOLDER": "Thư mục của workspace đó thuộc tài khoản của người dùng khác.",
|
||||
"EBADROLE": "Vai trò không hợp lệ.",
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server."
|
||||
"ELOCALMODE": "Chỉ quản lý tài khoản được ở chế độ server.",
|
||||
"ELASTADMIN": "Không thể xoá admin cuối cùng."
|
||||
},
|
||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||
"auditTitle": "Nhật ký audit",
|
||||
@@ -126,7 +135,8 @@
|
||||
"livePanes": "Đang chạy",
|
||||
"quota": "Quota",
|
||||
"spend": "5 giờ",
|
||||
"spend7d": "{{cost}} trong 7 ngày",
|
||||
"spend7d": "{{cost}} · {{tokens}} tokens trong 7 ngày",
|
||||
"tokens": "{{tokens}} tokens",
|
||||
"added": "thêm lúc {{at}}",
|
||||
"expired": "hết hạn",
|
||||
"full": "đầy",
|
||||
@@ -187,5 +197,47 @@
|
||||
"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"
|
||||
},
|
||||
"delete": {
|
||||
"button": "Xoá user",
|
||||
"title": "Xoá {{name}}?",
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,35 @@ describe("panePaste", () => {
|
||||
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.`", () => {
|
||||
// No cursor on the list.
|
||||
expect(parsePaneMenu(["● Steps:", " 1. one", " 2. two", "", RULE, "❯ ", RULE])).toBeNull();
|
||||
@@ -313,12 +342,35 @@ const CODEX_APPROVAL = [
|
||||
" 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", () => {
|
||||
// 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.
|
||||
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));
|
||||
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(has(menu)).toBe(true);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+451
-16
@@ -544,6 +544,79 @@ export type ServerUserUsage = AuthUser & {
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
/** One member's own tmux server (`GET /api/users/tmux`). */
|
||||
export interface MemberTmuxRow {
|
||||
id: number;
|
||||
username: string;
|
||||
running: boolean;
|
||||
/** Did not answer (hung, or backed off). */
|
||||
stuck?: boolean;
|
||||
version?: string | null;
|
||||
pid?: number | null;
|
||||
startedAt?: number | null;
|
||||
sessions?: number | null;
|
||||
/** Workspaces of theirs with an agent working now. */
|
||||
busy: number;
|
||||
/** Runs an older tmux than the installed one. */
|
||||
outdated: boolean;
|
||||
}
|
||||
/** `GET /api/users/tmux`: `available` only with one Linux user per account. */
|
||||
export interface MemberTmux {
|
||||
available: boolean;
|
||||
installed?: string | null;
|
||||
/** Calls on an older server already use its own binary (tmux-member installed). */
|
||||
matched?: boolean;
|
||||
users: MemberTmuxRow[];
|
||||
}
|
||||
|
||||
/** One account's (or the host's / other accounts') live share of the machine (`GET /api/host`). */
|
||||
export interface HostUsageGroup {
|
||||
/** `user:<id>`, `host` (the host account outside any workspace) or `system`. */
|
||||
key: string;
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
memory: number;
|
||||
memoryPercent: number;
|
||||
processes: number;
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
mount: string;
|
||||
type: string;
|
||||
kind: "HDD" | "SSD" | null;
|
||||
total: number;
|
||||
used: number;
|
||||
free: number;
|
||||
}[];
|
||||
/** The mount holding the users' homes, or null. */
|
||||
homesMount: string | null;
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
export type UserStorage = {
|
||||
bytes: number;
|
||||
files: number;
|
||||
/** The walk timed out or met an unreadable folder: a lower bound. */
|
||||
partial: boolean;
|
||||
measuredAt: string;
|
||||
} | null;
|
||||
/** GET /api/users/:id — one account plus the workspaces it owns. */
|
||||
export type UserDetail = {
|
||||
user: AuthUser & { disabled: boolean; logins?: number; lastLoginAt?: string | null };
|
||||
@@ -585,6 +658,8 @@ export type PoolAccount = {
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -596,6 +671,8 @@ export type CodexPoolAccount = {
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
tokens5h: number;
|
||||
tokens7d: number;
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
};
|
||||
@@ -644,6 +721,8 @@ export type AuthUser = {
|
||||
/** Optional profile fields — absent from an older server's answers. */
|
||||
displayName?: string | null;
|
||||
email?: string | null;
|
||||
/** When the account's avatar was last set (null = none) — versions its URL. */
|
||||
avatarAt?: string | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
export type AuthState = { mode: "local" } | { mode: "server"; user: AuthUser | null };
|
||||
@@ -679,6 +758,10 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
...(token ? { "x-dashboard-token": token } : {}),
|
||||
...((options?.headers as Record<string, string>) || {}),
|
||||
};
|
||||
// A multipart upload: the browser writes Content-Type with its boundary.
|
||||
if (typeof FormData !== "undefined" && options?.body instanceof FormData) {
|
||||
delete headers["Content-Type"];
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, { ...options, headers });
|
||||
if (!res.ok) {
|
||||
// Try to recover a structured error message from the JSON body; if the body
|
||||
@@ -799,6 +882,11 @@ export interface FsTrashItem {
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
/** A home's recycle bin only: which of the user's trash folders holds it. */
|
||||
store?: "home" | "host";
|
||||
/** The merged view only (`allTrash`): the home it belongs to, and its user. */
|
||||
home?: string;
|
||||
owner?: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
@@ -857,14 +945,39 @@ export const api = {
|
||||
}),
|
||||
/** POST /api/me/sign-out-others — end every login of this account but this one. */
|
||||
signOutOthers: () => request<{ ended: number }>("/me/sign-out-others", { method: "POST" }),
|
||||
/** URL of this account's avatar, versioned by `avatarAt` (null = none). */
|
||||
avatarUrl: (avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/me/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** PUT /api/me/avatar — one PNG, JPEG, GIF or WebP image, at most 1 MB. */
|
||||
setAvatar: (file: Blob) =>
|
||||
request<MyAccount>("/me/avatar", {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
}),
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () => request<{ users: ServerUserUsage[] }>("/users"),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
/** GET /api/users/storage — each user's home size, measured in the background
|
||||
* (`measuring` = more numbers are coming); `refresh` re-measures every home. */
|
||||
storage: (refresh = false) =>
|
||||
request<{ storage: Record<string, UserStorage>; measuring: boolean }>(
|
||||
`/users/storage${refresh ? "?refresh=1" : ""}`
|
||||
),
|
||||
// Account changes (admin only). Each carries the admin's OWN password
|
||||
// again (`currentPassword`): the server refuses without it (EREAUTH).
|
||||
/** POST /api/users — a new account. */
|
||||
@@ -876,6 +989,24 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/** GET /api/users/tmux — each member's own tmux server beside the installed version (S3). */
|
||||
tmux: () => request<MemberTmux>("/users/tmux"),
|
||||
/** POST /api/users/:id/tmux-restart — end that member's tmux server (every pane of theirs). */
|
||||
tmuxRestart: (id: number, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/tmux-restart`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
/** POST /api/users/tmux-restart-idle — restart every outdated member server with no agent working. */
|
||||
tmuxRestartIdle: (currentPassword: string) =>
|
||||
request<{
|
||||
restarted: string[];
|
||||
skipped: string[];
|
||||
failed: { username: string; message: string }[];
|
||||
}>("/users/tmux-restart-idle", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
/** GET /api/users/:id — one account and the workspaces it owns (admin/analyst). */
|
||||
get: (id: number) => request<UserDetail>(`/users/${id}`),
|
||||
/** PATCH /api/users/:id — profile, role, state, password, workspaces and
|
||||
@@ -885,6 +1016,16 @@ export const api = {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ ...body, currentPassword }),
|
||||
}),
|
||||
/**
|
||||
* DELETE /api/users/:id — delete the user for good: logins, their
|
||||
* workspaces' records and (one Linux user per account) their Linux
|
||||
* account and whole home. Never yourself or the last admin.
|
||||
*/
|
||||
remove: (id: number, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ currentPassword }),
|
||||
}),
|
||||
/** POST /api/users/:id/password — a new password; that user's logins end. */
|
||||
setPassword: (id: number, password: string, currentPassword: string) =>
|
||||
request<{ user: AuthUser }>(`/users/${id}/password`, {
|
||||
@@ -1508,6 +1649,23 @@ export const api = {
|
||||
/** POST /api/settings/backup/run — starts one; poll `get` while `running` */
|
||||
run: () => request<BackupState & { ok: boolean }>("/settings/backup/run", { method: "POST" }),
|
||||
},
|
||||
/** Opt-in automatic retention and Compact (`server/lib/db-retention.js`). */
|
||||
retention: {
|
||||
/** GET /api/settings/retention */
|
||||
get: () => request<RetentionState>("/settings/retention"),
|
||||
/** PUT /api/settings/retention — `days` 0 turns it off */
|
||||
save: (days: number) =>
|
||||
request<RetentionState & { ok: boolean }>("/settings/retention", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ days }),
|
||||
}),
|
||||
/** POST /api/settings/compact — a full VACUUM; poll `get` while `compacting` */
|
||||
compact: () =>
|
||||
request<RetentionState & { ok: boolean }>("/settings/compact", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ confirm: true }),
|
||||
}),
|
||||
},
|
||||
/** The bearer token for `POST /api/hooks/ingest-batch`. Only its hash is
|
||||
* stored: `rotate` returns the new token this once, `get` never does. */
|
||||
pushToken: {
|
||||
@@ -2545,17 +2703,23 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) =>
|
||||
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
trash: (t: FsPlace) => request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
/** Every Trash the caller may open, merged (an admin: every user's). */
|
||||
allTrash: () =>
|
||||
request<{
|
||||
items: FsTrashItem[];
|
||||
errors?: { home: string; owner: string; message: string }[];
|
||||
keepDays?: number;
|
||||
}>("/homes/trash"),
|
||||
restore: (t: FsPlace, id: string, store?: FsTrashItem["store"]) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
purge: (t: FsPlace, id: string | "all", store?: FsTrashItem["store"]) =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
body: JSON.stringify(store ? { id, store } : { id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
@@ -2613,13 +2777,34 @@ export const api = {
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
|
||||
generateKey: (replace = false) =>
|
||||
/**
|
||||
* Make the SSH key; `replace` swaps an existing one for a new pair.
|
||||
* `type`: ed25519 (default) or rsa (RSA 4096 — AWS CodeCommit takes only RSA).
|
||||
*/
|
||||
generateKey: (replace = false, type: "ed25519" | "rsa" = "ed25519", name?: string) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
body: JSON.stringify(name ? { replace, type, name } : { replace, type }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
/** DELETE /api/me/git/ssh-key — the default key of `type`, or every key. */
|
||||
removeKey: (type?: "ed25519" | "rsa") =>
|
||||
request<GitIdentity>(`/me/git/ssh-key${type ? `?type=${type}` : ""}`, { method: "DELETE" }),
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
/**
|
||||
* PUT /api/me/git/ssh-user — an SSH login (CodeCommit: the SSH key ID as
|
||||
* the user): for the host itself, or with `alias` one more account on it
|
||||
* (`ssh://<alias>/…`); `key` = the only key it uses.
|
||||
*/
|
||||
setSshUser: (body: { host: string; user: string; alias?: string; key?: string }) =>
|
||||
request<GitIdentity>("/me/git/ssh-user", { method: "PUT", body: JSON.stringify(body) }),
|
||||
/** DELETE a login by its host (the host's own rule) or its alias. */
|
||||
removeSshUser: (from: string) =>
|
||||
request<GitIdentity>(`/me/git/ssh-user/${encodeURIComponent(from)}`, { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
@@ -2646,6 +2831,115 @@ export const api = {
|
||||
),
|
||||
remove: (id: number) => request<{ ok: true }>(`/snapshots/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
/** The Artifacts hub (`/api/artifacts`, `server/lib/artifacts.js`). */
|
||||
artifacts: {
|
||||
list: (params: { scope?: "mine" | "hub" | "all"; kind?: ArtifactKind; q?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.scope) qs.set("scope", params.scope);
|
||||
if (params.kind) qs.set("kind", params.kind);
|
||||
if (params.q) qs.set("q", params.q);
|
||||
const s = qs.toString();
|
||||
return request<{ items: Artifact[] }>(`/artifacts${s ? `?${s}` : ""}`);
|
||||
},
|
||||
settings: () => request<ArtifactSettings>("/artifacts/settings"),
|
||||
setInstallsEnabled: (installsEnabled: boolean) =>
|
||||
request<{ installsEnabled: boolean }>("/artifacts/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ installsEnabled }),
|
||||
}),
|
||||
/** Documents a workspace's sessions wrote, to pin. */
|
||||
candidates: (laneId: number) =>
|
||||
request<{ items: ArtifactCandidate[] }>(`/artifacts/candidates?laneId=${laneId}`),
|
||||
/** Skill folders the caller may share (their own, and the workspace's). */
|
||||
skills: (laneId?: number) =>
|
||||
request<{ items: { name: string; from: "user" | "workspace" }[] }>(
|
||||
`/artifacts/skills${laneId ? `?laneId=${laneId}` : ""}`
|
||||
),
|
||||
addPage: (body: { url: string; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/page", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
pinFile: (body: { laneId: number; path: string; title?: string; sessionId?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/file", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
addSkill: (body: { name: string; laneId?: number; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/skill", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
addPlugin: (body: { key: string; market: string; title?: string; description?: string }) =>
|
||||
request<{ item: Artifact }>("/artifacts/plugin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** Read a marketplace (git link, owner/repo or marketplace.json link) and list its plugins. */
|
||||
inspectMarket: (market: string) =>
|
||||
request<MarketplaceInfo>("/artifacts/plugin/inspect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ market }),
|
||||
}),
|
||||
/** A skill written in the dialog: SKILL.md gets `name`/`description` in its frontmatter. */
|
||||
writeSkill: (body: { name: string; description: string; content: string; title?: string }) =>
|
||||
request<{ item: Artifact; items: Artifact[] }>("/artifacts/skill/new", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** Skills from files: one Markdown file, a folder, several files, a .zip/.skill, or
|
||||
* several skills at once (one artifact each). `path` keeps a folder's layout. */
|
||||
uploadSkills: (
|
||||
files: { file: File; path: string }[],
|
||||
fields: { name?: string; title?: string; description?: string } = {}
|
||||
) => {
|
||||
const form = new FormData();
|
||||
for (const f of files) form.append("files", f.file, f.file.name);
|
||||
form.append("paths", JSON.stringify(files.map((f) => f.path)));
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ items: Artifact[] }>("/artifacts/skill/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
/** One file from the user's computer, kept like a pinned one (≤ 25 MB). */
|
||||
uploadFile: (file: File, fields: { title?: string; description?: string } = {}) => {
|
||||
const form = new FormData();
|
||||
form.append("files", file, file.name);
|
||||
for (const [k, v] of Object.entries(fields)) if (v) form.append(k, v);
|
||||
return request<{ item: Artifact }>("/artifacts/file/upload", { method: "POST", body: form });
|
||||
},
|
||||
update: (
|
||||
id: number,
|
||||
body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
shared?: boolean;
|
||||
/** plugin only: `name@marketplace`. */
|
||||
source?: string;
|
||||
/** plugin only: the marketplace source. */
|
||||
market?: string;
|
||||
}
|
||||
) =>
|
||||
request<{ item: Artifact }>(`/artifacts/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
remove: (id: number) => request<{ ok: true }>(`/artifacts/${id}`, { method: "DELETE" }),
|
||||
install: (id: number, replace = false) =>
|
||||
request<{ installed: string; replaced?: boolean }>(`/artifacts/${id}/install`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
/** One of the snapshot's own files, served sandboxed. */
|
||||
rawUrl: (id: number, path?: string, download = false) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (path) qs.set("path", path);
|
||||
if (download) qs.set("download", "1");
|
||||
const s = qs.toString();
|
||||
return `${BASE}/artifacts/${id}/raw${s ? `?${s}` : ""}`;
|
||||
},
|
||||
},
|
||||
/** The mascot's custom pets (`/api/pets`, `server/lib/pets.js`). */
|
||||
pets: {
|
||||
list: () => request<PetList>("/pets"),
|
||||
@@ -2776,8 +3070,12 @@ export const api = {
|
||||
/** GET /api/run/binary - whether the lane tool's CLI was found on PATH (fresh look). */
|
||||
binary: (tool: LaneTool = "claude") =>
|
||||
request<{ found: boolean; path: string | null }>(`/run/binary?tool=${tool}`),
|
||||
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH. */
|
||||
tmuxAvailable: () => request<{ available: boolean }>("/run/tmux"),
|
||||
/** GET /api/run/tmux - whether the `tmux` binary was found on PATH, its
|
||||
* version, and this platform's upgrade advice when it is too old. */
|
||||
tmuxAvailable: () =>
|
||||
request<{ available: boolean; version?: string | null; upgrade?: string | null }>(
|
||||
"/run/tmux"
|
||||
),
|
||||
/** GET /api/run/cwds - suggested working directories for the cwd picker. */
|
||||
cwds: () => request<{ items: CwdSuggestion[] }>("/run/cwds"),
|
||||
/** GET /api/run/files - path-completion suggestions under `cwd`; `dirs`
|
||||
@@ -2828,9 +3126,13 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ mode }),
|
||||
}),
|
||||
/** DELETE /api/run/:id - kill the tmux session. */
|
||||
kill: (id: string) =>
|
||||
request<{ ok: true }>(`/run/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
/** DELETE /api/run/:id - kill the tmux session. `archive` (the UI's Stop)
|
||||
* also archives the session it held; Sleep leaves it listed to resume. */
|
||||
kill: (id: string, opts?: { archive?: boolean }) =>
|
||||
request<{ ok: true; archived?: string | null }>(
|
||||
`/run/${encodeURIComponent(id)}${opts?.archive ? "?archive=1" : ""}`,
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
/** GET /api/run/interrupted - sessions whose pane died under them (reboot,
|
||||
* killed tmux, crash), newest first; the caller's own workspaces only. */
|
||||
interrupted: () => request<{ items: InterruptedRun[] }>("/run/interrupted"),
|
||||
@@ -3640,6 +3942,21 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/clone — clone a related repo BESIDE the
|
||||
* workspace folder and add it as an extra folder (409 ECREDENTIALS /
|
||||
* ESSHAUTH like {@link lanes.clone}). */
|
||||
cloneFolder: (
|
||||
id: number,
|
||||
body: {
|
||||
url: string;
|
||||
name?: string;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||
addFolder: (id: number, path: string) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
||||
@@ -3820,6 +4137,67 @@ function requestCodexBackupsHelper(params?: {
|
||||
export type CcArtifactType =
|
||||
"skills" | "agents" | "commands" | "output-styles" | "memory" | "auto-memory";
|
||||
|
||||
export type ArtifactKind = "page" | "file" | "skill" | "plugin";
|
||||
|
||||
/** One row of the Artifacts hub (`server/lib/artifacts.js` `view`). */
|
||||
export interface Artifact {
|
||||
id: number;
|
||||
kind: ArtifactKind;
|
||||
title: string;
|
||||
description: string;
|
||||
ownerId: number | null;
|
||||
owner: string | null;
|
||||
mine: boolean;
|
||||
canChange: boolean;
|
||||
laneId: number | null;
|
||||
sessionId: string | null;
|
||||
/** page: the file it was published from; file: its workspace path;
|
||||
* skill: its folder name; plugin: `name@marketplace`. */
|
||||
source: string | null;
|
||||
/** plugin: the marketplace source (owner/repo or a git URL). */
|
||||
market: string | null;
|
||||
/** page: the claude.ai link. */
|
||||
url: string | null;
|
||||
files: { path: string; size: number }[];
|
||||
size: number | null;
|
||||
shared: boolean;
|
||||
sharedAt: string | null;
|
||||
installs: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** POST /api/artifacts/plugin/inspect — a marketplace and the plugins it lists. */
|
||||
export interface MarketplaceInfo {
|
||||
/** The source as stored (owner/repo, a git URL or a marketplace.json link). */
|
||||
market: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plugins: {
|
||||
name: string;
|
||||
/** `name@marketplace`, what `claude plugin install` takes. */
|
||||
key: string;
|
||||
description: string;
|
||||
version: string;
|
||||
category: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ArtifactCandidate {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
at: string;
|
||||
exists: boolean;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface ArtifactSettings {
|
||||
serverMode: boolean;
|
||||
canShare: boolean;
|
||||
installsEnabled: boolean;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
/** Body for PUT /api/cc-config/file - create or overwrite one artifact. */
|
||||
export interface CcWriteArgs {
|
||||
// "auto-memory" targets a per-project memory file and requires `project`.
|
||||
@@ -3950,9 +4328,36 @@ export interface GitIdentity {
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { publicKey: string; fingerprint: string } | null;
|
||||
ssh: {
|
||||
type?: "ed25519" | "rsa";
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
/** Whether a plain `git` in the account's terminal uses the key (`~/.gitconfig` core.sshCommand); `foreign` = the user set their own. */
|
||||
terminal?: "on" | "off" | "foreign";
|
||||
} | null;
|
||||
/**
|
||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/**
|
||||
* SSH logins (`url.<…>.insteadOf` in `~/.gitconfig`): `alias` null = the
|
||||
* host's own (`ssh://<host>/`), else `ssh://<alias>/`; `key` = the one key
|
||||
* it uses, null = every key. Absent from an older server.
|
||||
*/
|
||||
sshUsers?: { host: string; user: string; alias?: string | null; key?: string | null }[];
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
/** The suggested email while none is set: `<username>@smartgift.vn`. */
|
||||
defaultEmail?: string | null;
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
@@ -5073,6 +5478,36 @@ export interface BackupState {
|
||||
env_owned: { dir: boolean; hour: boolean; keep: boolean };
|
||||
}
|
||||
|
||||
/** GET /api/settings/retention. `days` 0 = automatic retention off;
|
||||
* `last_run`/`last_compact` are this server process's own. */
|
||||
export interface RetentionState {
|
||||
days: number;
|
||||
max_days: number;
|
||||
env_owned: { days: boolean };
|
||||
last_run: {
|
||||
at: string;
|
||||
retention?: { purged_sessions: number; purged_events: number; error?: string };
|
||||
reclaim?: { reclaimed_pages: number; error?: string };
|
||||
} | null;
|
||||
space: {
|
||||
auto_vacuum: "none" | "full" | "incremental";
|
||||
page_size: number;
|
||||
page_count: number;
|
||||
freelist_pages: number;
|
||||
free_bytes: number;
|
||||
} | null;
|
||||
db_size: number;
|
||||
compacting: boolean;
|
||||
last_compact: {
|
||||
at: string;
|
||||
ok: boolean;
|
||||
size_before?: number;
|
||||
size_after?: number;
|
||||
ms?: number;
|
||||
error?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** GET /api/settings/push-token — whether push ingest is on; never the token. */
|
||||
export interface PushTokenStatus {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
* terminal moves the unsent prompt across (`takeRunInput` reads the TUI's
|
||||
* input box off the screen and empties it; the chat's draft is pasted back
|
||||
* without Enter), so the half-typed text is never left behind on the other side.
|
||||
* Attached images' paths go after the text, each in a paste of its own: `claude`
|
||||
* turns a paste that is only an image's path into `[Image #n]`, but left a
|
||||
* Windows path (`C:\…\drops\…png`) inside a paste of text as a plain path.
|
||||
* The Chat view also answers a menu the CLI asks with (`parsePaneMenu` reads
|
||||
* it off the screen; `chooseRunOption`/`answerRunMenu` press its digit, the
|
||||
* arrows and Enter, or type into its "Type something" row).
|
||||
@@ -24,7 +27,8 @@
|
||||
export type PaneScreen = { rows: string[]; cols: number };
|
||||
|
||||
interface Pane {
|
||||
paste: (text: string, submit?: boolean) => void;
|
||||
/** `files`: image paths pasted after `text`, one paste each. */
|
||||
paste: (text: string, submit?: boolean, files?: string[]) => void;
|
||||
visible: () => boolean;
|
||||
key?: (data: string) => void;
|
||||
/** `menuOnly`: null at once unless a row could be a menu's cursor row. */
|
||||
@@ -62,19 +66,19 @@ function ofLane(runId: string, laneId: number): boolean {
|
||||
|
||||
/** Pastes `text` into the pane of `runId` when it is mounted and on screen;
|
||||
* false otherwise (not mounted yet, or hidden). */
|
||||
export function pasteToRun(runId: string, text: string): boolean {
|
||||
export function pasteToRun(runId: string, text: string, files: string[] = []): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text);
|
||||
p.paste(text, false, files);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Sends `text` as a message to the pane of `runId`: a paste, then Enter.
|
||||
* False when the pane is not mounted or not attached. */
|
||||
export function submitToRun(runId: string, text: string): boolean {
|
||||
export function submitToRun(runId: string, text: string, files: string[] = []): boolean {
|
||||
const p = panes.get(runId);
|
||||
if (!p?.visible()) return false;
|
||||
p.paste(text, true);
|
||||
p.paste(text, true, files);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -176,8 +180,9 @@ export type PaneMenu = {
|
||||
};
|
||||
|
||||
const OPTION = /^ {0,4}([❯›][ \u00a0]?)? *(\d{1,2})\. +(.*)$/;
|
||||
/** A row a menu's cursor sits on (a numbered option or Submit): a menu has one. */
|
||||
export const MENU_CURSOR_ROW = /^ *[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)/;
|
||||
/** A row only a menu shows: its cursor on a numbered option or Submit, or the
|
||||
* "Enter to confirm" footer of Claude's unnumbered menus (the trust prompt). */
|
||||
export const MENU_CURSOR_ROW = /^ *(?:[❯›][ \u00a0]* *(?:\d{1,2}\. |Submit\s*$)|Enter to confirm)/;
|
||||
const SUBMIT = /^ *([❯›])?[ \u00a0]*Submit$/;
|
||||
const RULE_ROW = /^ *[─╌]{3,} *$/;
|
||||
const CHECK = /^\[([ ✔])\] *(.*)$/;
|
||||
@@ -224,7 +229,8 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
}
|
||||
}
|
||||
const numbered = options.filter((o) => o.n !== null);
|
||||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? "")) return null;
|
||||
if (top < 0 || cursor < 0 || numbered.length < 2 || RULE_ROW.test(r[top - 1] ?? ""))
|
||||
return parseBareMenu(r.slice(0, end + 1));
|
||||
if (numbered.some((o, i) => o.n !== i + 1)) return null;
|
||||
// The question: the block of text right above the first option.
|
||||
let q = top - 1;
|
||||
@@ -241,6 +247,39 @@ export function parsePaneMenu(rows: string[]): PaneMenu | null {
|
||||
};
|
||||
}
|
||||
|
||||
const BARE_CURSOR = /^( {0,4})❯[ \u00a0]+(\S.*)$/;
|
||||
|
||||
/** Claude's UNNUMBERED menu at the bottom of `r` (the folder-trust prompt:
|
||||
* `❯ No, exit` over ` Yes, I trust this folder`), or null. Its rows share the
|
||||
* cursor row's text column, and nothing under them is a rule — Claude's input
|
||||
* box draws one above and below its `❯` row, so neither the box nor a prompt
|
||||
* in the history above it reads as a menu. Picked by arrows and Enter. */
|
||||
function parseBareMenu(r: string[]): PaneMenu | null {
|
||||
let at = -1;
|
||||
for (let i = r.length - 1; i >= 0 && at < 0; i--) if (BARE_CURSOR.test(r[i] ?? "")) at = i;
|
||||
if (at < 0 || RULE_ROW.test(r[at - 1] ?? "") || r.slice(at + 1).some((l) => RULE_ROW.test(l)))
|
||||
return null;
|
||||
const m = BARE_CURSOR.exec(r[at]!)!;
|
||||
const indent = " ".repeat((m[1] ?? "").length + 2);
|
||||
const sibling = (l = "") => l.startsWith(indent) && /\S/.test(l[indent.length] ?? " ");
|
||||
let top = at;
|
||||
while (top > 0 && sibling(r[top - 1])) top--;
|
||||
let last = at;
|
||||
while (last < r.length - 1 && sibling(r[last + 1])) last++;
|
||||
if (last === top) return null;
|
||||
const options = r.slice(top, last + 1).map((l, i) => ({
|
||||
n: null,
|
||||
label: top + i === at ? (m[2] ?? "").trim() : l.trim(),
|
||||
hint: "",
|
||||
checked: null,
|
||||
}));
|
||||
// The question: the paragraphs above, up to the dialog's top rule.
|
||||
const lines: string[] = [];
|
||||
for (let q = top - 1; q >= 0 && !RULE_ROW.test(r[q]!) && lines.length < 8; q--)
|
||||
if (r[q]) lines.unshift(r[q]!.trim());
|
||||
return { question: lines.join("\n"), options, cursor: at - top, multi: false, typeAt: -1 };
|
||||
}
|
||||
|
||||
/** The "Type something" row. Text typed into it replaces its label, and it is
|
||||
* still the last numbered row above "Chat about this". */
|
||||
function typeRow(options: PaneMenuOption[]): number {
|
||||
|
||||
@@ -64,6 +64,43 @@ export function droppedAttachments(dt: DataTransfer | null | undefined): Attachm
|
||||
}
|
||||
}
|
||||
|
||||
/** A drop that carries no file and none of ours, only text — a file manager
|
||||
* or an editor (VS Code) that hands over `file://` URIs, a selection, a
|
||||
* link: the local `file://` URIs as `@path` attachments, else the text to
|
||||
* insert, as a terminal pastes it. */
|
||||
export function droppedText(
|
||||
dt: DataTransfer | null | undefined
|
||||
): { attachments: Attachment[] } | { text: string } | null {
|
||||
try {
|
||||
const paths = (dt?.getData("text/uri-list") ?? "")
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => l && !l.startsWith("#"))
|
||||
.map((l) => {
|
||||
try {
|
||||
const u = new URL(l.trim());
|
||||
return u.protocol === "file:" && (!u.host || u.host === "localhost")
|
||||
? decodeURIComponent(u.pathname)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (paths.length && paths.every(Boolean)) {
|
||||
return {
|
||||
attachments: (paths as string[]).map((p) => ({
|
||||
// A space would end the mention: Claude Code reads `@"…"` whole.
|
||||
path: /\s/.test(p) ? `@"${p}"` : `@${p}`,
|
||||
name: p.replace(/\/$/, "").split("/").pop() || p,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const text = dt?.getData("text/plain") ?? "";
|
||||
return text.trim() ? { text } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SKILL_MIME = "application/x-agenthub-skill";
|
||||
// A skill's name as Claude Code types it: `name` or `plugin:name`.
|
||||
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @file resolvePath.ts — resolves a path a chat reply names against the pane's
|
||||
* folder: an absolute one (POSIX, `~`-less, or `C:\…`) is kept, a relative one
|
||||
* (`server/lib/x.js`, `./a`, `../b`) is joined to `cwd` with `.`/`..` folded,
|
||||
* so the preview and the Changes panel see the same absolute path the agent meant.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** `path` as an absolute path; relative ones resolve against `cwd`. */
|
||||
export function resolveIn(cwd: string, path: string): string {
|
||||
if (/^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(path) || !cwd) return path;
|
||||
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
||||
for (const seg of path.split(/[\\/]/)) {
|
||||
if (seg === "" || seg === ".") continue;
|
||||
if (seg === "..") {
|
||||
if (parts.length > 1) parts.pop();
|
||||
} else parts.push(seg);
|
||||
}
|
||||
return parts.join("/") || "/";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* @file sessionChange.ts
|
||||
* @description `session_created` / `session_updated` matcher for live-refresh
|
||||
* callers that care about only a few session columns. A session row is
|
||||
* re-broadcast on nearly every hook (last activity, name, tokens…), so
|
||||
* matching the bare message types made every such frame refetch; this one
|
||||
* fires on a new session, or when the chosen key of a session actually
|
||||
* changed. Each call keeps its own memory of the keys it has seen.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { Session, WSMessage } from "./types";
|
||||
|
||||
/** The columns that move a session between the active / waiting / archived counts. */
|
||||
export const sessionStateKeyOf = (s: Partial<Session>): string =>
|
||||
`${s.status}:${s.awaiting_input_since ? 1 : 0}:${s.archived_at ? 1 : 0}`;
|
||||
|
||||
export function sessionChangeMatcher(
|
||||
keyOf: (s: Partial<Session>) => string = sessionStateKeyOf
|
||||
): (msg: WSMessage) => boolean {
|
||||
const seen = new Map<string, string>();
|
||||
return (msg) => {
|
||||
if (msg.type !== "session_created" && msg.type !== "session_updated") return false;
|
||||
const s = msg.data as Partial<Session> | undefined;
|
||||
// A frame we cannot key is taken as a change: refetching is the safe side.
|
||||
if (!s || typeof s.id !== "string") return true;
|
||||
const key = keyOf(s);
|
||||
if (msg.type === "session_updated" && seen.get(s.id) === key) return false;
|
||||
seen.set(s.id, key);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -34,6 +34,8 @@ export const SETTINGS_INDEX: {
|
||||
to?: string;
|
||||
/** Server mode: a control only an admin sees, kept out of anyone else's search. */
|
||||
adminOnly?: boolean;
|
||||
/** Hidden in local mode (AgentHub's own git logins), so kept out of its search. */
|
||||
serverOnly?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
section: "account",
|
||||
@@ -249,6 +251,15 @@ export const SETTINGS_INDEX: {
|
||||
descKey: "aiTools.description",
|
||||
keywords: ["install", "update", "default"],
|
||||
},
|
||||
{
|
||||
section: "agents",
|
||||
titleKey: "artifacts:installsSwitch",
|
||||
descKey: "artifacts:installsSwitchHint",
|
||||
keywords: ["artifacts", "hub", "skill", "plugin", "cài đặt"],
|
||||
to: "#artifacts-hub",
|
||||
adminOnly: true,
|
||||
serverOnly: true,
|
||||
},
|
||||
{ section: "agents/claude-code", titleKey: "hooks.title" },
|
||||
{ section: "agents/claude-code", titleKey: "claudeHome.title" },
|
||||
{ section: "agents/claude-code", titleKey: "ccConfig:tabs.skills", to: "?tab=skills" },
|
||||
@@ -330,10 +341,17 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "agents/*", titleKey: "toolTab.defaultModel", keywords: ["model", "config"] },
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitCreds.title",
|
||||
adminOnly: true,
|
||||
serverOnly: true,
|
||||
keywords: ["login", "token"],
|
||||
},
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitIdentity.title",
|
||||
serverOnly: true,
|
||||
keywords: ["ssh", "key", "clone", "https"],
|
||||
to: "#git-identity",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file stopSession — the one way the UI ends live panes: **Stop** kills the
|
||||
* pane and ARCHIVES the session it held (`DELETE /api/run/:id?archive=1`), so
|
||||
* a session the user closed leaves its workspace's lists; **Sleep** kills the
|
||||
* pane and KEEPS the session listed, to be resumed later by sending it a
|
||||
* message. Both ask first when any pane holds an AI agent (it may be
|
||||
* mid-turn); a plain shell holds no session, so it neither asks nor sleeps.
|
||||
* Used by the tab bar (`ConsoleTabs`), the split panes (`Workspace`) and a
|
||||
* session's right-click menu (`SessionMenu`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { TFunction } from "i18next";
|
||||
import { api, type RunHandle } from "./api";
|
||||
import { confirmDialog } from "./confirm";
|
||||
import { toast } from "./toast";
|
||||
|
||||
/** Whether a pane can sleep: an AI pane (a shell has no session to keep). */
|
||||
export const canSleep = (run: RunHandle | null | undefined): boolean =>
|
||||
!!run && run.tool !== "shell";
|
||||
|
||||
/**
|
||||
* Stop (archive) or sleep (keep) `runs` after a confirm, and return the ids
|
||||
* that were ended. `name` titles a one-pane confirm; `t` is the `lanes`
|
||||
* namespace.
|
||||
*/
|
||||
export async function endRuns(
|
||||
runs: RunHandle[],
|
||||
{ sleep = false, name, t }: { sleep?: boolean; name: string; t: TFunction }
|
||||
): Promise<string[]> {
|
||||
const list = sleep ? runs.filter(canSleep) : runs;
|
||||
if (!list.length) return [];
|
||||
const kind = sleep ? "sleep" : "stop";
|
||||
if (
|
||||
list.some((r) => r.tool !== "shell") &&
|
||||
!(await confirmDialog({
|
||||
title:
|
||||
list.length > 1
|
||||
? t(`tabs.${kind}GroupTitle`, { count: list.length })
|
||||
: t(`tabs.${kind}Title`, { name }),
|
||||
message: t(`tabs.${kind}Body`),
|
||||
confirmLabel: t(`tabs.${kind}Confirm`),
|
||||
destructive: !sleep,
|
||||
}))
|
||||
)
|
||||
return [];
|
||||
const ended: string[] = [];
|
||||
for (const r of list) {
|
||||
try {
|
||||
await api.run.kill(r.id, { archive: !sleep });
|
||||
ended.push(r.id);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}
|
||||
return ended;
|
||||
}
|
||||
@@ -1925,7 +1925,9 @@ export interface WSMessage {
|
||||
/** A lane's profile hook exited, with its status code. */
|
||||
| "lane_hook_result"
|
||||
/** A pane ended; refetch GET /api/run/interrupted (no payload). */
|
||||
| "runs_interrupted";
|
||||
| "runs_interrupted"
|
||||
/** An artifact was added, changed, shared or deleted; refetch the list. */
|
||||
| "artifact_changed";
|
||||
/** The message body, whose concrete shape is selected by `type` above. */
|
||||
data:
|
||||
| Session
|
||||
@@ -2671,6 +2673,8 @@ export interface Lane {
|
||||
owner_name?: string | null;
|
||||
/** The owner's display name (`users.display_name`); null when none is set. */
|
||||
owner_display_name?: string | null;
|
||||
/** When the owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||
owner_avatar_at?: string | null;
|
||||
branch: string | null;
|
||||
kind: "managed" | "adopted";
|
||||
/** The CLI this lane's runs spawn. Immutable once the lane exists. */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst: a tab
|
||||
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
|
||||
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
|
||||
* (the machine's hardware and each user's share of it, `ServerPanel`).
|
||||
* Local mode and a developer are sent home, like the Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
import { Users } from "./Users";
|
||||
|
||||
interface AdminTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Administration() {
|
||||
const { t } = useTranslation("admin");
|
||||
const auth = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: AdminTab[] = [
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: Server },
|
||||
];
|
||||
const first = tabs[0]!;
|
||||
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<ShieldCheck className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
|
||||
tabs={tabs}
|
||||
>
|
||||
{active.id === "server" ? (
|
||||
<ServerPanel isAdmin={me.role === "admin"} />
|
||||
) : (
|
||||
<Users embedded />
|
||||
)}
|
||||
</PageTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
/**
|
||||
* @file Artifacts.tsx
|
||||
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
|
||||
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
|
||||
* workspace files (or one uploaded from the computer), skills (picked,
|
||||
* uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and
|
||||
* plugins (read from a marketplace link, or typed). **My library** lists the caller's
|
||||
* own; in server mode the **Hub** tab lists what every user shared, and a
|
||||
* shared skill or plugin is installed into the viewer's own account from
|
||||
* there (after a confirmation that lists what will be written or run). An
|
||||
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
|
||||
* installs off for everyone. Previews of a file are
|
||||
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||
* shared HTML file carries runs on the dashboard's origin. Right-clicking a
|
||||
* card offers the same actions as its buttons, plus copying a page's link.
|
||||
* Preview is offered only for what a browser shows inside that sandbox (text,
|
||||
* images, HTML, audio/video); an Office file, a zip or a PDF is downloaded —
|
||||
* the sandbox blocks both a download and the PDF viewer, so it would be blank.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
Globe,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type Artifact,
|
||||
type ArtifactCandidate,
|
||||
type ArtifactKind,
|
||||
type ArtifactSettings,
|
||||
} from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
PluginAddPanel,
|
||||
SkillAddPanel,
|
||||
} from "../components/artifacts/ArtifactAddPanels";
|
||||
|
||||
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
|
||||
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
|
||||
|
||||
function fmtBytes(n: number | null): string {
|
||||
if (!n) return "";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
|
||||
// What the sandboxed preview can show: the server's typed list minus PDF.
|
||||
const PREVIEWABLE =
|
||||
/\.(html?|md|markdown|txt|log|csv|tsv|json|ya?ml|sh|py|js|ts|png|jpe?g|gif|webp|svg|mp4|webm|mp3|wav)$/i;
|
||||
|
||||
export function Artifacts() {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [settings, setSettings] = useState<ArtifactSettings | null>(null);
|
||||
const [tab, setTab] = useState<"mine" | "hub">("mine");
|
||||
const [kind, setKind] = useState<ArtifactKind | "">("");
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState<Artifact[] | null>(null);
|
||||
const [adding, setAdding] = useState<ArtifactKind | null>(null);
|
||||
const [addMenu, setAddMenu] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [preview, setPreview] = useState<{ item: Artifact; path: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.artifacts
|
||||
.settings()
|
||||
.then(setSettings)
|
||||
.catch(() => setSettings(null));
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
api.artifacts
|
||||
.list({ scope: tab, kind: kind || undefined, q: q.trim() || undefined })
|
||||
.then((r) => setItems(r.items))
|
||||
.catch((err) => {
|
||||
setItems([]);
|
||||
toast.error(err);
|
||||
});
|
||||
}, [tab, kind, q]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(load, q ? 250 : 0);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [load, q]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg) => {
|
||||
if (msg.type === "artifact_changed") load();
|
||||
}),
|
||||
[load]
|
||||
);
|
||||
|
||||
// The Add menu closes on Escape or a click anywhere outside it.
|
||||
useEffect(() => {
|
||||
if (!addMenu) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(e.target as Node)) setAddMenu(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setAddMenu(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [addMenu]);
|
||||
|
||||
const server = !!settings?.serverMode;
|
||||
|
||||
const share = async (a: Artifact) => {
|
||||
try {
|
||||
await api.artifacts.update(a.id, { shared: !a.shared });
|
||||
toast.success(t(a.shared ? "unshareDone" : "shareDone"));
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const [renaming, setRenaming] = useState<Artifact | null>(null);
|
||||
|
||||
const remove = async (a: Artifact) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("deleteTitle", { title: a.title }),
|
||||
message: t("deleteBody"),
|
||||
confirmLabel: t("deleteConfirm"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.artifacts.remove(a.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const install = async (a: Artifact) => {
|
||||
const message =
|
||||
a.kind === "skill"
|
||||
? `${t("installSkillBody", { name: a.source })} ${a.files.map((f) => f.path).join(", ")}`
|
||||
: t("installPluginBody", { market: a.market, name: a.source });
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("installTitle", { title: a.title }),
|
||||
message,
|
||||
confirmLabel: t("installConfirm"),
|
||||
destructive: false,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
const done = (r: { installed: string }) =>
|
||||
toast.success(
|
||||
a.kind === "skill"
|
||||
? t("installed", { path: r.installed })
|
||||
: t("installedPlugin", { name: r.installed })
|
||||
);
|
||||
try {
|
||||
done(await api.artifacts.install(a.id));
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string }).code;
|
||||
if (code !== "EEXISTS") return toast.error(err);
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("replaceTitle", { name: a.source }),
|
||||
message: t("replaceBody"),
|
||||
confirmLabel: t("replaceConfirm"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
done(await api.artifacts.install(a.id, true));
|
||||
} catch (e) {
|
||||
toast.error(e);
|
||||
}
|
||||
}
|
||||
load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="artifacts-page">
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative" ref={addMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
data-testid="artifacts-add"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenu}
|
||||
onClick={() => setAddMenu((v) => !v)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("add")}
|
||||
</button>
|
||||
{addMenu && (
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-surface-1 p-1 shadow-xl">
|
||||
{KINDS.map((k) => {
|
||||
const Icon = KIND_ICON[k];
|
||||
return (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAdding(k);
|
||||
setAddMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{t(`add.${k}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{server && (
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["mine", "hub"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
aria-pressed={tab === v}
|
||||
onClick={() => setTab(v)}
|
||||
className={`rounded px-3 py-1 text-xs font-medium ${
|
||||
tab === v
|
||||
? "bg-accent-solid text-white"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(`tab.${v}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center rounded border border-border bg-surface-2 p-0.5">
|
||||
{(["", ...KINDS] as const).map((k) => (
|
||||
<button
|
||||
key={k || "all"}
|
||||
type="button"
|
||||
aria-pressed={kind === k}
|
||||
onClick={() => setKind(k)}
|
||||
className={`rounded px-2.5 py-1 text-xs ${
|
||||
kind === k
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{t(k ? `kind.${k}` : "kind.all")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t("search")}
|
||||
aria-label={t("search")}
|
||||
className="ml-auto w-56 rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{items === null ? (
|
||||
<p className="text-sm text-fg-muted">{t("loading")}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-10 text-center text-sm text-fg-muted">
|
||||
{t(tab === "hub" ? "empty.hub" : "empty.mine")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" data-testid="artifacts-list">
|
||||
{items.map((a) => (
|
||||
<ArtifactCard
|
||||
key={a.id}
|
||||
item={a}
|
||||
server={server}
|
||||
canInstall={!!settings?.installsEnabled}
|
||||
onShare={() => void share(a)}
|
||||
onRename={() => setRenaming(a)}
|
||||
onDelete={() => void remove(a)}
|
||||
onInstall={() => void install(a)}
|
||||
onPreview={(path) => setPreview({ item: a, path })}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
||||
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
||||
{renaming && (
|
||||
<RenameDialog
|
||||
item={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={() => {
|
||||
setRenaming(null);
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArtifactCard({
|
||||
item: a,
|
||||
server,
|
||||
canInstall,
|
||||
onShare,
|
||||
onRename,
|
||||
onDelete,
|
||||
onInstall,
|
||||
onPreview,
|
||||
}: {
|
||||
item: Artifact;
|
||||
server: boolean;
|
||||
canInstall: boolean;
|
||||
onShare: () => void;
|
||||
onRename: () => void;
|
||||
onDelete: () => void;
|
||||
onInstall: () => void;
|
||||
onPreview: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const Icon = KIND_ICON[a.kind];
|
||||
const menu = useContextMenu<null>();
|
||||
const pick = (fn: () => void) => () => {
|
||||
menu.close();
|
||||
fn();
|
||||
};
|
||||
const previewPath = a.kind === "skill" ? "SKILL.md" : (a.files[0]?.path ?? "");
|
||||
const canPreview =
|
||||
a.files.length > 0 &&
|
||||
(a.kind === "skill" || (a.kind === "file" && PREVIEWABLE.test(previewPath)));
|
||||
const btn =
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
return (
|
||||
<li
|
||||
className="flex flex-col gap-2 rounded-lg border border-border bg-surface-1 p-3"
|
||||
data-testid={`artifact-${a.id}`}
|
||||
onContextMenu={(e) => menu.open(e, null)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-fg-muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-fg-primary" title={a.title}>
|
||||
{a.title}
|
||||
</p>
|
||||
<p className="truncate text-[11px] text-fg-muted" title={a.source || a.url || ""}>
|
||||
{t(`kindOne.${a.kind}`)}
|
||||
{a.source ? ` · ${a.source}` : ""}
|
||||
{a.size ? ` · ${fmtBytes(a.size)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{a.shared && (
|
||||
<span className="shrink-0 rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
|
||||
{t("shared")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{a.description && <p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>}
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
|
||||
{new Date(a.createdAt).toLocaleString()}
|
||||
{a.installs > 0 ? ` · ${t("installs", { count: a.installs })}` : ""}
|
||||
</p>
|
||||
<div className="mt-auto flex flex-wrap items-center gap-1 border-t border-border-light pt-2">
|
||||
{a.kind === "page" && a.url && (
|
||||
<a href={a.url} target="_blank" rel="noopener noreferrer" className={btn}>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("open")}
|
||||
</a>
|
||||
)}
|
||||
{canPreview && (
|
||||
<button type="button" className={btn} onClick={() => onPreview(previewPath)}>
|
||||
<Eye className="h-3 w-3" />
|
||||
{t("preview")}
|
||||
</button>
|
||||
)}
|
||||
{a.kind === "file" && a.files.length > 0 && (
|
||||
<a href={api.artifacts.rawUrl(a.id, a.files[0]?.path, true)} className={btn}>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("download")}
|
||||
</a>
|
||||
)}
|
||||
{(a.kind === "skill" || a.kind === "plugin") && (
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!canInstall}
|
||||
title={canInstall ? undefined : t("installsOff")}
|
||||
onClick={onInstall}
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("install")}
|
||||
</button>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{server && a.canChange && (
|
||||
<button type="button" className={btn} onClick={onShare}>
|
||||
<Share2 className="h-3 w-3" />
|
||||
{t(a.shared ? "unshare" : "share")}
|
||||
</button>
|
||||
)}
|
||||
{a.canChange && (
|
||||
<>
|
||||
<button type="button" className={btn} aria-label={t("rename")} onClick={onRename}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" className={btn} aria-label={t("delete")} onClick={onDelete}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{menu.menu && (
|
||||
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="artifact-menu">
|
||||
{a.kind === "page" && a.url && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => window.open(a.url!, "_blank", "noopener,noreferrer"))}>
|
||||
{t("open")}
|
||||
</MenuItem>
|
||||
<MenuItem onSelect={pick(() => void copyText(a.url!))}>{t("copyLink")}</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{canPreview && (
|
||||
<MenuItem onSelect={pick(() => onPreview(previewPath))}>{t("preview")}</MenuItem>
|
||||
)}
|
||||
{a.kind === "file" && a.files.length > 0 && (
|
||||
<MenuItem
|
||||
onSelect={pick(() => {
|
||||
window.location.href = api.artifacts.rawUrl(a.id, a.files[0]?.path, true);
|
||||
})}
|
||||
>
|
||||
{t("download")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{(a.kind === "skill" || a.kind === "plugin") && (
|
||||
<MenuItem disabled={!canInstall} onSelect={pick(onInstall)}>
|
||||
{t("install")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{a.canChange && (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
{server && (
|
||||
<MenuItem onSelect={pick(onShare)}>{t(a.shared ? "unshare" : "share")}</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(onRename)}>{t("rename")}</MenuItem>
|
||||
<MenuItem danger onSelect={pick(onDelete)}>
|
||||
{t("delete")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function Modal({
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
useDialog(true, panel, onClose);
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={`flex max-h-[85vh] w-full ${wide ? "max-w-5xl" : "max-w-lg"} flex-col rounded-xl border border-border bg-surface-1 p-5 shadow-xl`}
|
||||
>
|
||||
<div className="mb-3 flex shrink-0 items-center justify-between gap-2">
|
||||
<h2 className="truncate text-sm font-semibold text-fg-primary">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("close")}
|
||||
onClick={onClose}
|
||||
className="text-fg-muted hover:text-fg-primary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDialog({
|
||||
item,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
item: Artifact;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [title, setTitle] = useState(item.title);
|
||||
const [description, setDescription] = useState(item.description);
|
||||
const [source, setSource] = useState(item.source ?? "");
|
||||
const [market, setMarket] = useState(item.market ?? "");
|
||||
const plugin = item.kind === "plugin";
|
||||
const field =
|
||||
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
return (
|
||||
<Modal title={t("rename")} onClose={onClose}>
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.artifacts.update(item.id, {
|
||||
title,
|
||||
description,
|
||||
...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}),
|
||||
...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}),
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
autoFocus
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={3}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{plugin && (
|
||||
<>
|
||||
<input
|
||||
className={field}
|
||||
aria-label={t("form.pluginKey")}
|
||||
placeholder="my-plugin@my-marketplace"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
aria-label={t("form.market")}
|
||||
placeholder="owner/repo · https://…"
|
||||
value={market}
|
||||
onChange={(e) => setMarket(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={!title.trim() || (plugin && (!source.trim() || !market.trim()))}
|
||||
>
|
||||
{t("form.saveEdit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewDialog({
|
||||
item,
|
||||
path,
|
||||
onClose,
|
||||
}: {
|
||||
item: Artifact;
|
||||
path: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [current, setCurrent] = useState(path);
|
||||
const url = api.artifacts.rawUrl(item.id, current);
|
||||
return (
|
||||
<Modal title={item.title} onClose={onClose} wide>
|
||||
<div className="flex h-[70vh] gap-3">
|
||||
{item.files.length > 1 && (
|
||||
<ul className="w-56 shrink-0 space-y-0.5 overflow-auto">
|
||||
{item.files.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrent(f.path)}
|
||||
className={`w-full truncate rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
|
||||
f.path === current
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-2"
|
||||
}`}
|
||||
title={f.path}
|
||||
>
|
||||
{f.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 rounded border border-border bg-white">
|
||||
{!PREVIEWABLE.test(current) ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 bg-surface-1 p-6 text-center">
|
||||
<p className="text-sm text-fg-secondary">{t("noPreview")}</p>
|
||||
<a
|
||||
href={api.artifacts.rawUrl(item.id, current, true)}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</div>
|
||||
) : IMAGE.test(current) && !/\.svg$/i.test(current) ? (
|
||||
<img src={url} alt={current} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
// No allow-* tokens: a shared page renders, nothing in it runs.
|
||||
<iframe
|
||||
title={current}
|
||||
src={url}
|
||||
sandbox=""
|
||||
className="h-full w-full"
|
||||
data-testid="artifact-preview"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function AddDialog({
|
||||
kind,
|
||||
onClose,
|
||||
onAdded,
|
||||
}: {
|
||||
kind: ArtifactKind;
|
||||
onClose: () => void;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "file" && kind !== "skill") return;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind === "file") {
|
||||
setCandidates(null);
|
||||
if (laneId)
|
||||
api.artifacts
|
||||
.candidates(laneId)
|
||||
.then((r) => setCandidates(r.items))
|
||||
.catch((err) => {
|
||||
setCandidates([]);
|
||||
toast.error(err);
|
||||
});
|
||||
}
|
||||
}, [kind, laneId]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, close = true) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(t("added"));
|
||||
onAdded();
|
||||
if (close) onClose();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field =
|
||||
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
const meta = (
|
||||
<>
|
||||
<input
|
||||
className={field}
|
||||
placeholder={t("form.title")}
|
||||
aria-label={t("form.title")}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<textarea
|
||||
className={field}
|
||||
rows={2}
|
||||
placeholder={t("form.description")}
|
||||
aria-label={t("form.description")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const workspacePicker = (
|
||||
<select
|
||||
className={field}
|
||||
aria-label={t("form.workspace")}
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
>
|
||||
<option value="">{t("form.pickWorkspace")}</option>
|
||||
{lanes.map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{l.title || l.cwd}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title={t(`add.${kind}`)} onClose={onClose}>
|
||||
<div className="space-y-2" data-testid={`artifacts-add-${kind}`}>
|
||||
{kind === "page" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(() => api.artifacts.addPage({ url, title, description }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="https://claude.ai/…"
|
||||
aria-label={t("form.url")}
|
||||
value={url}
|
||||
autoFocus
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary text-sm" disabled={busy || !url.trim()}>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{kind === "file" && (
|
||||
<>
|
||||
<FileUploadPanel onAdded={onAdded} />
|
||||
<p className="border-t border-border-light pt-2 text-[11px] font-medium text-fg-secondary">
|
||||
{t("file.fromWorkspace")}
|
||||
</p>
|
||||
{workspacePicker}
|
||||
{laneId && candidates === null && (
|
||||
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
||||
)}
|
||||
{candidates && candidates.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("form.noCandidates")}</p>
|
||||
)}
|
||||
{candidates && candidates.length > 0 && (
|
||||
<ul className="max-h-80 space-y-0.5 overflow-auto">
|
||||
{candidates.map((c) => (
|
||||
<li key={c.path} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
|
||||
title={c.path}
|
||||
>
|
||||
{c.path}
|
||||
</span>
|
||||
{!c.exists ? (
|
||||
<span className="text-fg-muted">{t("form.missing")}</span>
|
||||
) : c.pinned ? (
|
||||
<span className="text-fg-muted">{t("form.pinned")}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
await api.artifacts.pinFile({
|
||||
laneId: laneId!,
|
||||
path: c.path,
|
||||
sessionId: c.sessionId,
|
||||
});
|
||||
setCandidates((list) =>
|
||||
(list ?? []).map((x) =>
|
||||
x.path === c.path ? { ...x, pinned: true } : x
|
||||
)
|
||||
);
|
||||
}, false)
|
||||
}
|
||||
>
|
||||
{t("form.pin")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
|
||||
|
||||
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2790,9 +2790,12 @@ function RecentActivityPanel({ sessionsById }: { sessionsById: Map<string, Sessi
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useLiveRefresh(load, { intervalMs: 10000 });
|
||||
// New events arrive over the WebSocket (prepended below); a reload is only
|
||||
// for what the socket cannot carry: a remote source's import, a gap while it
|
||||
// was down (reconnect) or the tab hidden (shown again), and a slow safety poll.
|
||||
useLiveRefresh(load, { intervalMs: 60000, match: isRemoteDataRefreshMessage });
|
||||
|
||||
// A new event is prepended at once (no reload); the poll reconciles.
|
||||
// A new event is prepended at once (no reload); the reloads reconcile.
|
||||
useEffect(
|
||||
() =>
|
||||
eventBus.subscribe((msg: WSMessage) => {
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
* @description `/files` — the file manager (`FileBrowser`) as a page of its
|
||||
* own, reached from the sidebar's **Files** entry. The location is kept in the
|
||||
* URL (`?home=<me|user>&path=` or `?lane=<id>&path=`), so Back and a shared
|
||||
* link land in the same folder. With neither it opens the user's own HOME
|
||||
* (their Linux home under S3), or — where they have none — the last
|
||||
* workspace shown (`agenthub.workspace.selected`), else the first.
|
||||
* link land in the same folder. With neither it opens the workspace last
|
||||
* shown (`agenthub.workspace.selected`) — reached from a workspace, Files is
|
||||
* about THAT workspace — else the user's own HOME (their Linux home under S3),
|
||||
* else the first workspace.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -31,24 +32,31 @@ export function FilesPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const askedLane = Number(params.get("lane"));
|
||||
const askedHome = params.get("home");
|
||||
const [start, setStart] = useState<Start | null>(
|
||||
askedHome
|
||||
? { home: askedHome }
|
||||
: Number.isInteger(askedLane) && askedLane > 0
|
||||
? { lane: askedLane }
|
||||
: null
|
||||
);
|
||||
// The URL wins on every render: a place picked in the browser (another
|
||||
// user's home) lands in the URL, and a frozen start would send the next
|
||||
// folder opened there back to the first place.
|
||||
const asked: Start | null = askedHome
|
||||
? { home: askedHome }
|
||||
: Number.isInteger(askedLane) && askedLane > 0
|
||||
? { lane: askedLane }
|
||||
: null;
|
||||
const [fallback, setStart] = useState<Start | null>(null);
|
||||
const start = asked ?? fallback;
|
||||
const [none, setNone] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (start) return;
|
||||
void (async () => {
|
||||
const homes = await api.files.homes().catch(() => ({ homes: [] }));
|
||||
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
|
||||
// The workspace last shown first: Files reached from a workspace opens
|
||||
// THAT workspace (from PhuNT → AC_SHOP it opened your own home, empty
|
||||
// but for hidden files); the home is the fallback.
|
||||
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
|
||||
const last = lastWorkspace();
|
||||
const pick = lanes.find((l) => l.id === last) ?? lanes[0];
|
||||
if (pick) setStart({ lane: pick.id });
|
||||
const shown = lanes.find((l) => l.id === last);
|
||||
if (shown) return setStart({ lane: shown.id });
|
||||
const homes = await api.files.homes().catch(() => ({ homes: [] }));
|
||||
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
|
||||
if (lanes[0]) setStart({ lane: lanes[0].id });
|
||||
else setNone(true);
|
||||
})();
|
||||
}, [start]);
|
||||
|
||||
@@ -1129,7 +1129,11 @@ export function SessionDetail() {
|
||||
|
||||
{visitedTabs.has("conversation") && (
|
||||
<div hidden={activeTab !== "conversation"}>
|
||||
<ConversationView sessionId={session.id} initialTranscriptId={pendingTranscriptId} />
|
||||
<ConversationView
|
||||
sessionId={session.id}
|
||||
initialTranscriptId={pendingTranscriptId}
|
||||
ended={session.status !== "active"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -103,6 +103,7 @@ import { RemoteSources } from "../components/RemoteSources";
|
||||
import { PushIngestSettings } from "../components/settings/PushIngestSettings";
|
||||
import { PrivacySettings } from "../components/settings/PrivacySettings";
|
||||
import { BackupSettings } from "../components/settings/BackupSettings";
|
||||
import { RetentionSettings } from "../components/settings/RetentionSettings";
|
||||
import { AutostartSettings } from "../components/settings/AutostartSettings";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { Skeleton } from "../components/Skeleton";
|
||||
@@ -1054,6 +1055,8 @@ export function Settings({
|
||||
</button>
|
||||
|
||||
{actionBanner(["cleanup"])}
|
||||
|
||||
<RetentionSettings />
|
||||
</section>
|
||||
|
||||
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
|
||||
|
||||
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -242,7 +243,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
};
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin } = usePermissions();
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
@@ -292,6 +293,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -394,15 +397,17 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
...s("git"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Git logins are the host's own: an admin's. */}
|
||||
{isAdmin && (
|
||||
{/* Git logins are the host's own: an admin's. Not in local mode,
|
||||
nor the identity below: git uses this machine's own config,
|
||||
SSH keys and credential helper there. */}
|
||||
{isAdmin && !local && (
|
||||
<section id="git-credentials" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</section>
|
||||
)}
|
||||
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
|
||||
{(isAdmin || signedIn) && (
|
||||
{!local && (isAdmin || signedIn) && (
|
||||
<section id="git-identity" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("gitIdentity.title")}
|
||||
@@ -434,6 +439,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
...SETTINGS_INDEX.filter(
|
||||
(e) =>
|
||||
(isAdmin || !e.adminOnly) &&
|
||||
(!local || !e.serverOnly) &&
|
||||
(e.section === sec.id ||
|
||||
(e.section === "agents/*" &&
|
||||
sec.id.startsWith("agents/") &&
|
||||
|
||||
+145
-19
@@ -1,22 +1,28 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`). Every change asks for the admin's OWN password again (the
|
||||
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set.
|
||||
* name and email under the username when set; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -30,8 +36,14 @@ import {
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import {
|
||||
api,
|
||||
type AuditEntry,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -41,11 +53,13 @@ import { StatCard } from "../components/StatCard";
|
||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export function Users() {
|
||||
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
@@ -56,10 +70,27 @@ export function Users() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
const rowMenu = useContextMenu<ServerUserUsage>();
|
||||
const navigate = useNavigate();
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
const loadStorage = useCallback(
|
||||
(remeasure = false) =>
|
||||
api.users
|
||||
.storage(remeasure)
|
||||
.then((r) => {
|
||||
setStorage(r.storage);
|
||||
setMeasuring(r.measuring);
|
||||
})
|
||||
.catch(() => setMeasuring(false)),
|
||||
[]
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
(remeasure = false) =>
|
||||
Promise.all([
|
||||
loadStorage(remeasure),
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => {
|
||||
@@ -72,14 +103,23 @@ export function Users() {
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([])),
|
||||
]),
|
||||
[]
|
||||
[loadStorage]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
// The Refresh button also re-measures every home; a plain load reuses the
|
||||
// server's cached numbers.
|
||||
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "developer") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Homes are measured in the background: ask again until every number is in.
|
||||
useEffect(() => {
|
||||
if (!measuring) return;
|
||||
const id = setTimeout(() => void loadStorage(), 4000);
|
||||
return () => clearTimeout(id);
|
||||
}, [measuring, storage, loadStorage]);
|
||||
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
@@ -92,11 +132,16 @@ export function Users() {
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
{!embedded && (
|
||||
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
||||
<UsersIcon className="w-4.5 h-4.5 text-accent" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
{/* Under Administration's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,7 +213,7 @@ export function Users() {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
@@ -177,6 +222,7 @@ export function Users() {
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -186,8 +232,8 @@ export function Users() {
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 7 : 6}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||
columns={isAdmin ? 8 : 7}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
@@ -195,6 +241,7 @@ export function Users() {
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
onContextMenu={(e) => rowMenu.open(e, u)}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
@@ -244,6 +291,10 @@ export function Users() {
|
||||
</td>
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
storage={storage ? (storage[u.id] ?? null) : undefined}
|
||||
measuring={measuring}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
@@ -258,6 +309,47 @@ export function Users() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{rowMenu.menu &&
|
||||
(() => {
|
||||
const u = rowMenu.menu.item;
|
||||
const pick = (fn: () => void) => () => {
|
||||
rowMenu.close();
|
||||
fn();
|
||||
};
|
||||
return (
|
||||
<ContextMenu
|
||||
x={rowMenu.menu.x}
|
||||
y={rowMenu.menu.y}
|
||||
onClose={rowMenu.close}
|
||||
testId="users-row-menu"
|
||||
>
|
||||
{isAdmin && (
|
||||
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
|
||||
)}
|
||||
{isAdmin && !u.disabled && (
|
||||
<MenuItem
|
||||
onSelect={pick(() =>
|
||||
navigate(
|
||||
`/files?home=${encodeURIComponent(u.id === me.id ? "me" : u.username)}`
|
||||
)
|
||||
)}
|
||||
>
|
||||
{t("rowMenu.openHome")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isAdmin && <MenuSeparator />}
|
||||
<MenuItem onSelect={pick(() => void copyText(u.username))}>
|
||||
{t("rowMenu.copyUsername")}
|
||||
</MenuItem>
|
||||
{u.email && (
|
||||
<MenuItem onSelect={pick(() => void copyText(u.email!))}>
|
||||
{t("rowMenu.copyEmail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
})()}
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
<section className="card">
|
||||
@@ -341,6 +433,40 @@ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's home folder size (`undefined` = not loaded yet). `null` while the
|
||||
* server is still measuring reads "Measuring…", otherwise "—" (no home of
|
||||
* their own).
|
||||
*/
|
||||
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!storage) {
|
||||
const pending = storage === undefined || measuring;
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
|
||||
title={pending ? undefined : t("storageNone")}
|
||||
>
|
||||
{pending ? t("storageMeasuring") : "—"}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right tabular-nums"
|
||||
title={storage.partial ? t("storagePartial") : undefined}
|
||||
>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{storage.partial ? "≥ " : ""}
|
||||
{formatBytes(storage.bytes)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user